'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 } 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('upload') // Session state const [session, setSession] = useState(null) const [sessionName, setSessionName] = useState('') const [isCreatingSession, setIsCreatingSession] = useState(false) const [error, setError] = useState(null) const [extractionStatus, setExtractionStatus] = useState('') // Existing sessions list const [existingSessions, setExistingSessions] = useState([]) const [isLoadingSessions, setIsLoadingSessions] = useState(true) // Documents from storage const [storedDocuments, setStoredDocuments] = useState([]) const [selectedDocumentId, setSelectedDocumentId] = useState(null) // Direct file upload const [directFile, setDirectFile] = useState(null) const [directFilePreview, setDirectFilePreview] = useState(null) const directFileInputRef = useRef(null) // PDF page selection state const [pdfPageCount, setPdfPageCount] = useState(0) const [selectedPages, setSelectedPages] = useState([]) const [pagesThumbnails, setPagesThumbnails] = useState([]) const [isLoadingThumbnails, setIsLoadingThumbnails] = useState(false) const [excludedPages, setExcludedPages] = useState([]) // Upload state const [uploadedImage, setUploadedImage] = useState(null) const [isExtracting, setIsExtracting] = useState(false) const fileInputRef = useRef(null) // Vocabulary state const [vocabulary, setVocabulary] = useState([]) // Worksheet state const [selectedTypes, setSelectedTypes] = useState(['en_to_de']) const [worksheetTitle, setWorksheetTitle] = useState('') const [includeSolutions, setIncludeSolutions] = useState(true) const [lineHeight, setLineHeight] = useState('normal') const [selectedFormat, setSelectedFormat] = useState('standard') // Export state const [worksheetId, setWorksheetId] = useState(null) const [isGenerating, setIsGenerating] = useState(false) // Processing results const [processingErrors, setProcessingErrors] = useState([]) const [successfulPages, setSuccessfulPages] = useState([]) const [failedPages, setFailedPages] = useState([]) const [currentlyProcessingPage, setCurrentlyProcessingPage] = useState(null) const [processingQueue, setProcessingQueue] = useState([]) // OCR Prompts/Settings const [ocrPrompts, setOcrPrompts] = useState(defaultOcrPrompts) const [showSettings, setShowSettings] = useState(false) // QR Code Upload const [showQRModal, setShowQRModal] = useState(false) const [uploadSessionId, setUploadSessionId] = useState('') const [mobileUploadedFiles, setMobileUploadedFiles] = useState([]) const [selectedMobileFile, setSelectedMobileFile] = useState(null) // OCR Comparison const [showOcrComparison, setShowOcrComparison] = useState(false) const [ocrComparePageIndex, setOcrComparePageIndex] = useState(null) const [ocrCompareResult, setOcrCompareResult] = useState(null) const [isComparingOcr, setIsComparingOcr] = useState(false) const [ocrCompareError, setOcrCompareError] = useState(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) => { 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: keyof VocabularyEntry, value: string) => { setVocabulary(prev => prev.map(v => v.id === id ? { ...v, [field]: value } : v )) } // 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 (
) } return (
{/* Animated Background Blobs */}
{/* Sidebar */}
{/* Main Content */}
{/* Header */}

Vokabel-Arbeitsblatt Generator

Schulbuchseiten scannen → KI extrahiert Vokabeln → Druckfertige Arbeitsblaetter

{/* Settings Button */} {session && ( )}
{/* OCR Settings Panel */} {showSettings && (

OCR-Filter Einstellungen

Diese Einstellungen helfen, unerwuenschte Elemente wie Seitenzahlen, Kapitelnamen oder Kopfzeilen aus dem OCR-Ergebnis zu filtern.

{/* Checkboxes */}
{/* Patterns */}
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`} />
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`} />