Add SmartSpellChecker + refactor vocab-worksheet page.tsx
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
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>
This commit is contained in:
57
studio-v2/app/vocab-worksheet/components/ExportTab.tsx
Normal file
57
studio-v2/app/vocab-worksheet/components/ExportTab.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import type { VocabWorksheetHook } from '../types'
|
||||
|
||||
export function ExportTab({ h }: { h: VocabWorksheetHook }) {
|
||||
const { isDark, glassCard } = h
|
||||
|
||||
return (
|
||||
<div className={`${glassCard} rounded-2xl p-6`}>
|
||||
<h2 className={`text-lg font-semibold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>PDF herunterladen</h2>
|
||||
|
||||
{h.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={() => h.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>
|
||||
|
||||
{h.includeSolutions && (
|
||||
<button onClick={() => h.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={h.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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import type { VocabWorksheetHook } from '../types'
|
||||
|
||||
export function FullscreenPreview({ h }: { h: VocabWorksheetHook }) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/80 backdrop-blur-sm flex items-center justify-center" onClick={() => h.setShowFullPreview(false)}>
|
||||
<button
|
||||
onClick={() => h.setShowFullPreview(false)}
|
||||
className="absolute top-4 right-4 p-2 rounded-full bg-white/10 hover:bg-white/20 text-white z-10 transition-colors"
|
||||
>
|
||||
<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 className="max-w-[95vw] max-h-[95vh] overflow-auto" onClick={(e) => e.stopPropagation()}>
|
||||
{h.directFile?.type.startsWith('image/') && h.directFilePreview && (
|
||||
<img src={h.directFilePreview} alt="Original" className="max-w-none" />
|
||||
)}
|
||||
{h.directFile?.type === 'application/pdf' && h.directFilePreview && (
|
||||
<iframe src={h.directFilePreview} className="border-0 rounded-xl bg-white" style={{ width: '90vw', height: '90vh' }} />
|
||||
)}
|
||||
{h.selectedMobileFile && !h.directFile && (
|
||||
h.selectedMobileFile.type.startsWith('image/')
|
||||
? <img src={h.selectedMobileFile.dataUrl} alt="Original" className="max-w-none" />
|
||||
: <iframe src={h.selectedMobileFile.dataUrl} className="border-0 rounded-xl bg-white" style={{ width: '90vw', height: '90vh' }} />
|
||||
)}
|
||||
{h.selectedDocumentId && !h.directFile && !h.selectedMobileFile && (() => {
|
||||
const doc = h.storedDocuments.find(d => d.id === h.selectedDocumentId)
|
||||
if (!doc?.url) return null
|
||||
return doc.type.startsWith('image/')
|
||||
? <img src={doc.url} alt="Original" className="max-w-none" />
|
||||
: <iframe src={doc.url} className="border-0 rounded-xl bg-white" style={{ width: '90vw', height: '90vh' }} />
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
135
studio-v2/app/vocab-worksheet/components/OcrComparisonModal.tsx
Normal file
135
studio-v2/app/vocab-worksheet/components/OcrComparisonModal.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import type { VocabWorksheetHook } from '../types'
|
||||
|
||||
export function OcrComparisonModal({ h }: { h: VocabWorksheetHook }) {
|
||||
const { isDark, glassCard } = h
|
||||
|
||||
return (
|
||||
<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 {h.ocrComparePageIndex !== null ? h.ocrComparePageIndex + 1 : '-'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => h.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 */}
|
||||
{h.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 */}
|
||||
{h.ocrCompareError && (
|
||||
<div className={`p-4 rounded-xl ${isDark ? 'bg-red-500/20 text-red-300' : 'bg-red-100 text-red-700'}`}>
|
||||
Fehler: {h.ocrCompareError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{h.ocrCompareResult && !h.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(h.ocrCompareResult.methods || {}).map(([key, method]: [string, any]) => (
|
||||
<div
|
||||
key={key}
|
||||
className={`p-4 rounded-2xl ${
|
||||
h.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>
|
||||
{h.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 */}
|
||||
{h.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">{h.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">{h.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">{h.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((h.ocrCompareResult.comparison.agreement_rate || 0) * 100)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
125
studio-v2/app/vocab-worksheet/components/OcrSettingsPanel.tsx
Normal file
125
studio-v2/app/vocab-worksheet/components/OcrSettingsPanel.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import type { VocabWorksheetHook } from '../types'
|
||||
import { defaultOcrPrompts } from '../constants'
|
||||
|
||||
export function OcrSettingsPanel({ h }: { h: VocabWorksheetHook }) {
|
||||
const { isDark, glassCard, glassInput } = h
|
||||
|
||||
return (
|
||||
<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={() => h.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={h.ocrPrompts.filterHeaders}
|
||||
onChange={(e) => h.saveOcrPrompts({ ...h.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={h.ocrPrompts.filterFooters}
|
||||
onChange={(e) => h.saveOcrPrompts({ ...h.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={h.ocrPrompts.filterPageNumbers}
|
||||
onChange={(e) => h.saveOcrPrompts({ ...h.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={h.ocrPrompts.headerPatterns.join(', ')}
|
||||
onChange={(e) => h.saveOcrPrompts({
|
||||
...h.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={h.ocrPrompts.footerPatterns.join(', ')}
|
||||
onChange={(e) => h.saveOcrPrompts({
|
||||
...h.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={h.ocrPrompts.customFilter}
|
||||
onChange={(e) => h.saveOcrPrompts({ ...h.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={() => h.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>
|
||||
)
|
||||
}
|
||||
108
studio-v2/app/vocab-worksheet/components/PageSelection.tsx
Normal file
108
studio-v2/app/vocab-worksheet/components/PageSelection.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import type { VocabWorksheetHook } from '../types'
|
||||
|
||||
export function PageSelection({ h }: { h: VocabWorksheetHook }) {
|
||||
const { isDark, glassCard } = h
|
||||
|
||||
return (
|
||||
<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 ({h.selectedPages.length} von {h.pdfPageCount - h.excludedPages.length} ausgewaehlt)
|
||||
</h2>
|
||||
<div className="flex gap-2">
|
||||
{h.excludedPages.length > 0 && (
|
||||
<button onClick={h.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'}`}>
|
||||
{h.excludedPages.length} ausgeblendet - wiederherstellen
|
||||
</button>
|
||||
)}
|
||||
<button onClick={h.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={h.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>
|
||||
|
||||
{h.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">
|
||||
{h.pagesThumbnails.map((thumb, idx) => {
|
||||
if (h.excludedPages.includes(idx)) return null
|
||||
return (
|
||||
<div key={idx} className="relative group">
|
||||
{/* Exclude/Delete Button */}
|
||||
<button
|
||||
onClick={(e) => h.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(); h.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={() => h.togglePageSelection(idx)}
|
||||
className={`relative rounded-xl overflow-hidden border-2 transition-all w-full ${
|
||||
h.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 ${
|
||||
h.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>
|
||||
{h.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={h.processSelectedPages}
|
||||
disabled={h.selectedPages.length === 0 || h.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"
|
||||
>
|
||||
{h.isExtracting ? 'Extrahiere Vokabeln...' : `${h.selectedPages.length} Seiten verarbeiten`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
31
studio-v2/app/vocab-worksheet/components/QRCodeModal.tsx
Normal file
31
studio-v2/app/vocab-worksheet/components/QRCodeModal.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { QRCodeUpload } from '@/components/QRCodeUpload'
|
||||
import type { VocabWorksheetHook } from '../types'
|
||||
|
||||
export function QRCodeModal({ h }: { h: VocabWorksheetHook }) {
|
||||
const { isDark } = h
|
||||
|
||||
return (
|
||||
<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={() => h.setShowQRModal(false)} />
|
||||
<div className={`relative w-full max-w-md rounded-3xl ${
|
||||
isDark ? 'bg-slate-900' : 'bg-white'
|
||||
}`}>
|
||||
<QRCodeUpload
|
||||
sessionId={h.uploadSessionId}
|
||||
onClose={() => h.setShowQRModal(false)}
|
||||
onFilesChanged={(files) => {
|
||||
h.setMobileUploadedFiles(files)
|
||||
if (files.length > 0) {
|
||||
h.setSelectedMobileFile(files[files.length - 1])
|
||||
h.setDirectFile(null)
|
||||
h.setSelectedDocumentId(null)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
315
studio-v2/app/vocab-worksheet/components/UploadScreen.tsx
Normal file
315
studio-v2/app/vocab-worksheet/components/UploadScreen.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import type { VocabWorksheetHook } from '../types'
|
||||
import { formatFileSize } from '../constants'
|
||||
|
||||
export function UploadScreen({ h }: { h: VocabWorksheetHook }) {
|
||||
const { isDark, glassCard, glassInput } = h
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Existing Sessions */}
|
||||
{h.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>
|
||||
{h.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">
|
||||
{h.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={() => h.resumeSession(s)}
|
||||
>
|
||||
{/* Delete Button */}
|
||||
<button
|
||||
onClick={(e) => h.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'}`}>
|
||||
{h.existingSessions.length > 0 ? 'Oder neue Session starten:' : 'So funktioniert es:'}
|
||||
</h2>
|
||||
<ol className={`space-y-2 ${isDark ? 'text-white/70' : 'text-slate-600'}`}>
|
||||
{['Dokument (Bild oder PDF) auswaehlen', 'Vorschau pruefen und Session benennen', 'Bei PDFs: Seiten auswaehlen die verarbeitet werden sollen', 'KI extrahiert Vokabeln — pruefen, korrigieren, Arbeitsblatt-Typ waehlen', 'PDF herunterladen und ausdrucken'].map((text, i) => (
|
||||
<li key={i} 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'}`}>{i + 1}</span>
|
||||
<span>{text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{/* Step 1: Document Selection */}
|
||||
<div className={`${glassCard} rounded-2xl p-6`}>
|
||||
<h2 className={`text-lg font-semibold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
1. Dokument auswaehlen
|
||||
</h2>
|
||||
|
||||
<input ref={h.directFileInputRef} type="file" accept="image/png,image/jpeg,image/jpg,application/pdf" onChange={h.handleDirectFileSelect} className="hidden" />
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 mb-4">
|
||||
{/* File Upload Button */}
|
||||
<button
|
||||
onClick={() => h.directFileInputRef.current?.click()}
|
||||
className={`p-4 rounded-xl border-2 border-dashed transition-all ${
|
||||
h.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')
|
||||
}`}
|
||||
>
|
||||
{h.directFile ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">{h.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'}`}>{h.directFile.name}</p>
|
||||
<p className={`text-xs ${isDark ? 'text-white/60' : 'text-slate-500'}`}>{formatFileSize(h.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={() => h.setShowQRModal(true)}
|
||||
className={`p-4 rounded-xl border-2 border-dashed transition-all ${
|
||||
h.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')
|
||||
}`}
|
||||
>
|
||||
{h.selectedMobileFile ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">{h.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'}`}>{h.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 */}
|
||||
{h.mobileUploadedFiles.length > 0 && !h.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">
|
||||
{h.mobileUploadedFiles.map((file) => (
|
||||
<button
|
||||
key={file.id}
|
||||
onClick={() => { h.setSelectedMobileFile(file); h.setDirectFile(null); h.setSelectedDocumentId(null); h.setError(null) }}
|
||||
className={`w-full flex items-center gap-3 p-3 rounded-xl text-left transition-all ${
|
||||
h.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>
|
||||
{h.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 */}
|
||||
{h.storedDocuments.length > 0 && !h.directFile && !h.selectedMobileFile && (
|
||||
<>
|
||||
<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">
|
||||
{h.storedDocuments.map((doc) => (
|
||||
<button
|
||||
key={doc.id}
|
||||
onClick={() => { h.setSelectedDocumentId(doc.id); h.setDirectFile(null); h.setSelectedMobileFile(null); h.setError(null) }}
|
||||
className={`w-full flex items-center gap-3 p-3 rounded-xl text-left transition-all ${
|
||||
h.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>
|
||||
{h.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>
|
||||
|
||||
{/* Step 2: Preview + Session Name */}
|
||||
{(h.directFile || h.selectedMobileFile || h.selectedDocumentId) && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
|
||||
{/* Document Preview */}
|
||||
<div className={`${glassCard} rounded-2xl p-6 lg:col-span-3`}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className={`text-lg font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Vorschau
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => h.setShowFullPreview(true)}
|
||||
className={`px-3 py-1.5 rounded-lg text-sm font-medium transition-all flex items-center gap-2 ${
|
||||
isDark ? 'bg-white/10 hover:bg-white/20 text-white' : 'bg-slate-100 hover:bg-slate-200 text-slate-700'
|
||||
}`}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0zM10 7v3m0 0v3m0-3h3m-3 0H7" />
|
||||
</svg>
|
||||
Originalgroesse
|
||||
</button>
|
||||
</div>
|
||||
<div className={`max-h-[60vh] overflow-auto rounded-xl border ${isDark ? 'border-white/10' : 'border-black/10'}`}>
|
||||
{h.directFile?.type.startsWith('image/') && h.directFilePreview && (
|
||||
<img src={h.directFilePreview} alt="Vorschau" className="w-full h-auto" />
|
||||
)}
|
||||
{h.directFile?.type === 'application/pdf' && h.directFilePreview && (
|
||||
<iframe src={h.directFilePreview} className="w-full border-0 rounded-xl" style={{ height: '60vh' }} />
|
||||
)}
|
||||
{h.selectedMobileFile && !h.directFile && (
|
||||
h.selectedMobileFile.type.startsWith('image/')
|
||||
? <img src={h.selectedMobileFile.dataUrl} alt="Vorschau" className="w-full h-auto" />
|
||||
: <iframe src={h.selectedMobileFile.dataUrl} className="w-full border-0 rounded-xl" style={{ height: '60vh' }} />
|
||||
)}
|
||||
{h.selectedDocumentId && !h.directFile && !h.selectedMobileFile && (() => {
|
||||
const doc = h.storedDocuments.find(d => d.id === h.selectedDocumentId)
|
||||
if (!doc?.url) return <p className={`p-8 text-center ${isDark ? 'text-white/40' : 'text-slate-400'}`}>Keine Vorschau verfuegbar</p>
|
||||
return doc.type.startsWith('image/')
|
||||
? <img src={doc.url} alt="Vorschau" className="w-full h-auto" />
|
||||
: <iframe src={doc.url} className="w-full border-0 rounded-xl" style={{ height: '60vh' }} />
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Session Name + Start */}
|
||||
<div className={`${glassCard} rounded-2xl p-6 lg:col-span-2 flex flex-col`}>
|
||||
<h2 className={`text-lg font-semibold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
2. Session benennen
|
||||
</h2>
|
||||
<input
|
||||
type="text"
|
||||
value={h.sessionName}
|
||||
onChange={(e) => { h.setSessionName(e.target.value); h.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 mb-4`}
|
||||
autoFocus
|
||||
/>
|
||||
<p className={`text-sm mb-6 ${isDark ? 'text-white/50' : 'text-slate-500'}`}>
|
||||
Benennen Sie die Session z.B. nach dem Schulbuch-Kapitel, damit Sie sie spaeter wiederfinden.
|
||||
</p>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!h.sessionName.trim()) {
|
||||
h.setError('Bitte geben Sie einen Session-Namen ein (z.B. "Englisch Klasse 7 - Unit 3")')
|
||||
return
|
||||
}
|
||||
h.startSession()
|
||||
}}
|
||||
disabled={h.isCreatingSession || !h.sessionName.trim()}
|
||||
className="w-full px-6 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"
|
||||
>
|
||||
{h.isCreatingSession ? 'Verarbeite...' : 'Weiter →'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
305
studio-v2/app/vocab-worksheet/components/VocabularyTab.tsx
Normal file
305
studio-v2/app/vocab-worksheet/components/VocabularyTab.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
155
studio-v2/app/vocab-worksheet/components/WorksheetTab.tsx
Normal file
155
studio-v2/app/vocab-worksheet/components/WorksheetTab.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import type { VocabWorksheetHook } from '../types'
|
||||
import { worksheetFormats, worksheetTypes } from '../constants'
|
||||
|
||||
export function WorksheetTab({ h }: { h: VocabWorksheetHook }) {
|
||||
const { isDark, glassCard, glassInput } = h
|
||||
|
||||
return (
|
||||
<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={() => h.setSelectedFormat(format.id)}
|
||||
className={`p-5 rounded-xl border text-left transition-all ${
|
||||
h.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 ${
|
||||
h.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 ${h.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 ${h.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>
|
||||
{h.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 */}
|
||||
<div className="mb-6">
|
||||
<h2 className={`text-lg font-semibold mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
2. Arbeitsblatt konfigurieren
|
||||
</h2>
|
||||
|
||||
{/* Title */}
|
||||
<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={h.worksheetTitle}
|
||||
onChange={(e) => h.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 */}
|
||||
{h.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={() => h.toggleWorksheetType(type.id)}
|
||||
className={`p-4 rounded-xl border text-left transition-all ${
|
||||
h.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>
|
||||
{h.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={h.lineHeight} onChange={(e) => h.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={h.includeSolutions} onChange={(e) => h.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 */}
|
||||
{h.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={h.includeSolutions} onChange={(e) => h.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={h.generateWorksheet}
|
||||
disabled={(h.selectedFormat === 'standard' && h.selectedTypes.length === 0) || h.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"
|
||||
>
|
||||
{h.isGenerating ? 'Generiere PDF...' : `${h.selectedFormat === 'nru' ? 'NRU-Arbeitsblatt' : 'Arbeitsblatt'} generieren`}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user