Add interactive learning modules MVP (Phases 1-3.1)
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 44s
CI / test-go-edu-search (push) Successful in 51s
CI / test-python-klausur (push) Failing after 2m44s
CI / test-python-agent-core (push) Successful in 33s
CI / test-nodejs-website (push) Successful in 34s
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 44s
CI / test-go-edu-search (push) Successful in 51s
CI / test-python-klausur (push) Failing after 2m44s
CI / test-python-agent-core (push) Successful in 33s
CI / test-nodejs-website (push) Successful in 34s
New feature: After OCR vocabulary extraction, users can generate interactive
learning modules (flashcards, quiz, type trainer) with one click.
Frontend (studio-v2):
- Fortune Sheet spreadsheet editor tab in vocab-worksheet
- "Lernmodule generieren" button in ExportTab
- /learn page with unit overview and exercise type cards
- /learn/[unitId]/flashcards — Flip-card trainer with Leitner spaced repetition
- /learn/[unitId]/quiz — Multiple choice quiz with explanations
- /learn/[unitId]/type — Type-in trainer with Levenshtein distance feedback
- AudioButton component using Web Speech API for EN+DE TTS
Backend (klausur-service):
- vocab_learn_bridge.py: Converts VocabularyEntry[] to analysis_data format
- POST /sessions/{id}/generate-learning-unit endpoint
Backend (backend-lehrer):
- generate-qa, generate-mc, generate-cloze endpoints on learning units
- get-qa/mc/cloze data retrieval endpoints
- Leitner progress update + next review items endpoints
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
189
studio-v2/app/learn/[unitId]/flashcards/page.tsx
Normal file
189
studio-v2/app/learn/[unitId]/flashcards/page.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { FlashCard } from '@/components/learn/FlashCard'
|
||||
import { AudioButton } from '@/components/learn/AudioButton'
|
||||
|
||||
interface QAItem {
|
||||
id: string
|
||||
question: string
|
||||
answer: string
|
||||
leitner_box: number
|
||||
correct_count: number
|
||||
incorrect_count: number
|
||||
}
|
||||
|
||||
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`
|
||||
}
|
||||
|
||||
export default function FlashcardsPage() {
|
||||
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 [error, setError] = useState<string | null>(null)
|
||||
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(() => {
|
||||
loadQA()
|
||||
}, [unitId])
|
||||
|
||||
const loadQA = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const resp = await fetch(`${getBackendUrl()}/api/learning-units/${unitId}/qa`)
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
setItems(data.qa_items || [])
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAnswer = useCallback(async (correct: boolean) => {
|
||||
const item = items[currentIndex]
|
||||
if (!item) return
|
||||
|
||||
// Update Leitner progress
|
||||
try {
|
||||
await fetch(
|
||||
`${getBackendUrl()}/api/learning-units/${unitId}/leitner/update?item_id=${item.id}&correct=${correct}`,
|
||||
{ method: 'POST' }
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('Leitner update failed:', err)
|
||||
}
|
||||
|
||||
setStats((prev) => ({
|
||||
correct: prev.correct + (correct ? 1 : 0),
|
||||
incorrect: prev.incorrect + (correct ? 0 : 1),
|
||||
}))
|
||||
|
||||
if (currentIndex + 1 >= items.length) {
|
||||
setIsComplete(true)
|
||||
} else {
|
||||
setCurrentIndex((i) => i + 1)
|
||||
}
|
||||
}, [items, currentIndex, unitId])
|
||||
|
||||
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-blue-50 to-cyan-100'}`}>
|
||||
<div className={`w-8 h-8 border-4 ${isDark ? 'border-blue-400' : 'border-blue-600'} border-t-transparent rounded-full animate-spin`} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
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-blue-50 to-cyan-100'}`}>
|
||||
<div className={`${glassCard} rounded-2xl p-8 text-center max-w-md`}>
|
||||
<p className={isDark ? 'text-red-300' : 'text-red-600'}>Fehler: {error}</p>
|
||||
<button onClick={() => router.push('/learn')} className="mt-4 px-4 py-2 rounded-xl bg-blue-500 text-white text-sm">
|
||||
Zurueck
|
||||
</button>
|
||||
</div>
|
||||
</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-blue-50 to-cyan-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'}`}>
|
||||
Karteikarten
|
||||
</h1>
|
||||
<span className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||
{items.length} Karten
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<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`}>
|
||||
<div className="text-5xl mb-4">
|
||||
{stats.correct > stats.incorrect ? '🎉' : '💪'}
|
||||
</div>
|
||||
<h2 className={`text-2xl font-bold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Geschafft!
|
||||
</h2>
|
||||
<div className={`flex justify-center gap-8 mb-6 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
|
||||
<div>
|
||||
<span className="text-3xl font-bold text-green-500">{stats.correct}</span>
|
||||
<p className="text-sm mt-1">Richtig</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-3xl font-bold text-red-500">{stats.incorrect}</span>
|
||||
<p className="text-sm mt-1">Falsch</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => { setCurrentIndex(0); setStats({ correct: 0, incorrect: 0 }); setIsComplete(false); loadQA() }}
|
||||
className="flex-1 py-3 rounded-xl bg-gradient-to-r from-blue-500 to-cyan-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>
|
||||
) : items.length > 0 ? (
|
||||
<div className="w-full max-w-lg">
|
||||
<FlashCard
|
||||
front={items[currentIndex].question}
|
||||
back={items[currentIndex].answer}
|
||||
cardNumber={currentIndex + 1}
|
||||
totalCards={items.length}
|
||||
leitnerBox={items[currentIndex].leitner_box}
|
||||
onCorrect={() => handleAnswer(true)}
|
||||
onIncorrect={() => handleAnswer(false)}
|
||||
isDark={isDark}
|
||||
/>
|
||||
{/* Audio Button */}
|
||||
<div className="flex justify-center mt-4">
|
||||
<AudioButton text={items[currentIndex].question} lang="en" isDark={isDark} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`${glassCard} rounded-2xl p-8 text-center`}>
|
||||
<p className={isDark ? 'text-white/60' : 'text-slate-500'}>Keine Karteikarten verfuegbar.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
160
studio-v2/app/learn/[unitId]/quiz/page.tsx
Normal file
160
studio-v2/app/learn/[unitId]/quiz/page.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { QuizQuestion } from '@/components/learn/QuizQuestion'
|
||||
|
||||
interface MCQuestion {
|
||||
id: string
|
||||
question: string
|
||||
options: { id: string; text: string }[]
|
||||
correct_answer: string
|
||||
explanation?: string
|
||||
}
|
||||
|
||||
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`
|
||||
}
|
||||
|
||||
export default function QuizPage() {
|
||||
const { unitId } = useParams<{ unitId: string }>()
|
||||
const router = useRouter()
|
||||
const { isDark } = useTheme()
|
||||
|
||||
const [questions, setQuestions] = useState<MCQuestion[]>([])
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
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(() => {
|
||||
loadMC()
|
||||
}, [unitId])
|
||||
|
||||
const loadMC = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const resp = await fetch(`${getBackendUrl()}/api/learning-units/${unitId}/mc`)
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
setQuestions(data.questions || [])
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAnswer = useCallback((correct: boolean) => {
|
||||
setStats((prev) => ({
|
||||
correct: prev.correct + (correct ? 1 : 0),
|
||||
incorrect: prev.incorrect + (correct ? 0 : 1),
|
||||
}))
|
||||
|
||||
if (currentIndex + 1 >= questions.length) {
|
||||
setIsComplete(true)
|
||||
} else {
|
||||
setCurrentIndex((i) => i + 1)
|
||||
}
|
||||
}, [currentIndex, questions.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-blue-50 to-cyan-100'}`}>
|
||||
<div className={`w-8 h-8 border-4 ${isDark ? 'border-purple-400' : 'border-purple-600'} 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-blue-50 to-cyan-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'}`}>
|
||||
Quiz
|
||||
</h1>
|
||||
<span className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||
{questions.length} Fragen
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 flex items-center justify-center px-6 py-8">
|
||||
{error ? (
|
||||
<div className={`${glassCard} rounded-2xl p-8 text-center max-w-md`}>
|
||||
<p className={isDark ? 'text-red-300' : 'text-red-600'}>{error}</p>
|
||||
<button onClick={() => router.push('/learn')} className="mt-4 px-4 py-2 rounded-xl bg-purple-500 text-white text-sm">
|
||||
Zurueck
|
||||
</button>
|
||||
</div>
|
||||
) : isComplete ? (
|
||||
<div className={`${glassCard} rounded-3xl p-10 text-center max-w-md w-full`}>
|
||||
<div className="text-5xl mb-4">
|
||||
{stats.correct === questions.length ? '🏆' : stats.correct > stats.incorrect ? '🎉' : '💪'}
|
||||
</div>
|
||||
<h2 className={`text-2xl font-bold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{stats.correct === questions.length ? 'Perfekt!' : 'Geschafft!'}
|
||||
</h2>
|
||||
<p className={`text-lg mb-4 ${isDark ? 'text-white/70' : 'text-slate-600'}`}>
|
||||
{stats.correct} von {questions.length} richtig
|
||||
({Math.round((stats.correct / questions.length) * 100)}%)
|
||||
</p>
|
||||
<div className="w-full h-3 rounded-full bg-white/10 overflow-hidden mb-6">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-purple-500 to-pink-500"
|
||||
style={{ width: `${(stats.correct / questions.length) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => { setCurrentIndex(0); setStats({ correct: 0, incorrect: 0 }); setIsComplete(false); loadMC() }}
|
||||
className="flex-1 py-3 rounded-xl bg-gradient-to-r from-purple-500 to-pink-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>
|
||||
) : questions[currentIndex] ? (
|
||||
<QuizQuestion
|
||||
question={questions[currentIndex].question}
|
||||
options={questions[currentIndex].options}
|
||||
correctAnswer={questions[currentIndex].correct_answer}
|
||||
explanation={questions[currentIndex].explanation}
|
||||
questionNumber={currentIndex + 1}
|
||||
totalQuestions={questions.length}
|
||||
onAnswer={handleAnswer}
|
||||
isDark={isDark}
|
||||
/>
|
||||
) : (
|
||||
<p className={isDark ? 'text-white/60' : 'text-slate-500'}>Keine Quiz-Fragen verfuegbar.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
194
studio-v2/app/learn/[unitId]/type/page.tsx
Normal file
194
studio-v2/app/learn/[unitId]/type/page.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { TypeInput } from '@/components/learn/TypeInput'
|
||||
import { AudioButton } from '@/components/learn/AudioButton'
|
||||
|
||||
interface QAItem {
|
||||
id: string
|
||||
question: string
|
||||
answer: string
|
||||
leitner_box: number
|
||||
}
|
||||
|
||||
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`
|
||||
}
|
||||
|
||||
export default function TypePage() {
|
||||
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 [error, setError] = useState<string | null>(null)
|
||||
const [stats, setStats] = useState({ correct: 0, incorrect: 0 })
|
||||
const [isComplete, setIsComplete] = useState(false)
|
||||
const [direction, setDirection] = useState<'en_to_de' | 'de_to_en'>('en_to_de')
|
||||
|
||||
const glassCard = isDark
|
||||
? 'bg-white/10 backdrop-blur-xl border border-white/10'
|
||||
: 'bg-white/80 backdrop-blur-xl border border-black/5'
|
||||
|
||||
useEffect(() => {
|
||||
loadQA()
|
||||
}, [unitId])
|
||||
|
||||
const loadQA = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const resp = await fetch(`${getBackendUrl()}/api/learning-units/${unitId}/qa`)
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
setItems(data.qa_items || [])
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleResult = useCallback(async (correct: boolean) => {
|
||||
const item = items[currentIndex]
|
||||
if (!item) return
|
||||
|
||||
try {
|
||||
await fetch(
|
||||
`${getBackendUrl()}/api/learning-units/${unitId}/leitner/update?item_id=${item.id}&correct=${correct}`,
|
||||
{ method: 'POST' }
|
||||
)
|
||||
} catch (err) {
|
||||
console.error('Leitner update failed:', err)
|
||||
}
|
||||
|
||||
setStats((prev) => ({
|
||||
correct: prev.correct + (correct ? 1 : 0),
|
||||
incorrect: prev.incorrect + (correct ? 0 : 1),
|
||||
}))
|
||||
|
||||
if (currentIndex + 1 >= items.length) {
|
||||
setIsComplete(true)
|
||||
} else {
|
||||
setCurrentIndex((i) => i + 1)
|
||||
}
|
||||
}, [items, currentIndex, unitId])
|
||||
|
||||
const currentItem = items[currentIndex]
|
||||
const prompt = currentItem
|
||||
? (direction === 'en_to_de' ? currentItem.question : currentItem.answer)
|
||||
: ''
|
||||
const answer = currentItem
|
||||
? (direction === 'en_to_de' ? currentItem.answer : currentItem.question)
|
||||
: ''
|
||||
|
||||
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-blue-50 to-cyan-100'}`}>
|
||||
<div className={`w-8 h-8 border-4 ${isDark ? 'border-blue-400' : 'border-blue-600'} 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-blue-50 to-cyan-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'}`}>
|
||||
Eintippen
|
||||
</h1>
|
||||
{/* Direction toggle */}
|
||||
<button
|
||||
onClick={() => setDirection((d) => d === 'en_to_de' ? 'de_to_en' : 'en_to_de')}
|
||||
className={`text-xs px-3 py-1.5 rounded-lg ${isDark ? 'bg-white/10 text-white/70' : 'bg-slate-100 text-slate-600'}`}
|
||||
>
|
||||
{direction === 'en_to_de' ? 'EN → DE' : 'DE → EN'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<div className="w-full h-1 bg-white/10">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-blue-500 to-cyan-500 transition-all"
|
||||
style={{ width: `${((currentIndex) / Math.max(items.length, 1)) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 flex items-center justify-center px-6 py-8">
|
||||
{error ? (
|
||||
<div className={`${glassCard} rounded-2xl p-8 text-center max-w-md`}>
|
||||
<p className={isDark ? 'text-red-300' : 'text-red-600'}>{error}</p>
|
||||
</div>
|
||||
) : isComplete ? (
|
||||
<div className={`${glassCard} rounded-3xl p-10 text-center max-w-md w-full`}>
|
||||
<div className="text-5xl mb-4">
|
||||
{stats.correct > stats.incorrect ? '🎉' : '💪'}
|
||||
</div>
|
||||
<h2 className={`text-2xl font-bold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Geschafft!
|
||||
</h2>
|
||||
<div className={`flex justify-center gap-8 mb-6 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
|
||||
<div>
|
||||
<span className="text-3xl font-bold text-green-500">{stats.correct}</span>
|
||||
<p className="text-sm mt-1">Richtig</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-3xl font-bold text-red-500">{stats.incorrect}</span>
|
||||
<p className="text-sm mt-1">Falsch</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => { setCurrentIndex(0); setStats({ correct: 0, incorrect: 0 }); setIsComplete(false); loadQA() }}
|
||||
className="flex-1 py-3 rounded-xl bg-gradient-to-r from-blue-500 to-cyan-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-4">
|
||||
<div className="flex justify-center">
|
||||
<AudioButton text={prompt} lang={direction === 'en_to_de' ? 'en' : 'de'} isDark={isDark} />
|
||||
</div>
|
||||
<TypeInput
|
||||
prompt={prompt}
|
||||
answer={answer}
|
||||
onResult={handleResult}
|
||||
isDark={isDark}
|
||||
/>
|
||||
<p className={`text-center text-sm ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
|
||||
{currentIndex + 1} / {items.length}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className={isDark ? 'text-white/60' : 'text-slate-500'}>Keine Vokabeln verfuegbar.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
164
studio-v2/app/learn/page.tsx
Normal file
164
studio-v2/app/learn/page.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { Sidebar } from '@/components/Sidebar'
|
||||
import { UnitCard } from '@/components/learn/UnitCard'
|
||||
|
||||
interface LearningUnit {
|
||||
id: string
|
||||
label: string
|
||||
meta: string
|
||||
title: string
|
||||
topic: string | null
|
||||
grade_level: string | null
|
||||
status: string
|
||||
vocabulary_count?: number
|
||||
created_at: string
|
||||
}
|
||||
|
||||
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`
|
||||
}
|
||||
|
||||
export default function LearnPage() {
|
||||
const { isDark } = useTheme()
|
||||
const [units, setUnits] = useState<LearningUnit[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const glassCard = isDark
|
||||
? 'bg-white/10 backdrop-blur-xl border border-white/10'
|
||||
: 'bg-white/80 backdrop-blur-xl border border-black/5'
|
||||
|
||||
useEffect(() => {
|
||||
loadUnits()
|
||||
}, [])
|
||||
|
||||
const loadUnits = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const resp = await fetch(`${getBackendUrl()}/api/learning-units/`)
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const data = await resp.json()
|
||||
setUnits(data)
|
||||
} catch (err: any) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (unitId: string) => {
|
||||
try {
|
||||
const resp = await fetch(`${getBackendUrl()}/api/learning-units/${unitId}`, { method: 'DELETE' })
|
||||
if (resp.ok) {
|
||||
setUnits((prev) => prev.filter((u) => u.id !== unitId))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Delete failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`min-h-screen flex relative overflow-hidden ${
|
||||
isDark
|
||||
? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800'
|
||||
: 'bg-gradient-to-br from-slate-100 via-blue-50 to-cyan-100'
|
||||
}`}>
|
||||
{/* Background Blobs */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className={`absolute -top-40 -right-40 w-80 h-80 rounded-full mix-blend-multiply filter blur-3xl animate-pulse ${
|
||||
isDark ? 'bg-blue-500 opacity-50' : 'bg-blue-300 opacity-30'
|
||||
}`} />
|
||||
<div className={`absolute -bottom-40 -left-40 w-80 h-80 rounded-full mix-blend-multiply filter blur-3xl animate-pulse ${
|
||||
isDark ? 'bg-cyan-500 opacity-50' : 'bg-cyan-300 opacity-30'
|
||||
}`} style={{ animationDelay: '2s' }} />
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="relative z-10 p-4">
|
||||
<Sidebar />
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col relative z-10 overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className={`${glassCard} border-0 border-b`}>
|
||||
<div className="max-w-5xl mx-auto px-6 py-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${
|
||||
isDark ? 'bg-blue-500/30' : 'bg-blue-200'
|
||||
}`}>
|
||||
<svg className={`w-6 h-6 ${isDark ? 'text-blue-300' : 'text-blue-600'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="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" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className={`text-xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Meine Lernmodule
|
||||
</h1>
|
||||
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
Karteikarten, Quiz und Lueckentexte aus deinen Vokabeln
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="max-w-5xl mx-auto w-full px-6 py-6">
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className={`w-8 h-8 border-4 ${isDark ? 'border-blue-400' : 'border-blue-600'} border-t-transparent rounded-full animate-spin`} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className={`${glassCard} rounded-2xl p-6 text-center`}>
|
||||
<p className={`${isDark ? 'text-red-300' : 'text-red-600'}`}>Fehler: {error}</p>
|
||||
<button onClick={loadUnits} className="mt-3 px-4 py-2 rounded-xl bg-blue-500 text-white text-sm">
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && units.length === 0 && (
|
||||
<div className={`${glassCard} rounded-2xl p-12 text-center`}>
|
||||
<div className={`w-16 h-16 mx-auto mb-4 rounded-2xl flex items-center justify-center ${
|
||||
isDark ? 'bg-blue-500/20' : 'bg-blue-100'
|
||||
}`}>
|
||||
<svg className={`w-8 h-8 ${isDark ? 'text-blue-300' : 'text-blue-600'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="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" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className={`text-lg font-semibold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Noch keine Lernmodule
|
||||
</h3>
|
||||
<p className={`text-sm mb-4 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
Scanne eine Schulbuchseite im Vokabel-Arbeitsblatt Generator und klicke "Lernmodule generieren".
|
||||
</p>
|
||||
<a
|
||||
href="/vocab-worksheet"
|
||||
className="inline-block px-6 py-3 rounded-xl bg-gradient-to-r from-blue-500 to-cyan-500 text-white font-medium hover:shadow-lg transition-all"
|
||||
>
|
||||
Zum Vokabel-Scanner
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && units.length > 0 && (
|
||||
<div className="grid gap-4">
|
||||
{units.map((unit) => (
|
||||
<UnitCard key={unit.id} unit={unit} isDark={isDark} glassCard={glassCard} onDelete={handleDelete} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,57 +1,153 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import type { VocabWorksheetHook } from '../types'
|
||||
import { getApiBase } from '../constants'
|
||||
|
||||
export function ExportTab({ h }: { h: VocabWorksheetHook }) {
|
||||
const { isDark, glassCard } = h
|
||||
const router = useRouter()
|
||||
|
||||
const [isGeneratingLearning, setIsGeneratingLearning] = useState(false)
|
||||
const [learningUnitId, setLearningUnitId] = useState<string | null>(null)
|
||||
const [learningError, setLearningError] = useState<string | null>(null)
|
||||
|
||||
const handleGenerateLearningUnit = async () => {
|
||||
if (!h.session) return
|
||||
setIsGeneratingLearning(true)
|
||||
setLearningError(null)
|
||||
|
||||
try {
|
||||
const apiBase = getApiBase()
|
||||
const resp = await fetch(`${apiBase}/api/v1/vocab/sessions/${h.session.id}/generate-learning-unit`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ generate_modules: true }),
|
||||
})
|
||||
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}))
|
||||
throw new Error(err.detail || `HTTP ${resp.status}`)
|
||||
}
|
||||
|
||||
const result = await resp.json()
|
||||
setLearningUnitId(result.unit_id)
|
||||
} catch (err: any) {
|
||||
setLearningError(err.message || 'Fehler bei der Generierung')
|
||||
} finally {
|
||||
setIsGeneratingLearning(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${glassCard} rounded-2xl p-6`}>
|
||||
<h2 className={`text-lg font-semibold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>PDF herunterladen</h2>
|
||||
<div className="space-y-6">
|
||||
{/* PDF Download Section */}
|
||||
<div className={`${glassCard} rounded-2xl p-6`}>
|
||||
<h2 className={`text-lg font-semibold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>PDF herunterladen</h2>
|
||||
|
||||
{h.worksheetId ? (
|
||||
<div className="space-y-4">
|
||||
<div className={`p-4 rounded-xl ${isDark ? 'bg-green-500/20 border border-green-500/30' : 'bg-green-100 border border-green-200'}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<svg className="w-6 h-6 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className={`font-medium ${isDark ? 'text-green-200' : 'text-green-700'}`}>Arbeitsblatt erfolgreich generiert!</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<button onClick={() => h.downloadPDF('worksheet')} className={`${glassCard} p-6 rounded-xl text-left transition-all hover:shadow-lg ${isDark ? 'hover:border-purple-400/50' : 'hover:border-purple-500'}`}>
|
||||
<div className={`w-12 h-12 mb-3 rounded-xl flex items-center justify-center ${isDark ? 'bg-purple-500/30' : 'bg-purple-100'}`}>
|
||||
<svg className={`w-6 h-6 ${isDark ? 'text-purple-300' : 'text-purple-600'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
{h.worksheetId ? (
|
||||
<div className="space-y-4">
|
||||
<div className={`p-4 rounded-xl ${isDark ? 'bg-green-500/20 border border-green-500/30' : 'bg-green-100 border border-green-200'}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<svg className="w-6 h-6 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className={`font-medium ${isDark ? 'text-green-200' : 'text-green-700'}`}>Arbeitsblatt erfolgreich generiert!</span>
|
||||
</div>
|
||||
<h3 className={`font-semibold mb-1 ${isDark ? 'text-white' : 'text-slate-900'}`}>Arbeitsblatt</h3>
|
||||
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>PDF zum Ausdrucken</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{h.includeSolutions && (
|
||||
<button onClick={() => h.downloadPDF('solution')} className={`${glassCard} p-6 rounded-xl text-left transition-all hover:shadow-lg ${isDark ? 'hover:border-green-400/50' : 'hover:border-green-500'}`}>
|
||||
<div className={`w-12 h-12 mb-3 rounded-xl flex items-center justify-center ${isDark ? 'bg-green-500/30' : 'bg-green-100'}`}>
|
||||
<svg className={`w-6 h-6 ${isDark ? 'text-green-300' : 'text-green-600'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<button onClick={() => h.downloadPDF('worksheet')} className={`${glassCard} p-6 rounded-xl text-left transition-all hover:shadow-lg ${isDark ? 'hover:border-purple-400/50' : 'hover:border-purple-500'}`}>
|
||||
<div className={`w-12 h-12 mb-3 rounded-xl flex items-center justify-center ${isDark ? 'bg-purple-500/30' : 'bg-purple-100'}`}>
|
||||
<svg className={`w-6 h-6 ${isDark ? 'text-purple-300' : 'text-purple-600'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className={`font-semibold mb-1 ${isDark ? 'text-white' : 'text-slate-900'}`}>Loesungsblatt</h3>
|
||||
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>PDF mit Loesungen</p>
|
||||
<h3 className={`font-semibold mb-1 ${isDark ? 'text-white' : 'text-slate-900'}`}>Arbeitsblatt</h3>
|
||||
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>PDF zum Ausdrucken</p>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button onClick={h.resetSession} className={`w-full py-3 rounded-xl border font-medium transition-colors ${isDark ? 'border-white/20 text-white/80 hover:bg-white/10' : 'border-slate-300 text-slate-700 hover:bg-slate-50'}`}>
|
||||
Neues Arbeitsblatt erstellen
|
||||
{h.includeSolutions && (
|
||||
<button onClick={() => h.downloadPDF('solution')} className={`${glassCard} p-6 rounded-xl text-left transition-all hover:shadow-lg ${isDark ? 'hover:border-green-400/50' : 'hover:border-green-500'}`}>
|
||||
<div className={`w-12 h-12 mb-3 rounded-xl flex items-center justify-center ${isDark ? 'bg-green-500/30' : 'bg-green-100'}`}>
|
||||
<svg className={`w-6 h-6 ${isDark ? 'text-green-300' : 'text-green-600'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className={`font-semibold mb-1 ${isDark ? 'text-white' : 'text-slate-900'}`}>Loesungsblatt</h3>
|
||||
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>PDF mit Loesungen</p>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className={`text-center py-8 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>Noch kein Arbeitsblatt generiert.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Learning Module Generation Section */}
|
||||
<div className={`${glassCard} rounded-2xl p-6`}>
|
||||
<h2 className={`text-lg font-semibold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>Interaktive Lernmodule</h2>
|
||||
<p className={`text-sm mb-4 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
Aus den Vokabeln automatisch Karteikarten, Quiz und Lueckentexte erstellen.
|
||||
</p>
|
||||
|
||||
{learningError && (
|
||||
<div className={`p-3 rounded-xl mb-4 ${isDark ? 'bg-red-500/20 border border-red-500/30' : 'bg-red-100 border border-red-200'}`}>
|
||||
<p className={`text-sm ${isDark ? 'text-red-200' : 'text-red-700'}`}>{learningError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{learningUnitId ? (
|
||||
<div className="space-y-4">
|
||||
<div className={`p-4 rounded-xl ${isDark ? 'bg-blue-500/20 border border-blue-500/30' : 'bg-blue-100 border border-blue-200'}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<svg className="w-6 h-6 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
<span className={`font-medium ${isDark ? 'text-blue-200' : 'text-blue-700'}`}>Lernmodule wurden generiert!</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => router.push(`/learn/${learningUnitId}`)}
|
||||
className="w-full py-3 rounded-xl font-medium bg-gradient-to-r from-blue-500 to-cyan-500 text-white hover:shadow-lg transition-all"
|
||||
>
|
||||
Lernmodule oeffnen
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleGenerateLearningUnit}
|
||||
disabled={isGeneratingLearning || h.vocabulary.length === 0}
|
||||
className={`w-full py-4 rounded-xl font-medium transition-all ${
|
||||
isGeneratingLearning || h.vocabulary.length === 0
|
||||
? (isDark ? 'bg-white/5 text-white/30 cursor-not-allowed' : 'bg-slate-100 text-slate-400 cursor-not-allowed')
|
||||
: 'bg-gradient-to-r from-blue-500 to-cyan-500 text-white hover:shadow-lg hover:shadow-blue-500/25'
|
||||
}`}
|
||||
>
|
||||
{isGeneratingLearning ? (
|
||||
<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" />
|
||||
Lernmodule werden generiert...
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
Lernmodule generieren ({h.vocabulary.length} Vokabeln)
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p className={`text-center py-12 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>Noch kein Arbeitsblatt generiert.</p>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reset Button */}
|
||||
<button onClick={h.resetSession} className={`w-full py-3 rounded-xl border font-medium transition-colors ${isDark ? 'border-white/20 text-white/80 hover:bg-white/10' : 'border-slate-300 text-slate-700 hover:bg-slate-50'}`}>
|
||||
Neues Arbeitsblatt erstellen
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
157
studio-v2/app/vocab-worksheet/components/SpreadsheetTab.tsx
Normal file
157
studio-v2/app/vocab-worksheet/components/SpreadsheetTab.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* SpreadsheetTab — Fortune Sheet editor for vocabulary data.
|
||||
*
|
||||
* Converts VocabularyEntry[] into a Fortune Sheet workbook
|
||||
* where users can edit vocabulary in a familiar Excel-like UI.
|
||||
*/
|
||||
|
||||
import React, { useMemo, useCallback } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import type { VocabWorksheetHook } from '../types'
|
||||
|
||||
const Workbook = dynamic(
|
||||
() => import('@fortune-sheet/react').then((m) => m.Workbook),
|
||||
{ ssr: false, loading: () => <div className="py-8 text-center text-sm text-gray-400">Spreadsheet wird geladen...</div> },
|
||||
)
|
||||
|
||||
import '@fortune-sheet/react/dist/index.css'
|
||||
|
||||
/** Convert VocabularyEntry[] to Fortune Sheet sheet data */
|
||||
function vocabToSheet(vocabulary: VocabWorksheetHook['vocabulary']) {
|
||||
const headers = ['Englisch', 'Deutsch', 'Beispielsatz', 'Wortart', 'Seite']
|
||||
const numCols = headers.length
|
||||
const numRows = vocabulary.length + 1 // +1 for header
|
||||
|
||||
const celldata: any[] = []
|
||||
|
||||
// Header row
|
||||
headers.forEach((label, c) => {
|
||||
celldata.push({
|
||||
r: 0,
|
||||
c,
|
||||
v: { v: label, m: label, bl: 1, bg: '#f0f4ff', fc: '#1e293b' },
|
||||
})
|
||||
})
|
||||
|
||||
// Data rows
|
||||
vocabulary.forEach((entry, idx) => {
|
||||
const r = idx + 1
|
||||
celldata.push({ r, c: 0, v: { v: entry.english, m: entry.english } })
|
||||
celldata.push({ r, c: 1, v: { v: entry.german, m: entry.german } })
|
||||
celldata.push({ r, c: 2, v: { v: entry.example_sentence || '', m: entry.example_sentence || '' } })
|
||||
celldata.push({ r, c: 3, v: { v: entry.word_type || '', m: entry.word_type || '' } })
|
||||
celldata.push({ r, c: 4, v: { v: entry.source_page != null ? String(entry.source_page) : '', m: entry.source_page != null ? String(entry.source_page) : '' } })
|
||||
})
|
||||
|
||||
// Column widths
|
||||
const columnlen: Record<string, number> = {
|
||||
'0': 180, // Englisch
|
||||
'1': 180, // Deutsch
|
||||
'2': 280, // Beispielsatz
|
||||
'3': 100, // Wortart
|
||||
'4': 60, // Seite
|
||||
}
|
||||
|
||||
// Row heights
|
||||
const rowlen: Record<string, number> = {}
|
||||
rowlen['0'] = 28 // header
|
||||
|
||||
// Borders: light grid
|
||||
const borderInfo = numRows > 0 && numCols > 0 ? [{
|
||||
rangeType: 'range',
|
||||
borderType: 'border-all',
|
||||
color: '#e5e7eb',
|
||||
style: 1,
|
||||
range: [{ row: [0, numRows - 1], column: [0, numCols - 1] }],
|
||||
}] : []
|
||||
|
||||
return {
|
||||
name: 'Vokabeln',
|
||||
id: 'vocab_sheet',
|
||||
celldata,
|
||||
row: numRows,
|
||||
column: numCols,
|
||||
status: 1,
|
||||
config: {
|
||||
columnlen,
|
||||
rowlen,
|
||||
borderInfo,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function SpreadsheetTab({ h }: { h: VocabWorksheetHook }) {
|
||||
const { isDark, glassCard, vocabulary } = h
|
||||
|
||||
const sheets = useMemo(() => {
|
||||
if (!vocabulary || vocabulary.length === 0) return []
|
||||
return [vocabToSheet(vocabulary)]
|
||||
}, [vocabulary])
|
||||
|
||||
const estimatedHeight = Math.max(500, (vocabulary.length + 2) * 26 + 80)
|
||||
|
||||
const handleSaveFromSheet = useCallback(async () => {
|
||||
await h.saveVocabulary()
|
||||
}, [h])
|
||||
|
||||
if (vocabulary.length === 0) {
|
||||
return (
|
||||
<div className={`${glassCard} rounded-2xl p-6`}>
|
||||
<p className={`text-center py-12 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
Keine Vokabeln vorhanden. Bitte zuerst Seiten verarbeiten.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${glassCard} rounded-2xl p-4`}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Spreadsheet-Editor
|
||||
</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||
{vocabulary.length} Vokabeln
|
||||
</span>
|
||||
<button
|
||||
onClick={handleSaveFromSheet}
|
||||
className="px-4 py-2 rounded-xl text-sm font-medium bg-gradient-to-r from-purple-500 to-pink-500 text-white hover:shadow-lg transition-all"
|
||||
>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="rounded-xl overflow-hidden border"
|
||||
style={{
|
||||
borderColor: isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)',
|
||||
}}
|
||||
>
|
||||
{sheets.length > 0 && (
|
||||
<div style={{ width: '100%', height: `${estimatedHeight}px` }}>
|
||||
<Workbook
|
||||
data={sheets}
|
||||
lang="en"
|
||||
showToolbar
|
||||
showFormulaBar={false}
|
||||
showSheetTabs={false}
|
||||
toolbarItems={[
|
||||
'undo', 'redo', '|',
|
||||
'font-bold', 'font-italic', 'font-strikethrough', '|',
|
||||
'font-color', 'background', '|',
|
||||
'font-size', '|',
|
||||
'horizontal-align', 'vertical-align', '|',
|
||||
'text-wrap', '|',
|
||||
'border',
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { PageSelection } from './components/PageSelection'
|
||||
import { VocabularyTab } from './components/VocabularyTab'
|
||||
import { WorksheetTab } from './components/WorksheetTab'
|
||||
import { ExportTab } from './components/ExportTab'
|
||||
import { SpreadsheetTab } from './components/SpreadsheetTab'
|
||||
import { OcrSettingsPanel } from './components/OcrSettingsPanel'
|
||||
import { FullscreenPreview } from './components/FullscreenPreview'
|
||||
import { QRCodeModal } from './components/QRCodeModal'
|
||||
@@ -144,6 +145,7 @@ export default function VocabWorksheetPage() {
|
||||
{!session && <UploadScreen h={h} />}
|
||||
{session && activeTab === 'pages' && <PageSelection h={h} />}
|
||||
{session && activeTab === 'vocabulary' && <VocabularyTab h={h} />}
|
||||
{session && activeTab === 'spreadsheet' && <SpreadsheetTab h={h} />}
|
||||
{session && activeTab === 'worksheet' && <WorksheetTab h={h} />}
|
||||
{session && activeTab === 'export' && <ExportTab h={h} />}
|
||||
|
||||
@@ -151,7 +153,7 @@ export default function VocabWorksheetPage() {
|
||||
{session && activeTab !== 'pages' && (
|
||||
<div className={`mt-6 border-t pt-4 ${isDark ? 'border-white/10' : 'border-black/5'}`}>
|
||||
<div className="flex justify-center gap-2">
|
||||
{(['vocabulary', 'worksheet', 'export'] as TabId[]).map((tab) => (
|
||||
{(['vocabulary', 'spreadsheet', 'worksheet', 'export'] as TabId[]).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => h.setActiveTab(tab)}
|
||||
@@ -161,7 +163,7 @@ export default function VocabWorksheetPage() {
|
||||
: (isDark ? 'bg-white/10 text-white/80 hover:bg-white/20' : 'bg-slate-100 text-slate-700 hover:bg-slate-200')
|
||||
}`}
|
||||
>
|
||||
{tab === 'vocabulary' ? 'Vokabeln' : tab === 'worksheet' ? 'Arbeitsblatt' : 'Export'}
|
||||
{tab === 'vocabulary' ? 'Vokabeln' : tab === 'spreadsheet' ? 'Spreadsheet' : tab === 'worksheet' ? 'Arbeitsblatt' : 'Export'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -47,7 +47,7 @@ export interface OcrPrompts {
|
||||
footerPatterns: string[]
|
||||
}
|
||||
|
||||
export type TabId = 'upload' | 'pages' | 'vocabulary' | 'worksheet' | 'export' | 'settings'
|
||||
export type TabId = 'upload' | 'pages' | 'vocabulary' | 'spreadsheet' | 'worksheet' | 'export' | 'settings'
|
||||
export type WorksheetType = 'en_to_de' | 'de_to_en' | 'copy' | 'gap_fill'
|
||||
export type WorksheetFormat = 'standard' | 'nru'
|
||||
export type IpaMode = 'auto' | 'en' | 'de' | 'all' | 'none'
|
||||
|
||||
Reference in New Issue
Block a user