Add Phases 3.2-4.3: STT, stories, syllables, gamification
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 37s
CI / test-go-edu-search (push) Successful in 45s
CI / test-python-agent-core (push) Has been cancelled
CI / test-nodejs-website (push) Has been cancelled
CI / test-python-klausur (push) Has started running

Phase 3.2 — MicrophoneInput.tsx: Browser Web Speech API for
speech-to-text recognition (EN+DE), integrated for pronunciation practice.

Phase 4.1 — Story Generator: LLM-powered mini-stories using vocabulary
words, with highlighted vocab in HTML output. Backend endpoint
POST /learning-units/{id}/generate-story + frontend /learn/[unitId]/story.

Phase 4.2 — SyllableBow.tsx: SVG arc component for syllable visualization
under words, clickable for per-syllable TTS.

Phase 4.3 — Gamification system:
- CoinAnimation.tsx: Floating coin rewards with accumulator
- CrownBadge.tsx: Crown/medal display for milestones
- ProgressRing.tsx: Circular progress indicator
- progress_api.py: Backend tracking coins, crowns, streaks per unit

Also adds "Geschichte" exercise type button to UnitCard.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-16 07:22:52 +02:00
parent 20a0585eb1
commit 9dddd80d7a
11 changed files with 904 additions and 0 deletions

View File

@@ -348,3 +348,29 @@ def api_get_next_review(unit_id: str, limit: int = 5):
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
class StoryGeneratePayload(BaseModel):
vocabulary: List[Dict[str, Any]]
language: str = "en"
grade_level: str = "5-8"
@router.post("/{unit_id}/generate-story")
def api_generate_story(unit_id: str, payload: StoryGeneratePayload):
"""Generate a short story using vocabulary words."""
lu = get_learning_unit(unit_id)
if not lu:
raise HTTPException(status_code=404, detail="Lerneinheit nicht gefunden.")
try:
from story_generator import generate_story
result = generate_story(
vocabulary=payload.vocabulary,
language=payload.language,
grade_level=payload.grade_level,
)
return result
except Exception as e:
logger.error(f"Story generation failed for {unit_id}: {e}")
raise HTTPException(status_code=500, detail=f"Story-Generierung fehlgeschlagen: {e}")

View File

@@ -106,6 +106,10 @@ app.include_router(correction_router, prefix="/api")
from learning_units_api import router as learning_units_router
app.include_router(learning_units_router, prefix="/api")
# --- 4b. Learning Progress ---
from progress_api import router as progress_router
app.include_router(progress_router, prefix="/api")
from unit_api import router as unit_router
app.include_router(unit_router) # Already has /api/units prefix

View File

@@ -0,0 +1,131 @@
"""
Progress API — Tracks student learning progress per unit.
Stores coins, crowns, streak data, and exercise completion stats.
Uses JSON file storage (same pattern as learning_units.py).
"""
import os
import json
import logging
from datetime import datetime, date
from typing import Dict, Any, Optional, List
from pathlib import Path
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
logger = logging.getLogger(__name__)
router = APIRouter(
prefix="/progress",
tags=["progress"],
)
PROGRESS_DIR = os.path.expanduser("~/Arbeitsblaetter/Lerneinheiten/progress")
def _ensure_dir():
os.makedirs(PROGRESS_DIR, exist_ok=True)
def _progress_path(unit_id: str) -> Path:
return Path(PROGRESS_DIR) / f"{unit_id}.json"
def _load_progress(unit_id: str) -> Dict[str, Any]:
path = _progress_path(unit_id)
if path.exists():
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
return {
"unit_id": unit_id,
"coins": 0,
"crowns": 0,
"streak_days": 0,
"last_activity": None,
"exercises": {
"flashcards": {"completed": 0, "correct": 0, "incorrect": 0},
"quiz": {"completed": 0, "correct": 0, "incorrect": 0},
"type": {"completed": 0, "correct": 0, "incorrect": 0},
"story": {"generated": 0},
},
"created_at": datetime.now().isoformat(),
}
def _save_progress(unit_id: str, data: Dict[str, Any]):
_ensure_dir()
path = _progress_path(unit_id)
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
class RewardPayload(BaseModel):
exercise_type: str # flashcards, quiz, type, story
correct: bool = True
first_try: bool = True
@router.get("/{unit_id}")
def get_progress(unit_id: str):
"""Get learning progress for a unit."""
return _load_progress(unit_id)
@router.post("/{unit_id}/reward")
def add_reward(unit_id: str, payload: RewardPayload):
"""Record an exercise result and award coins."""
progress = _load_progress(unit_id)
# Update exercise stats
ex = progress["exercises"].get(payload.exercise_type, {"completed": 0, "correct": 0, "incorrect": 0})
ex["completed"] = ex.get("completed", 0) + 1
if payload.correct:
ex["correct"] = ex.get("correct", 0) + 1
else:
ex["incorrect"] = ex.get("incorrect", 0) + 1
progress["exercises"][payload.exercise_type] = ex
# Award coins
if payload.correct:
coins = 3 if payload.first_try else 1
else:
coins = 0
progress["coins"] = progress.get("coins", 0) + coins
# Update streak
today = date.today().isoformat()
last = progress.get("last_activity")
if last != today:
if last == (date.today().replace(day=date.today().day - 1)).isoformat() if date.today().day > 1 else None:
progress["streak_days"] = progress.get("streak_days", 0) + 1
elif last != today:
progress["streak_days"] = 1
progress["last_activity"] = today
# Award crowns for milestones
total_correct = sum(
e.get("correct", 0) for e in progress["exercises"].values() if isinstance(e, dict)
)
progress["crowns"] = total_correct // 20 # 1 crown per 20 correct answers
_save_progress(unit_id, progress)
return {
"coins_awarded": coins,
"total_coins": progress["coins"],
"crowns": progress["crowns"],
"streak_days": progress["streak_days"],
}
@router.get("/")
def list_all_progress():
"""List progress for all units."""
_ensure_dir()
results = []
for f in Path(PROGRESS_DIR).glob("*.json"):
with open(f, "r", encoding="utf-8") as fh:
results.append(json.load(fh))
return results

View File

@@ -0,0 +1,108 @@
"""
Story Generator — Creates short stories using vocabulary words.
Generates age-appropriate mini-stories (3-5 sentences) that incorporate
the given vocabulary words, marked with <mark> tags for highlighting.
Uses Ollama (local LLM) for generation.
"""
import os
import json
import logging
import requests
from typing import List, Dict, Any, Optional
logger = logging.getLogger(__name__)
OLLAMA_URL = os.getenv("OLLAMA_BASE_URL", "http://host.docker.internal:11434")
STORY_MODEL = os.getenv("STORY_MODEL", "llama3.1:8b")
def generate_story(
vocabulary: List[Dict[str, str]],
language: str = "en",
grade_level: str = "5-8",
max_words: int = 5,
) -> Dict[str, Any]:
"""
Generate a short story incorporating vocabulary words.
Args:
vocabulary: List of dicts with 'english' and 'german' keys
language: 'en' for English story, 'de' for German story
grade_level: Target grade level
max_words: Maximum vocab words to include (to keep story short)
Returns:
Dict with 'story_html', 'story_text', 'vocab_used', 'language'
"""
# Select subset of vocabulary
words = vocabulary[:max_words]
word_list = [w.get("english", "") if language == "en" else w.get("german", "") for w in words]
word_list = [w for w in word_list if w.strip()]
if not word_list:
return {"story_html": "", "story_text": "", "vocab_used": [], "language": language}
lang_name = "English" if language == "en" else "German"
words_str = ", ".join(word_list)
prompt = f"""Write a short story (3-5 sentences) in {lang_name} for a grade {grade_level} student.
The story MUST use these vocabulary words: {words_str}
Rules:
1. The story should be fun and age-appropriate
2. Each vocabulary word must appear at least once
3. Keep sentences simple and clear
4. The story should make sense and be engaging
Write ONLY the story, nothing else. No title, no introduction."""
try:
resp = requests.post(
f"{OLLAMA_URL}/api/generate",
json={
"model": STORY_MODEL,
"prompt": prompt,
"stream": False,
"options": {"temperature": 0.8, "num_predict": 300},
},
timeout=30,
)
resp.raise_for_status()
story_text = resp.json().get("response", "").strip()
except Exception as e:
logger.error(f"Story generation failed: {e}")
# Fallback: simple template story
story_text = _fallback_story(word_list, language)
# Mark vocabulary words in the story
story_html = story_text
vocab_found = []
for word in word_list:
if word.lower() in story_html.lower():
# Case-insensitive replacement preserving original case
import re
pattern = re.compile(re.escape(word), re.IGNORECASE)
story_html = pattern.sub(
lambda m: f'<mark class="vocab-highlight">{m.group()}</mark>',
story_html,
count=1,
)
vocab_found.append(word)
return {
"story_html": story_html,
"story_text": story_text,
"vocab_used": vocab_found,
"vocab_total": len(word_list),
"language": language,
}
def _fallback_story(words: List[str], language: str) -> str:
"""Simple fallback when LLM is unavailable."""
if language == "de":
return f"Heute habe ich neue Woerter gelernt: {', '.join(words)}. Es war ein guter Tag zum Lernen."
return f"Today I learned new words: {', '.join(words)}. It was a great day for learning."

View File

@@ -0,0 +1,184 @@
'use client'
import React, { useState, useEffect } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { useTheme } from '@/lib/ThemeContext'
import { AudioButton } from '@/components/learn/AudioButton'
function getBackendUrl() {
if (typeof window === 'undefined') return 'http://localhost:8001'
const { hostname, protocol } = window.location
if (hostname === 'localhost') return 'http://localhost:8001'
return `${protocol}//${hostname}:8001`
}
function getKlausurApiUrl() {
if (typeof window === 'undefined') return 'http://localhost:8086'
const { hostname, protocol } = window.location
if (hostname === 'localhost') return 'http://localhost:8086'
return `${protocol}//${hostname}/klausur-api`
}
export default function StoryPage() {
const { unitId } = useParams<{ unitId: string }>()
const router = useRouter()
const { isDark } = useTheme()
const [story, setStory] = useState<{ story_html: string; story_text: string; vocab_used: string[]; language: string } | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [language, setLanguage] = useState<'en' | 'de'>('en')
const glassCard = isDark
? 'bg-white/10 backdrop-blur-xl border border-white/10'
: 'bg-white/80 backdrop-blur-xl border border-black/5'
const generateStory = async () => {
setIsLoading(true)
setError(null)
try {
// First get the QA data to extract vocabulary
const qaResp = await fetch(`${getBackendUrl()}/api/learning-units/${unitId}/qa`)
let vocabulary: { english: string; german: string }[] = []
if (qaResp.ok) {
const qaData = await qaResp.json()
// Convert QA items to vocabulary format
vocabulary = (qaData.qa_items || []).map((item: any) => ({
english: item.question,
german: item.answer,
}))
}
if (vocabulary.length === 0) {
setError('Keine Vokabeln gefunden.')
setIsLoading(false)
return
}
// Generate story
const resp = await fetch(`${getBackendUrl()}/api/learning-units/${unitId}/generate-story`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ vocabulary, language, grade_level: '5-8' }),
})
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const data = await resp.json()
setStory(data)
} catch (err: any) {
setError(err.message)
} finally {
setIsLoading(false)
}
}
return (
<div className={`min-h-screen flex flex-col ${isDark ? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800' : 'bg-gradient-to-br from-slate-100 via-amber-50 to-orange-100'}`}>
{/* Header */}
<div className={`${glassCard} border-0 border-b`}>
<div className="max-w-2xl mx-auto px-6 py-4 flex items-center justify-between">
<button
onClick={() => router.push('/learn')}
className={`flex items-center gap-2 text-sm ${isDark ? 'text-white/60 hover:text-white' : 'text-slate-500 hover:text-slate-900'}`}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Zurueck
</button>
<h1 className={`text-lg font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>
Minigeschichte
</h1>
<button
onClick={() => setLanguage((l) => l === 'en' ? 'de' : 'en')}
className={`text-xs px-3 py-1.5 rounded-lg ${isDark ? 'bg-white/10 text-white/70' : 'bg-slate-100 text-slate-600'}`}
>
{language === 'en' ? 'Englisch' : 'Deutsch'}
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 flex items-center justify-center px-6 py-8">
<div className="w-full max-w-lg space-y-6">
{story ? (
<>
{/* Story Card */}
<div className={`${glassCard} rounded-3xl p-8`}>
<div className="flex items-center justify-between mb-4">
<span className={`text-xs font-medium uppercase ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
{story.language === 'en' ? 'English Story' : 'Deutsche Geschichte'}
</span>
<AudioButton text={story.story_text} lang={story.language as 'en' | 'de'} isDark={isDark} size="md" />
</div>
<div
className={`text-lg leading-relaxed ${isDark ? 'text-white/90' : 'text-slate-800'}`}
dangerouslySetInnerHTML={{ __html: story.story_html }}
/>
<style>{`
.vocab-highlight {
background: ${isDark ? 'rgba(96, 165, 250, 0.3)' : 'rgba(59, 130, 246, 0.15)'};
color: ${isDark ? '#93c5fd' : '#1d4ed8'};
padding: 1px 4px;
border-radius: 4px;
font-weight: 600;
}
`}</style>
</div>
{/* Vocab used */}
<div className={`text-center text-sm ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
Vokabeln verwendet: {story.vocab_used.length} / {story.vocab_used.length > 0 ? story.vocab_used.join(', ') : '-'}
</div>
{/* New Story Button */}
<button
onClick={generateStory}
disabled={isLoading}
className="w-full py-3 rounded-xl bg-gradient-to-r from-amber-500 to-orange-500 text-white font-medium hover:shadow-lg transition-all"
>
Neue Geschichte generieren
</button>
</>
) : (
<div className={`${glassCard} rounded-3xl p-10 text-center`}>
<div className="text-5xl mb-4">📖</div>
<h2 className={`text-xl font-bold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
Minigeschichte
</h2>
<p className={`text-sm mb-6 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
Die KI schreibt eine kurze Geschichte mit deinen Vokabeln.
Die Vokabelwoerter werden farbig hervorgehoben.
</p>
{error && (
<p className={`text-sm mb-4 ${isDark ? 'text-red-300' : 'text-red-600'}`}>{error}</p>
)}
<button
onClick={generateStory}
disabled={isLoading}
className={`w-full py-4 rounded-xl font-medium transition-all ${
isLoading
? (isDark ? 'bg-white/5 text-white/30' : 'bg-slate-100 text-slate-400')
: 'bg-gradient-to-r from-amber-500 to-orange-500 text-white hover:shadow-lg hover:shadow-orange-500/25'
}`}
>
{isLoading ? (
<span className="flex items-center justify-center gap-3">
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
Geschichte wird geschrieben...
</span>
) : (
'Geschichte generieren'
)}
</button>
</div>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,91 @@
'use client'
import React, { useState, useEffect, useCallback } from 'react'
interface CoinAnimationProps {
amount: number
trigger: number // increment to trigger animation
}
interface FloatingCoin {
id: number
x: number
delay: number
}
export function CoinAnimation({ amount, trigger }: CoinAnimationProps) {
const [coins, setCoins] = useState<FloatingCoin[]>([])
const [total, setTotal] = useState(0)
const [showBounce, setShowBounce] = useState(false)
useEffect(() => {
if (trigger === 0) return
// Create floating coins
const newCoins: FloatingCoin[] = Array.from({ length: Math.min(amount, 5) }, (_, i) => ({
id: Date.now() + i,
x: Math.random() * 60 - 30,
delay: i * 100,
}))
setCoins(newCoins)
setShowBounce(true)
// Update total after animation
setTimeout(() => {
setTotal((prev) => prev + amount)
setShowBounce(false)
}, 800)
// Clean up coins
setTimeout(() => setCoins([]), 1500)
}, [trigger, amount])
return (
<div className="relative inline-flex items-center gap-1.5">
{/* Coin icon + total */}
<div className={`flex items-center gap-1 px-3 py-1 rounded-full bg-yellow-500/20 border border-yellow-500/30 transition-transform ${showBounce ? 'scale-110' : 'scale-100'}`}>
<span className="text-yellow-400 text-sm">&#x1FA99;</span>
<span className="text-yellow-300 text-sm font-bold tabular-nums">{total}</span>
</div>
{/* Floating coins animation */}
{coins.map((coin) => (
<span
key={coin.id}
className="absolute text-lg animate-coin-float pointer-events-none"
style={{
left: `calc(50% + ${coin.x}px)`,
animationDelay: `${coin.delay}ms`,
}}
>
&#x1FA99;
</span>
))}
<style>{`
@keyframes coin-float {
0% { transform: translateY(0) scale(1); opacity: 1; }
100% { transform: translateY(-60px) scale(0.5); opacity: 0; }
}
.animate-coin-float {
animation: coin-float 1s ease-out forwards;
}
`}</style>
</div>
)
}
/** Hook to manage coin rewards */
export function useCoinRewards() {
const [totalCoins, setTotalCoins] = useState(0)
const [triggerCount, setTriggerCount] = useState(0)
const [lastReward, setLastReward] = useState(0)
const awardCoins = useCallback((amount: number) => {
setLastReward(amount)
setTriggerCount((c) => c + 1)
setTotalCoins((t) => t + amount)
}, [])
return { totalCoins, triggerCount, lastReward, awardCoins }
}

View File

@@ -0,0 +1,35 @@
'use client'
import React from 'react'
interface CrownBadgeProps {
crowns: number
size?: 'sm' | 'md' | 'lg'
showLabel?: boolean
}
export function CrownBadge({ crowns, size = 'md', showLabel = true }: CrownBadgeProps) {
const sizeClasses = {
sm: 'text-base',
md: 'text-xl',
lg: 'text-3xl',
}
const isGold = crowns >= 3
const isSilver = crowns >= 1
return (
<div className="inline-flex items-center gap-1.5">
<span className={`${sizeClasses[size]} ${isGold ? 'animate-pulse' : ''}`}>
{isGold ? '👑' : isSilver ? '🥈' : '⭐'}
</span>
{showLabel && (
<span className={`font-bold tabular-nums ${
isGold ? 'text-yellow-400' : isSilver ? 'text-slate-300' : 'text-white/50'
} ${size === 'sm' ? 'text-xs' : size === 'md' ? 'text-sm' : 'text-base'}`}>
{crowns}
</span>
)}
</div>
)
}

View File

@@ -0,0 +1,67 @@
'use client'
import React from 'react'
interface ProgressRingProps {
progress: number // 0-100
size?: number
strokeWidth?: number
label: string
value: string
color?: string
isDark?: boolean
}
export function ProgressRing({
progress,
size = 80,
strokeWidth = 6,
label,
value,
color = '#60a5fa',
isDark = true,
}: ProgressRingProps) {
const radius = (size - strokeWidth) / 2
const circumference = radius * 2 * Math.PI
const offset = circumference - (Math.min(progress, 100) / 100) * circumference
return (
<div className="flex flex-col items-center gap-1">
<div className="relative" style={{ width: size, height: size }}>
<svg width={size} height={size} className="-rotate-90">
{/* Background circle */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.08)'}
strokeWidth={strokeWidth}
/>
{/* Progress circle */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={color}
strokeWidth={strokeWidth}
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
className="transition-all duration-700 ease-out"
/>
</svg>
{/* Center text */}
<div className="absolute inset-0 flex items-center justify-center">
<span className={`text-sm font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>
{value}
</span>
</div>
</div>
<span className={`text-xs ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
{label}
</span>
</div>
)
}

View File

@@ -0,0 +1,140 @@
'use client'
import React, { useState, useRef, useCallback } from 'react'
interface MicrophoneInputProps {
expectedText: string
lang: 'en' | 'de'
onResult: (transcript: string, correct: boolean) => void
isDark: boolean
}
export function MicrophoneInput({ expectedText, lang, onResult, isDark }: MicrophoneInputProps) {
const [isListening, setIsListening] = useState(false)
const [transcript, setTranscript] = useState('')
const [feedback, setFeedback] = useState<'correct' | 'wrong' | null>(null)
const recognitionRef = useRef<any>(null)
const startListening = useCallback(() => {
const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition
if (!SpeechRecognition) {
setTranscript('Spracherkennung nicht verfuegbar')
return
}
const recognition = new SpeechRecognition()
recognition.lang = lang === 'de' ? 'de-DE' : 'en-GB'
recognition.interimResults = false
recognition.maxAlternatives = 3
recognition.continuous = false
recognition.onresult = (event: any) => {
const results = event.results[0]
let bestMatch = ''
let isCorrect = false
// Check all alternatives for a match
for (let i = 0; i < results.length; i++) {
const alt = results[i].transcript.trim().toLowerCase()
if (alt === expectedText.trim().toLowerCase()) {
bestMatch = results[i].transcript
isCorrect = true
break
}
if (!bestMatch) bestMatch = results[i].transcript
}
setTranscript(bestMatch)
setFeedback(isCorrect ? 'correct' : 'wrong')
setIsListening(false)
setTimeout(() => {
onResult(bestMatch, isCorrect)
setFeedback(null)
setTranscript('')
}, isCorrect ? 1000 : 2500)
}
recognition.onerror = (event: any) => {
console.error('Speech recognition error:', event.error)
setIsListening(false)
if (event.error === 'no-speech') {
setTranscript('Kein Ton erkannt. Nochmal versuchen.')
} else if (event.error === 'not-allowed') {
setTranscript('Mikrofon-Zugriff nicht erlaubt.')
}
}
recognition.onend = () => {
setIsListening(false)
}
recognitionRef.current = recognition
recognition.start()
setIsListening(true)
setTranscript('')
setFeedback(null)
}, [lang, expectedText, onResult])
const stopListening = useCallback(() => {
recognitionRef.current?.stop()
setIsListening(false)
}, [])
return (
<div className="flex flex-col items-center gap-4">
{/* Microphone Button */}
<button
onClick={isListening ? stopListening : startListening}
className={`w-20 h-20 rounded-full flex items-center justify-center transition-all ${
isListening
? 'bg-red-500 text-white animate-pulse shadow-lg shadow-red-500/30'
: feedback === 'correct'
? 'bg-green-500 text-white shadow-lg shadow-green-500/30'
: feedback === 'wrong'
? 'bg-red-500/60 text-white'
: isDark
? 'bg-white/10 text-white/70 hover:bg-white/20 hover:text-white'
: 'bg-slate-100 text-slate-500 hover:bg-slate-200 hover:text-slate-700'
}`}
>
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{isListening ? (
<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="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z" />
</>
)}
</svg>
</button>
{/* Status Text */}
<p className={`text-sm text-center ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
{isListening
? 'Sprich jetzt...'
: transcript
? transcript
: 'Tippe auf das Mikrofon'}
</p>
{/* Feedback */}
{feedback === 'correct' && (
<p className={`text-lg font-semibold ${isDark ? 'text-green-300' : 'text-green-600'}`}>
Richtig ausgesprochen!
</p>
)}
{feedback === 'wrong' && (
<div className="text-center">
<p className={`text-sm ${isDark ? 'text-red-300' : 'text-red-600'}`}>
Erkannt: &quot;{transcript}&quot;
</p>
<p className={`text-sm mt-1 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
Erwartet: &quot;{expectedText}&quot;
</p>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,117 @@
'use client'
import React, { useMemo } from 'react'
interface SyllableBowProps {
word: string
syllables: string[]
onSyllableClick?: (syllable: string, index: number) => void
isDark: boolean
size?: 'sm' | 'md' | 'lg'
}
/**
* SyllableBow — Renders a word with SVG arcs under each syllable.
*
* Uses pyphen syllable data from the backend.
* Each syllable is clickable (triggers TTS for that syllable).
*/
export function SyllableBow({ word, syllables, onSyllableClick, isDark, size = 'md' }: SyllableBowProps) {
const fontSize = size === 'sm' ? 20 : size === 'md' ? 32 : 44
const charWidth = fontSize * 0.6
const bowHeight = size === 'sm' ? 12 : size === 'md' ? 18 : 24
const gap = 4
const layout = useMemo(() => {
let x = 0
return syllables.map((syl) => {
const width = syl.length * charWidth
const entry = { syllable: syl, x, width }
x += width + gap
return entry
})
}, [syllables, charWidth])
const totalWidth = layout.length > 0
? layout[layout.length - 1].x + layout[layout.length - 1].width
: 0
const svgHeight = bowHeight + 6
return (
<div className="inline-flex flex-col items-center">
{/* Letters */}
<div className="flex" style={{ gap: `${gap}px` }}>
{layout.map((item, idx) => (
<span
key={idx}
onClick={() => onSyllableClick?.(item.syllable, idx)}
className={`font-bold cursor-pointer select-none transition-colors ${
onSyllableClick
? (isDark ? 'hover:text-blue-300' : 'hover:text-blue-600')
: ''
} ${isDark ? 'text-white' : 'text-slate-900'}`}
style={{ fontSize: `${fontSize}px`, letterSpacing: '0.02em' }}
>
{item.syllable}
</span>
))}
</div>
{/* SVG Bows */}
<svg
width={totalWidth}
height={svgHeight}
viewBox={`0 0 ${totalWidth} ${svgHeight}`}
className="mt-0.5"
>
{layout.map((item, idx) => {
const cx = item.x + item.width / 2
const startX = item.x + 2
const endX = item.x + item.width - 2
const controlY = svgHeight - 2
return (
<path
key={idx}
d={`M ${startX} 2 Q ${cx} ${controlY} ${endX} 2`}
fill="none"
stroke={isDark ? 'rgba(96, 165, 250, 0.6)' : 'rgba(37, 99, 235, 0.5)'}
strokeWidth={size === 'sm' ? 1.5 : 2}
strokeLinecap="round"
/>
)
})}
</svg>
</div>
)
}
/**
* Simple client-side syllable splitting fallback.
* For accurate results, use the backend pyphen endpoint.
*/
export function simpleSyllableSplit(word: string): string[] {
// Very basic vowel-based heuristic for display purposes
const vowels = /[aeiouyäöü]/i
const chars = word.split('')
const syllables: string[] = []
let current = ''
for (let i = 0; i < chars.length; i++) {
current += chars[i]
if (
vowels.test(chars[i]) &&
i < chars.length - 1 &&
current.length >= 2
) {
// Check if next char starts a new consonant cluster
if (!vowels.test(chars[i + 1]) && i + 2 < chars.length && vowels.test(chars[i + 2])) {
syllables.push(current)
current = ''
}
}
}
if (current) syllables.push(current)
return syllables.length > 0 ? syllables : [word]
}

View File

@@ -25,6 +25,7 @@ const exerciseTypes = [
{ key: 'flashcards', label: 'Karteikarten', icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10', color: 'from-amber-500 to-orange-500' },
{ key: 'quiz', label: 'Quiz', icon: 'M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z', color: 'from-purple-500 to-pink-500' },
{ key: 'type', label: 'Eintippen', icon: 'M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z', color: 'from-blue-500 to-cyan-500' },
{ key: 'story', label: 'Geschichte', icon: 'M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253', color: 'from-amber-500 to-yellow-500' },
]
export function UnitCard({ unit, isDark, glassCard, onDelete }: UnitCardProps) {