Files
breakpilot-lehrer/studio-v2/app/vocab-worksheet/page.tsx
Benjamin Admin b0bfc0a960
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 24s
CI / test-go-edu-search (push) Successful in 25s
CI / test-python-klausur (push) Failing after 1m50s
CI / test-python-agent-core (push) Successful in 15s
CI / test-nodejs-website (push) Successful in 17s
feat: Session-ID in Vocab-Worksheet Kopfzeile anzeigen
Zeigt die ersten 8 Zeichen der Session-ID neben dem Untertitel an,
damit die Session einfach identifiziert und kommuniziert werden kann.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 17:16:47 +01:00

2146 lines
103 KiB
TypeScript

'use client'
import React, { useState, useRef, useEffect } from 'react'
import { useTheme } from '@/lib/ThemeContext'
import { useLanguage } from '@/lib/LanguageContext'
import { useRouter } from 'next/navigation'
import { useActivity } from '@/lib/ActivityContext'
import { QRCodeUpload, UploadedFile } from '@/components/QRCodeUpload'
import { Sidebar } from '@/components/Sidebar'
// API Base URL - dynamisch basierend auf Browser-Host
// Verwendet /klausur-api/ Proxy um Zertifikat-Probleme zu vermeiden
const getApiBase = () => {
if (typeof window === 'undefined') return 'http://localhost:8086'
const { hostname, protocol } = window.location
if (hostname === 'localhost') return 'http://localhost:8086'
// Für macmini/lokales Netzwerk: Proxy-Pfad verwenden (gleiches Zertifikat wie Hauptseite)
return `${protocol}//${hostname}/klausur-api`
}
// LocalStorage Keys
const DOCUMENTS_KEY = 'bp_documents'
const OCR_PROMPTS_KEY = 'bp_ocr_prompts'
const SESSION_ID_KEY = 'bp_upload_session'
// Types
interface VocabularyEntry {
id: string
english: string
german: string
example_sentence?: string
example_sentence_gap?: string
word_type?: string
source_page?: number
selected?: boolean
extras?: Record<string, string>
}
// Dynamic column definition (per source page)
interface ExtraColumn {
key: string
label: string
}
interface Session {
id: string
name: string
status: string
vocabulary_count: number
image_path?: string
description?: string
source_language?: string
target_language?: string
created_at?: string
}
interface StoredDocument {
id: string
name: string
type: string
size: number
uploadedAt: Date
url?: string
}
interface OcrPrompts {
filterHeaders: boolean
filterFooters: boolean
filterPageNumbers: boolean
customFilter: string
headerPatterns: string[]
footerPatterns: string[]
}
type TabId = 'upload' | 'pages' | 'vocabulary' | 'worksheet' | 'export' | 'settings'
type WorksheetType = 'en_to_de' | 'de_to_en' | 'copy' | 'gap_fill'
type WorksheetFormat = 'standard' | 'nru'
// Worksheet format templates
const worksheetFormats: { id: WorksheetFormat; label: string; description: string; icon: string }[] = [
{
id: 'standard',
label: 'Standard-Format',
description: 'Klassisches Arbeitsblatt mit waehlbarer Uebersetzungsrichtung',
icon: 'document'
},
{
id: 'nru',
label: 'NRU-Vorlage',
description: '3-Spalten-Tabelle (EN|DE|Korrektur) + Lernsaetze mit Uebersetzungszeilen',
icon: 'template'
},
]
// Default OCR filtering prompts
const defaultOcrPrompts: OcrPrompts = {
filterHeaders: true,
filterFooters: true,
filterPageNumbers: true,
customFilter: '',
headerPatterns: ['Unit', 'Chapter', 'Lesson', 'Kapitel', 'Lektion'],
footerPatterns: ['zweihundert', 'dreihundert', 'vierhundert', 'Page', 'Seite']
}
export default function VocabWorksheetPage() {
const { isDark } = useTheme()
const { t } = useLanguage()
const router = useRouter()
const { startActivity, completeActivity } = useActivity()
const [mounted, setMounted] = useState(false)
// Tab state
const [activeTab, setActiveTab] = useState<TabId>('upload')
// Session state
const [session, setSession] = useState<Session | null>(null)
const [sessionName, setSessionName] = useState('')
const [isCreatingSession, setIsCreatingSession] = useState(false)
const [error, setError] = useState<string | null>(null)
const [extractionStatus, setExtractionStatus] = useState<string>('')
// Existing sessions list
const [existingSessions, setExistingSessions] = useState<Session[]>([])
const [isLoadingSessions, setIsLoadingSessions] = useState(true)
// Documents from storage
const [storedDocuments, setStoredDocuments] = useState<StoredDocument[]>([])
const [selectedDocumentId, setSelectedDocumentId] = useState<string | null>(null)
// Direct file upload
const [directFile, setDirectFile] = useState<File | null>(null)
const [directFilePreview, setDirectFilePreview] = useState<string | null>(null)
const directFileInputRef = useRef<HTMLInputElement>(null)
// PDF page selection state
const [pdfPageCount, setPdfPageCount] = useState<number>(0)
const [selectedPages, setSelectedPages] = useState<number[]>([])
const [pagesThumbnails, setPagesThumbnails] = useState<string[]>([])
const [isLoadingThumbnails, setIsLoadingThumbnails] = useState(false)
const [excludedPages, setExcludedPages] = useState<number[]>([])
// Dynamic extra columns per source page (key: page number, value: extra columns)
const [pageExtraColumns, setPageExtraColumns] = useState<Record<number, ExtraColumn[]>>({})
// Upload state
const [uploadedImage, setUploadedImage] = useState<string | null>(null)
const [isExtracting, setIsExtracting] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
// Vocabulary state
const [vocabulary, setVocabulary] = useState<VocabularyEntry[]>([])
// Worksheet state
const [selectedTypes, setSelectedTypes] = useState<WorksheetType[]>(['en_to_de'])
const [worksheetTitle, setWorksheetTitle] = useState('')
const [includeSolutions, setIncludeSolutions] = useState(true)
const [lineHeight, setLineHeight] = useState('normal')
const [selectedFormat, setSelectedFormat] = useState<WorksheetFormat>('standard')
// Export state
const [worksheetId, setWorksheetId] = useState<string | null>(null)
const [isGenerating, setIsGenerating] = useState(false)
// Processing results
const [processingErrors, setProcessingErrors] = useState<string[]>([])
const [successfulPages, setSuccessfulPages] = useState<number[]>([])
const [failedPages, setFailedPages] = useState<number[]>([])
const [currentlyProcessingPage, setCurrentlyProcessingPage] = useState<number | null>(null)
const [processingQueue, setProcessingQueue] = useState<number[]>([])
// OCR Prompts/Settings
const [ocrPrompts, setOcrPrompts] = useState<OcrPrompts>(defaultOcrPrompts)
const [showSettings, setShowSettings] = useState(false)
// QR Code Upload
const [showQRModal, setShowQRModal] = useState(false)
const [uploadSessionId, setUploadSessionId] = useState('')
const [mobileUploadedFiles, setMobileUploadedFiles] = useState<UploadedFile[]>([])
const [selectedMobileFile, setSelectedMobileFile] = useState<UploadedFile | null>(null)
// OCR Comparison
const [showOcrComparison, setShowOcrComparison] = useState(false)
const [ocrComparePageIndex, setOcrComparePageIndex] = useState<number | null>(null)
const [ocrCompareResult, setOcrCompareResult] = useState<any>(null)
const [isComparingOcr, setIsComparingOcr] = useState(false)
const [ocrCompareError, setOcrCompareError] = useState<string | null>(null)
// SSR Safety
useEffect(() => {
setMounted(true)
// Initialize upload session ID for QR code
let storedSessionId = localStorage.getItem(SESSION_ID_KEY)
if (!storedSessionId) {
storedSessionId = `vocab-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
localStorage.setItem(SESSION_ID_KEY, storedSessionId)
}
setUploadSessionId(storedSessionId)
}, [])
// Load OCR prompts from localStorage
useEffect(() => {
if (!mounted) return
const stored = localStorage.getItem(OCR_PROMPTS_KEY)
if (stored) {
try {
setOcrPrompts({ ...defaultOcrPrompts, ...JSON.parse(stored) })
} catch (e) {
console.error('Failed to parse OCR prompts:', e)
}
}
}, [mounted])
// Save OCR prompts to localStorage
const saveOcrPrompts = (prompts: OcrPrompts) => {
setOcrPrompts(prompts)
localStorage.setItem(OCR_PROMPTS_KEY, JSON.stringify(prompts))
}
// Load documents from localStorage
useEffect(() => {
if (!mounted) return
const stored = localStorage.getItem(DOCUMENTS_KEY)
if (stored) {
try {
const docs = JSON.parse(stored)
const imagesDocs = docs.filter((d: StoredDocument) =>
d.type?.startsWith('image/') || d.type === 'application/pdf'
)
setStoredDocuments(imagesDocs)
} catch (e) {
console.error('Failed to parse stored documents:', e)
}
}
}, [mounted])
// Load existing sessions from API
useEffect(() => {
if (!mounted) return
const loadSessions = async () => {
const API_BASE = getApiBase()
try {
const res = await fetch(`${API_BASE}/api/v1/vocab/sessions`)
if (res.ok) {
const sessions = await res.json()
setExistingSessions(sessions)
}
} catch (e) {
console.error('Failed to load sessions:', e)
} finally {
setIsLoadingSessions(false)
}
}
loadSessions()
}, [mounted])
// Handle direct file selection
const handleDirectFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setDirectFile(file)
setSelectedDocumentId(null)
if (file.type.startsWith('image/')) {
const reader = new FileReader()
reader.onload = (ev) => {
setDirectFilePreview(ev.target?.result as string)
}
reader.readAsDataURL(file)
} else {
setDirectFilePreview(null)
}
}
// Create session and handle file upload
const startSession = async () => {
if (!sessionName.trim()) {
setError('Bitte geben Sie einen Namen fuer die Session ein.')
return
}
if (!selectedDocumentId && !directFile && !selectedMobileFile) {
setError('Bitte waehlen Sie ein Dokument aus oder laden Sie eine Datei hoch.')
return
}
setError(null)
setIsCreatingSession(true)
setExtractionStatus('Session wird erstellt...')
const API_BASE = getApiBase()
try {
const sessionRes = await fetch(`${API_BASE}/api/v1/vocab/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: sessionName,
ocr_prompts: ocrPrompts
}),
})
if (!sessionRes.ok) {
throw new Error('Session konnte nicht erstellt werden')
}
const sessionData = await sessionRes.json()
setSession(sessionData)
setWorksheetTitle(sessionName)
// Start activity tracking for time savings calculation
startActivity('vocab_extraction', { description: sessionName })
let file: File
let isPdf = false
if (directFile) {
file = directFile
isPdf = directFile.type === 'application/pdf'
} else if (selectedMobileFile) {
// Convert mobile uploaded file (base64 dataUrl) to File object
isPdf = selectedMobileFile.type === 'application/pdf'
const base64Data = selectedMobileFile.dataUrl.split(',')[1]
const byteCharacters = atob(base64Data)
const byteNumbers = new Array(byteCharacters.length)
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i)
}
const byteArray = new Uint8Array(byteNumbers)
const blob = new Blob([byteArray], { type: selectedMobileFile.type })
file = new File([blob], selectedMobileFile.name, { type: selectedMobileFile.type })
} else {
const selectedDoc = storedDocuments.find(d => d.id === selectedDocumentId)
if (!selectedDoc || !selectedDoc.url) {
throw new Error('Das ausgewaehlte Dokument ist nicht verfuegbar.')
}
isPdf = selectedDoc.type === 'application/pdf'
const base64Data = selectedDoc.url.split(',')[1]
const byteCharacters = atob(base64Data)
const byteNumbers = new Array(byteCharacters.length)
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i)
}
const byteArray = new Uint8Array(byteNumbers)
const blob = new Blob([byteArray], { type: selectedDoc.type })
file = new File([blob], selectedDoc.name, { type: selectedDoc.type })
}
if (isPdf) {
setExtractionStatus('PDF wird analysiert...')
const formData = new FormData()
formData.append('file', file)
const pdfInfoRes = await fetch(`${API_BASE}/api/v1/vocab/sessions/${sessionData.id}/upload-pdf-info`, {
method: 'POST',
body: formData,
})
if (!pdfInfoRes.ok) {
throw new Error('PDF konnte nicht verarbeitet werden')
}
const pdfInfo = await pdfInfoRes.json()
setPdfPageCount(pdfInfo.page_count)
setIsLoadingThumbnails(true)
const thumbnails: string[] = []
for (let i = 0; i < pdfInfo.page_count; i++) {
try {
const thumbRes = await fetch(`${API_BASE}/api/v1/vocab/sessions/${sessionData.id}/pdf-thumbnail/${i}?hires=true`)
if (thumbRes.ok) {
const blob = await thumbRes.blob()
thumbnails.push(URL.createObjectURL(blob))
}
} catch (e) {
console.error(`Failed to load thumbnail for page ${i}`)
}
}
setPagesThumbnails(thumbnails)
setIsLoadingThumbnails(false)
setSelectedPages(Array.from({ length: pdfInfo.page_count }, (_, i) => i))
setActiveTab('pages')
setExtractionStatus(`PDF hat ${pdfInfo.page_count} Seiten. Bitte waehlen Sie die zu verarbeitenden Seiten.`)
} else {
setExtractionStatus('KI analysiert das Bild... (kann 30-60 Sekunden dauern)')
const formData = new FormData()
formData.append('file', file)
const uploadRes = await fetch(`${API_BASE}/api/v1/vocab/sessions/${sessionData.id}/upload`, {
method: 'POST',
body: formData,
})
if (!uploadRes.ok) {
throw new Error('Bild konnte nicht verarbeitet werden')
}
const uploadData = await uploadRes.json()
setSession(prev => prev ? { ...prev, status: 'extracted', vocabulary_count: uploadData.vocabulary_count } : null)
const vocabRes = await fetch(`${API_BASE}/api/v1/vocab/sessions/${sessionData.id}/vocabulary`)
if (vocabRes.ok) {
const vocabData = await vocabRes.json()
setVocabulary(vocabData.vocabulary || [])
setExtractionStatus(`${vocabData.vocabulary?.length || 0} Vokabeln gefunden!`)
}
await new Promise(r => setTimeout(r, 1000))
setActiveTab('vocabulary')
}
} catch (error) {
console.error('Session start failed:', error)
setError(error instanceof Error ? error.message : 'Ein Fehler ist aufgetreten')
setExtractionStatus('')
setSession(null)
} finally {
setIsCreatingSession(false)
}
}
// Process a single page
const processSinglePage = async (pageIndex: number): Promise<{ success: boolean; vocabulary: VocabularyEntry[]; error?: string }> => {
const API_BASE = getApiBase()
try {
const res = await fetch(`${API_BASE}/api/v1/vocab/sessions/${session!.id}/process-single-page/${pageIndex}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ocr_prompts: ocrPrompts }),
})
if (!res.ok) {
return { success: false, vocabulary: [], error: `Seite ${pageIndex + 1}: HTTP ${res.status}` }
}
const data = await res.json()
if (!data.success) {
return { success: false, vocabulary: [], error: data.error || `Seite ${pageIndex + 1}: Unbekannter Fehler` }
}
return { success: true, vocabulary: data.vocabulary || [] }
} catch (e) {
return { success: false, vocabulary: [], error: `Seite ${pageIndex + 1}: ${e instanceof Error ? e.message : 'Netzwerkfehler'}` }
}
}
// Process selected PDF pages
const processSelectedPages = async () => {
if (!session || selectedPages.length === 0) return
const pagesToProcess = [...selectedPages].sort((a, b) => a - b)
setIsExtracting(true)
setProcessingErrors([])
setSuccessfulPages([])
setFailedPages([])
setProcessingQueue(pagesToProcess)
setVocabulary([])
setActiveTab('vocabulary')
const API_BASE = getApiBase()
const errors: string[] = []
const successful: number[] = []
const failed: number[] = []
for (let i = 0; i < pagesToProcess.length; i++) {
const pageIndex = pagesToProcess[i]
setCurrentlyProcessingPage(pageIndex + 1)
setExtractionStatus(`Verarbeite Seite ${pageIndex + 1} von ${pagesToProcess.length}... (kann 30-60 Sekunden dauern)`)
const result = await processSinglePage(pageIndex)
if (result.success) {
successful.push(pageIndex + 1)
setSuccessfulPages([...successful])
setVocabulary(prev => [...prev, ...result.vocabulary])
setExtractionStatus(`Seite ${pageIndex + 1} fertig: ${result.vocabulary.length} Vokabeln gefunden`)
} else {
failed.push(pageIndex + 1)
setFailedPages([...failed])
if (result.error) {
errors.push(result.error)
setProcessingErrors([...errors])
}
setExtractionStatus(`Seite ${pageIndex + 1} fehlgeschlagen`)
}
await new Promise(r => setTimeout(r, 500))
}
setCurrentlyProcessingPage(null)
setProcessingQueue([])
setIsExtracting(false)
const totalVocab = vocabulary.length
if (successful.length === pagesToProcess.length) {
setExtractionStatus(`Fertig! Alle ${successful.length} Seiten verarbeitet.`)
} else if (successful.length > 0) {
setExtractionStatus(`${successful.length} von ${pagesToProcess.length} Seiten verarbeitet. ${failed.length} fehlgeschlagen.`)
} else {
setExtractionStatus(`Alle Seiten fehlgeschlagen.`)
}
setSession(prev => prev ? { ...prev, status: 'extracted' } : null)
}
// Toggle page selection
const togglePageSelection = (pageIndex: number) => {
setSelectedPages(prev =>
prev.includes(pageIndex)
? prev.filter(p => p !== pageIndex)
: [...prev, pageIndex].sort((a, b) => a - b)
)
}
const selectAllPages = () => setSelectedPages(
Array.from({ length: pdfPageCount }, (_, i) => i).filter(p => !excludedPages.includes(p))
)
const selectNoPages = () => setSelectedPages([])
const excludePage = (pageIndex: number, e: React.MouseEvent) => {
e.stopPropagation()
setExcludedPages(prev => [...prev, pageIndex])
setSelectedPages(prev => prev.filter(p => p !== pageIndex))
}
const restoreExcludedPages = () => {
setExcludedPages([])
}
// Run OCR Comparison on a single page
const runOcrComparison = async (pageIndex: number) => {
if (!session) return
setOcrComparePageIndex(pageIndex)
setShowOcrComparison(true)
setIsComparingOcr(true)
setOcrCompareError(null)
setOcrCompareResult(null)
const API_BASE = getApiBase()
try {
const res = await fetch(`${API_BASE}/api/v1/vocab/sessions/${session.id}/compare-ocr/${pageIndex}`, {
method: 'POST',
})
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
}
const data = await res.json()
setOcrCompareResult(data)
} catch (e) {
setOcrCompareError(e instanceof Error ? e.message : 'Vergleich fehlgeschlagen')
} finally {
setIsComparingOcr(false)
}
}
// Update vocabulary entry
const updateVocabularyEntry = (id: string, field: string, value: string) => {
setVocabulary(prev => prev.map(v => {
if (v.id !== id) return v
// Check if it's a base field or an extra column
if (field === 'english' || field === 'german' || field === 'example_sentence' || field === 'word_type') {
return { ...v, [field]: value }
}
// Extra column
return { ...v, extras: { ...(v.extras || {}), [field]: value } }
}))
}
// Add a custom column for a specific source page (0 = all pages)
const addExtraColumn = (sourcePage: number) => {
const label = prompt('Spaltenname:')
if (!label || !label.trim()) return
const key = `extra_${Date.now()}`
setPageExtraColumns(prev => ({
...prev,
[sourcePage]: [...(prev[sourcePage] || []), { key, label: label.trim() }],
}))
}
// Remove a custom column
const removeExtraColumn = (sourcePage: number, key: string) => {
setPageExtraColumns(prev => ({
...prev,
[sourcePage]: (prev[sourcePage] || []).filter(c => c.key !== key),
}))
// Clean up extras from entries
setVocabulary(prev => prev.map(v => {
if (!v.extras || !(key in v.extras)) return v
const { [key]: _, ...rest } = v.extras
return { ...v, extras: rest }
}))
}
// Get extra columns for a given source page (page-specific + global)
const getExtraColumnsForPage = (sourcePage: number): ExtraColumn[] => {
const global = pageExtraColumns[0] || []
const pageSpecific = pageExtraColumns[sourcePage] || []
return [...global, ...pageSpecific]
}
// Get ALL extra columns across all pages (for unified table header)
const getAllExtraColumns = (): ExtraColumn[] => {
const seen = new Set<string>()
const result: ExtraColumn[] = []
for (const cols of Object.values(pageExtraColumns)) {
for (const col of cols) {
if (!seen.has(col.key)) {
seen.add(col.key)
result.push(col)
}
}
}
return result
}
// Delete vocabulary entry
const deleteVocabularyEntry = (id: string) => {
setVocabulary(prev => prev.filter(v => v.id !== id))
}
// Toggle vocabulary entry selection
const toggleVocabularySelection = (id: string) => {
setVocabulary(prev => prev.map(v =>
v.id === id ? { ...v, selected: !v.selected } : v
))
}
// Toggle all vocabulary entries selection
const toggleAllSelection = () => {
const allSelected = vocabulary.every(v => v.selected)
setVocabulary(prev => prev.map(v => ({ ...v, selected: !allSelected })))
}
// Add new vocabulary entry at specific position (default: end)
const addVocabularyEntry = (atIndex?: number) => {
const newEntry: VocabularyEntry = {
id: `new-${Date.now()}`,
english: '',
german: '',
example_sentence: '',
selected: true
}
setVocabulary(prev => {
if (atIndex === undefined) {
return [...prev, newEntry]
}
const newList = [...prev]
newList.splice(atIndex, 0, newEntry)
return newList
})
}
// Save vocabulary changes
const saveVocabulary = async () => {
if (!session) return
const API_BASE = getApiBase()
try {
await fetch(`${API_BASE}/api/v1/vocab/sessions/${session.id}/vocabulary`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ vocabulary }),
})
} catch (error) {
console.error('Failed to save vocabulary:', error)
}
}
// Generate worksheet
const generateWorksheet = async () => {
if (!session) return
// For standard format, require worksheet types; for NRU, types are not needed
if (selectedFormat === 'standard' && selectedTypes.length === 0) return
setIsGenerating(true)
const API_BASE = getApiBase()
try {
await saveVocabulary()
let res: Response
if (selectedFormat === 'nru') {
// Use NRU format endpoint
res = await fetch(`${API_BASE}/api/v1/vocab/sessions/${session.id}/generate-nru`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: worksheetTitle || session.name,
include_solutions: includeSolutions,
}),
})
} else {
// Use standard format endpoint
res = await fetch(`${API_BASE}/api/v1/vocab/sessions/${session.id}/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
worksheet_types: selectedTypes,
title: worksheetTitle || session.name,
include_solutions: includeSolutions,
line_height: lineHeight,
}),
})
}
if (res.ok) {
const data = await res.json()
// NRU endpoint returns worksheet_id, standard returns id
setWorksheetId(data.worksheet_id || data.id)
setActiveTab('export')
// Complete activity tracking with vocab count
completeActivity({ vocabCount: vocabulary.length })
}
} catch (error) {
console.error('Failed to generate worksheet:', error)
} finally {
setIsGenerating(false)
}
}
// Download PDF
const downloadPDF = (type: 'worksheet' | 'solution') => {
if (!worksheetId) return
const API_BASE = getApiBase()
const endpoint = type === 'worksheet' ? 'pdf' : 'solution'
window.open(`${API_BASE}/api/v1/vocab/worksheets/${worksheetId}/${endpoint}`, '_blank')
}
// Toggle worksheet type selection
const toggleWorksheetType = (type: WorksheetType) => {
setSelectedTypes(prev =>
prev.includes(type) ? prev.filter(t => t !== type) : [...prev, type]
)
}
// Resume an existing session
const resumeSession = async (existingSession: Session) => {
setError(null)
setExtractionStatus('Session wird geladen...')
const API_BASE = getApiBase()
try {
const sessionRes = await fetch(`${API_BASE}/api/v1/vocab/sessions/${existingSession.id}`)
if (!sessionRes.ok) throw new Error('Session nicht gefunden')
const sessionData = await sessionRes.json()
setSession(sessionData)
setWorksheetTitle(sessionData.name)
if (sessionData.status === 'extracted' || sessionData.status === 'completed') {
const vocabRes = await fetch(`${API_BASE}/api/v1/vocab/sessions/${existingSession.id}/vocabulary`)
if (vocabRes.ok) {
const vocabData = await vocabRes.json()
setVocabulary(vocabData.vocabulary || [])
}
setActiveTab('vocabulary')
setExtractionStatus('')
} else if (sessionData.status === 'pending') {
setActiveTab('upload')
setExtractionStatus('Diese Session hat noch keine Vokabeln. Bitte laden Sie ein Dokument hoch.')
} else {
setActiveTab('vocabulary')
setExtractionStatus('')
}
} catch (error) {
console.error('Failed to resume session:', error)
setError(error instanceof Error ? error.message : 'Fehler beim Laden der Session')
setExtractionStatus('')
}
}
// Reset session
const resetSession = async () => {
setSession(null)
setSessionName('')
setVocabulary([])
setUploadedImage(null)
setWorksheetId(null)
setSelectedDocumentId(null)
setDirectFile(null)
setDirectFilePreview(null)
setPdfPageCount(0)
setSelectedPages([])
setPagesThumbnails([])
setExcludedPages([])
setActiveTab('upload')
setError(null)
setExtractionStatus('')
const API_BASE = getApiBase()
try {
const res = await fetch(`${API_BASE}/api/v1/vocab/sessions`)
if (res.ok) {
const sessions = await res.json()
setExistingSessions(sessions)
}
} catch (e) {
console.error('Failed to reload sessions:', e)
}
}
// Delete a session
const deleteSession = async (sessionId: string, e: React.MouseEvent) => {
e.stopPropagation()
if (!confirm('Session wirklich loeschen? Diese Aktion kann nicht rueckgaengig gemacht werden.')) {
return
}
const API_BASE = getApiBase()
try {
const res = await fetch(`${API_BASE}/api/v1/vocab/sessions/${sessionId}`, {
method: 'DELETE',
})
if (res.ok) {
setExistingSessions(prev => prev.filter(s => s.id !== sessionId))
}
} catch (e) {
console.error('Failed to delete session:', e)
}
}
// Format file size
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
const worksheetTypes: { id: WorksheetType; label: string; description: string }[] = [
{ id: 'en_to_de', label: 'Englisch → Deutsch', description: 'Englische Woerter uebersetzen' },
{ id: 'de_to_en', label: 'Deutsch → Englisch', description: 'Deutsche Woerter uebersetzen' },
{ id: 'copy', label: 'Abschreibuebung', description: 'Woerter mehrfach schreiben' },
{ id: 'gap_fill', label: 'Lueckensaetze', description: 'Saetze mit Luecken ausfuellen' },
]
// Glassmorphism styles
const glassCard = isDark
? 'backdrop-blur-xl bg-white/10 border border-white/20'
: 'backdrop-blur-xl bg-white/70 border border-black/10'
const glassInput = isDark
? 'bg-white/10 border-white/20 text-white placeholder-white/40 focus:border-purple-400'
: 'bg-white/50 border-black/10 text-slate-900 placeholder-slate-400 focus:border-purple-500'
if (!mounted) {
return (
<div className="min-h-screen bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800 flex items-center justify-center">
<div className="w-8 h-8 border-4 border-purple-400 border-t-transparent rounded-full animate-spin" />
</div>
)
}
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-purple-50 to-pink-100'
}`}>
{/* Animated 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-blob ${
isDark ? 'bg-purple-500 opacity-70' : 'bg-purple-300 opacity-50'
}`} />
<div className={`absolute -bottom-40 -left-40 w-80 h-80 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-2000 ${
isDark ? 'bg-pink-500 opacity-70' : 'bg-pink-300 opacity-50'
}`} />
<div className={`absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-80 h-80 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-4000 ${
isDark ? 'bg-indigo-500 opacity-70' : 'bg-indigo-300 opacity-50'
}`} />
</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={`border-b ${isDark ? 'border-white/10' : 'border-black/5'}`}>
<div className={`${glassCard} border-0 border-b`}>
<div className="max-w-7xl mx-auto px-6 py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${
isDark ? 'bg-purple-500/30' : 'bg-purple-200'
}`}>
<svg className={`w-6 h-6 ${isDark ? 'text-purple-300' : 'text-purple-600'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 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'}`}>
Vokabel-Arbeitsblatt Generator
</h1>
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
Schulbuchseiten scannen KI extrahiert Vokabeln Druckfertige Arbeitsblaetter
{session && (
<span className={`ml-2 font-mono text-xs ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
Session: {session.id.slice(0, 8)}
</span>
)}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{/* Settings Button */}
<button
onClick={() => setShowSettings(!showSettings)}
className={`p-2 rounded-xl transition-all ${
showSettings
? (isDark ? 'bg-purple-500/30 text-purple-300' : 'bg-purple-200 text-purple-700')
: (isDark ? 'hover:bg-white/10 text-white/60 hover:text-white' : 'hover:bg-black/5 text-slate-500 hover:text-slate-700')
}`}
title="OCR-Einstellungen"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
{session && (
<button
onClick={resetSession}
className={`px-4 py-2 rounded-xl text-sm font-medium transition-all ${
isDark
? 'bg-white/10 text-white/80 hover:bg-white/20'
: 'bg-black/5 text-slate-700 hover:bg-black/10'
}`}
>
Neue Session
</button>
)}
</div>
</div>
</div>
</div>
</div>
<div className="relative z-10 w-full px-6 py-6">
{/* OCR Settings Panel */}
{showSettings && (
<div className={`${glassCard} rounded-2xl p-6 mb-6`}>
<div className="flex items-center justify-between mb-4">
<h2 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
OCR-Filter Einstellungen
</h2>
<button
onClick={() => setShowSettings(false)}
className={`p-1 rounded-lg ${isDark ? 'hover:bg-white/10 text-white/60' : 'hover:bg-black/5 text-slate-500'}`}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className={`p-4 rounded-xl mb-4 ${isDark ? 'bg-blue-500/20 text-blue-200' : 'bg-blue-100 text-blue-800'}`}>
<p className="text-sm">
Diese Einstellungen helfen, unerwuenschte Elemente wie Seitenzahlen, Kapitelnamen oder Kopfzeilen aus dem OCR-Ergebnis zu filtern.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Checkboxes */}
<div className="space-y-3">
<label className={`flex items-center gap-3 cursor-pointer ${isDark ? 'text-white' : 'text-slate-900'}`}>
<input
type="checkbox"
checked={ocrPrompts.filterHeaders}
onChange={(e) => saveOcrPrompts({ ...ocrPrompts, filterHeaders: e.target.checked })}
className="w-5 h-5 rounded border-2 border-purple-500 text-purple-500 focus:ring-purple-500"
/>
<span>Kopfzeilen filtern (z.B. Kapitelnamen)</span>
</label>
<label className={`flex items-center gap-3 cursor-pointer ${isDark ? 'text-white' : 'text-slate-900'}`}>
<input
type="checkbox"
checked={ocrPrompts.filterFooters}
onChange={(e) => saveOcrPrompts({ ...ocrPrompts, filterFooters: e.target.checked })}
className="w-5 h-5 rounded border-2 border-purple-500 text-purple-500 focus:ring-purple-500"
/>
<span>Fusszeilen filtern</span>
</label>
<label className={`flex items-center gap-3 cursor-pointer ${isDark ? 'text-white' : 'text-slate-900'}`}>
<input
type="checkbox"
checked={ocrPrompts.filterPageNumbers}
onChange={(e) => saveOcrPrompts({ ...ocrPrompts, filterPageNumbers: e.target.checked })}
className="w-5 h-5 rounded border-2 border-purple-500 text-purple-500 focus:ring-purple-500"
/>
<span>Seitenzahlen filtern (auch ausgeschrieben: "zweihundertzwoelf")</span>
</label>
</div>
{/* Patterns */}
<div className="space-y-4">
<div>
<label className={`block text-sm font-medium mb-2 ${isDark ? 'text-white/70' : 'text-slate-600'}`}>
Kopfzeilen-Muster (kommagetrennt)
</label>
<input
type="text"
value={ocrPrompts.headerPatterns.join(', ')}
onChange={(e) => saveOcrPrompts({
...ocrPrompts,
headerPatterns: e.target.value.split(',').map(s => s.trim()).filter(Boolean)
})}
placeholder="Unit, Chapter, Lesson..."
className={`w-full px-4 py-2 rounded-xl border ${glassInput} focus:outline-none focus:ring-2 focus:ring-purple-500`}
/>
</div>
<div>
<label className={`block text-sm font-medium mb-2 ${isDark ? 'text-white/70' : 'text-slate-600'}`}>
Fusszeilen-Muster (kommagetrennt)
</label>
<input
type="text"
value={ocrPrompts.footerPatterns.join(', ')}
onChange={(e) => saveOcrPrompts({
...ocrPrompts,
footerPatterns: e.target.value.split(',').map(s => s.trim()).filter(Boolean)
})}
placeholder="zweihundert, Page, Seite..."
className={`w-full px-4 py-2 rounded-xl border ${glassInput} focus:outline-none focus:ring-2 focus:ring-purple-500`}
/>
</div>
</div>
</div>
<div className="mt-4">
<label className={`block text-sm font-medium mb-2 ${isDark ? 'text-white/70' : 'text-slate-600'}`}>
Zusaetzlicher Filter-Prompt (optional)
</label>
<textarea
value={ocrPrompts.customFilter}
onChange={(e) => saveOcrPrompts({ ...ocrPrompts, customFilter: e.target.value })}
placeholder="z.B.: Ignoriere alle Zeilen, die nur Zahlen oder Buchstaben enthalten..."
rows={2}
className={`w-full px-4 py-2 rounded-xl border ${glassInput} focus:outline-none focus:ring-2 focus:ring-purple-500 resize-none`}
/>
</div>
<div className="mt-4 flex justify-end">
<button
onClick={() => saveOcrPrompts(defaultOcrPrompts)}
className={`px-4 py-2 rounded-xl text-sm ${isDark ? 'text-white/60 hover:text-white' : 'text-slate-500 hover:text-slate-700'}`}
>
Auf Standard zuruecksetzen
</button>
</div>
</div>
)}
{/* Error Message */}
{error && (
<div className={`${glassCard} rounded-2xl p-4 mb-6 ${isDark ? 'bg-red-500/20 border-red-500/30' : 'bg-red-100/80 border-red-200'}`}>
<p className={isDark ? 'text-red-200' : 'text-red-700'}>{error}</p>
</div>
)}
{/* Status Message */}
{extractionStatus && (
<div className={`${glassCard} rounded-2xl p-4 mb-6 ${isDark ? 'bg-purple-500/20 border-purple-500/30' : 'bg-purple-100/80 border-purple-200'}`}>
{isCreatingSession || isExtracting ? (
<div className="flex items-center gap-3">
<div className={`w-5 h-5 border-2 ${isDark ? 'border-purple-300' : 'border-purple-600'} border-t-transparent rounded-full animate-spin`} />
<span className={isDark ? 'text-purple-200' : 'text-purple-700'}>{extractionStatus}</span>
</div>
) : (
<span className={isDark ? 'text-purple-200' : 'text-purple-700'}>{extractionStatus}</span>
)}
</div>
)}
{/* Initial Upload Screen */}
{!session && (
<div className="space-y-6">
{/* Existing Sessions */}
{existingSessions.length > 0 && (
<div className={`${glassCard} rounded-2xl p-6`}>
<h2 className={`text-lg font-semibold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>
Vorhandene Sessions fortsetzen
</h2>
{isLoadingSessions ? (
<div className="flex items-center gap-3 py-4">
<div className="w-5 h-5 border-2 border-purple-500 border-t-transparent rounded-full animate-spin" />
<span className={isDark ? 'text-white/60' : 'text-slate-500'}>Lade Sessions...</span>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{existingSessions.map((s) => (
<div
key={s.id}
className={`${glassCard} p-4 rounded-xl text-left transition-all hover:shadow-lg relative group cursor-pointer ${
isDark ? 'hover:border-purple-400/50' : 'hover:border-purple-400'
}`}
onClick={() => resumeSession(s)}
>
{/* Delete Button */}
<button
onClick={(e) => deleteSession(s.id, e)}
className={`absolute top-2 right-2 p-1.5 rounded-lg opacity-0 group-hover:opacity-100 transition-opacity ${
isDark ? 'hover:bg-red-500/20 text-red-400' : 'hover:bg-red-100 text-red-500'
}`}
title="Session loeschen"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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 className="flex items-start gap-3">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0 ${
s.status === 'extracted' || s.status === 'completed'
? (isDark ? 'bg-green-500/30' : 'bg-green-100')
: (isDark ? 'bg-white/10' : 'bg-slate-100')
}`}>
{s.status === 'extracted' || s.status === 'completed' ? (
<svg className="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className={`w-5 h-5 ${isDark ? 'text-white/40' : 'text-slate-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
)}
</div>
<div className="flex-1 min-w-0">
<h3 className={`font-medium truncate ${isDark ? 'text-white' : 'text-slate-900'}`}>{s.name}</h3>
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
{s.vocabulary_count} Vokabeln
{s.status === 'pending' && ' • Nicht gestartet'}
{s.status === 'extracted' && ' • Bereit'}
{s.status === 'completed' && ' • Abgeschlossen'}
</p>
{s.created_at && (
<p className={`text-xs mt-1 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
{new Date(s.created_at).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})}
</p>
)}
</div>
<svg className={`w-5 h-5 flex-shrink-0 ${isDark ? 'text-white/30' : 'text-slate-300'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
))}
</div>
)}
</div>
)}
{/* Explanation */}
<div className={`${glassCard} rounded-2xl p-6 ${isDark ? 'bg-gradient-to-br from-purple-500/20 to-pink-500/20' : 'bg-gradient-to-br from-purple-100/50 to-pink-100/50'}`}>
<h2 className={`text-lg font-semibold mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
{existingSessions.length > 0 ? 'Oder neue Session starten:' : 'So funktioniert es:'}
</h2>
<ol className={`space-y-2 ${isDark ? 'text-white/70' : 'text-slate-600'}`}>
<li className="flex items-start gap-2">
<span className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0 ${isDark ? 'bg-purple-500/30 text-purple-300' : 'bg-purple-200 text-purple-700'}`}>1</span>
<span>Session benennen und Dokument (Bild oder PDF) auswaehlen</span>
</li>
<li className="flex items-start gap-2">
<span className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0 ${isDark ? 'bg-purple-500/30 text-purple-300' : 'bg-purple-200 text-purple-700'}`}>2</span>
<span>Bei PDFs: Seiten auswaehlen die verarbeitet werden sollen</span>
</li>
<li className="flex items-start gap-2">
<span className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0 ${isDark ? 'bg-purple-500/30 text-purple-300' : 'bg-purple-200 text-purple-700'}`}>3</span>
<span>KI extrahiert automatisch Vokabeln (Englisch | Deutsch | Beispiel)</span>
</li>
<li className="flex items-start gap-2">
<span className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0 ${isDark ? 'bg-purple-500/30 text-purple-300' : 'bg-purple-200 text-purple-700'}`}>4</span>
<span>Vokabeln pruefen/korrigieren und Arbeitsblatt-Typ waehlen</span>
</li>
<li className="flex items-start gap-2">
<span className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0 ${isDark ? 'bg-purple-500/30 text-purple-300' : 'bg-purple-200 text-purple-700'}`}>5</span>
<span>PDF herunterladen und ausdrucken</span>
</li>
</ol>
</div>
{/* Two Column Layout */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Session Name */}
<div className={`${glassCard} rounded-2xl p-6`}>
<h2 className={`text-lg font-semibold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>
1. Session benennen
</h2>
<input
type="text"
value={sessionName}
onChange={(e) => { setSessionName(e.target.value); setError(null) }}
placeholder="z.B. Englisch Klasse 7 - Unit 3"
className={`w-full px-4 py-3 rounded-xl border ${glassInput} focus:outline-none focus:ring-2 focus:ring-purple-500`}
/>
</div>
{/* Document Selection */}
<div className={`${glassCard} rounded-2xl p-6`}>
<h2 className={`text-lg font-semibold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>
2. Dokument auswaehlen
</h2>
{/* Direct Upload */}
<input ref={directFileInputRef} type="file" accept="image/png,image/jpeg,image/jpg,application/pdf" onChange={handleDirectFileSelect} className="hidden" />
{/* Two upload options side by side */}
<div className="grid grid-cols-2 gap-3 mb-4">
{/* File Upload Button */}
<button
onClick={() => directFileInputRef.current?.click()}
className={`p-4 rounded-xl border-2 border-dashed transition-all ${
directFile
? (isDark ? 'border-green-400/50 bg-green-500/20' : 'border-green-500 bg-green-50')
: (isDark ? 'border-white/20 hover:border-purple-400/50' : 'border-slate-300 hover:border-purple-500')
}`}
>
{directFile ? (
<div className="flex items-center gap-3">
<span className="text-2xl">{directFile.type === 'application/pdf' ? '📄' : '🖼️'}</span>
<div className="text-left flex-1 min-w-0">
<p className={`font-medium truncate ${isDark ? 'text-white' : 'text-slate-900'}`}>{directFile.name}</p>
<p className={`text-xs ${isDark ? 'text-white/60' : 'text-slate-500'}`}>{formatFileSize(directFile.size)}</p>
</div>
<svg className="w-5 h-5 text-green-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
) : (
<div className={`text-center ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
<span className="text-2xl block mb-1">📁</span>
<span className="text-sm">Datei auswaehlen</span>
</div>
)}
</button>
{/* QR Code Upload Button */}
<button
onClick={() => setShowQRModal(true)}
className={`p-4 rounded-xl border-2 border-dashed transition-all ${
selectedMobileFile
? (isDark ? 'border-green-400/50 bg-green-500/20' : 'border-green-500 bg-green-50')
: (isDark ? 'border-white/20 hover:border-purple-400/50' : 'border-slate-300 hover:border-purple-500')
}`}
>
{selectedMobileFile ? (
<div className="flex items-center gap-3">
<span className="text-2xl">{selectedMobileFile.type.startsWith('image/') ? '🖼️' : '📄'}</span>
<div className="text-left flex-1 min-w-0">
<p className={`font-medium truncate text-sm ${isDark ? 'text-white' : 'text-slate-900'}`}>{selectedMobileFile.name}</p>
<p className={`text-xs ${isDark ? 'text-white/60' : 'text-slate-500'}`}>vom Handy</p>
</div>
<svg className="w-5 h-5 text-green-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
) : (
<div className={`text-center ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
<span className="text-2xl block mb-1">📱</span>
<span className="text-sm">Mit Handy scannen</span>
</div>
)}
</button>
</div>
{/* Mobile Uploaded Files */}
{mobileUploadedFiles.length > 0 && !directFile && (
<>
<div className={`text-center text-sm mb-3 ${isDark ? 'text-white/40' : 'text-slate-400'}`}> Vom Handy hochgeladen </div>
<div className="space-y-2 max-h-32 overflow-y-auto mb-4">
{mobileUploadedFiles.map((file) => (
<button
key={file.id}
onClick={() => { setSelectedMobileFile(file); setDirectFile(null); setSelectedDocumentId(null); setError(null) }}
className={`w-full flex items-center gap-3 p-3 rounded-xl text-left transition-all ${
selectedMobileFile?.id === file.id
? (isDark ? 'bg-green-500/30 border-2 border-green-400/50' : 'bg-green-100 border-2 border-green-500')
: (isDark ? 'bg-white/5 border-2 border-transparent hover:border-white/20' : 'bg-slate-50 border-2 border-transparent hover:border-slate-200')
}`}
>
<span className="text-xl">{file.type.startsWith('image/') ? '🖼️' : '📄'}</span>
<div className="flex-1 min-w-0">
<p className={`font-medium truncate ${isDark ? 'text-white' : 'text-slate-900'}`}>{file.name}</p>
<p className={`text-xs ${isDark ? 'text-white/60' : 'text-slate-500'}`}>{formatFileSize(file.size)}</p>
</div>
{selectedMobileFile?.id === file.id && (
<svg className="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)}
</button>
))}
</div>
</>
)}
{/* Stored Documents */}
{storedDocuments.length > 0 && !directFile && (
<>
<div className={`text-center text-sm mb-3 ${isDark ? 'text-white/40' : 'text-slate-400'}`}> oder aus Ihren Dokumenten </div>
<div className="space-y-2 max-h-32 overflow-y-auto">
{storedDocuments.map((doc) => (
<button
key={doc.id}
onClick={() => { setSelectedDocumentId(doc.id); setDirectFile(null); setError(null) }}
className={`w-full flex items-center gap-3 p-3 rounded-xl text-left transition-all ${
selectedDocumentId === doc.id
? (isDark ? 'bg-purple-500/30 border-2 border-purple-400/50' : 'bg-purple-100 border-2 border-purple-500')
: (isDark ? 'bg-white/5 border-2 border-transparent hover:border-white/20' : 'bg-slate-50 border-2 border-transparent hover:border-slate-200')
}`}
>
<span className="text-xl">{doc.type === 'application/pdf' ? '📄' : '🖼️'}</span>
<div className="flex-1 min-w-0">
<p className={`font-medium truncate ${isDark ? 'text-white' : 'text-slate-900'}`}>{doc.name}</p>
<p className={`text-xs ${isDark ? 'text-white/60' : 'text-slate-500'}`}>{formatFileSize(doc.size)}</p>
</div>
{selectedDocumentId === doc.id && (
<svg className="w-5 h-5 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)}
</button>
))}
</div>
</>
)}
</div>
</div>
{/* Start Button */}
<div className="flex justify-center">
<button
onClick={() => {
if (!sessionName.trim()) {
alert('Bitte geben Sie einen Session-Namen ein (z.B. "Englisch Klasse 7 - Unit 3")')
return
}
if (!selectedDocumentId && !directFile && !selectedMobileFile) {
alert('Bitte wählen Sie ein Dokument aus oder laden Sie eine Datei hoch.')
return
}
startSession()
}}
disabled={isCreatingSession}
className="px-8 py-4 bg-gradient-to-r from-purple-500 to-pink-500 text-white rounded-2xl font-semibold text-lg disabled:opacity-50 hover:shadow-xl hover:shadow-purple-500/30 transition-all transform hover:scale-105"
>
{isCreatingSession ? 'Verarbeite...' : 'Weiter →'}
</button>
</div>
</div>
)}
{/* PDF Page Selection */}
{session && activeTab === 'pages' && (
<div className={`${glassCard} rounded-2xl p-6`}>
<div className="flex items-center justify-between mb-4">
<h2 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
PDF-Seiten auswaehlen ({selectedPages.length} von {pdfPageCount - excludedPages.length} ausgewaehlt)
</h2>
<div className="flex gap-2">
{excludedPages.length > 0 && (
<button onClick={restoreExcludedPages} className={`px-3 py-1 rounded-lg text-sm ${isDark ? 'bg-orange-500/20 text-orange-300 hover:bg-orange-500/30' : 'bg-orange-100 text-orange-700 hover:bg-orange-200'}`}>
{excludedPages.length} ausgeblendet - wiederherstellen
</button>
)}
<button onClick={selectAllPages} className={`px-3 py-1 rounded-lg text-sm transition-colors ${isDark ? 'bg-white/10 hover:bg-white/20 text-white' : 'bg-slate-100 hover:bg-slate-200 text-slate-900'}`}>
Alle
</button>
<button onClick={selectNoPages} className={`px-3 py-1 rounded-lg text-sm transition-colors ${isDark ? 'bg-white/10 hover:bg-white/20 text-white' : 'bg-slate-100 hover:bg-slate-200 text-slate-900'}`}>
Keine
</button>
</div>
</div>
<p className={`text-sm mb-4 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
Klicken Sie auf eine Seite um sie auszuwaehlen. Klicken Sie auf das X um leere Seiten auszublenden.
</p>
{isLoadingThumbnails ? (
<div className="flex items-center justify-center py-12">
<div className="w-8 h-8 border-4 border-purple-500 border-t-transparent rounded-full animate-spin" />
<span className={`ml-3 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>Lade Seitenvorschau...</span>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4 mb-6">
{pagesThumbnails.map((thumb, idx) => {
if (excludedPages.includes(idx)) return null
return (
<div key={idx} className="relative group">
{/* Exclude/Delete Button */}
<button
onClick={(e) => excludePage(idx, e)}
className="absolute top-1 left-1 z-10 p-1 rounded-full opacity-0 group-hover:opacity-100 transition-opacity bg-red-500/80 hover:bg-red-600 text-white"
title="Seite ausblenden"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
{/* OCR Compare Button */}
<button
onClick={(e) => { e.stopPropagation(); runOcrComparison(idx); }}
className="absolute top-1 right-1 z-10 p-1 rounded-full opacity-0 group-hover:opacity-100 transition-opacity bg-blue-500/80 hover:bg-blue-600 text-white"
title="OCR-Methoden vergleichen"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
</button>
<button
onClick={() => togglePageSelection(idx)}
className={`relative rounded-xl overflow-hidden border-2 transition-all w-full ${
selectedPages.includes(idx)
? 'border-purple-500 ring-2 ring-purple-500/50'
: (isDark ? 'border-white/20 hover:border-white/40' : 'border-slate-200 hover:border-slate-300')
}`}
>
<img src={thumb} alt={`Seite ${idx + 1}`} className="w-full h-auto" />
<div className={`absolute bottom-0 left-0 right-0 py-1 text-center text-xs font-medium ${
selectedPages.includes(idx)
? 'bg-purple-500 text-white'
: (isDark ? 'bg-black/60 text-white/80' : 'bg-white/90 text-slate-700')
}`}>
Seite {idx + 1}
</div>
{selectedPages.includes(idx) && (
<div className="absolute top-2 right-2 w-6 h-6 bg-purple-500 rounded-full flex items-center justify-center">
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
)}
</button>
</div>
)
})}
</div>
)}
<div className="flex justify-center">
<button
onClick={processSelectedPages}
disabled={selectedPages.length === 0 || isExtracting}
className="px-8 py-4 bg-gradient-to-r from-purple-500 to-pink-500 text-white rounded-2xl font-semibold disabled:opacity-50 hover:shadow-xl hover:shadow-purple-500/30 transition-all transform hover:scale-105"
>
{isExtracting ? 'Extrahiere Vokabeln...' : `${selectedPages.length} Seiten verarbeiten`}
</button>
</div>
</div>
)}
{/* Vocabulary Tab */}
{session && activeTab === 'vocabulary' && (() => {
const extras = getAllExtraColumns()
const baseCols = 3 + extras.length // english, german, example + extras
const gridCols = `14px 32px 36px repeat(${baseCols}, 1fr) 32px`
return (
<div className="flex flex-col lg:flex-row gap-4" style={{ height: 'calc(100vh - 240px)', minHeight: '500px' }}>
{/* Left: Original pages — full quality */}
<div className={`${glassCard} rounded-2xl p-4 lg:w-1/3 flex flex-col overflow-hidden`}>
<h2 className={`text-sm font-semibold mb-3 flex-shrink-0 ${isDark ? 'text-white/70' : 'text-slate-600'}`}>
Original ({(() => { const pp = selectedPages.length > 0 ? selectedPages : [...new Set(vocabulary.map(v => (v.source_page || 1) - 1))]; return pp.length; })()} Seiten)
</h2>
<div className="flex-1 overflow-y-auto space-y-3">
{(() => {
const processedPageIndices = selectedPages.length > 0
? selectedPages
: [...new Set(vocabulary.map(v => (v.source_page || 1) - 1))].sort((a, b) => a - b)
const apiBase = getApiBase()
const pagesToShow = processedPageIndices
.filter(idx => idx >= 0)
.map(idx => ({
idx,
src: session ? `${apiBase}/api/v1/vocab/sessions/${session.id}/pdf-page-image/${idx}` : null,
}))
.filter(t => t.src !== null) as { idx: number; src: string }[]
if (pagesToShow.length > 0) {
return pagesToShow.map(({ idx, src }) => (
<div key={idx} className={`relative rounded-xl overflow-hidden border ${isDark ? 'border-white/10' : 'border-black/10'}`}>
<div className={`absolute top-2 left-2 px-2 py-0.5 rounded-lg text-xs font-medium z-10 ${isDark ? 'bg-black/60 text-white' : 'bg-white/90 text-slate-700'}`}>
S. {idx + 1}
</div>
<img src={src} alt={`Seite ${idx + 1}`} className="w-full h-auto" />
</div>
))
}
if (uploadedImage) {
return (
<div className={`relative rounded-xl overflow-hidden border ${isDark ? 'border-white/10' : 'border-black/10'}`}>
<img src={uploadedImage} alt="Arbeitsblatt" className="w-full h-auto" />
</div>
)
}
return (
<div className={`flex-1 flex items-center justify-center py-12 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
<div className="text-center">
<svg className="w-12 h-12 mx-auto mb-2 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<p className="text-xs">Kein Bild verfuegbar</p>
</div>
</div>
)
})()}
</div>
</div>
{/* Right: Vocabulary table (2/3 width) */}
<div className={`${glassCard} rounded-2xl p-4 lg:w-2/3 flex flex-col overflow-hidden`}>
<div className="flex items-center justify-between mb-3 flex-shrink-0">
<h2 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
Vokabeln ({vocabulary.length})
</h2>
<div className="flex gap-2">
<button onClick={saveVocabulary} className={`px-4 py-2 rounded-xl text-sm font-medium transition-colors ${isDark ? 'bg-white/10 hover:bg-white/20 text-white' : 'bg-slate-100 hover:bg-slate-200 text-slate-900'}`}>
Speichern
</button>
<button onClick={() => setActiveTab('worksheet')} 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">
Weiter
</button>
</div>
</div>
{/* Error messages for failed pages */}
{processingErrors.length > 0 && (
<div className={`rounded-xl p-3 mb-3 flex-shrink-0 ${isDark ? 'bg-orange-500/20 text-orange-200 border border-orange-500/30' : 'bg-orange-100 text-orange-700 border border-orange-200'}`}>
<div className="font-medium mb-1 text-sm">Einige Seiten konnten nicht verarbeitet werden:</div>
<ul className="text-xs space-y-0.5">
{processingErrors.map((err, idx) => (
<li key={idx}> {err}</li>
))}
</ul>
</div>
)}
{/* Processing Progress */}
{currentlyProcessingPage && (
<div className={`rounded-xl p-3 mb-3 flex-shrink-0 ${isDark ? 'bg-purple-500/20 border border-purple-500/30' : 'bg-purple-100 border border-purple-200'}`}>
<div className="flex items-center gap-3">
<div className={`w-4 h-4 border-2 ${isDark ? 'border-purple-300' : 'border-purple-600'} border-t-transparent rounded-full animate-spin`} />
<div>
<div className={`text-sm font-medium ${isDark ? 'text-purple-200' : 'text-purple-700'}`}>Verarbeite Seite {currentlyProcessingPage}...</div>
<div className={`text-xs ${isDark ? 'text-purple-300/70' : 'text-purple-600'}`}>
{successfulPages.length > 0 && `${successfulPages.length} Seite(n) fertig • `}
{vocabulary.length} Vokabeln bisher
</div>
</div>
</div>
</div>
)}
{/* Success info */}
{!currentlyProcessingPage && successfulPages.length > 0 && failedPages.length === 0 && (
<div className={`rounded-xl p-2 mb-3 text-xs flex-shrink-0 ${isDark ? 'bg-green-500/20 text-green-200 border border-green-500/30' : 'bg-green-100 text-green-700 border border-green-200'}`}>
Alle {successfulPages.length} Seite(n) erfolgreich verarbeitet - {vocabulary.length} Vokabeln insgesamt
</div>
)}
{/* Partial success info */}
{!currentlyProcessingPage && successfulPages.length > 0 && failedPages.length > 0 && (
<div className={`rounded-xl p-2 mb-3 text-xs flex-shrink-0 ${isDark ? 'bg-yellow-500/20 text-yellow-200 border border-yellow-500/30' : 'bg-yellow-100 text-yellow-700 border border-yellow-200'}`}>
{successfulPages.length} Seite(n) erfolgreich, {failedPages.length} fehlgeschlagen - {vocabulary.length} Vokabeln extrahiert
</div>
)}
{vocabulary.length === 0 ? (
<p className={`text-center py-8 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>Keine Vokabeln gefunden.</p>
) : (
<div className="flex flex-col flex-1 overflow-hidden">
{/* Fixed Header */}
<div className={`flex-shrink-0 grid gap-1 px-2 py-2 text-sm font-medium border-b items-center ${isDark ? 'border-white/10 text-white/60' : 'border-black/10 text-slate-500'}`} style={{ gridTemplateColumns: gridCols }}>
<div>{/* insert-triangle spacer */}</div>
<div className="flex items-center justify-center">
<input
type="checkbox"
checked={vocabulary.length > 0 && vocabulary.every(v => v.selected)}
onChange={toggleAllSelection}
className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500 cursor-pointer"
title="Alle auswaehlen"
/>
</div>
<div>S.</div>
<div>Englisch</div>
<div>Deutsch</div>
<div>Beispiel</div>
{extras.map(col => (
<div key={col.key} className="flex items-center gap-1 group">
<span className="truncate">{col.label}</span>
<button
onClick={() => {
const page = Object.entries(pageExtraColumns).find(([, cols]) => cols.some(c => c.key === col.key))
if (page) removeExtraColumn(Number(page[0]), col.key)
}}
className={`opacity-0 group-hover:opacity-100 transition-opacity ${isDark ? 'text-red-400 hover:text-red-300' : 'text-red-500 hover:text-red-600'}`}
title="Spalte entfernen"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
))}
<div className="flex items-center justify-center">
<button
onClick={() => addExtraColumn(0)}
className={`p-0.5 rounded transition-colors ${isDark ? 'hover:bg-white/10 text-white/40 hover:text-white/70' : 'hover:bg-slate-200 text-slate-400 hover:text-slate-600'}`}
title="Spalte hinzufuegen"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" /></svg>
</button>
</div>
</div>
{/* Scrollable Content */}
<div className="flex-1 overflow-y-auto">
{vocabulary.map((entry, index) => (
<React.Fragment key={entry.id}>
{/* Vocabulary row */}
<div className={`grid gap-1 px-2 py-1 items-center ${isDark ? 'hover:bg-white/5' : 'hover:bg-black/5'}`} style={{ gridTemplateColumns: gridCols }}>
{/* Insert triangle */}
<button
onClick={() => addVocabularyEntry(index)}
className={`w-3.5 h-3.5 flex items-center justify-center opacity-0 hover:opacity-100 transition-opacity ${isDark ? 'text-purple-400' : 'text-purple-500'}`}
title="Zeile einfuegen"
>
<svg className="w-2.5 h-2.5" viewBox="0 0 10 10" fill="currentColor"><polygon points="0,0 10,5 0,10" /></svg>
</button>
<div className="flex items-center justify-center">
<input
type="checkbox"
checked={entry.selected || false}
onChange={() => toggleVocabularySelection(entry.id)}
className="w-4 h-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500 cursor-pointer"
/>
</div>
<div className={`flex items-center justify-center text-xs font-medium rounded ${isDark ? 'bg-white/10 text-white/60' : 'bg-black/10 text-slate-600'}`}>
{entry.source_page || '-'}
</div>
<input
type="text"
value={entry.english}
onChange={(e) => updateVocabularyEntry(entry.id, 'english', e.target.value)}
className={`px-2 py-1 rounded-lg border text-sm min-w-0 ${glassInput} focus:outline-none focus:ring-1 focus:ring-purple-500`}
/>
<input
type="text"
value={entry.german}
onChange={(e) => updateVocabularyEntry(entry.id, 'german', e.target.value)}
className={`px-2 py-1 rounded-lg border text-sm min-w-0 ${glassInput} focus:outline-none focus:ring-1 focus:ring-purple-500`}
/>
<input
type="text"
value={entry.example_sentence || ''}
onChange={(e) => updateVocabularyEntry(entry.id, 'example_sentence', e.target.value)}
placeholder="Beispiel"
className={`px-2 py-1 rounded-lg border text-sm min-w-0 ${glassInput} focus:outline-none focus:ring-1 focus:ring-purple-500`}
/>
{extras.map(col => (
<input
key={col.key}
type="text"
value={(entry.extras && entry.extras[col.key]) || ''}
onChange={(e) => updateVocabularyEntry(entry.id, col.key, e.target.value)}
placeholder={col.label}
className={`px-2 py-1 rounded-lg border text-sm min-w-0 ${glassInput} focus:outline-none focus:ring-1 focus:ring-purple-500`}
/>
))}
<button onClick={() => deleteVocabularyEntry(entry.id)} className={`p-1 rounded-lg ${isDark ? 'hover:bg-red-500/20 text-red-400' : 'hover:bg-red-100 text-red-500'}`}>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} 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>
</React.Fragment>
))}
{/* Final insert triangle after last row */}
<div className="px-2 py-1">
<button
onClick={() => addVocabularyEntry()}
className={`w-3.5 h-3.5 flex items-center justify-center opacity-30 hover:opacity-100 transition-opacity ${isDark ? 'text-purple-400' : 'text-purple-500'}`}
title="Zeile am Ende einfuegen"
>
<svg className="w-2.5 h-2.5" viewBox="0 0 10 10" fill="currentColor"><polygon points="0,0 10,5 0,10" /></svg>
</button>
</div>
</div>
{/* Footer */}
<div className={`flex-shrink-0 pt-2 border-t flex items-center justify-between text-xs ${isDark ? 'border-white/10 text-white/50' : 'border-black/10 text-slate-400'}`}>
<span>
{vocabulary.length} Vokabeln
{vocabulary.filter(v => v.selected).length > 0 && ` (${vocabulary.filter(v => v.selected).length} ausgewaehlt)`}
{(() => {
const pages = [...new Set(vocabulary.map(v => v.source_page).filter(Boolean))].sort((a, b) => (a || 0) - (b || 0))
return pages.length > 1 ? ` • Seiten: ${pages.join(', ')}` : ''
})()}
</span>
<button
onClick={() => addVocabularyEntry()}
className={`px-3 py-1 rounded-lg text-xs flex items-center gap-1 transition-colors ${
isDark
? 'bg-white/10 hover:bg-white/20 text-white/70'
: 'bg-slate-100 hover:bg-slate-200 text-slate-600'
}`}
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Zeile
</button>
</div>
</div>
)}
</div>
</div>
)
})()}
{/* Worksheet Tab */}
{session && activeTab === 'worksheet' && (
<div className={`${glassCard} rounded-2xl p-6`}>
{/* Step 1: Format Selection */}
<div className="mb-8">
<h2 className={`text-lg font-semibold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>
1. Vorlage waehlen
</h2>
<div className="grid grid-cols-2 gap-4">
{worksheetFormats.map((format) => (
<button
key={format.id}
onClick={() => setSelectedFormat(format.id)}
className={`p-5 rounded-xl border text-left transition-all ${
selectedFormat === format.id
? (isDark ? 'border-purple-400/50 bg-purple-500/20 ring-2 ring-purple-500/50' : 'border-purple-500 bg-purple-50 ring-2 ring-purple-500/30')
: (isDark ? 'border-white/20 hover:border-white/40' : 'border-slate-200 hover:border-slate-300')
}`}
>
<div className="flex items-start gap-3">
<div className={`w-10 h-10 rounded-lg flex items-center justify-center shrink-0 ${
selectedFormat === format.id
? (isDark ? 'bg-purple-500/30' : 'bg-purple-200')
: (isDark ? 'bg-white/10' : 'bg-slate-100')
}`}>
{format.id === 'standard' ? (
<svg className={`w-5 h-5 ${selectedFormat === format.id ? 'text-purple-400' : (isDark ? 'text-white/60' : 'text-slate-500')}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
) : (
<svg className={`w-5 h-5 ${selectedFormat === format.id ? 'text-purple-400' : (isDark ? 'text-white/60' : 'text-slate-500')}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z" />
</svg>
)}
</div>
<div className="flex-1">
<div className="flex items-center justify-between">
<span className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>{format.label}</span>
{selectedFormat === format.id && (
<svg className="w-5 h-5 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
)}
</div>
<p className={`text-sm mt-1 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>{format.description}</p>
</div>
</div>
</button>
))}
</div>
</div>
{/* Step 2: Configuration based on format */}
<div className="mb-6">
<h2 className={`text-lg font-semibold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>
2. Arbeitsblatt konfigurieren
</h2>
{/* Title (for both formats) */}
<div className="mb-6">
<label className={`block text-sm font-medium mb-2 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>Titel</label>
<input
type="text"
value={worksheetTitle}
onChange={(e) => setWorksheetTitle(e.target.value)}
placeholder="z.B. Vokabeln Unit 3"
className={`w-full px-4 py-3 rounded-xl border ${glassInput} focus:outline-none focus:ring-2 focus:ring-purple-500`}
/>
</div>
{/* Standard format options */}
{selectedFormat === 'standard' && (
<>
<div className="mb-6">
<label className={`block text-sm font-medium mb-3 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>Arbeitsblatt-Typen</label>
<div className="grid grid-cols-2 gap-3">
{worksheetTypes.map((type) => (
<button
key={type.id}
onClick={() => toggleWorksheetType(type.id)}
className={`p-4 rounded-xl border text-left transition-all ${
selectedTypes.includes(type.id)
? (isDark ? 'border-purple-400/50 bg-purple-500/20' : 'border-purple-500 bg-purple-50')
: (isDark ? 'border-white/20 hover:border-white/40' : 'border-slate-200 hover:border-slate-300')
}`}
>
<div className="flex items-center justify-between">
<span className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>{type.label}</span>
{selectedTypes.includes(type.id) && <svg className="w-5 h-5 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>}
</div>
<p className={`text-sm mt-1 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>{type.description}</p>
</button>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-6 mb-6">
<div>
<label className={`block text-sm font-medium mb-2 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>Zeilenhoehe</label>
<select value={lineHeight} onChange={(e) => setLineHeight(e.target.value)} className={`w-full px-4 py-3 rounded-xl border ${glassInput} focus:outline-none focus:ring-2 focus:ring-purple-500`}>
<option value="normal">Normal</option>
<option value="large">Gross</option>
<option value="extra-large">Extra gross</option>
</select>
</div>
<div className="flex items-center">
<label className={`flex items-center gap-3 cursor-pointer ${isDark ? 'text-white' : 'text-slate-900'}`}>
<input type="checkbox" checked={includeSolutions} onChange={(e) => setIncludeSolutions(e.target.checked)} className="w-5 h-5 rounded border-2 border-purple-500 text-purple-500 focus:ring-purple-500" />
<span>Loesungsblatt erstellen</span>
</label>
</div>
</div>
</>
)}
{/* NRU format options */}
{selectedFormat === 'nru' && (
<div className="space-y-4">
<div className={`p-4 rounded-xl ${isDark ? 'bg-indigo-500/20 border border-indigo-500/30' : 'bg-indigo-50 border border-indigo-200'}`}>
<h4 className={`font-medium mb-2 ${isDark ? 'text-indigo-200' : 'text-indigo-700'}`}>NRU-Format Uebersicht:</h4>
<ul className={`text-sm space-y-1 ${isDark ? 'text-indigo-200/80' : 'text-indigo-600'}`}>
<li> <strong>Vokabeln:</strong> 3-Spalten-Tabelle (Englisch | Deutsch leer | Korrektur leer)</li>
<li> <strong>Lernsaetze:</strong> Deutscher Satz + 2 leere Zeilen fuer englische Uebersetzung</li>
<li> Pro gescannter Seite werden 2 Arbeitsblatt-Seiten erzeugt</li>
</ul>
</div>
<div className="flex items-center">
<label className={`flex items-center gap-3 cursor-pointer ${isDark ? 'text-white' : 'text-slate-900'}`}>
<input type="checkbox" checked={includeSolutions} onChange={(e) => setIncludeSolutions(e.target.checked)} className="w-5 h-5 rounded border-2 border-purple-500 text-purple-500 focus:ring-purple-500" />
<span>Loesungsblatt erstellen (mit deutschen Uebersetzungen)</span>
</label>
</div>
</div>
)}
</div>
<button
onClick={generateWorksheet}
disabled={(selectedFormat === 'standard' && selectedTypes.length === 0) || isGenerating}
className="w-full py-4 bg-gradient-to-r from-purple-500 to-pink-500 text-white rounded-xl font-semibold disabled:opacity-50 hover:shadow-xl hover:shadow-purple-500/30 transition-all"
>
{isGenerating ? 'Generiere PDF...' : `${selectedFormat === 'nru' ? 'NRU-Arbeitsblatt' : 'Arbeitsblatt'} generieren`}
</button>
</div>
)}
{/* Export Tab */}
{session && activeTab === 'export' && (
<div className={`${glassCard} rounded-2xl p-6`}>
<h2 className={`text-lg font-semibold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>PDF herunterladen</h2>
{worksheetId ? (
<div className="space-y-4">
<div className={`p-4 rounded-xl ${isDark ? 'bg-green-500/20 border border-green-500/30' : 'bg-green-100 border border-green-200'}`}>
<div className="flex items-center gap-3">
<svg className="w-6 h-6 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className={`font-medium ${isDark ? 'text-green-200' : 'text-green-700'}`}>Arbeitsblatt erfolgreich generiert!</span>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<button onClick={() => downloadPDF('worksheet')} className={`${glassCard} p-6 rounded-xl text-left transition-all hover:shadow-lg ${isDark ? 'hover:border-purple-400/50' : 'hover:border-purple-500'}`}>
<div className={`w-12 h-12 mb-3 rounded-xl flex items-center justify-center ${isDark ? 'bg-purple-500/30' : 'bg-purple-100'}`}>
<svg className={`w-6 h-6 ${isDark ? 'text-purple-300' : 'text-purple-600'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<h3 className={`font-semibold mb-1 ${isDark ? 'text-white' : 'text-slate-900'}`}>Arbeitsblatt</h3>
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>PDF zum Ausdrucken</p>
</button>
{includeSolutions && (
<button onClick={() => downloadPDF('solution')} className={`${glassCard} p-6 rounded-xl text-left transition-all hover:shadow-lg ${isDark ? 'hover:border-green-400/50' : 'hover:border-green-500'}`}>
<div className={`w-12 h-12 mb-3 rounded-xl flex items-center justify-center ${isDark ? 'bg-green-500/30' : 'bg-green-100'}`}>
<svg className={`w-6 h-6 ${isDark ? 'text-green-300' : 'text-green-600'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h3 className={`font-semibold mb-1 ${isDark ? 'text-white' : 'text-slate-900'}`}>Loesungsblatt</h3>
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>PDF mit Loesungen</p>
</button>
)}
</div>
<button onClick={resetSession} className={`w-full py-3 rounded-xl border font-medium transition-colors ${isDark ? 'border-white/20 text-white/80 hover:bg-white/10' : 'border-slate-300 text-slate-700 hover:bg-slate-50'}`}>
Neues Arbeitsblatt erstellen
</button>
</div>
) : (
<p className={`text-center py-12 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>Noch kein Arbeitsblatt generiert.</p>
)}
</div>
)}
{/* Tab Navigation (when session exists) */}
{session && activeTab !== 'pages' && (
<div className={`mt-6 border-t pt-4 ${isDark ? 'border-white/10' : 'border-black/5'}`}>
<div className="flex justify-center gap-2">
{['vocabulary', 'worksheet', 'export'].map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab as TabId)}
className={`px-4 py-2 rounded-xl text-sm font-medium transition-all ${
activeTab === tab
? 'bg-gradient-to-r from-purple-500 to-pink-500 text-white shadow-lg'
: (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'}
</button>
))}
</div>
</div>
)}
</div>
{/* QR Code Modal */}
{showQRModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={() => setShowQRModal(false)} />
<div className={`relative w-full max-w-md rounded-3xl ${
isDark ? 'bg-slate-900' : 'bg-white'
}`}>
<QRCodeUpload
sessionId={uploadSessionId}
onClose={() => setShowQRModal(false)}
onFilesChanged={(files) => {
setMobileUploadedFiles(files)
if (files.length > 0) {
// Always select the latest uploaded file
setSelectedMobileFile(files[files.length - 1])
setDirectFile(null)
setSelectedDocumentId(null)
}
}}
/>
</div>
</div>
)}
{/* OCR Comparison Modal */}
{showOcrComparison && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className={`relative w-full max-w-6xl max-h-[90vh] overflow-auto rounded-3xl ${glassCard} p-6`}>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h2 className={`text-xl font-bold ${isDark ? 'text-white' : 'text-slate-900'}`}>
OCR-Methoden Vergleich
</h2>
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
Seite {ocrComparePageIndex !== null ? ocrComparePageIndex + 1 : '-'}
</p>
</div>
<button
onClick={() => setShowOcrComparison(false)}
className={`p-2 rounded-xl ${isDark ? 'hover:bg-white/10 text-white' : 'hover:bg-black/5 text-slate-500'}`}
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Loading State */}
{isComparingOcr && (
<div className="flex flex-col items-center justify-center py-12">
<div className="w-12 h-12 border-4 border-purple-500 border-t-transparent rounded-full animate-spin mb-4" />
<p className={isDark ? 'text-white/60' : 'text-slate-500'}>
Vergleiche OCR-Methoden... (kann 1-2 Minuten dauern)
</p>
</div>
)}
{/* Error State */}
{ocrCompareError && (
<div className={`p-4 rounded-xl ${isDark ? 'bg-red-500/20 text-red-300' : 'bg-red-100 text-red-700'}`}>
Fehler: {ocrCompareError}
</div>
)}
{/* Results */}
{ocrCompareResult && !isComparingOcr && (
<div className="space-y-6">
{/* Method Results Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Object.entries(ocrCompareResult.methods || {}).map(([key, method]: [string, any]) => (
<div
key={key}
className={`p-4 rounded-2xl ${
ocrCompareResult.recommendation?.best_method === key
? (isDark ? 'bg-green-500/20 border border-green-500/50' : 'bg-green-100 border border-green-300')
: (isDark ? 'bg-white/5 border border-white/10' : 'bg-white/50 border border-black/10')
}`}
>
<div className="flex items-center justify-between mb-3">
<h3 className={`font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
{method.name}
</h3>
{ocrCompareResult.recommendation?.best_method === key && (
<span className="px-2 py-1 text-xs font-medium bg-green-500 text-white rounded-full">
Beste
</span>
)}
</div>
{method.success ? (
<>
<div className={`text-sm mb-2 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
<span className="font-medium">{method.vocabulary_count}</span> Vokabeln in <span className="font-medium">{method.duration_seconds}s</span>
</div>
{method.vocabulary && method.vocabulary.length > 0 && (
<div className={`max-h-48 overflow-y-auto rounded-xl p-2 ${isDark ? 'bg-black/20' : 'bg-white/50'}`}>
{method.vocabulary.slice(0, 10).map((v: any, idx: number) => (
<div key={idx} className={`text-sm py-1 border-b last:border-0 ${isDark ? 'border-white/10 text-white/80' : 'border-black/5 text-slate-700'}`}>
<span className="font-medium">{v.english}</span> = {v.german}
</div>
))}
{method.vocabulary.length > 10 && (
<div className={`text-xs mt-2 ${isDark ? 'text-white/40' : 'text-slate-400'}`}>
+ {method.vocabulary.length - 10} weitere...
</div>
)}
</div>
)}
</>
) : (
<div className={`text-sm ${isDark ? 'text-red-300' : 'text-red-600'}`}>
{method.error || 'Fehler'}
</div>
)}
</div>
))}
</div>
{/* Comparison Summary */}
{ocrCompareResult.comparison && (
<div className={`p-4 rounded-2xl ${isDark ? 'bg-blue-500/20 border border-blue-500/30' : 'bg-blue-100 border border-blue-200'}`}>
<h3 className={`font-semibold mb-3 ${isDark ? 'text-blue-300' : 'text-blue-900'}`}>
Uebereinstimmung
</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<span className={isDark ? 'text-blue-200' : 'text-blue-700'}>Von allen erkannt:</span>
<span className="ml-2 font-bold">{ocrCompareResult.comparison.found_by_all_methods?.length || 0}</span>
</div>
<div>
<span className={isDark ? 'text-blue-200' : 'text-blue-700'}>Nur teilweise:</span>
<span className="ml-2 font-bold">{ocrCompareResult.comparison.found_by_some_methods?.length || 0}</span>
</div>
<div>
<span className={isDark ? 'text-blue-200' : 'text-blue-700'}>Gesamt einzigartig:</span>
<span className="ml-2 font-bold">{ocrCompareResult.comparison.total_unique_vocabulary || 0}</span>
</div>
<div>
<span className={isDark ? 'text-blue-200' : 'text-blue-700'}>Uebereinstimmung:</span>
<span className="ml-2 font-bold">{Math.round((ocrCompareResult.comparison.agreement_rate || 0) * 100)}%</span>
</div>
</div>
</div>
)}
</div>
)}
</div>
</div>
)}
{/* CSS for animations */}
<style jsx>{`
@keyframes blob {
0% { transform: translate(0px, 0px) scale(1); }
33% { transform: translate(30px, -50px) scale(1.1); }
66% { transform: translate(-20px, 20px) scale(0.9); }
100% { transform: translate(0px, 0px) scale(1); }
}
.animate-blob {
animation: blob 7s infinite;
}
.animation-delay-2000 {
animation-delay: 2s;
}
.animation-delay-4000 {
animation-delay: 4s;
}
`}</style>
</div>
</div>
)
}