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>
118 lines
3.4 KiB
TypeScript
118 lines
3.4 KiB
TypeScript
'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]
|
|
}
|