Fix: Use @shared/* alias instead of relative paths for Docker compat
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1 +1 @@
|
|||||||
export * from '../../../../../shared/types/ocr-labeling'
|
export * from '@shared/types/ocr-labeling'
|
||||||
|
|||||||
@@ -12,13 +12,13 @@ export type {
|
|||||||
ActiveTab,
|
ActiveTab,
|
||||||
GradeTotals,
|
GradeTotals,
|
||||||
CriteriaScores,
|
CriteriaScores,
|
||||||
} from '../../../../../../../../shared/types/klausur'
|
} from '@shared/types/klausur'
|
||||||
|
|
||||||
export {
|
export {
|
||||||
WORKFLOW_STATUS_LABELS,
|
WORKFLOW_STATUS_LABELS,
|
||||||
ROLE_LABELS,
|
ROLE_LABELS,
|
||||||
GRADE_LABELS,
|
GRADE_LABELS,
|
||||||
} from '../../../../../../../../shared/types/klausur'
|
} from '@shared/types/klausur'
|
||||||
|
|
||||||
/** Same-origin proxy to avoid CORS issues */
|
/** Same-origin proxy to avoid CORS issues */
|
||||||
export const API_BASE = '/klausur-api'
|
export const API_BASE = '/klausur-api'
|
||||||
|
|||||||
@@ -8,4 +8,4 @@ export type {
|
|||||||
VorabiturEHForm,
|
VorabiturEHForm,
|
||||||
EHTemplate,
|
EHTemplate,
|
||||||
DirektuploadForm,
|
DirektuploadForm,
|
||||||
} from '../../../../../../shared/types/klausur'
|
} from '@shared/types/klausur'
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export * from '../../../../../shared/types/klausur'
|
export * from '@shared/types/klausur'
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export * from '../../../shared/types/companion'
|
export * from '@shared/types/companion'
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export * from '../../../shared/types/klausur'
|
export * from '@shared/types/klausur'
|
||||||
|
|||||||
137
studio-v2/app/learn/[unitId]/listen/page.tsx
Normal file
137
studio-v2/app/learn/[unitId]/listen/page.tsx
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
||||||
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
|
import { useTheme } from '@/lib/ThemeContext'
|
||||||
|
import { AudioButton } from '@/components/learn/AudioButton'
|
||||||
|
import { StarRating, accuracyToStars } from '@/components/gamification/StarRating'
|
||||||
|
|
||||||
|
interface QAItem { id: string; question: string; answer: string }
|
||||||
|
|
||||||
|
function getApiBase() { return '' }
|
||||||
|
|
||||||
|
export default function ListenPage() {
|
||||||
|
const { unitId } = useParams<{ unitId: string }>()
|
||||||
|
const router = useRouter()
|
||||||
|
const { isDark } = useTheme()
|
||||||
|
|
||||||
|
const [items, setItems] = useState<QAItem[]>([])
|
||||||
|
const [currentIndex, setCurrentIndex] = useState(0)
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [selected, setSelected] = useState<string | null>(null)
|
||||||
|
const [revealed, setRevealed] = useState(false)
|
||||||
|
const [stats, setStats] = useState({ correct: 0, incorrect: 0 })
|
||||||
|
const [isComplete, setIsComplete] = useState(false)
|
||||||
|
|
||||||
|
const glassCard = isDark
|
||||||
|
? 'bg-white/10 backdrop-blur-xl border border-white/10'
|
||||||
|
: 'bg-white/80 backdrop-blur-xl border border-black/5'
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${getApiBase()}/api/learning-units/${unitId}/qa`)
|
||||||
|
if (resp.ok) { const d = await resp.json(); setItems(d.qa_items || []) }
|
||||||
|
} catch {} finally { setIsLoading(false) }
|
||||||
|
})()
|
||||||
|
}, [unitId])
|
||||||
|
|
||||||
|
// Generate 4 options (1 correct + 3 distractors)
|
||||||
|
const options = useMemo(() => {
|
||||||
|
if (!items.length || currentIndex >= items.length) return []
|
||||||
|
const correct = items[currentIndex]
|
||||||
|
const others = items.filter((_, i) => i !== currentIndex)
|
||||||
|
const shuffled = [...others].sort(() => Math.random() - 0.5).slice(0, 3)
|
||||||
|
const opts = [...shuffled.map(i => ({ id: i.id, text: i.answer })), { id: correct.id, text: correct.answer }]
|
||||||
|
return opts.sort(() => Math.random() - 0.5)
|
||||||
|
}, [items, currentIndex])
|
||||||
|
|
||||||
|
const handleSelect = useCallback((optionId: string) => {
|
||||||
|
if (revealed) return
|
||||||
|
setSelected(optionId)
|
||||||
|
setRevealed(true)
|
||||||
|
const isCorrect = optionId === items[currentIndex].id
|
||||||
|
|
||||||
|
setStats(prev => ({
|
||||||
|
correct: prev.correct + (isCorrect ? 1 : 0),
|
||||||
|
incorrect: prev.incorrect + (isCorrect ? 0 : 1),
|
||||||
|
}))
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (currentIndex + 1 >= items.length) { setIsComplete(true) }
|
||||||
|
else { setCurrentIndex(i => i + 1) }
|
||||||
|
setSelected(null)
|
||||||
|
setRevealed(false)
|
||||||
|
}, isCorrect ? 800 : 2000)
|
||||||
|
}, [revealed, items, currentIndex])
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div className={`min-h-screen flex items-center justify-center ${isDark ? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800' : 'bg-gradient-to-br from-slate-100 via-green-50 to-emerald-100'}`}>
|
||||||
|
<div className="w-8 h-8 border-4 border-green-400 border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentItem = items[currentIndex]
|
||||||
|
|
||||||
|
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-green-50 to-emerald-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'}`}>Hoerverstehen</h1>
|
||||||
|
<span className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>{currentIndex + 1}/{items.length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress */}
|
||||||
|
<div className="w-full h-1 bg-white/10">
|
||||||
|
<div className="h-full bg-gradient-to-r from-green-500 to-emerald-500 transition-all" style={{ width: `${(currentIndex / Math.max(items.length, 1)) * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 flex items-center justify-center px-6 py-8">
|
||||||
|
{isComplete ? (
|
||||||
|
<div className={`${glassCard} rounded-3xl p-10 text-center max-w-md w-full`}>
|
||||||
|
<StarRating stars={accuracyToStars(stats.correct, stats.correct + stats.incorrect)} size="lg" animated />
|
||||||
|
<h2 className={`text-2xl font-bold mt-4 mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>Geschafft!</h2>
|
||||||
|
<p className={`text-lg mb-6 ${isDark ? 'text-white/70' : 'text-slate-600'}`}>{stats.correct}/{stats.correct + stats.incorrect} richtig</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button onClick={() => { setCurrentIndex(0); setStats({ correct: 0, incorrect: 0 }); setIsComplete(false) }} className="flex-1 py-3 rounded-xl bg-gradient-to-r from-green-500 to-emerald-500 text-white font-medium">Nochmal</button>
|
||||||
|
<button onClick={() => router.push('/learn')} className={`flex-1 py-3 rounded-xl border font-medium ${isDark ? 'border-white/20 text-white/80' : 'border-slate-300 text-slate-700'}`}>Zurueck</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : currentItem ? (
|
||||||
|
<div className="w-full max-w-lg space-y-6">
|
||||||
|
{/* Audio prompt */}
|
||||||
|
<div className={`${glassCard} rounded-3xl p-8 text-center`}>
|
||||||
|
<p className={`text-sm mb-4 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>Hoere das Wort und waehle die richtige Uebersetzung</p>
|
||||||
|
<AudioButton text={currentItem.question} lang="en" isDark={isDark} size="lg" />
|
||||||
|
<p className={`text-sm mt-4 ${isDark ? 'text-white/30' : 'text-slate-400'}`}>Tippe auf den Lautsprecher</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Options */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
{options.map(opt => {
|
||||||
|
const isCorrectAnswer = opt.id === currentItem.id
|
||||||
|
const isSelected = selected === opt.id
|
||||||
|
let style = isDark ? 'bg-white/10 border-white/20 text-white' : 'bg-white border-slate-200 text-slate-900'
|
||||||
|
if (revealed && isCorrectAnswer) style = isDark ? 'bg-green-500/30 border-green-400 text-green-200' : 'bg-green-50 border-green-500 text-green-800'
|
||||||
|
if (revealed && isSelected && !isCorrectAnswer) style = isDark ? 'bg-red-500/30 border-red-400 text-red-200' : 'bg-red-50 border-red-500 text-red-800'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button key={opt.id} onClick={() => handleSelect(opt.id)} disabled={revealed}
|
||||||
|
className={`p-4 rounded-xl border-2 text-center font-medium transition-all ${style}`}>
|
||||||
|
{opt.text}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
170
studio-v2/app/learn/[unitId]/match/page.tsx
Normal file
170
studio-v2/app/learn/[unitId]/match/page.tsx
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
||||||
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
|
import { useTheme } from '@/lib/ThemeContext'
|
||||||
|
import { StarRating, accuracyToStars } from '@/components/gamification/StarRating'
|
||||||
|
|
||||||
|
interface QAItem { id: string; question: string; answer: string }
|
||||||
|
|
||||||
|
function getApiBase() { return '' }
|
||||||
|
|
||||||
|
export default function MatchPage() {
|
||||||
|
const { unitId } = useParams<{ unitId: string }>()
|
||||||
|
const router = useRouter()
|
||||||
|
const { isDark } = useTheme()
|
||||||
|
|
||||||
|
const [allItems, setAllItems] = useState<QAItem[]>([])
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [round, setRound] = useState(0)
|
||||||
|
const [selectedLeft, setSelectedLeft] = useState<string | null>(null)
|
||||||
|
const [matched, setMatched] = useState<Set<string>>(new Set())
|
||||||
|
const [wrongPair, setWrongPair] = useState<string | null>(null)
|
||||||
|
const [errors, setErrors] = useState(0)
|
||||||
|
const [isComplete, setIsComplete] = useState(false)
|
||||||
|
|
||||||
|
const glassCard = isDark
|
||||||
|
? 'bg-white/10 backdrop-blur-xl border border-white/10'
|
||||||
|
: 'bg-white/80 backdrop-blur-xl border border-black/5'
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${getApiBase()}/api/learning-units/${unitId}/qa`)
|
||||||
|
if (resp.ok) { const d = await resp.json(); setAllItems(d.qa_items || []) }
|
||||||
|
} catch {} finally { setIsLoading(false) }
|
||||||
|
})()
|
||||||
|
}, [unitId])
|
||||||
|
|
||||||
|
// Take 6 items per round
|
||||||
|
const roundItems = useMemo(() => {
|
||||||
|
const start = round * 6
|
||||||
|
return allItems.slice(start, start + 6)
|
||||||
|
}, [allItems, round])
|
||||||
|
|
||||||
|
// Shuffled right column
|
||||||
|
const shuffledRight = useMemo(() => {
|
||||||
|
return [...roundItems].sort(() => Math.random() - 0.5)
|
||||||
|
}, [roundItems])
|
||||||
|
|
||||||
|
const handleLeftTap = useCallback((id: string) => {
|
||||||
|
if (matched.has(id)) return
|
||||||
|
setSelectedLeft(id === selectedLeft ? null : id)
|
||||||
|
setWrongPair(null)
|
||||||
|
}, [selectedLeft, matched])
|
||||||
|
|
||||||
|
const handleRightTap = useCallback((id: string) => {
|
||||||
|
if (!selectedLeft || matched.has(id)) return
|
||||||
|
|
||||||
|
if (selectedLeft === id) {
|
||||||
|
// Correct match
|
||||||
|
setMatched(prev => new Set([...prev, id]))
|
||||||
|
setSelectedLeft(null)
|
||||||
|
|
||||||
|
// Check if round complete
|
||||||
|
if (matched.size + 1 >= roundItems.length) {
|
||||||
|
const nextStart = (round + 1) * 6
|
||||||
|
if (nextStart >= allItems.length) {
|
||||||
|
setTimeout(() => setIsComplete(true), 500)
|
||||||
|
} else {
|
||||||
|
setTimeout(() => {
|
||||||
|
setRound(r => r + 1)
|
||||||
|
setMatched(new Set())
|
||||||
|
setSelectedLeft(null)
|
||||||
|
}, 800)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Wrong match
|
||||||
|
setWrongPair(id)
|
||||||
|
setErrors(e => e + 1)
|
||||||
|
setTimeout(() => {
|
||||||
|
setWrongPair(null)
|
||||||
|
setSelectedLeft(null)
|
||||||
|
}, 600)
|
||||||
|
}
|
||||||
|
}, [selectedLeft, matched, roundItems, round, allItems])
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div className={`min-h-screen flex items-center justify-center ${isDark ? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800' : 'bg-gradient-to-br from-slate-100 via-indigo-50 to-violet-100'}`}>
|
||||||
|
<div className="w-8 h-8 border-4 border-indigo-400 border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPairs = allItems.length
|
||||||
|
const matchedTotal = round * 6 + matched.size
|
||||||
|
|
||||||
|
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-indigo-50 to-violet-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'}`}>Zuordnen</h1>
|
||||||
|
<span className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>{matchedTotal}/{totalPairs}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 flex items-center justify-center px-6 py-8">
|
||||||
|
{isComplete ? (
|
||||||
|
<div className={`${glassCard} rounded-3xl p-10 text-center max-w-md w-full`}>
|
||||||
|
<StarRating stars={accuracyToStars(totalPairs, totalPairs + errors)} size="lg" animated />
|
||||||
|
<h2 className={`text-2xl font-bold mt-4 mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>Alle zugeordnet!</h2>
|
||||||
|
<p className={`mb-6 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>{errors} Fehler</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button onClick={() => { setRound(0); setMatched(new Set()); setErrors(0); setIsComplete(false) }} className="flex-1 py-3 rounded-xl bg-gradient-to-r from-indigo-500 to-violet-500 text-white font-medium">Nochmal</button>
|
||||||
|
<button onClick={() => router.push('/learn')} className={`flex-1 py-3 rounded-xl border font-medium ${isDark ? 'border-white/20 text-white/80' : 'border-slate-300 text-slate-700'}`}>Zurueck</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-full max-w-2xl grid grid-cols-2 gap-6">
|
||||||
|
{/* Left column: English */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className={`text-xs font-medium text-center mb-2 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>English</p>
|
||||||
|
{roundItems.map(item => (
|
||||||
|
<button
|
||||||
|
key={`l-${item.id}`}
|
||||||
|
onClick={() => handleLeftTap(item.id)}
|
||||||
|
disabled={matched.has(item.id)}
|
||||||
|
className={`w-full p-3 rounded-xl border-2 text-sm font-medium transition-all ${
|
||||||
|
matched.has(item.id)
|
||||||
|
? 'opacity-30 border-green-400 bg-green-500/10 cursor-default'
|
||||||
|
: selectedLeft === item.id
|
||||||
|
? (isDark ? 'border-blue-400 bg-blue-500/20 text-white' : 'border-blue-500 bg-blue-50 text-blue-900')
|
||||||
|
: (isDark ? 'border-white/20 bg-white/5 text-white hover:bg-white/10' : 'border-slate-200 bg-white text-slate-900 hover:bg-slate-50')
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.question}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right column: German (shuffled) */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className={`text-xs font-medium text-center mb-2 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>Deutsch</p>
|
||||||
|
{shuffledRight.map(item => (
|
||||||
|
<button
|
||||||
|
key={`r-${item.id}`}
|
||||||
|
onClick={() => handleRightTap(item.id)}
|
||||||
|
disabled={matched.has(item.id) || !selectedLeft}
|
||||||
|
className={`w-full p-3 rounded-xl border-2 text-sm font-medium transition-all ${
|
||||||
|
matched.has(item.id)
|
||||||
|
? 'opacity-30 border-green-400 bg-green-500/10 cursor-default'
|
||||||
|
: wrongPair === item.id
|
||||||
|
? (isDark ? 'border-red-400 bg-red-500/20 text-red-200 animate-pulse' : 'border-red-500 bg-red-50 text-red-800 animate-pulse')
|
||||||
|
: (isDark ? 'border-white/20 bg-white/5 text-white hover:bg-white/10' : 'border-slate-200 bg-white text-slate-900 hover:bg-slate-50')
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item.answer}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
133
studio-v2/app/learn/[unitId]/pronounce/page.tsx
Normal file
133
studio-v2/app/learn/[unitId]/pronounce/page.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
|
import { useTheme } from '@/lib/ThemeContext'
|
||||||
|
import { AudioButton } from '@/components/learn/AudioButton'
|
||||||
|
import { MicrophoneInput } from '@/components/learn/MicrophoneInput'
|
||||||
|
import { SyllableBow, simpleSyllableSplit } from '@/components/learn/SyllableBow'
|
||||||
|
import { StarRating, accuracyToStars } from '@/components/gamification/StarRating'
|
||||||
|
|
||||||
|
interface QAItem {
|
||||||
|
id: string; question: string; answer: string
|
||||||
|
syllables_en?: string[]; syllables_de?: string[]
|
||||||
|
ipa_en?: string; ipa_de?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function getApiBase() { return '' }
|
||||||
|
|
||||||
|
export default function PronouncePage() {
|
||||||
|
const { unitId } = useParams<{ unitId: string }>()
|
||||||
|
const router = useRouter()
|
||||||
|
const { isDark } = useTheme()
|
||||||
|
|
||||||
|
const [items, setItems] = useState<QAItem[]>([])
|
||||||
|
const [currentIndex, setCurrentIndex] = useState(0)
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [stats, setStats] = useState({ correct: 0, incorrect: 0 })
|
||||||
|
const [isComplete, setIsComplete] = useState(false)
|
||||||
|
const [lang, setLang] = 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'
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${getApiBase()}/api/learning-units/${unitId}/qa`)
|
||||||
|
if (resp.ok) { const d = await resp.json(); setItems(d.qa_items || []) }
|
||||||
|
} catch {} finally { setIsLoading(false) }
|
||||||
|
})()
|
||||||
|
}, [unitId])
|
||||||
|
|
||||||
|
const currentItem = items[currentIndex]
|
||||||
|
const currentWord = currentItem ? (lang === 'en' ? currentItem.question : currentItem.answer) : ''
|
||||||
|
const syllables = currentItem
|
||||||
|
? (lang === 'en' ? currentItem.syllables_en : currentItem.syllables_de) || simpleSyllableSplit(currentWord)
|
||||||
|
: []
|
||||||
|
const ipa = currentItem ? (lang === 'en' ? currentItem.ipa_en : currentItem.ipa_de) : ''
|
||||||
|
|
||||||
|
const handleResult = useCallback((transcript: string, correct: boolean) => {
|
||||||
|
setStats(prev => ({
|
||||||
|
correct: prev.correct + (correct ? 1 : 0),
|
||||||
|
incorrect: prev.incorrect + (correct ? 0 : 1),
|
||||||
|
}))
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (currentIndex + 1 >= items.length) { setIsComplete(true) }
|
||||||
|
else { setCurrentIndex(i => i + 1) }
|
||||||
|
}, correct ? 1000 : 2500)
|
||||||
|
}, [currentIndex, items.length])
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div className={`min-h-screen flex items-center justify-center ${isDark ? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800' : 'bg-gradient-to-br from-slate-100 via-rose-50 to-red-100'}`}>
|
||||||
|
<div className="w-8 h-8 border-4 border-rose-400 border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
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-rose-50 to-red-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'}`}>Nachsprechen</h1>
|
||||||
|
<button onClick={() => setLang(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'}`}>
|
||||||
|
{lang === 'en' ? 'EN' : 'DE'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress */}
|
||||||
|
<div className="w-full h-1 bg-white/10">
|
||||||
|
<div className="h-full bg-gradient-to-r from-rose-500 to-red-500 transition-all" style={{ width: `${(currentIndex / Math.max(items.length, 1)) * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 flex items-center justify-center px-6 py-8">
|
||||||
|
{isComplete ? (
|
||||||
|
<div className={`${glassCard} rounded-3xl p-10 text-center max-w-md w-full`}>
|
||||||
|
<StarRating stars={accuracyToStars(stats.correct, stats.correct + stats.incorrect)} size="lg" animated />
|
||||||
|
<h2 className={`text-2xl font-bold mt-4 mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>Super gemacht!</h2>
|
||||||
|
<p className={`mb-6 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>{stats.correct}/{stats.correct + stats.incorrect} richtig ausgesprochen</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button onClick={() => { setCurrentIndex(0); setStats({ correct: 0, incorrect: 0 }); setIsComplete(false) }} className="flex-1 py-3 rounded-xl bg-gradient-to-r from-rose-500 to-red-500 text-white font-medium">Nochmal</button>
|
||||||
|
<button onClick={() => router.push('/learn')} className={`flex-1 py-3 rounded-xl border font-medium ${isDark ? 'border-white/20 text-white/80' : 'border-slate-300 text-slate-700'}`}>Zurueck</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : currentItem ? (
|
||||||
|
<div className="w-full max-w-lg space-y-8">
|
||||||
|
{/* Word + Syllables */}
|
||||||
|
<div className={`${glassCard} rounded-3xl p-8 text-center`}>
|
||||||
|
<div className="flex justify-center mb-4">
|
||||||
|
<SyllableBow word={currentWord} syllables={syllables} isDark={isDark} size="lg" />
|
||||||
|
</div>
|
||||||
|
{ipa && <p className={`text-lg ${isDark ? 'text-white/40' : 'text-slate-400'}`}>{ipa}</p>}
|
||||||
|
<div className="flex justify-center gap-3 mt-4">
|
||||||
|
<AudioButton text={currentWord} lang={lang} isDark={isDark} size="lg" />
|
||||||
|
</div>
|
||||||
|
<p className={`text-sm mt-3 ${isDark ? 'text-white/30' : 'text-slate-400'}`}>
|
||||||
|
Hoere zu, dann sprich nach
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Microphone */}
|
||||||
|
<MicrophoneInput
|
||||||
|
expectedText={currentWord}
|
||||||
|
lang={lang}
|
||||||
|
onResult={handleResult}
|
||||||
|
isDark={isDark}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p className={`text-center text-sm ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||||
|
{currentIndex + 1} / {items.length}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
45
studio-v2/components/gamification/StarRating.tsx
Normal file
45
studio-v2/components/gamification/StarRating.tsx
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
interface StarRatingProps {
|
||||||
|
stars: number // 0-3
|
||||||
|
total?: number // total stars earned (shown as badge)
|
||||||
|
size?: 'sm' | 'md' | 'lg'
|
||||||
|
animated?: boolean
|
||||||
|
showLabel?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeMap = { sm: 'text-lg', md: 'text-2xl', lg: 'text-4xl' }
|
||||||
|
|
||||||
|
export function StarRating({ stars, total, size = 'md', animated = false, showLabel = false }: StarRatingProps) {
|
||||||
|
return (
|
||||||
|
<div className="inline-flex items-center gap-1">
|
||||||
|
{[1, 2, 3].map((i) => (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className={`${sizeMap[size]} transition-all ${
|
||||||
|
i <= stars
|
||||||
|
? `text-yellow-400 ${animated ? 'animate-bounce' : ''}`
|
||||||
|
: 'text-gray-300 dark:text-gray-600'
|
||||||
|
}`}
|
||||||
|
style={animated && i <= stars ? { animationDelay: `${(i - 1) * 150}ms` } : undefined}
|
||||||
|
>
|
||||||
|
{i <= stars ? '\u2B50' : '\u2606'}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{total != null && showLabel && (
|
||||||
|
<span className="ml-1 text-sm font-bold text-yellow-500 tabular-nums">{total}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Calculate stars from accuracy percentage */
|
||||||
|
export function accuracyToStars(correct: number, total: number): number {
|
||||||
|
if (total === 0) return 0
|
||||||
|
const pct = (correct / total) * 100
|
||||||
|
if (pct >= 100) return 3
|
||||||
|
if (pct >= 70) return 2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import React, { useState, useCallback } from 'react'
|
import React, { useState, useCallback } from 'react'
|
||||||
|
import { SyllableBow, simpleSyllableSplit } from './SyllableBow'
|
||||||
|
|
||||||
interface FlashCardProps {
|
interface FlashCardProps {
|
||||||
front: string
|
front: string
|
||||||
@@ -11,6 +12,10 @@ interface FlashCardProps {
|
|||||||
onCorrect: () => void
|
onCorrect: () => void
|
||||||
onIncorrect: () => void
|
onIncorrect: () => void
|
||||||
isDark: boolean
|
isDark: boolean
|
||||||
|
syllablesFront?: string[]
|
||||||
|
syllablesBack?: string[]
|
||||||
|
ipaFront?: string
|
||||||
|
ipaBack?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const boxLabels = ['Neu', 'Gelernt', 'Gefestigt']
|
const boxLabels = ['Neu', 'Gelernt', 'Gefestigt']
|
||||||
@@ -25,6 +30,10 @@ export function FlashCard({
|
|||||||
onCorrect,
|
onCorrect,
|
||||||
onIncorrect,
|
onIncorrect,
|
||||||
isDark,
|
isDark,
|
||||||
|
syllablesFront,
|
||||||
|
syllablesBack,
|
||||||
|
ipaFront,
|
||||||
|
ipaBack,
|
||||||
}: FlashCardProps) {
|
}: FlashCardProps) {
|
||||||
const [isFlipped, setIsFlipped] = useState(false)
|
const [isFlipped, setIsFlipped] = useState(false)
|
||||||
|
|
||||||
@@ -69,10 +78,15 @@ export function FlashCard({
|
|||||||
<span className={`text-xs font-medium mb-4 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
<span className={`text-xs font-medium mb-4 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||||
ENGLISCH
|
ENGLISCH
|
||||||
</span>
|
</span>
|
||||||
|
{syllablesFront && syllablesFront.length > 0 ? (
|
||||||
|
<SyllableBow word={front} syllables={syllablesFront} isDark={isDark} size="md" />
|
||||||
|
) : (
|
||||||
<span className={`text-3xl font-bold text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
<span className={`text-3xl font-bold text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||||
{front}
|
{front}
|
||||||
</span>
|
</span>
|
||||||
<span className={`text-sm mt-6 ${isDark ? 'text-white/30' : 'text-slate-400'}`}>
|
)}
|
||||||
|
{ipaFront && <span className={`text-sm mt-2 ${isDark ? 'text-white/30' : 'text-slate-400'}`}>{ipaFront}</span>}
|
||||||
|
<span className={`text-sm mt-4 ${isDark ? 'text-white/30' : 'text-slate-400'}`}>
|
||||||
Klick zum Umdrehen
|
Klick zum Umdrehen
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -14,42 +14,66 @@ interface LearningUnit {
|
|||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface UnitProgress {
|
||||||
|
total_stars?: number
|
||||||
|
exercises?: Record<string, { completed?: number; correct?: number; incorrect?: number }>
|
||||||
|
}
|
||||||
|
|
||||||
interface UnitCardProps {
|
interface UnitCardProps {
|
||||||
unit: LearningUnit
|
unit: LearningUnit
|
||||||
|
progress?: UnitProgress | null
|
||||||
|
wordCount?: number
|
||||||
isDark: boolean
|
isDark: boolean
|
||||||
glassCard: string
|
glassCard: string
|
||||||
onDelete: (id: string) => void
|
onDelete: (id: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const exerciseTypes = [
|
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: 'flashcards', label: 'Karten', 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: '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: 'type', label: 'Tippen', 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' },
|
{ key: 'listen', label: 'Hoeren', icon: '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', color: 'from-green-500 to-emerald-500' },
|
||||||
|
{ key: 'match', label: 'Zuordnen', icon: 'M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1', color: 'from-indigo-500 to-violet-500' },
|
||||||
|
{ key: 'pronounce', label: 'Sprechen', icon: 'M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4M12 1a3 3 0 00-3 3v8a3 3 0 006 0V4a3 3 0 00-3-3z', color: 'from-rose-500 to-red-500' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export function UnitCard({ unit, isDark, glassCard, onDelete }: UnitCardProps) {
|
export function UnitCard({ unit, progress, wordCount, isDark, glassCard, onDelete }: UnitCardProps) {
|
||||||
const createdDate = new Date(unit.created_at).toLocaleDateString('de-DE', {
|
const createdDate = new Date(unit.created_at).toLocaleDateString('de-DE', {
|
||||||
day: '2-digit',
|
day: '2-digit', month: '2-digit', year: 'numeric',
|
||||||
month: '2-digit',
|
|
||||||
year: 'numeric',
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Calculate progress percentage from exercises
|
||||||
|
const totalCorrect = progress?.exercises
|
||||||
|
? Object.values(progress.exercises).reduce((s, e) => s + (e?.correct || 0), 0)
|
||||||
|
: 0
|
||||||
|
const totalAnswered = progress?.exercises
|
||||||
|
? Object.values(progress.exercises).reduce((s, e) => s + (e?.completed || 0), 0)
|
||||||
|
: 0
|
||||||
|
const progressPct = totalAnswered > 0 ? Math.round((totalCorrect / totalAnswered) * 100) : 0
|
||||||
|
const stars = progress?.total_stars || 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${glassCard} rounded-2xl p-6 transition-all hover:shadow-lg`}>
|
<div className={`${glassCard} rounded-2xl p-5 transition-all hover:shadow-lg`}>
|
||||||
<div className="flex items-start justify-between mb-4">
|
{/* Header */}
|
||||||
<div>
|
<div className="flex items-start justify-between mb-3">
|
||||||
<h3 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className={`text-lg font-semibold truncate ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||||
{unit.label}
|
{unit.label}
|
||||||
</h3>
|
</h3>
|
||||||
<p className={`text-sm mt-1 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
{stars > 0 && (
|
||||||
{unit.meta}
|
<span className="flex items-center gap-0.5 text-yellow-400 text-sm font-bold">
|
||||||
|
<span>⭐</span>{stars}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className={`text-sm mt-0.5 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||||
|
{wordCount ? `${wordCount} Woerter` : unit.meta} · {createdDate}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => onDelete(unit.id)}
|
onClick={() => onDelete(unit.id)}
|
||||||
className={`p-2 rounded-lg transition-colors ${isDark ? 'hover:bg-white/10 text-white/40 hover:text-red-400' : 'hover:bg-slate-100 text-slate-400 hover:text-red-500'}`}
|
className={`p-1.5 rounded-lg transition-colors ${isDark ? 'hover:bg-white/10 text-white/30 hover:text-red-400' : 'hover:bg-slate-100 text-slate-300 hover:text-red-500'}`}
|
||||||
title="Loeschen"
|
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
@@ -57,35 +81,36 @@ export function UnitCard({ unit, isDark, glassCard, onDelete }: UnitCardProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Exercise Type Buttons */}
|
{/* Progress Bar */}
|
||||||
<div className="flex flex-wrap gap-2">
|
{totalAnswered > 0 && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<div className={`h-2 rounded-full overflow-hidden ${isDark ? 'bg-white/10' : 'bg-slate-100'}`}>
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-gradient-to-r from-blue-500 to-green-500 transition-all duration-500"
|
||||||
|
style={{ width: `${progressPct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className={`text-xs mt-1 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||||
|
{progressPct}% · {totalCorrect}/{totalAnswered} richtig
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Exercise Buttons */}
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{exerciseTypes.map((ex) => (
|
{exerciseTypes.map((ex) => (
|
||||||
<Link
|
<Link
|
||||||
key={ex.key}
|
key={ex.key}
|
||||||
href={`/learn/${unit.id}/${ex.key}`}
|
href={`/learn/${unit.id}/${ex.key}`}
|
||||||
className={`flex items-center gap-2 px-4 py-2.5 rounded-xl text-sm font-medium text-white bg-gradient-to-r ${ex.color} hover:shadow-lg hover:scale-[1.02] transition-all`}
|
className={`flex items-center gap-1.5 px-3 py-2 rounded-xl text-xs font-medium text-white bg-gradient-to-r ${ex.color} hover:shadow-md hover:scale-[1.02] transition-all`}
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d={ex.icon} />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d={ex.icon} />
|
||||||
</svg>
|
</svg>
|
||||||
{ex.label}
|
{ex.label}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status */}
|
|
||||||
<div className={`flex items-center gap-3 mt-4 pt-3 border-t ${isDark ? 'border-white/10' : 'border-black/5'}`}>
|
|
||||||
<span className={`text-xs ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
|
||||||
Erstellt: {createdDate}
|
|
||||||
</span>
|
|
||||||
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
|
||||||
unit.status === 'qa_generated' || unit.status === 'mc_generated' || unit.status === 'cloze_generated'
|
|
||||||
? (isDark ? 'bg-green-500/20 text-green-300' : 'bg-green-100 text-green-700')
|
|
||||||
: (isDark ? 'bg-yellow-500/20 text-yellow-300' : 'bg-yellow-100 text-yellow-700')
|
|
||||||
}`}>
|
|
||||||
{unit.status === 'raw' ? 'Neu' : 'Module generiert'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export * from '../../../shared/types/companion'
|
export * from '@shared/types/companion'
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export * from '../../../../shared/types/klausur'
|
export * from '@shared/types/klausur'
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export * from '../../../../shared/types/ocr-labeling'
|
export * from '@shared/types/ocr-labeling'
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export * from '../../../../shared/types/klausur'
|
export * from '@shared/types/klausur'
|
||||||
|
|||||||
@@ -12,6 +12,6 @@ export type {
|
|||||||
VorabiturEHForm,
|
VorabiturEHForm,
|
||||||
EHTemplate,
|
EHTemplate,
|
||||||
DirektuploadForm,
|
DirektuploadForm,
|
||||||
} from '../../../shared/types/klausur'
|
} from '@shared/types/klausur'
|
||||||
|
|
||||||
export const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
|
export const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ export type {
|
|||||||
ExaminerWorkflow,
|
ExaminerWorkflow,
|
||||||
ActiveTab,
|
ActiveTab,
|
||||||
CriteriaScores,
|
CriteriaScores,
|
||||||
} from '../../../shared/types/klausur'
|
} from '@shared/types/klausur'
|
||||||
|
|
||||||
export {
|
export {
|
||||||
WORKFLOW_STATUS_LABELS,
|
WORKFLOW_STATUS_LABELS,
|
||||||
ROLE_LABELS,
|
ROLE_LABELS,
|
||||||
GRADE_LABELS,
|
GRADE_LABELS,
|
||||||
} from '../../../shared/types/klausur'
|
} from '@shared/types/klausur'
|
||||||
|
|
||||||
export const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
|
export const API_BASE = process.env.NEXT_PUBLIC_KLAUSUR_SERVICE_URL || 'http://localhost:8086'
|
||||||
|
|||||||
Reference in New Issue
Block a user