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:
@@ -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))
|
||||||
|
|
||||||
|
|||||||
196
klausur-service/backend/vocab_learn_bridge.py
Normal file
196
klausur-service/backend/vocab_learn_bridge.py
Normal 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
|
||||||
@@ -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))
|
||||||
|
|||||||
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,12 +1,48 @@
|
|||||||
'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="space-y-6">
|
||||||
|
{/* PDF Download Section */}
|
||||||
<div className={`${glassCard} rounded-2xl p-6`}>
|
<div className={`${glassCard} rounded-2xl p-6`}>
|
||||||
<h2 className={`text-lg font-semibold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>PDF herunterladen</h2>
|
<h2 className={`text-lg font-semibold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>PDF herunterladen</h2>
|
||||||
|
|
||||||
@@ -44,14 +80,74 @@ export function ExportTab({ h }: { h: VocabWorksheetHook }) {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
|
{/* 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'}`}>
|
<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
|
Neues Arbeitsblatt erstellen
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<p className={`text-center py-12 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>Noch kein Arbeitsblatt generiert.</p>
|
|
||||||
)}
|
|
||||||
</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 { 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>
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
75
studio-v2/components/learn/AudioButton.tsx
Normal file
75
studio-v2/components/learn/AudioButton.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
136
studio-v2/components/learn/FlashCard.tsx
Normal file
136
studio-v2/components/learn/FlashCard.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
126
studio-v2/components/learn/QuizQuestion.tsx
Normal file
126
studio-v2/components/learn/QuizQuestion.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
149
studio-v2/components/learn/TypeInput.tsx
Normal file
149
studio-v2/components/learn/TypeInput.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
90
studio-v2/components/learn/UnitCard.tsx
Normal file
90
studio-v2/components/learn/UnitCard.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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": {
|
||||||
|
|||||||
Reference in New Issue
Block a user