diff --git a/backend-lehrer/learning_units_api.py b/backend-lehrer/learning_units_api.py index 0d07459..a853122 100644 --- a/backend-lehrer/learning_units_api.py +++ b/backend-lehrer/learning_units_api.py @@ -1,5 +1,9 @@ from typing import List, Dict, Any, Optional from datetime import datetime +from pathlib import Path +import json +import os +import logging from fastapi import APIRouter, HTTPException from pydantic import BaseModel @@ -15,6 +19,8 @@ from learning_units import ( delete_learning_unit, ) +logger = logging.getLogger(__name__) + router = APIRouter( prefix="/learning-units", @@ -49,6 +55,11 @@ class RemoveWorksheetPayload(BaseModel): worksheet_file: str +class GenerateFromAnalysisPayload(BaseModel): + analysis_data: Dict[str, Any] + num_questions: int = 8 + + # ---------- 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.") 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)) + diff --git a/klausur-service/backend/vocab_learn_bridge.py b/klausur-service/backend/vocab_learn_bridge.py new file mode 100644 index 0000000..786c552 --- /dev/null +++ b/klausur-service/backend/vocab_learn_bridge.py @@ -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 diff --git a/klausur-service/backend/vocab_worksheet_api.py b/klausur-service/backend/vocab_worksheet_api.py index bec9294..43d2a45 100644 --- a/klausur-service/backend/vocab_worksheet_api.py +++ b/klausur-service/backend/vocab_worksheet_api.py @@ -2677,3 +2677,66 @@ async def load_ground_truth(session_id: str, page_number: int): gt_data = json.load(f) 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)) diff --git a/studio-v2/app/learn/[unitId]/flashcards/page.tsx b/studio-v2/app/learn/[unitId]/flashcards/page.tsx new file mode 100644 index 0000000..96e0f2b --- /dev/null +++ b/studio-v2/app/learn/[unitId]/flashcards/page.tsx @@ -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([]) + const [currentIndex, setCurrentIndex] = useState(0) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(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 ( +
+
+
+ ) + } + + if (error) { + return ( +
+
+

Fehler: {error}

+ +
+
+ ) + } + + return ( +
+ {/* Header */} +
+
+ +

+ Karteikarten +

+ + {items.length} Karten + +
+
+ + {/* Content */} +
+ {isComplete ? ( +
+
+ {stats.correct > stats.incorrect ? '🎉' : '💪'} +
+

+ Geschafft! +

+
+
+ {stats.correct} +

Richtig

+
+
+ {stats.incorrect} +

Falsch

+
+
+
+ + +
+
+ ) : items.length > 0 ? ( +
+ handleAnswer(true)} + onIncorrect={() => handleAnswer(false)} + isDark={isDark} + /> + {/* Audio Button */} +
+ +
+
+ ) : ( +
+

Keine Karteikarten verfuegbar.

+
+ )} +
+
+ ) +} diff --git a/studio-v2/app/learn/[unitId]/quiz/page.tsx b/studio-v2/app/learn/[unitId]/quiz/page.tsx new file mode 100644 index 0000000..9900d01 --- /dev/null +++ b/studio-v2/app/learn/[unitId]/quiz/page.tsx @@ -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([]) + const [currentIndex, setCurrentIndex] = useState(0) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(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 ( +
+
+
+ ) + } + + return ( +
+ {/* Header */} +
+
+ +

+ Quiz +

+ + {questions.length} Fragen + +
+
+ + {/* Content */} +
+ {error ? ( +
+

{error}

+ +
+ ) : isComplete ? ( +
+
+ {stats.correct === questions.length ? '🏆' : stats.correct > stats.incorrect ? '🎉' : '💪'} +
+

+ {stats.correct === questions.length ? 'Perfekt!' : 'Geschafft!'} +

+

+ {stats.correct} von {questions.length} richtig + ({Math.round((stats.correct / questions.length) * 100)}%) +

+
+
+
+
+ + +
+
+ ) : questions[currentIndex] ? ( + + ) : ( +

Keine Quiz-Fragen verfuegbar.

+ )} +
+
+ ) +} diff --git a/studio-v2/app/learn/[unitId]/type/page.tsx b/studio-v2/app/learn/[unitId]/type/page.tsx new file mode 100644 index 0000000..09f8e9e --- /dev/null +++ b/studio-v2/app/learn/[unitId]/type/page.tsx @@ -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([]) + const [currentIndex, setCurrentIndex] = useState(0) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(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 ( +
+
+
+ ) + } + + return ( +
+ {/* Header */} +
+
+ +

+ Eintippen +

+ {/* Direction toggle */} + +
+
+ + {/* Progress */} +
+
+
+ + {/* Content */} +
+ {error ? ( +
+

{error}

+
+ ) : isComplete ? ( +
+
+ {stats.correct > stats.incorrect ? '🎉' : '💪'} +
+

+ Geschafft! +

+
+
+ {stats.correct} +

Richtig

+
+
+ {stats.incorrect} +

Falsch

+
+
+
+ + +
+
+ ) : currentItem ? ( +
+
+ +
+ +

+ {currentIndex + 1} / {items.length} +

+
+ ) : ( +

Keine Vokabeln verfuegbar.

+ )} +
+
+ ) +} diff --git a/studio-v2/app/learn/page.tsx b/studio-v2/app/learn/page.tsx new file mode 100644 index 0000000..b234370 --- /dev/null +++ b/studio-v2/app/learn/page.tsx @@ -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([]) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(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 ( +
+ {/* Background Blobs */} +
+
+
+
+ + {/* Sidebar */} +
+ +
+ + {/* Main Content */} +
+ {/* Header */} +
+
+
+
+ + + +
+
+

+ Meine Lernmodule +

+

+ Karteikarten, Quiz und Lueckentexte aus deinen Vokabeln +

+
+
+
+
+ + {/* Content */} +
+ {isLoading && ( +
+
+
+ )} + + {error && ( +
+

Fehler: {error}

+ +
+ )} + + {!isLoading && !error && units.length === 0 && ( +
+
+ + + +
+

+ Noch keine Lernmodule +

+

+ Scanne eine Schulbuchseite im Vokabel-Arbeitsblatt Generator und klicke "Lernmodule generieren". +

+ + Zum Vokabel-Scanner + +
+ )} + + {!isLoading && units.length > 0 && ( +
+ {units.map((unit) => ( + + ))} +
+ )} +
+
+
+ ) +} diff --git a/studio-v2/app/vocab-worksheet/components/ExportTab.tsx b/studio-v2/app/vocab-worksheet/components/ExportTab.tsx index 6604b95..ded0aca 100644 --- a/studio-v2/app/vocab-worksheet/components/ExportTab.tsx +++ b/studio-v2/app/vocab-worksheet/components/ExportTab.tsx @@ -1,57 +1,153 @@ 'use client' -import React from 'react' +import React, { useState } from 'react' +import { useRouter } from 'next/navigation' import type { VocabWorksheetHook } from '../types' +import { getApiBase } from '../constants' export function ExportTab({ h }: { h: VocabWorksheetHook }) { const { isDark, glassCard } = h + const router = useRouter() + + const [isGeneratingLearning, setIsGeneratingLearning] = useState(false) + const [learningUnitId, setLearningUnitId] = useState(null) + const [learningError, setLearningError] = useState(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 ( -
-

PDF herunterladen

+
+ {/* PDF Download Section */} +
+

PDF herunterladen

- {h.worksheetId ? ( -
-
-
- - - - Arbeitsblatt erfolgreich generiert! -
-
- -
- +
- {h.includeSolutions && ( - - )} -
- + )} +
+
+ ) : ( +

Noch kein Arbeitsblatt generiert.

+ )} +
+ + {/* Learning Module Generation Section */} +
+

Interaktive Lernmodule

+

+ Aus den Vokabeln automatisch Karteikarten, Quiz und Lueckentexte erstellen. +

+ + {learningError && ( +
+

{learningError}

+
+ )} + + {learningUnitId ? ( +
+
+
+ + + + Lernmodule wurden generiert! +
+
+ + +
+ ) : ( + -
- ) : ( -

Noch kein Arbeitsblatt generiert.

- )} + )} +
+ + {/* Reset Button */} +
) } diff --git a/studio-v2/app/vocab-worksheet/components/SpreadsheetTab.tsx b/studio-v2/app/vocab-worksheet/components/SpreadsheetTab.tsx new file mode 100644 index 0000000..da0addd --- /dev/null +++ b/studio-v2/app/vocab-worksheet/components/SpreadsheetTab.tsx @@ -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: () =>
Spreadsheet wird geladen...
}, +) + +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 = { + '0': 180, // Englisch + '1': 180, // Deutsch + '2': 280, // Beispielsatz + '3': 100, // Wortart + '4': 60, // Seite + } + + // Row heights + const rowlen: Record = {} + 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 ( +
+

+ Keine Vokabeln vorhanden. Bitte zuerst Seiten verarbeiten. +

+
+ ) + } + + return ( +
+
+

+ Spreadsheet-Editor +

+
+ + {vocabulary.length} Vokabeln + + +
+
+ +
+ {sheets.length > 0 && ( +
+ +
+ )} +
+
+ ) +} diff --git a/studio-v2/app/vocab-worksheet/page.tsx b/studio-v2/app/vocab-worksheet/page.tsx index dd2582e..2d57fca 100644 --- a/studio-v2/app/vocab-worksheet/page.tsx +++ b/studio-v2/app/vocab-worksheet/page.tsx @@ -8,6 +8,7 @@ import { PageSelection } from './components/PageSelection' import { VocabularyTab } from './components/VocabularyTab' import { WorksheetTab } from './components/WorksheetTab' import { ExportTab } from './components/ExportTab' +import { SpreadsheetTab } from './components/SpreadsheetTab' import { OcrSettingsPanel } from './components/OcrSettingsPanel' import { FullscreenPreview } from './components/FullscreenPreview' import { QRCodeModal } from './components/QRCodeModal' @@ -144,6 +145,7 @@ export default function VocabWorksheetPage() { {!session && } {session && activeTab === 'pages' && } {session && activeTab === 'vocabulary' && } + {session && activeTab === 'spreadsheet' && } {session && activeTab === 'worksheet' && } {session && activeTab === 'export' && } @@ -151,7 +153,7 @@ export default function VocabWorksheetPage() { {session && activeTab !== 'pages' && (
- {(['vocabulary', 'worksheet', 'export'] as TabId[]).map((tab) => ( + {(['vocabulary', 'spreadsheet', 'worksheet', 'export'] as TabId[]).map((tab) => ( ))}
diff --git a/studio-v2/app/vocab-worksheet/types.ts b/studio-v2/app/vocab-worksheet/types.ts index b4fa6f7..d7c621b 100644 --- a/studio-v2/app/vocab-worksheet/types.ts +++ b/studio-v2/app/vocab-worksheet/types.ts @@ -47,7 +47,7 @@ export interface OcrPrompts { footerPatterns: string[] } -export type TabId = 'upload' | 'pages' | 'vocabulary' | 'worksheet' | 'export' | 'settings' +export type TabId = 'upload' | 'pages' | 'vocabulary' | 'spreadsheet' | 'worksheet' | 'export' | 'settings' export type WorksheetType = 'en_to_de' | 'de_to_en' | 'copy' | 'gap_fill' export type WorksheetFormat = 'standard' | 'nru' export type IpaMode = 'auto' | 'en' | 'de' | 'all' | 'none' diff --git a/studio-v2/components/learn/AudioButton.tsx b/studio-v2/components/learn/AudioButton.tsx new file mode 100644 index 0000000..aa48013 --- /dev/null +++ b/studio-v2/components/learn/AudioButton.tsx @@ -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 ( + + ) +} diff --git a/studio-v2/components/learn/FlashCard.tsx b/studio-v2/components/learn/FlashCard.tsx new file mode 100644 index 0000000..a976f32 --- /dev/null +++ b/studio-v2/components/learn/FlashCard.tsx @@ -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 ( +
+ {/* Card */} +
+
+ {/* Front */} +
+ + ENGLISCH + + + {front} + + + Klick zum Umdrehen + +
+ + {/* Back */} +
+ + DEUTSCH + + + {back} + +
+
+
+ + {/* Status Bar */} +
+ + Karte {cardNumber} von {totalCards} + + + Box: {boxLabels[leitnerBox] || boxLabels[0]} + +
+ + {/* Progress */} +
+
+
+ + {/* Answer Buttons */} + {isFlipped && ( +
+ + +
+ )} +
+ ) +} diff --git a/studio-v2/components/learn/QuizQuestion.tsx b/studio-v2/components/learn/QuizQuestion.tsx new file mode 100644 index 0000000..095fee7 --- /dev/null +++ b/studio-v2/components/learn/QuizQuestion.tsx @@ -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(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 ( +
+ {/* Progress */} +
+ + Frage {questionNumber} / {totalQuestions} + +
+
+
+
+ + {/* Question */} +
+

+ {question} +

+
+ + {/* Options */} +
+ {options.map((opt, idx) => ( + + ))} +
+ + {/* Explanation */} + {revealed && explanation && ( +
+

+ {explanation} +

+
+ )} +
+ ) +} diff --git a/studio-v2/components/learn/TypeInput.tsx b/studio-v2/components/learn/TypeInput.tsx new file mode 100644 index 0000000..ce6c037 --- /dev/null +++ b/studio-v2/components/learn/TypeInput.tsx @@ -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(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 ( +
+ {/* Prompt */} +
+ + UEBERSETZE + +

+ {prompt} +

+
+ + {/* Input */} +
+
+ 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' + }`} + /> +
+ + {!feedback && ( + + )} +
+ + {/* Feedback Message */} + {feedback === 'correct' && ( +
+ +

+ Richtig! +

+
+ )} + + {feedback === 'almost' && ( +
+ 🤏 +

+ Fast richtig! Meintest du: {answer} +

+
+ )} + + {feedback === 'wrong' && ( +
+ +

+ Falsch. Richtige Antwort: +

+

+ {answer} +

+
+ )} +
+ ) +} diff --git a/studio-v2/components/learn/UnitCard.tsx b/studio-v2/components/learn/UnitCard.tsx new file mode 100644 index 0000000..ee20180 --- /dev/null +++ b/studio-v2/components/learn/UnitCard.tsx @@ -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 ( +
+
+
+

+ {unit.label} +

+

+ {unit.meta} +

+
+ +
+ + {/* Exercise Type Buttons */} +
+ {exerciseTypes.map((ex) => ( + + + + + {ex.label} + + ))} +
+ + {/* Status */} +
+ + Erstellt: {createdDate} + + + {unit.status === 'raw' ? 'Neu' : 'Module generiert'} + +
+
+ ) +} diff --git a/studio-v2/package.json b/studio-v2/package.json index d9fa7d7..81f8bdb 100644 --- a/studio-v2/package.json +++ b/studio-v2/package.json @@ -22,6 +22,7 @@ "pdf-lib": "^1.17.1", "react": "^19.0.0", "react-dom": "^19.0.0", + "@fortune-sheet/react": "^1.0.4", "react-leaflet": "^5.0.0" }, "devDependencies": {