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
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:
91
studio-v2/components/gamification/CoinAnimation.tsx
Normal file
91
studio-v2/components/gamification/CoinAnimation.tsx
Normal 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">🪙</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`,
|
||||
}}
|
||||
>
|
||||
🪙
|
||||
</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 }
|
||||
}
|
||||
35
studio-v2/components/gamification/CrownBadge.tsx
Normal file
35
studio-v2/components/gamification/CrownBadge.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
67
studio-v2/components/gamification/ProgressRing.tsx
Normal file
67
studio-v2/components/gamification/ProgressRing.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user