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>
316 lines
18 KiB
TypeScript
316 lines
18 KiB
TypeScript
'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>
|
|
)
|
|
}
|