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

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:
Benjamin Admin
2026-04-16 07:13:23 +02:00
parent 4561320e0d
commit 20a0585eb1
17 changed files with 1991 additions and 40 deletions

View File

@@ -1,5 +1,9 @@
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
from datetime import datetime from datetime import datetime
from pathlib import Path
import json
import os
import logging
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
@@ -15,6 +19,8 @@ from learning_units import (
delete_learning_unit, delete_learning_unit,
) )
logger = logging.getLogger(__name__)
router = APIRouter( router = APIRouter(
prefix="/learning-units", prefix="/learning-units",
@@ -49,6 +55,11 @@ class RemoveWorksheetPayload(BaseModel):
worksheet_file: str worksheet_file: str
class GenerateFromAnalysisPayload(BaseModel):
analysis_data: Dict[str, Any]
num_questions: int = 8
# ---------- Hilfsfunktion: Backend-Modell -> Frontend-Objekt ---------- # ---------- Hilfsfunktion: Backend-Modell -> Frontend-Objekt ----------
@@ -195,3 +206,145 @@ def api_delete_learning_unit(unit_id: str):
raise HTTPException(status_code=404, detail="Lerneinheit nicht gefunden.") raise HTTPException(status_code=404, detail="Lerneinheit nicht gefunden.")
return {"status": "deleted", "id": unit_id} return {"status": "deleted", "id": unit_id}
# ---------- Generator-Endpunkte ----------
LERNEINHEITEN_DIR = os.path.expanduser("~/Arbeitsblaetter/Lerneinheiten")
def _save_analysis_and_get_path(unit_id: str, analysis_data: Dict[str, Any]) -> Path:
"""Save analysis_data to disk and return the path."""
os.makedirs(LERNEINHEITEN_DIR, exist_ok=True)
path = Path(LERNEINHEITEN_DIR) / f"{unit_id}_analyse.json"
with open(path, "w", encoding="utf-8") as f:
json.dump(analysis_data, f, ensure_ascii=False, indent=2)
return path
@router.post("/{unit_id}/generate-qa")
def api_generate_qa(unit_id: str, payload: GenerateFromAnalysisPayload):
"""Generate Q&A items with Leitner fields from analysis data."""
lu = get_learning_unit(unit_id)
if not lu:
raise HTTPException(status_code=404, detail="Lerneinheit nicht gefunden.")
analysis_path = _save_analysis_and_get_path(unit_id, payload.analysis_data)
try:
from ai_processing.qa_generator import generate_qa_from_analysis
qa_path = generate_qa_from_analysis(analysis_path, num_questions=payload.num_questions)
with open(qa_path, "r", encoding="utf-8") as f:
qa_data = json.load(f)
# Update unit status
update_learning_unit(unit_id, LearningUnitUpdate(status="qa_generated"))
logger.info(f"Generated QA for unit {unit_id}: {len(qa_data.get('qa_items', []))} items")
return qa_data
except Exception as e:
logger.error(f"QA generation failed for {unit_id}: {e}")
raise HTTPException(status_code=500, detail=f"QA-Generierung fehlgeschlagen: {e}")
@router.post("/{unit_id}/generate-mc")
def api_generate_mc(unit_id: str, payload: GenerateFromAnalysisPayload):
"""Generate multiple choice questions from analysis data."""
lu = get_learning_unit(unit_id)
if not lu:
raise HTTPException(status_code=404, detail="Lerneinheit nicht gefunden.")
analysis_path = _save_analysis_and_get_path(unit_id, payload.analysis_data)
try:
from ai_processing.mc_generator import generate_mc_from_analysis
mc_path = generate_mc_from_analysis(analysis_path, num_questions=payload.num_questions)
with open(mc_path, "r", encoding="utf-8") as f:
mc_data = json.load(f)
update_learning_unit(unit_id, LearningUnitUpdate(status="mc_generated"))
logger.info(f"Generated MC for unit {unit_id}: {len(mc_data.get('questions', []))} questions")
return mc_data
except Exception as e:
logger.error(f"MC generation failed for {unit_id}: {e}")
raise HTTPException(status_code=500, detail=f"MC-Generierung fehlgeschlagen: {e}")
@router.post("/{unit_id}/generate-cloze")
def api_generate_cloze(unit_id: str, payload: GenerateFromAnalysisPayload):
"""Generate cloze (fill-in-the-blank) items from analysis data."""
lu = get_learning_unit(unit_id)
if not lu:
raise HTTPException(status_code=404, detail="Lerneinheit nicht gefunden.")
analysis_path = _save_analysis_and_get_path(unit_id, payload.analysis_data)
try:
from ai_processing.cloze_generator import generate_cloze_from_analysis
cloze_path = generate_cloze_from_analysis(analysis_path)
with open(cloze_path, "r", encoding="utf-8") as f:
cloze_data = json.load(f)
update_learning_unit(unit_id, LearningUnitUpdate(status="cloze_generated"))
logger.info(f"Generated Cloze for unit {unit_id}: {len(cloze_data.get('cloze_items', []))} items")
return cloze_data
except Exception as e:
logger.error(f"Cloze generation failed for {unit_id}: {e}")
raise HTTPException(status_code=500, detail=f"Cloze-Generierung fehlgeschlagen: {e}")
@router.get("/{unit_id}/qa")
def api_get_qa(unit_id: str):
"""Get generated QA items for a unit."""
qa_path = Path(LERNEINHEITEN_DIR) / f"{unit_id}_qa.json"
if not qa_path.exists():
raise HTTPException(status_code=404, detail="Keine QA-Daten gefunden.")
with open(qa_path, "r", encoding="utf-8") as f:
return json.load(f)
@router.get("/{unit_id}/mc")
def api_get_mc(unit_id: str):
"""Get generated MC questions for a unit."""
mc_path = Path(LERNEINHEITEN_DIR) / f"{unit_id}_mc.json"
if not mc_path.exists():
raise HTTPException(status_code=404, detail="Keine MC-Daten gefunden.")
with open(mc_path, "r", encoding="utf-8") as f:
return json.load(f)
@router.get("/{unit_id}/cloze")
def api_get_cloze(unit_id: str):
"""Get generated cloze items for a unit."""
cloze_path = Path(LERNEINHEITEN_DIR) / f"{unit_id}_cloze.json"
if not cloze_path.exists():
raise HTTPException(status_code=404, detail="Keine Cloze-Daten gefunden.")
with open(cloze_path, "r", encoding="utf-8") as f:
return json.load(f)
@router.post("/{unit_id}/leitner/update")
def api_update_leitner(unit_id: str, item_id: str, correct: bool):
"""Update Leitner progress for a QA item."""
qa_path = Path(LERNEINHEITEN_DIR) / f"{unit_id}_qa.json"
if not qa_path.exists():
raise HTTPException(status_code=404, detail="Keine QA-Daten gefunden.")
try:
from ai_processing.qa_generator import update_leitner_progress
result = update_leitner_progress(qa_path, item_id, correct)
return result
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{unit_id}/leitner/next")
def api_get_next_review(unit_id: str, limit: int = 5):
"""Get next Leitner review items."""
qa_path = Path(LERNEINHEITEN_DIR) / f"{unit_id}_qa.json"
if not qa_path.exists():
raise HTTPException(status_code=404, detail="Keine QA-Daten gefunden.")
try:
from ai_processing.qa_generator import get_next_review_items
items = get_next_review_items(qa_path, limit=limit)
return {"items": items, "count": len(items)}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -0,0 +1,196 @@
"""
Vocab Learn Bridge — Converts vocabulary session data into Learning Units.
Bridges klausur-service (vocab extraction) with backend-lehrer (learning units + generators).
Creates a Learning Unit in backend-lehrer, then triggers MC/Cloze/QA generation.
DATENSCHUTZ: All communication stays within Docker network (breakpilot-network).
"""
import os
import json
import logging
import httpx
from typing import List, Dict, Any, Optional
logger = logging.getLogger(__name__)
BACKEND_LEHRER_URL = os.getenv("BACKEND_LEHRER_URL", "http://backend-lehrer:8001")
def vocab_to_analysis_data(session_name: str, vocabulary: List[Dict[str, Any]]) -> Dict[str, Any]:
"""
Convert vocabulary entries from a vocab session into the analysis_data format
expected by backend-lehrer generators (MC, Cloze, QA).
The generators consume:
- title: Display name
- subject: Subject area
- grade_level: Target grade
- canonical_text: Full text representation
- printed_blocks: Individual text blocks
- vocabulary: Original vocab data (for vocab-specific modules)
"""
canonical_lines = []
printed_blocks = []
for v in vocabulary:
en = v.get("english", "").strip()
de = v.get("german", "").strip()
example = v.get("example_sentence", "").strip()
if not en and not de:
continue
line = f"{en} = {de}"
if example:
line += f" ({example})"
canonical_lines.append(line)
block_text = f"{en}{de}"
if example:
block_text += f" | {example}"
printed_blocks.append({"text": block_text})
return {
"title": session_name,
"subject": "English Vocabulary",
"grade_level": "5-8",
"canonical_text": "\n".join(canonical_lines),
"printed_blocks": printed_blocks,
"vocabulary": vocabulary,
}
async def create_learning_unit(
session_name: str,
vocabulary: List[Dict[str, Any]],
grade: Optional[str] = None,
) -> Dict[str, Any]:
"""
Create a Learning Unit in backend-lehrer from vocabulary data.
Steps:
1. Create unit via POST /api/learning-units/
2. Return the created unit info
Returns dict with unit_id, status, vocabulary_count.
"""
if not vocabulary:
raise ValueError("No vocabulary entries provided")
analysis_data = vocab_to_analysis_data(session_name, vocabulary)
async with httpx.AsyncClient(timeout=30.0) as client:
# 1. Create Learning Unit
create_payload = {
"title": session_name,
"subject": "Englisch",
"grade": grade or "5-8",
}
try:
resp = await client.post(
f"{BACKEND_LEHRER_URL}/api/learning-units/",
json=create_payload,
)
resp.raise_for_status()
unit = resp.json()
except httpx.HTTPError as e:
logger.error(f"Failed to create learning unit: {e}")
raise RuntimeError(f"Backend-Lehrer nicht erreichbar: {e}")
unit_id = unit.get("id")
if not unit_id:
raise RuntimeError("Learning Unit created but no ID returned")
logger.info(f"Created learning unit {unit_id} with {len(vocabulary)} vocabulary entries")
# 2. Save analysis_data as JSON file for generators
analysis_dir = os.path.expanduser("~/Arbeitsblaetter/Lerneinheiten")
os.makedirs(analysis_dir, exist_ok=True)
analysis_path = os.path.join(analysis_dir, f"{unit_id}_analyse.json")
with open(analysis_path, "w", encoding="utf-8") as f:
json.dump(analysis_data, f, ensure_ascii=False, indent=2)
logger.info(f"Saved analysis data to {analysis_path}")
return {
"unit_id": unit_id,
"unit": unit,
"analysis_path": analysis_path,
"vocabulary_count": len(vocabulary),
"status": "created",
}
async def generate_learning_modules(
unit_id: str,
analysis_path: str,
) -> Dict[str, Any]:
"""
Trigger MC, Cloze, and QA generation from analysis data.
Imports generators directly (they run in-process for klausur-service)
or calls backend-lehrer API if generators aren't available locally.
Returns dict with generation results.
"""
results = {
"unit_id": unit_id,
"mc": {"status": "pending"},
"cloze": {"status": "pending"},
"qa": {"status": "pending"},
}
# Load analysis data
with open(analysis_path, "r", encoding="utf-8") as f:
analysis_data = json.load(f)
# Try to generate via backend-lehrer API
async with httpx.AsyncClient(timeout=120.0) as client:
# Generate QA (includes Leitner fields)
try:
resp = await client.post(
f"{BACKEND_LEHRER_URL}/api/learning-units/{unit_id}/generate-qa",
json={"analysis_data": analysis_data, "num_questions": min(len(analysis_data.get("vocabulary", [])), 20)},
)
if resp.status_code == 200:
results["qa"] = {"status": "generated", "data": resp.json()}
else:
logger.warning(f"QA generation returned {resp.status_code}")
results["qa"] = {"status": "skipped", "reason": f"HTTP {resp.status_code}"}
except Exception as e:
logger.warning(f"QA generation failed: {e}")
results["qa"] = {"status": "error", "reason": str(e)}
# Generate MC
try:
resp = await client.post(
f"{BACKEND_LEHRER_URL}/api/learning-units/{unit_id}/generate-mc",
json={"analysis_data": analysis_data, "num_questions": min(len(analysis_data.get("vocabulary", [])), 10)},
)
if resp.status_code == 200:
results["mc"] = {"status": "generated", "data": resp.json()}
else:
results["mc"] = {"status": "skipped", "reason": f"HTTP {resp.status_code}"}
except Exception as e:
logger.warning(f"MC generation failed: {e}")
results["mc"] = {"status": "error", "reason": str(e)}
# Generate Cloze
try:
resp = await client.post(
f"{BACKEND_LEHRER_URL}/api/learning-units/{unit_id}/generate-cloze",
json={"analysis_data": analysis_data},
)
if resp.status_code == 200:
results["cloze"] = {"status": "generated", "data": resp.json()}
else:
results["cloze"] = {"status": "skipped", "reason": f"HTTP {resp.status_code}"}
except Exception as e:
logger.warning(f"Cloze generation failed: {e}")
results["cloze"] = {"status": "error", "reason": str(e)}
return results

View File

@@ -2677,3 +2677,66 @@ async def load_ground_truth(session_id: str, page_number: int):
gt_data = json.load(f) gt_data = json.load(f)
return {"success": True, "entries": gt_data.get("entries", []), "source": "disk"} return {"success": True, "entries": gt_data.get("entries", []), "source": "disk"}
# ─── Learning Module Generation ─────────────────────────────────────────────
class GenerateLearningUnitRequest(BaseModel):
grade: Optional[str] = None
generate_modules: bool = True
@router.post("/sessions/{session_id}/generate-learning-unit")
async def generate_learning_unit_endpoint(session_id: str, request: GenerateLearningUnitRequest = None):
"""
Create a Learning Unit from the vocabulary in this session.
1. Takes vocabulary from the session
2. Creates a Learning Unit in backend-lehrer
3. Optionally triggers MC/Cloze/QA generation
Returns the created unit info and generation status.
"""
if request is None:
request = GenerateLearningUnitRequest()
if session_id not in _sessions:
raise HTTPException(status_code=404, detail="Session not found")
session = _sessions[session_id]
vocabulary = session.get("vocabulary", [])
if not vocabulary:
raise HTTPException(status_code=400, detail="No vocabulary in this session")
try:
from vocab_learn_bridge import create_learning_unit, generate_learning_modules
# Step 1: Create Learning Unit
result = await create_learning_unit(
session_name=session["name"],
vocabulary=vocabulary,
grade=request.grade,
)
# Step 2: Generate modules if requested
if request.generate_modules:
try:
gen_result = await generate_learning_modules(
unit_id=result["unit_id"],
analysis_path=result["analysis_path"],
)
result["generation"] = gen_result
except Exception as e:
logger.warning(f"Module generation failed (unit created): {e}")
result["generation"] = {"status": "error", "reason": str(e)}
return result
except ImportError:
raise HTTPException(status_code=501, detail="vocab_learn_bridge module not available")
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except RuntimeError as e:
raise HTTPException(status_code=502, detail=str(e))

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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 &quot;Lernmodule generieren&quot;.
</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>
)
}

View File

@@ -1,57 +1,153 @@
'use client' 'use client'
import React from 'react' import React, { useState } from 'react'
import { useRouter } from 'next/navigation'
import type { VocabWorksheetHook } from '../types' import type { VocabWorksheetHook } from '../types'
import { getApiBase } from '../constants'
export function ExportTab({ h }: { h: VocabWorksheetHook }) { export function ExportTab({ h }: { h: VocabWorksheetHook }) {
const { isDark, glassCard } = h 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 ( return (
<div className={`${glassCard} rounded-2xl p-6`}> <div className="space-y-6">
<h2 className={`text-lg font-semibold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>PDF herunterladen</h2> {/* 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 ? ( {h.worksheetId ? (
<div className="space-y-4"> <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={`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"> <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"> <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" /> <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" />
</svg> </svg>
<span className={`font-medium ${isDark ? 'text-green-200' : 'text-green-700'}`}>Arbeitsblatt erfolgreich generiert!</span>
</div> </div>
<h3 className={`font-semibold mb-1 ${isDark ? 'text-white' : 'text-slate-900'}`}>Arbeitsblatt</h3> </div>
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>PDF zum Ausdrucken</p>
</button>
{h.includeSolutions && ( <div className="grid grid-cols-2 gap-4">
<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'}`}> <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-green-500/30' : 'bg-green-100'}`}> <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-green-300' : 'text-green-600'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /> <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> </svg>
</div> </div>
<h3 className={`font-semibold mb-1 ${isDark ? 'text-white' : 'text-slate-900'}`}>Loesungsblatt</h3> <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 mit Loesungen</p> <p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>PDF zum Ausdrucken</p>
</button> </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'}`}> {h.includeSolutions && (
Neues Arbeitsblatt erstellen <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> </button>
</div> )}
) : ( </div>
<p className={`text-center py-12 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>Noch kein Arbeitsblatt generiert.</p>
)} {/* 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> </div>
) )
} }

View 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>
)
}

View File

@@ -8,6 +8,7 @@ import { PageSelection } from './components/PageSelection'
import { VocabularyTab } from './components/VocabularyTab' import { VocabularyTab } from './components/VocabularyTab'
import { WorksheetTab } from './components/WorksheetTab' import { WorksheetTab } from './components/WorksheetTab'
import { ExportTab } from './components/ExportTab' import { ExportTab } from './components/ExportTab'
import { SpreadsheetTab } from './components/SpreadsheetTab'
import { OcrSettingsPanel } from './components/OcrSettingsPanel' import { OcrSettingsPanel } from './components/OcrSettingsPanel'
import { FullscreenPreview } from './components/FullscreenPreview' import { FullscreenPreview } from './components/FullscreenPreview'
import { QRCodeModal } from './components/QRCodeModal' import { QRCodeModal } from './components/QRCodeModal'
@@ -144,6 +145,7 @@ export default function VocabWorksheetPage() {
{!session && <UploadScreen h={h} />} {!session && <UploadScreen h={h} />}
{session && activeTab === 'pages' && <PageSelection h={h} />} {session && activeTab === 'pages' && <PageSelection h={h} />}
{session && activeTab === 'vocabulary' && <VocabularyTab h={h} />} {session && activeTab === 'vocabulary' && <VocabularyTab h={h} />}
{session && activeTab === 'spreadsheet' && <SpreadsheetTab h={h} />}
{session && activeTab === 'worksheet' && <WorksheetTab h={h} />} {session && activeTab === 'worksheet' && <WorksheetTab h={h} />}
{session && activeTab === 'export' && <ExportTab h={h} />} {session && activeTab === 'export' && <ExportTab h={h} />}
@@ -151,7 +153,7 @@ export default function VocabWorksheetPage() {
{session && activeTab !== 'pages' && ( {session && activeTab !== 'pages' && (
<div className={`mt-6 border-t pt-4 ${isDark ? 'border-white/10' : 'border-black/5'}`}> <div className={`mt-6 border-t pt-4 ${isDark ? 'border-white/10' : 'border-black/5'}`}>
<div className="flex justify-center gap-2"> <div className="flex justify-center gap-2">
{(['vocabulary', 'worksheet', 'export'] as TabId[]).map((tab) => ( {(['vocabulary', 'spreadsheet', 'worksheet', 'export'] as TabId[]).map((tab) => (
<button <button
key={tab} key={tab}
onClick={() => h.setActiveTab(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') : (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> </button>
))} ))}
</div> </div>

View File

@@ -47,7 +47,7 @@ export interface OcrPrompts {
footerPatterns: string[] 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 WorksheetType = 'en_to_de' | 'de_to_en' | 'copy' | 'gap_fill'
export type WorksheetFormat = 'standard' | 'nru' export type WorksheetFormat = 'standard' | 'nru'
export type IpaMode = 'auto' | 'en' | 'de' | 'all' | 'none' export type IpaMode = 'auto' | 'en' | 'de' | 'all' | 'none'

View File

@@ -0,0 +1,75 @@
'use client'
import React, { useCallback, useState } from 'react'
interface AudioButtonProps {
text: string
lang: 'en' | 'de'
isDark: boolean
size?: 'sm' | 'md' | 'lg'
}
export function AudioButton({ text, lang, isDark, size = 'md' }: AudioButtonProps) {
const [isSpeaking, setIsSpeaking] = useState(false)
const speak = useCallback(() => {
if (!('speechSynthesis' in window)) return
if (isSpeaking) {
window.speechSynthesis.cancel()
setIsSpeaking(false)
return
}
const utterance = new SpeechSynthesisUtterance(text)
utterance.lang = lang === 'de' ? 'de-DE' : 'en-GB'
utterance.rate = 0.9
utterance.pitch = 1.0
// Try to find a good voice
const voices = window.speechSynthesis.getVoices()
const preferred = voices.find((v) =>
v.lang.startsWith(lang === 'de' ? 'de' : 'en') && v.localService
) || voices.find((v) => v.lang.startsWith(lang === 'de' ? 'de' : 'en'))
if (preferred) utterance.voice = preferred
utterance.onend = () => setIsSpeaking(false)
utterance.onerror = () => setIsSpeaking(false)
setIsSpeaking(true)
window.speechSynthesis.speak(utterance)
}, [text, lang, isSpeaking])
const sizeClasses = {
sm: 'w-7 h-7',
md: 'w-9 h-9',
lg: 'w-11 h-11',
}
const iconSizes = {
sm: 'w-3.5 h-3.5',
md: 'w-4 h-4',
lg: 'w-5 h-5',
}
return (
<button
onClick={speak}
className={`${sizeClasses[size]} rounded-full flex items-center justify-center transition-all ${
isSpeaking
? 'bg-blue-500 text-white animate-pulse'
: isDark
? 'bg-white/10 text-white/60 hover:bg-white/20 hover:text-white'
: 'bg-slate-100 text-slate-500 hover:bg-slate-200 hover:text-slate-700'
}`}
title={isSpeaking ? 'Stop' : `${lang === 'de' ? 'Deutsch' : 'Englisch'} vorlesen`}
>
<svg className={iconSizes[size]} fill="none" stroke="currentColor" viewBox="0 0 24 24">
{isSpeaking ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0zM10 9v6m4-6v6" />
) : (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="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" />
)}
</svg>
</button>
)
}

View File

@@ -0,0 +1,136 @@
'use client'
import React, { useState, useCallback } from 'react'
interface FlashCardProps {
front: string
back: string
cardNumber: number
totalCards: number
leitnerBox: number
onCorrect: () => void
onIncorrect: () => void
isDark: boolean
}
const boxLabels = ['Neu', 'Gelernt', 'Gefestigt']
const boxColors = ['text-yellow-400', 'text-blue-400', 'text-green-400']
export function FlashCard({
front,
back,
cardNumber,
totalCards,
leitnerBox,
onCorrect,
onIncorrect,
isDark,
}: FlashCardProps) {
const [isFlipped, setIsFlipped] = useState(false)
const handleFlip = useCallback(() => {
setIsFlipped((f) => !f)
}, [])
const handleCorrect = useCallback(() => {
setIsFlipped(false)
onCorrect()
}, [onCorrect])
const handleIncorrect = useCallback(() => {
setIsFlipped(false)
onIncorrect()
}, [onIncorrect])
return (
<div className="flex flex-col items-center gap-6 w-full max-w-lg mx-auto">
{/* Card */}
<div
onClick={handleFlip}
className="w-full cursor-pointer select-none"
style={{ perspective: '1000px' }}
>
<div
className="relative w-full transition-transform duration-500"
style={{
transformStyle: 'preserve-3d',
transform: isFlipped ? 'rotateY(180deg)' : 'rotateY(0deg)',
}}
>
{/* Front */}
<div
className={`w-full min-h-[280px] rounded-3xl p-8 flex flex-col items-center justify-center ${
isDark
? 'bg-white/10 backdrop-blur-xl border border-white/20'
: 'bg-white shadow-xl border border-slate-200'
}`}
style={{ backfaceVisibility: 'hidden' }}
>
<span className={`text-xs font-medium mb-4 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
ENGLISCH
</span>
<span className={`text-3xl font-bold text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
{front}
</span>
<span className={`text-sm mt-6 ${isDark ? 'text-white/30' : 'text-slate-400'}`}>
Klick zum Umdrehen
</span>
</div>
{/* Back */}
<div
className={`w-full min-h-[280px] rounded-3xl p-8 flex flex-col items-center justify-center absolute inset-0 ${
isDark
? 'bg-gradient-to-br from-blue-500/20 to-cyan-500/20 backdrop-blur-xl border border-blue-400/30'
: 'bg-gradient-to-br from-blue-50 to-cyan-50 shadow-xl border border-blue-200'
}`}
style={{ backfaceVisibility: 'hidden', transform: 'rotateY(180deg)' }}
>
<span className={`text-xs font-medium mb-4 ${isDark ? 'text-blue-300/60' : 'text-blue-500'}`}>
DEUTSCH
</span>
<span className={`text-3xl font-bold text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
{back}
</span>
</div>
</div>
</div>
{/* Status Bar */}
<div className={`flex items-center justify-between w-full px-2 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
<span className="text-sm">
Karte {cardNumber} von {totalCards}
</span>
<span className={`text-sm font-medium ${boxColors[leitnerBox] || boxColors[0]}`}>
Box: {boxLabels[leitnerBox] || boxLabels[0]}
</span>
</div>
{/* Progress */}
<div className="w-full h-1.5 rounded-full bg-white/10 overflow-hidden">
<div
className="h-full rounded-full bg-gradient-to-r from-blue-500 to-cyan-500 transition-all"
style={{ width: `${(cardNumber / totalCards) * 100}%` }}
/>
</div>
{/* Answer Buttons */}
{isFlipped && (
<div className="flex gap-4 w-full">
<button
onClick={handleIncorrect}
className="flex-1 py-4 rounded-2xl font-semibold text-lg transition-all bg-gradient-to-r from-red-500 to-rose-500 text-white hover:shadow-lg hover:shadow-red-500/25 hover:scale-[1.02]"
>
Falsch
</button>
<button
onClick={handleCorrect}
className="flex-1 py-4 rounded-2xl font-semibold text-lg transition-all bg-gradient-to-r from-green-500 to-emerald-500 text-white hover:shadow-lg hover:shadow-green-500/25 hover:scale-[1.02]"
>
Richtig
</button>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,126 @@
'use client'
import React, { useState, useCallback } from 'react'
interface Option {
id: string
text: string
}
interface QuizQuestionProps {
question: string
options: Option[]
correctAnswer: string
explanation?: string
questionNumber: number
totalQuestions: number
onAnswer: (correct: boolean) => void
isDark: boolean
}
export function QuizQuestion({
question,
options,
correctAnswer,
explanation,
questionNumber,
totalQuestions,
onAnswer,
isDark,
}: QuizQuestionProps) {
const [selected, setSelected] = useState<string | null>(null)
const [revealed, setRevealed] = useState(false)
const handleSelect = useCallback((optionId: string) => {
if (revealed) return
setSelected(optionId)
setRevealed(true)
const isCorrect = optionId === correctAnswer
setTimeout(() => {
onAnswer(isCorrect)
setSelected(null)
setRevealed(false)
}, isCorrect ? 1000 : 2500)
}, [revealed, correctAnswer, onAnswer])
const getOptionStyle = (optionId: string) => {
if (!revealed) {
return isDark
? 'bg-white/10 border-white/20 hover:bg-white/20 hover:border-white/30 text-white'
: 'bg-white border-slate-200 hover:bg-slate-50 hover:border-slate-300 text-slate-900'
}
if (optionId === correctAnswer) {
return isDark
? 'bg-green-500/20 border-green-400 text-green-200'
: 'bg-green-50 border-green-500 text-green-800'
}
if (optionId === selected && optionId !== correctAnswer) {
return isDark
? 'bg-red-500/20 border-red-400 text-red-200'
: 'bg-red-50 border-red-500 text-red-800'
}
return isDark
? 'bg-white/5 border-white/10 text-white/40'
: 'bg-slate-50 border-slate-200 text-slate-400'
}
return (
<div className="w-full max-w-lg mx-auto space-y-6">
{/* Progress */}
<div className="flex items-center justify-between">
<span className={`text-sm ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
Frage {questionNumber} / {totalQuestions}
</span>
<div className="w-32 h-1.5 rounded-full bg-white/10 overflow-hidden">
<div
className="h-full rounded-full bg-gradient-to-r from-purple-500 to-pink-500 transition-all"
style={{ width: `${(questionNumber / totalQuestions) * 100}%` }}
/>
</div>
</div>
{/* Question */}
<div className={`p-6 rounded-2xl ${isDark ? 'bg-white/10 backdrop-blur-xl border border-white/20' : 'bg-white shadow-lg border border-slate-200'}`}>
<p className={`text-xl font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
{question}
</p>
</div>
{/* Options */}
<div className="space-y-3">
{options.map((opt, idx) => (
<button
key={opt.id}
onClick={() => handleSelect(opt.id)}
disabled={revealed}
className={`w-full p-4 rounded-xl border-2 text-left transition-all flex items-center gap-3 ${getOptionStyle(opt.id)}`}
>
<span className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold flex-shrink-0 ${
revealed && opt.id === correctAnswer
? 'bg-green-500 text-white'
: revealed && opt.id === selected
? 'bg-red-500 text-white'
: isDark ? 'bg-white/10' : 'bg-slate-100'
}`}>
{String.fromCharCode(65 + idx)}
</span>
<span className="text-base">{opt.text}</span>
</button>
))}
</div>
{/* Explanation */}
{revealed && explanation && (
<div className={`p-4 rounded-xl ${isDark ? 'bg-blue-500/10 border border-blue-500/20' : 'bg-blue-50 border border-blue-200'}`}>
<p className={`text-sm ${isDark ? 'text-blue-200' : 'text-blue-700'}`}>
{explanation}
</p>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,149 @@
'use client'
import React, { useState, useRef, useEffect } from 'react'
interface TypeInputProps {
prompt: string
answer: string
onResult: (correct: boolean) => void
isDark: boolean
}
function levenshtein(a: string, b: string): number {
const m = a.length
const n = b.length
const dp: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0))
for (let i = 0; i <= m; i++) dp[i][0] = i
for (let j = 0; j <= n; j++) dp[0][j] = j
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
dp[i][j] = Math.min(
dp[i - 1][j] + 1,
dp[i][j - 1] + 1,
dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)
)
}
}
return dp[m][n]
}
export function TypeInput({ prompt, answer, onResult, isDark }: TypeInputProps) {
const [input, setInput] = useState('')
const [feedback, setFeedback] = useState<'correct' | 'almost' | 'wrong' | null>(null)
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
setInput('')
setFeedback(null)
inputRef.current?.focus()
}, [prompt, answer])
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
const userAnswer = input.trim().toLowerCase()
const correctAnswer = answer.trim().toLowerCase()
if (userAnswer === correctAnswer) {
setFeedback('correct')
setTimeout(() => onResult(true), 800)
} else if (levenshtein(userAnswer, correctAnswer) <= 2) {
setFeedback('almost')
setTimeout(() => {
setFeedback('wrong')
setTimeout(() => onResult(false), 2000)
}, 1500)
} else {
setFeedback('wrong')
setTimeout(() => onResult(false), 2500)
}
}
const feedbackColors = {
correct: isDark ? 'border-green-400 bg-green-500/20' : 'border-green-500 bg-green-50',
almost: isDark ? 'border-yellow-400 bg-yellow-500/20' : 'border-yellow-500 bg-yellow-50',
wrong: isDark ? 'border-red-400 bg-red-500/20' : 'border-red-500 bg-red-50',
}
return (
<div className="w-full max-w-lg mx-auto space-y-6">
{/* Prompt */}
<div className={`text-center p-8 rounded-3xl ${isDark ? 'bg-white/10 backdrop-blur-xl border border-white/20' : 'bg-white shadow-xl border border-slate-200'}`}>
<span className={`text-xs font-medium ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
UEBERSETZE
</span>
<p className={`text-3xl font-bold mt-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
{prompt}
</p>
</div>
{/* Input */}
<form onSubmit={handleSubmit}>
<div className={`rounded-2xl overflow-hidden border-2 transition-colors ${
feedback ? feedbackColors[feedback] : (isDark ? 'border-white/20' : 'border-slate-200')
}`}>
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
disabled={feedback !== null}
placeholder="Antwort eintippen..."
autoComplete="off"
autoCorrect="off"
spellCheck={false}
className={`w-full px-6 py-4 text-xl text-center outline-none ${
isDark
? 'bg-transparent text-white placeholder-white/30'
: 'bg-transparent text-slate-900 placeholder-slate-400'
}`}
/>
</div>
{!feedback && (
<button
type="submit"
disabled={!input.trim()}
className={`w-full mt-4 py-3 rounded-xl font-medium transition-all ${
input.trim()
? 'bg-gradient-to-r from-blue-500 to-cyan-500 text-white hover:shadow-lg'
: isDark ? 'bg-white/5 text-white/30' : 'bg-slate-100 text-slate-400'
}`}
>
Pruefen
</button>
)}
</form>
{/* Feedback Message */}
{feedback === 'correct' && (
<div className="text-center">
<span className="text-2xl"></span>
<p className={`text-lg font-semibold mt-1 ${isDark ? 'text-green-300' : 'text-green-600'}`}>
Richtig!
</p>
</div>
)}
{feedback === 'almost' && (
<div className="text-center">
<span className="text-2xl">🤏</span>
<p className={`text-lg font-semibold mt-1 ${isDark ? 'text-yellow-300' : 'text-yellow-600'}`}>
Fast richtig! Meintest du: <span className="underline">{answer}</span>
</p>
</div>
)}
{feedback === 'wrong' && (
<div className="text-center">
<span className="text-2xl"></span>
<p className={`text-lg font-semibold mt-1 ${isDark ? 'text-red-300' : 'text-red-600'}`}>
Falsch. Richtige Antwort:
</p>
<p className={`text-xl font-bold mt-1 ${isDark ? 'text-white' : 'text-slate-900'}`}>
{answer}
</p>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,90 @@
'use client'
import React from 'react'
import Link from 'next/link'
interface LearningUnit {
id: string
label: string
meta: string
title: string
topic: string | null
grade_level: string | null
status: string
created_at: string
}
interface UnitCardProps {
unit: LearningUnit
isDark: boolean
glassCard: string
onDelete: (id: string) => void
}
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: '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' },
]
export function UnitCard({ unit, isDark, glassCard, onDelete }: UnitCardProps) {
const createdDate = new Date(unit.created_at).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
return (
<div className={`${glassCard} rounded-2xl p-6 transition-all hover:shadow-lg`}>
<div className="flex items-start justify-between mb-4">
<div>
<h3 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
{unit.label}
</h3>
<p className={`text-sm mt-1 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
{unit.meta}
</p>
</div>
<button
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'}`}
title="Loeschen"
>
<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" />
</svg>
</button>
</div>
{/* Exercise Type Buttons */}
<div className="flex flex-wrap gap-2">
{exerciseTypes.map((ex) => (
<Link
key={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`}
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d={ex.icon} />
</svg>
{ex.label}
</Link>
))}
</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>
)
}

View File

@@ -22,6 +22,7 @@
"pdf-lib": "^1.17.1", "pdf-lib": "^1.17.1",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"@fortune-sheet/react": "^1.0.4",
"react-leaflet": "^5.0.0" "react-leaflet": "^5.0.0"
}, },
"devDependencies": { "devDependencies": {