Files
breakpilot-lehrer/studio-v2/components/learn/SyllableBow.tsx
Benjamin Admin 9dddd80d7a
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
Add Phases 3.2-4.3: STT, stories, syllables, gamification
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>
2026-04-16 07:22:52 +02:00

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]
}