Files
breakpilot-lehrer/studio-v2/app/vocab-worksheet/useVocabWorksheet.ts
Benjamin Admin b6983ab1dc [split-required] Split 500-1000 LOC files across all services
backend-lehrer (5 files):
- alerts_agent/db/repository.py (992 → 5), abitur_docs_api.py (956 → 3)
- teacher_dashboard_api.py (951 → 3), services/pdf_service.py (916 → 3)
- mail/mail_db.py (987 → 6)

klausur-service (5 files):
- legal_templates_ingestion.py (942 → 3), ocr_pipeline_postprocess.py (929 → 4)
- ocr_pipeline_words.py (876 → 3), ocr_pipeline_ocr_merge.py (616 → 2)
- KorrekturPage.tsx (956 → 6)

website (5 pages):
- mail (985 → 9), edu-search (958 → 8), mac-mini (950 → 7)
- ocr-labeling (946 → 7), audit-workspace (871 → 4)

studio-v2 (5 files + 1 deleted):
- page.tsx (946 → 5), MessagesContext.tsx (925 → 4)
- korrektur (914 → 6), worksheet-cleanup (899 → 6)
- useVocabWorksheet.ts (888 → 3)
- Deleted dead page-original.tsx (934 LOC)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-24 23:35:37 +02:00

375 lines
19 KiB
TypeScript

'use client'
import { useState, useRef, useEffect } from 'react'
import { useTheme } from '@/lib/ThemeContext'
import { useLanguage } from '@/lib/LanguageContext'
import { useActivity } from '@/lib/ActivityContext'
import type { UploadedFile } from '@/components/QRCodeUpload'
import type {
VocabularyEntry, ExtraColumn, Session, StoredDocument, OcrPrompts,
TabId, WorksheetType, WorksheetFormat, IpaMode, SyllableMode,
VocabWorksheetHook,
} from './types'
import {
getApiBase, DOCUMENTS_KEY, OCR_PROMPTS_KEY, SESSION_ID_KEY,
defaultOcrPrompts, formatFileSize,
} from './constants'
import { startSessionFlow, resumeSessionFlow } from './useSessionHandlers'
import { processSinglePage, reprocessPagesFlow } from './usePageProcessing'
export function useVocabWorksheet(): VocabWorksheetHook {
const { isDark } = useTheme()
const { t } = useLanguage()
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
const [existingSessions, setExistingSessions] = useState<Session[]>([])
const [isLoadingSessions, setIsLoadingSessions] = useState(true)
// Documents
const [storedDocuments, setStoredDocuments] = useState<StoredDocument[]>([])
const [selectedDocumentId, setSelectedDocumentId] = useState<string | null>(null)
// Direct file
const [directFile, setDirectFile] = useState<File | null>(null)
const [directFilePreview, setDirectFilePreview] = useState<string | null>(null)
const [showFullPreview, setShowFullPreview] = useState(false)
const directFileInputRef = useRef<HTMLInputElement>(null)
// PDF pages
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[]>([])
// Extra columns
const [pageExtraColumns, setPageExtraColumns] = useState<Record<number, ExtraColumn[]>>({})
// Upload
const [uploadedImage, setUploadedImage] = useState<string | null>(null)
const [isExtracting, setIsExtracting] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
// Vocabulary
const [vocabulary, setVocabulary] = useState<VocabularyEntry[]>([])
// Worksheet
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')
const [ipaMode, setIpaMode] = useState<IpaMode>('none')
const [syllableMode, setSyllableMode] = useState<SyllableMode>('none')
// Export
const [worksheetId, setWorksheetId] = useState<string | null>(null)
const [isGenerating, setIsGenerating] = useState(false)
// Processing
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 Settings
const [ocrPrompts, setOcrPrompts] = useState<OcrPrompts>(defaultOcrPrompts)
const [showSettings, setShowSettings] = useState(false)
const [ocrEnhance, setOcrEnhance] = useState(true)
const [ocrMaxCols, setOcrMaxCols] = useState(3)
const [ocrMinConf, setOcrMinConf] = useState(0)
// QR
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)
// --- Effects ---
useEffect(() => {
setMounted(true)
let sid = localStorage.getItem(SESSION_ID_KEY)
if (!sid) { sid = `vocab-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; localStorage.setItem(SESSION_ID_KEY, sid) }
setUploadSessionId(sid)
}, [])
useEffect(() => {
if (!mounted) return
const stored = localStorage.getItem(OCR_PROMPTS_KEY)
if (stored) { try { setOcrPrompts({ ...defaultOcrPrompts, ...JSON.parse(stored) }) } catch {} }
}, [mounted])
useEffect(() => {
if (!mounted) return
const stored = localStorage.getItem(DOCUMENTS_KEY)
if (stored) {
try { setStoredDocuments(JSON.parse(stored).filter((d: StoredDocument) => d.type?.startsWith('image/') || d.type === 'application/pdf')) } catch {}
}
}, [mounted])
useEffect(() => {
if (!mounted) return
;(async () => {
try {
const res = await fetch(`${getApiBase()}/api/v1/vocab/sessions`)
if (res.ok) setExistingSessions(await res.json())
} catch {} finally { setIsLoadingSessions(false) }
})()
}, [mounted])
// --- 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'
// --- Handlers ---
const saveOcrPrompts = (prompts: OcrPrompts) => { setOcrPrompts(prompts); localStorage.setItem(OCR_PROMPTS_KEY, JSON.stringify(prompts)) }
const handleDirectFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; if (!file) return
setDirectFile(file); setSelectedDocumentId(null); setSelectedMobileFile(null)
if (file.type.startsWith('image/')) {
const reader = new FileReader(); reader.onload = (ev) => setDirectFilePreview(ev.target?.result as string); reader.readAsDataURL(file)
} else if (file.type === 'application/pdf') { setDirectFilePreview(URL.createObjectURL(file)) }
else { setDirectFilePreview(null) }
}
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 }
setIsCreatingSession(true)
try {
await startSessionFlow({
sessionName, selectedDocumentId, directFile, selectedMobileFile, storedDocuments,
ocrPrompts, startActivity, setSession, setWorksheetTitle, setExtractionStatus,
setPdfPageCount, setSelectedPages, setPagesThumbnails, setIsLoadingThumbnails,
setVocabulary, setActiveTab, setError,
})
} catch (error) {
setError(error instanceof Error ? error.message : 'Ein Fehler ist aufgetreten')
setExtractionStatus(''); setSession(null)
} finally { setIsCreatingSession(false) }
}
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 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(session.id, pageIndex, ipaMode, syllableMode, ocrPrompts, ocrEnhance, ocrMaxCols, ocrMinConf)
if (result.success) {
successful.push(pageIndex + 1); setSuccessfulPages([...successful]); setVocabulary(prev => [...prev, ...result.vocabulary])
const qi = result.scanQuality ? ` | Qualitaet: ${result.scanQuality.quality_pct}%${result.scanQuality.is_degraded ? ' (degradiert!)' : ''}` : ''
setExtractionStatus(`Seite ${pageIndex + 1} fertig: ${result.vocabulary.length} Vokabeln gefunden${qi}`)
} 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)
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.`)
// Reload thumbnails for processed pages
if (successful.length > 0 && session) {
const API_BASE = getApiBase(); const updatedThumbs = [...pagesThumbnails]
for (const pageNum of successful) {
const idx = pageNum - 1
try {
const res = await fetch(`${API_BASE}/api/v1/vocab/sessions/${session.id}/pdf-thumbnail/${idx}?hires=true&t=${Date.now()}`)
if (res.ok) { if (updatedThumbs[idx]) URL.revokeObjectURL(updatedThumbs[idx]); updatedThumbs[idx] = URL.createObjectURL(await res.blob()) }
} catch {}
}
setPagesThumbnails(updatedThumbs)
}
setSession(prev => prev ? { ...prev, status: 'extracted' } : null)
}
const togglePageSelection = (i: number) => { setSelectedPages(p => p.includes(i) ? p.filter(x => x !== i) : [...p, i].sort((a, b) => a - b)) }
const selectAllPages = () => setSelectedPages(Array.from({ length: pdfPageCount }, (_, i) => i).filter(p => !excludedPages.includes(p)))
const selectNoPages = () => setSelectedPages([])
const excludePage = (i: number, e: React.MouseEvent) => { e.stopPropagation(); setExcludedPages(p => [...p, i]); setSelectedPages(p => p.filter(x => x !== i)) }
const restoreExcludedPages = () => setExcludedPages([])
const runOcrComparison = async (pageIndex: number) => {
if (!session) return
setOcrComparePageIndex(pageIndex); setShowOcrComparison(true); setIsComparingOcr(true); setOcrCompareError(null); setOcrCompareResult(null)
try {
const res = await fetch(`${getApiBase()}/api/v1/vocab/sessions/${session.id}/compare-ocr/${pageIndex}`, { method: 'POST' })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
setOcrCompareResult(await res.json())
} catch (e) { setOcrCompareError(e instanceof Error ? e.message : 'Vergleich fehlgeschlagen') }
finally { setIsComparingOcr(false) }
}
const updateVocabularyEntry = (id: string, field: string, value: string) => {
setVocabulary(prev => prev.map(v => {
if (v.id !== id) return v
if (field === 'english' || field === 'german' || field === 'example_sentence' || field === 'word_type') return { ...v, [field]: value }
return { ...v, extras: { ...(v.extras || {}), [field]: value } }
}))
}
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() }] }))
}
const removeExtraColumn = (sourcePage: number, key: string) => {
setPageExtraColumns(prev => ({ ...prev, [sourcePage]: (prev[sourcePage] || []).filter(c => c.key !== key) }))
setVocabulary(prev => prev.map(v => { if (!v.extras || !(key in v.extras)) return v; const { [key]: _, ...rest } = v.extras; return { ...v, extras: rest } }))
}
const getExtraColumnsForPage = (sourcePage: number): ExtraColumn[] => [...(pageExtraColumns[0] || []), ...(pageExtraColumns[sourcePage] || [])]
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
}
const deleteVocabularyEntry = (id: string) => setVocabulary(prev => prev.filter(v => v.id !== id))
const toggleVocabularySelection = (id: string) => setVocabulary(prev => prev.map(v => v.id === id ? { ...v, selected: !v.selected } : v))
const toggleAllSelection = () => { const all = vocabulary.every(v => v.selected); setVocabulary(prev => prev.map(v => ({ ...v, selected: !all }))) }
const addVocabularyEntry = (atIndex?: number) => {
const ne: VocabularyEntry = { id: `new-${Date.now()}`, english: '', german: '', example_sentence: '', selected: true }
setVocabulary(prev => { if (atIndex === undefined) return [...prev, ne]; const nl = [...prev]; nl.splice(atIndex, 0, ne); return nl })
}
const saveVocabulary = async () => {
if (!session) return
try { await fetch(`${getApiBase()}/api/v1/vocab/sessions/${session.id}/vocabulary`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ vocabulary }) }) }
catch (e) { console.error('Failed to save vocabulary:', e) }
}
const generateWorksheet = async () => {
if (!session) return; if (selectedFormat === 'standard' && selectedTypes.length === 0) return
setIsGenerating(true)
try {
await saveVocabulary()
const API_BASE = getApiBase()
const endpoint = selectedFormat === 'nru' ? 'generate-nru' : 'generate'
const body = selectedFormat === 'nru'
? { title: worksheetTitle || session.name, include_solutions: includeSolutions }
: { worksheet_types: selectedTypes, title: worksheetTitle || session.name, include_solutions: includeSolutions, line_height: lineHeight }
const res = await fetch(`${API_BASE}/api/v1/vocab/sessions/${session.id}/${endpoint}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
})
if (res.ok) { const data = await res.json(); setWorksheetId(data.worksheet_id || data.id); setActiveTab('export'); completeActivity({ vocabCount: vocabulary.length }) }
} catch (e) { console.error('Failed to generate worksheet:', e) }
finally { setIsGenerating(false) }
}
const downloadPDF = (type: 'worksheet' | 'solution') => {
if (!worksheetId) return
window.open(`${getApiBase()}/api/v1/vocab/worksheets/${worksheetId}/${type === 'worksheet' ? 'pdf' : 'solution'}`, '_blank')
}
const toggleWorksheetType = (type: WorksheetType) => setSelectedTypes(prev => prev.includes(type) ? prev.filter(t => t !== type) : [...prev, type])
const resumeSession = async (existingSession: Session) => {
setError(null); setExtractionStatus('Session wird geladen...')
try { await resumeSessionFlow(existingSession, setSession, setWorksheetTitle, setVocabulary, setActiveTab, setExtractionStatus) }
catch (e) { setError(e instanceof Error ? e.message : 'Fehler beim Laden der Session'); setExtractionStatus('') }
}
const resetSession = async () => {
setSession(null); setSessionName(''); setVocabulary([]); setUploadedImage(null); setWorksheetId(null)
setSelectedDocumentId(null); setDirectFile(null); setDirectFilePreview(null); setShowFullPreview(false)
setPdfPageCount(0); setSelectedPages([]); setPagesThumbnails([]); setExcludedPages([])
setActiveTab('upload'); setError(null); setExtractionStatus('')
try { const res = await fetch(`${getApiBase()}/api/v1/vocab/sessions`); if (res.ok) setExistingSessions(await res.json()) } catch {}
}
const deleteSession = async (sessionId: string, e: React.MouseEvent) => {
e.stopPropagation()
if (!confirm('Session wirklich loeschen? Diese Aktion kann nicht rueckgaengig gemacht werden.')) return
try { const res = await fetch(`${getApiBase()}/api/v1/vocab/sessions/${sessionId}`, { method: 'DELETE' }); if (res.ok) setExistingSessions(prev => prev.filter(s => s.id !== sessionId)) } catch {}
}
const reprocessPages = (ipa: IpaMode, syllable: SyllableMode) => {
if (!session) return
let pages: number[]
if (successfulPages.length > 0) pages = successfulPages.map(p => p - 1)
else if (vocabulary.length > 0) pages = [...new Set(vocabulary.map(v => (v.source_page || 1) - 1))].sort((a, b) => a - b)
else if (selectedPages.length > 0) pages = [...selectedPages]
else pages = [0]
if (pages.length === 0) return
setIsExtracting(true); setExtractionStatus('Verarbeite mit neuen Einstellungen...')
;(async () => {
const { vocabulary: allVocab, qualityInfo } = await reprocessPagesFlow(
session.id, pages, ipa, syllable, ocrPrompts, ocrEnhance, ocrMaxCols, ocrMinConf, setExtractionStatus
)
setVocabulary(allVocab); setIsExtracting(false)
setExtractionStatus(`${allVocab.length} Vokabeln mit neuen Einstellungen${qualityInfo}`)
})()
}
return {
mounted, isDark, glassCard, glassInput,
activeTab, setActiveTab,
session, sessionName, setSessionName, isCreatingSession, error, setError, extractionStatus,
existingSessions, isLoadingSessions,
storedDocuments, selectedDocumentId, setSelectedDocumentId,
directFile, setDirectFile, directFilePreview, showFullPreview, setShowFullPreview, directFileInputRef,
pdfPageCount, selectedPages, pagesThumbnails, isLoadingThumbnails, excludedPages,
pageExtraColumns,
uploadedImage, isExtracting,
vocabulary,
selectedTypes, worksheetTitle, setWorksheetTitle,
includeSolutions, setIncludeSolutions, lineHeight, setLineHeight,
selectedFormat, setSelectedFormat, ipaMode, setIpaMode, syllableMode, setSyllableMode,
worksheetId, isGenerating,
processingErrors, successfulPages, failedPages, currentlyProcessingPage,
ocrPrompts, showSettings, setShowSettings,
showQRModal, setShowQRModal, uploadSessionId,
mobileUploadedFiles, selectedMobileFile, setSelectedMobileFile, setMobileUploadedFiles,
showOcrComparison, setShowOcrComparison,
ocrComparePageIndex, ocrCompareResult, isComparingOcr, ocrCompareError,
handleDirectFileSelect, startSession, processSelectedPages,
togglePageSelection, selectAllPages, selectNoPages, excludePage, restoreExcludedPages,
runOcrComparison,
updateVocabularyEntry, addExtraColumn, removeExtraColumn,
getExtraColumnsForPage, getAllExtraColumns,
deleteVocabularyEntry, toggleVocabularySelection, toggleAllSelection, addVocabularyEntry,
saveVocabulary, generateWorksheet, downloadPDF, toggleWorksheetType,
resumeSession, resetSession, deleteSession,
saveOcrPrompts, formatFileSize, reprocessPages,
}
}