Add interactive learning modules MVP (Phases 1-3.1)
Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 44s
CI / test-go-edu-search (push) Successful in 51s
CI / test-python-klausur (push) Failing after 2m44s
CI / test-python-agent-core (push) Successful in 33s
CI / test-nodejs-website (push) Successful in 34s
Some checks failed
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-school (push) Successful in 44s
CI / test-go-edu-search (push) Successful in 51s
CI / test-python-klausur (push) Failing after 2m44s
CI / test-python-agent-core (push) Successful in 33s
CI / test-nodejs-website (push) Successful in 34s
New feature: After OCR vocabulary extraction, users can generate interactive
learning modules (flashcards, quiz, type trainer) with one click.
Frontend (studio-v2):
- Fortune Sheet spreadsheet editor tab in vocab-worksheet
- "Lernmodule generieren" button in ExportTab
- /learn page with unit overview and exercise type cards
- /learn/[unitId]/flashcards — Flip-card trainer with Leitner spaced repetition
- /learn/[unitId]/quiz — Multiple choice quiz with explanations
- /learn/[unitId]/type — Type-in trainer with Levenshtein distance feedback
- AudioButton component using Web Speech API for EN+DE TTS
Backend (klausur-service):
- vocab_learn_bridge.py: Converts VocabularyEntry[] to analysis_data format
- POST /sessions/{id}/generate-learning-unit endpoint
Backend (backend-lehrer):
- generate-qa, generate-mc, generate-cloze endpoints on learning units
- get-qa/mc/cloze data retrieval endpoints
- Leitner progress update + next review items endpoints
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
75
studio-v2/components/learn/AudioButton.tsx
Normal file
75
studio-v2/components/learn/AudioButton.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
'use client'
|
||||
|
||||
import React, { useCallback, useState } from 'react'
|
||||
|
||||
interface AudioButtonProps {
|
||||
text: string
|
||||
lang: 'en' | 'de'
|
||||
isDark: boolean
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
}
|
||||
|
||||
export function AudioButton({ text, lang, isDark, size = 'md' }: AudioButtonProps) {
|
||||
const [isSpeaking, setIsSpeaking] = useState(false)
|
||||
|
||||
const speak = useCallback(() => {
|
||||
if (!('speechSynthesis' in window)) return
|
||||
if (isSpeaking) {
|
||||
window.speechSynthesis.cancel()
|
||||
setIsSpeaking(false)
|
||||
return
|
||||
}
|
||||
|
||||
const utterance = new SpeechSynthesisUtterance(text)
|
||||
utterance.lang = lang === 'de' ? 'de-DE' : 'en-GB'
|
||||
utterance.rate = 0.9
|
||||
utterance.pitch = 1.0
|
||||
|
||||
// Try to find a good voice
|
||||
const voices = window.speechSynthesis.getVoices()
|
||||
const preferred = voices.find((v) =>
|
||||
v.lang.startsWith(lang === 'de' ? 'de' : 'en') && v.localService
|
||||
) || voices.find((v) => v.lang.startsWith(lang === 'de' ? 'de' : 'en'))
|
||||
if (preferred) utterance.voice = preferred
|
||||
|
||||
utterance.onend = () => setIsSpeaking(false)
|
||||
utterance.onerror = () => setIsSpeaking(false)
|
||||
|
||||
setIsSpeaking(true)
|
||||
window.speechSynthesis.speak(utterance)
|
||||
}, [text, lang, isSpeaking])
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'w-7 h-7',
|
||||
md: 'w-9 h-9',
|
||||
lg: 'w-11 h-11',
|
||||
}
|
||||
|
||||
const iconSizes = {
|
||||
sm: 'w-3.5 h-3.5',
|
||||
md: 'w-4 h-4',
|
||||
lg: 'w-5 h-5',
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={speak}
|
||||
className={`${sizeClasses[size]} rounded-full flex items-center justify-center transition-all ${
|
||||
isSpeaking
|
||||
? 'bg-blue-500 text-white animate-pulse'
|
||||
: isDark
|
||||
? 'bg-white/10 text-white/60 hover:bg-white/20 hover:text-white'
|
||||
: 'bg-slate-100 text-slate-500 hover:bg-slate-200 hover:text-slate-700'
|
||||
}`}
|
||||
title={isSpeaking ? 'Stop' : `${lang === 'de' ? 'Deutsch' : 'Englisch'} vorlesen`}
|
||||
>
|
||||
<svg className={iconSizes[size]} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
{isSpeaking ? (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0zM10 9v6m4-6v6" />
|
||||
) : (
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" />
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user