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 45s
CI / test-go-edu-search (push) Successful in 43s
CI / test-python-klausur (push) Failing after 2m51s
CI / test-python-agent-core (push) Successful in 36s
CI / test-nodejs-website (push) Successful in 37s
SmartSpellChecker (klausur-service): - Language-aware OCR post-correction without LLMs - Dual-dictionary heuristic for EN/DE language detection - Context-based a/I disambiguation via bigram lookup - Multi-digit substitution (sch00l→school) - Cross-language guard (don't false-correct DE words in EN column) - Umlaut correction (Schuler→Schüler, uber→über) - Integrated into spell_review_entries_sync() pipeline - 31 tests, 9ms/100 corrections Vocab-worksheet refactoring (studio-v2): - Split 2337-line page.tsx into 14 files - Custom hook useVocabWorksheet.ts (all state + logic) - 9 components in components/ directory - types.ts, constants.ts for shared definitions Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
306 lines
17 KiB
TypeScript
306 lines
17 KiB
TypeScript
'use client'
|
|
|
|
import React from 'react'
|
|
import type { VocabWorksheetHook, IpaMode, SyllableMode } from '../types'
|
|
import { getApiBase } from '../constants'
|
|
|
|
export function VocabularyTab({ h }: { h: VocabWorksheetHook }) {
|
|
const { isDark, glassCard, glassInput } = h
|
|
const extras = h.getAllExtraColumns()
|
|
const baseCols = 3 + extras.length
|
|
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 */}
|
|
<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 = h.selectedPages.length > 0 ? h.selectedPages : [...new Set(h.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 = h.selectedPages.length > 0
|
|
? h.selectedPages
|
|
: [...new Set(h.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: h.session ? `${apiBase}/api/v1/vocab/sessions/${h.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 (h.uploadedImage) {
|
|
return (
|
|
<div className={`relative rounded-xl overflow-hidden border ${isDark ? 'border-white/10' : 'border-black/10'}`}>
|
|
<img src={h.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 */}
|
|
<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 ({h.vocabulary.length})
|
|
</h2>
|
|
<div className="flex items-center gap-2">
|
|
{/* IPA mode */}
|
|
<select
|
|
value={h.ipaMode}
|
|
onChange={(e) => {
|
|
const newIpa = e.target.value as IpaMode
|
|
h.setIpaMode(newIpa)
|
|
h.reprocessPages(newIpa, h.syllableMode)
|
|
}}
|
|
className={`px-2 py-1.5 text-xs rounded-md border ${isDark ? 'border-white/20 bg-white/10 text-white' : 'border-gray-200 bg-white text-gray-600'}`}
|
|
title="Lautschrift (IPA)"
|
|
>
|
|
<option value="none">IPA: Aus</option>
|
|
<option value="auto">IPA: Auto</option>
|
|
<option value="en">IPA: nur EN</option>
|
|
<option value="de">IPA: nur DE</option>
|
|
<option value="all">IPA: Alle</option>
|
|
</select>
|
|
{/* Syllable mode */}
|
|
<select
|
|
value={h.syllableMode}
|
|
onChange={(e) => {
|
|
const newSyl = e.target.value as SyllableMode
|
|
h.setSyllableMode(newSyl)
|
|
h.reprocessPages(h.ipaMode, newSyl)
|
|
}}
|
|
className={`px-2 py-1.5 text-xs rounded-md border ${isDark ? 'border-white/20 bg-white/10 text-white' : 'border-gray-200 bg-white text-gray-600'}`}
|
|
title="Silbentrennung"
|
|
>
|
|
<option value="none">Silben: Aus</option>
|
|
<option value="auto">Silben: Original</option>
|
|
<option value="en">Silben: nur EN</option>
|
|
<option value="de">Silben: nur DE</option>
|
|
<option value="all">Silben: Alle</option>
|
|
</select>
|
|
<button onClick={h.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={() => h.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 */}
|
|
{h.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">
|
|
{h.processingErrors.map((err, idx) => (
|
|
<li key={idx}>• {err}</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
)}
|
|
|
|
{/* Processing Progress */}
|
|
{h.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 {h.currentlyProcessingPage}...</div>
|
|
<div className={`text-xs ${isDark ? 'text-purple-300/70' : 'text-purple-600'}`}>
|
|
{h.successfulPages.length > 0 && `${h.successfulPages.length} Seite(n) fertig • `}
|
|
{h.vocabulary.length} Vokabeln bisher
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Success info */}
|
|
{!h.currentlyProcessingPage && h.successfulPages.length > 0 && h.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 {h.successfulPages.length} Seite(n) erfolgreich verarbeitet - {h.vocabulary.length} Vokabeln insgesamt
|
|
</div>
|
|
)}
|
|
|
|
{/* Partial success info */}
|
|
{!h.currentlyProcessingPage && h.successfulPages.length > 0 && h.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'}`}>
|
|
{h.successfulPages.length} Seite(n) erfolgreich, {h.failedPages.length} fehlgeschlagen - {h.vocabulary.length} Vokabeln extrahiert
|
|
</div>
|
|
)}
|
|
|
|
{h.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={h.vocabulary.length > 0 && h.vocabulary.every(v => v.selected)}
|
|
onChange={h.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(h.pageExtraColumns).find(([, cols]) => cols.some(c => c.key === col.key))
|
|
if (page) h.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={() => h.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">
|
|
{h.vocabulary.map((entry, index) => (
|
|
<React.Fragment key={entry.id}>
|
|
<div className={`grid gap-1 px-2 py-1 items-center ${isDark ? 'hover:bg-white/5' : 'hover:bg-black/5'}`} style={{ gridTemplateColumns: gridCols }}>
|
|
<button
|
|
onClick={() => h.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={() => h.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) => h.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) => h.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) => h.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) => h.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={() => h.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 */}
|
|
<div className="px-2 py-1">
|
|
<button
|
|
onClick={() => h.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>
|
|
{h.vocabulary.length} Vokabeln
|
|
{h.vocabulary.filter(v => v.selected).length > 0 && ` (${h.vocabulary.filter(v => v.selected).length} ausgewaehlt)`}
|
|
{(() => {
|
|
const pages = [...new Set(h.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={() => h.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>
|
|
)
|
|
}
|