[split-required] Split remaining 500-680 LOC files (final batch)
website (17 pages + 3 components): - multiplayer/wizard, middleware/wizard+test-wizard, communication - builds/wizard, staff-search, voice, sbom/wizard - foerderantrag, mail/tasks, tools/communication, sbom - compliance/evidence, uni-crawler, brandbook (already done) - CollectionsTab, IngestionTab, RiskHeatmap backend-lehrer (5 files): - letters_api (641 → 2), certificates_api (636 → 2) - alerts_agent/db/models (636 → 3) - llm_gateway/communication_service (614 → 2) - game/database already done in prior batch klausur-service (2 files): - hybrid_vocab_extractor (664 → 2) - klausur-service/frontend: api.ts (620 → 3), EHUploadWizard (591 → 2) voice-service (3 files): - bqas/rag_judge (618 → 3), runner (529 → 2) - enhanced_task_orchestrator (519 → 2) studio-v2 (6 files): - korrektur/[klausurId] (578 → 4), fairness (569 → 2) - AlertsWizard (552 → 2), OnboardingWizard (513 → 2) - korrektur/api.ts (506 → 3), geo-lernwelt (501 → 2) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,586 +1,137 @@
|
||||
/**
|
||||
* BYOEH Upload Wizard Component
|
||||
*
|
||||
* 5-step wizard for uploading Erwartungshorizonte with client-side encryption:
|
||||
* 1. File Selection - Choose PDF file
|
||||
* 2. Metadata - Title, Subject, Niveau, Year
|
||||
* 3. Rights Confirmation - Legal acknowledgment (required)
|
||||
* 4. Encryption - Set passphrase (2x confirmation)
|
||||
* 5. Summary & Upload - Review and confirm
|
||||
* 5-step wizard for uploading Erwartungshorizonte with client-side encryption.
|
||||
* Step content extracted to eh-wizard/EHWizardSteps.tsx.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
encryptFile,
|
||||
generateSalt,
|
||||
isEncryptionSupported
|
||||
} from '../services/encryption'
|
||||
import { encryptFile, generateSalt, isEncryptionSupported } from '../services/encryption'
|
||||
import { ehApi } from '../services/api'
|
||||
|
||||
interface EHMetadata {
|
||||
title: string
|
||||
subject: string
|
||||
niveau: 'eA' | 'gA'
|
||||
year: number
|
||||
aufgaben_nummer?: string
|
||||
}
|
||||
import { FileStep, MetadataStep, RightsStep, EncryptionStep, SummaryStep } from './eh-wizard/EHWizardSteps'
|
||||
import type { EHMetadata } from './eh-wizard/EHWizardSteps'
|
||||
|
||||
interface EHUploadWizardProps {
|
||||
onClose: () => void
|
||||
onComplete?: (ehId: string) => void
|
||||
onSuccess?: (ehId: string) => void // Legacy alias for onComplete
|
||||
onSuccess?: (ehId: string) => void
|
||||
klausurSubject?: string
|
||||
klausurYear?: number
|
||||
defaultSubject?: string // Alias for klausurSubject
|
||||
defaultYear?: number // Alias for klausurYear
|
||||
klausurId?: string // If provided, automatically link EH to this Klausur
|
||||
defaultSubject?: string
|
||||
defaultYear?: number
|
||||
klausurId?: string
|
||||
}
|
||||
|
||||
type WizardStep = 'file' | 'metadata' | 'rights' | 'encryption' | 'summary'
|
||||
|
||||
const WIZARD_STEPS: WizardStep[] = ['file', 'metadata', 'rights', 'encryption', 'summary']
|
||||
const STEP_LABELS: Record<WizardStep, string> = { file: 'Datei', metadata: 'Metadaten', rights: 'Rechte', encryption: 'Verschluesselung', summary: 'Zusammenfassung' }
|
||||
|
||||
const STEP_LABELS: Record<WizardStep, string> = {
|
||||
file: 'Datei',
|
||||
metadata: 'Metadaten',
|
||||
rights: 'Rechte',
|
||||
encryption: 'Verschluesselung',
|
||||
summary: 'Zusammenfassung'
|
||||
}
|
||||
|
||||
const SUBJECTS = [
|
||||
'deutsch', 'englisch', 'mathematik', 'physik', 'chemie', 'biologie',
|
||||
'geschichte', 'politik', 'erdkunde', 'kunst', 'musik', 'sport',
|
||||
'informatik', 'latein', 'franzoesisch', 'spanisch'
|
||||
]
|
||||
|
||||
const RIGHTS_TEXT = `Ich bestaetige hiermit, dass:
|
||||
|
||||
1. Ich das Urheberrecht oder die notwendigen Nutzungsrechte an diesem
|
||||
Erwartungshorizont besitze.
|
||||
|
||||
2. Breakpilot diesen Erwartungshorizont NICHT fuer KI-Training verwendet,
|
||||
sondern ausschliesslich fuer RAG-gestuetzte Korrekturvorschlaege
|
||||
in meinem persoenlichen Arbeitsbereich.
|
||||
|
||||
3. Der Inhalt verschluesselt gespeichert wird und Breakpilot-Mitarbeiter
|
||||
keinen Zugriff auf den Klartext haben.
|
||||
|
||||
4. Ich diesen Erwartungshorizont jederzeit loeschen kann.`
|
||||
|
||||
function EHUploadWizard({
|
||||
onClose,
|
||||
onComplete,
|
||||
onSuccess,
|
||||
klausurSubject,
|
||||
klausurYear,
|
||||
defaultSubject,
|
||||
defaultYear,
|
||||
klausurId
|
||||
}: EHUploadWizardProps) {
|
||||
// Resolve aliases
|
||||
function EHUploadWizard({ onClose, onComplete, onSuccess, klausurSubject, klausurYear, defaultSubject, defaultYear, klausurId }: EHUploadWizardProps) {
|
||||
const effectiveSubject = klausurSubject || defaultSubject || 'deutsch'
|
||||
const effectiveYear = klausurYear || defaultYear || new Date().getFullYear()
|
||||
const handleComplete = onComplete || onSuccess || (() => {})
|
||||
|
||||
// Step state
|
||||
const [currentStep, setCurrentStep] = useState<WizardStep>('file')
|
||||
|
||||
// File step
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||||
const [fileError, setFileError] = useState<string | null>(null)
|
||||
|
||||
// Metadata step
|
||||
const [metadata, setMetadata] = useState<EHMetadata>({
|
||||
title: '',
|
||||
subject: effectiveSubject,
|
||||
niveau: 'eA',
|
||||
year: effectiveYear,
|
||||
aufgaben_nummer: ''
|
||||
})
|
||||
|
||||
// Rights step
|
||||
const [metadata, setMetadata] = useState<EHMetadata>({ title: '', subject: effectiveSubject, niveau: 'eA', year: effectiveYear, aufgaben_nummer: '' })
|
||||
const [rightsConfirmed, setRightsConfirmed] = useState(false)
|
||||
|
||||
// Encryption step
|
||||
const [passphrase, setPassphrase] = useState('')
|
||||
const [passphraseConfirm, setPassphraseConfirm] = useState('')
|
||||
const [showPassphrase, setShowPassphrase] = useState(false)
|
||||
const [passphraseStrength, setPassphraseStrength] = useState<'weak' | 'medium' | 'strong'>('weak')
|
||||
|
||||
// Upload state
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadProgress, setUploadProgress] = useState(0)
|
||||
const [uploadError, setUploadError] = useState<string | null>(null)
|
||||
|
||||
// Check encryption support
|
||||
const encryptionSupported = isEncryptionSupported()
|
||||
|
||||
// Calculate passphrase strength
|
||||
useEffect(() => {
|
||||
if (passphrase.length < 8) {
|
||||
setPassphraseStrength('weak')
|
||||
} else if (passphrase.length < 12 || !/\d/.test(passphrase) || !/[A-Z]/.test(passphrase)) {
|
||||
setPassphraseStrength('medium')
|
||||
} else {
|
||||
setPassphraseStrength('strong')
|
||||
}
|
||||
if (passphrase.length < 8) setPassphraseStrength('weak')
|
||||
else if (passphrase.length < 12 || !/\d/.test(passphrase) || !/[A-Z]/.test(passphrase)) setPassphraseStrength('medium')
|
||||
else setPassphraseStrength('strong')
|
||||
}, [passphrase])
|
||||
|
||||
// Step navigation
|
||||
const currentStepIndex = WIZARD_STEPS.indexOf(currentStep)
|
||||
const isFirstStep = currentStepIndex === 0
|
||||
const isLastStep = currentStepIndex === WIZARD_STEPS.length - 1
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
if (!isLastStep) {
|
||||
setCurrentStep(WIZARD_STEPS[currentStepIndex + 1])
|
||||
}
|
||||
}, [currentStepIndex, isLastStep])
|
||||
const goNext = useCallback(() => { if (!isLastStep) setCurrentStep(WIZARD_STEPS[currentStepIndex + 1]) }, [currentStepIndex, isLastStep])
|
||||
const goBack = useCallback(() => { if (!isFirstStep) setCurrentStep(WIZARD_STEPS[currentStepIndex - 1]) }, [currentStepIndex, isFirstStep])
|
||||
|
||||
const goBack = useCallback(() => {
|
||||
if (!isFirstStep) {
|
||||
setCurrentStep(WIZARD_STEPS[currentStepIndex - 1])
|
||||
}
|
||||
}, [currentStepIndex, isFirstStep])
|
||||
|
||||
// Validation
|
||||
const isStepValid = useCallback((step: WizardStep): boolean => {
|
||||
switch (step) {
|
||||
case 'file':
|
||||
return selectedFile !== null && fileError === null
|
||||
case 'metadata':
|
||||
return metadata.title.trim().length > 0 && metadata.subject.length > 0
|
||||
case 'rights':
|
||||
return rightsConfirmed
|
||||
case 'encryption':
|
||||
return passphrase.length >= 8 && passphrase === passphraseConfirm
|
||||
case 'summary':
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
case 'file': return selectedFile !== null && fileError === null
|
||||
case 'metadata': return metadata.title.trim().length > 0 && metadata.subject.length > 0
|
||||
case 'rights': return rightsConfirmed
|
||||
case 'encryption': return passphrase.length >= 8 && passphrase === passphraseConfirm
|
||||
case 'summary': return true
|
||||
default: return false
|
||||
}
|
||||
}, [selectedFile, fileError, metadata, rightsConfirmed, passphrase, passphraseConfirm])
|
||||
|
||||
// File handling
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
if (file.type !== 'application/pdf') {
|
||||
setFileError('Nur PDF-Dateien sind erlaubt')
|
||||
setSelectedFile(null)
|
||||
return
|
||||
}
|
||||
if (file.size > 50 * 1024 * 1024) { // 50MB limit
|
||||
setFileError('Datei ist zu gross (max. 50MB)')
|
||||
setSelectedFile(null)
|
||||
return
|
||||
}
|
||||
setFileError(null)
|
||||
setSelectedFile(file)
|
||||
// Auto-fill title from filename
|
||||
if (!metadata.title) {
|
||||
const name = file.name.replace(/\.pdf$/i, '').replace(/[_-]/g, ' ')
|
||||
setMetadata(prev => ({ ...prev, title: name }))
|
||||
}
|
||||
if (file.type !== 'application/pdf') { setFileError('Nur PDF-Dateien sind erlaubt'); setSelectedFile(null); return }
|
||||
if (file.size > 50 * 1024 * 1024) { setFileError('Datei ist zu gross (max. 50MB)'); setSelectedFile(null); return }
|
||||
setFileError(null); setSelectedFile(file)
|
||||
if (!metadata.title) { const name = file.name.replace(/\.pdf$/i, '').replace(/[_-]/g, ' '); setMetadata(prev => ({ ...prev, title: name })) }
|
||||
}
|
||||
}
|
||||
|
||||
// Upload handler
|
||||
const handleUpload = async () => {
|
||||
if (!selectedFile || !encryptionSupported) return
|
||||
|
||||
setUploading(true)
|
||||
setUploadProgress(10)
|
||||
setUploadError(null)
|
||||
|
||||
setUploading(true); setUploadProgress(10); setUploadError(null)
|
||||
try {
|
||||
// Step 1: Generate salt (used via encrypted.salt below)
|
||||
generateSalt()
|
||||
setUploadProgress(20)
|
||||
|
||||
// Step 2: Encrypt file client-side
|
||||
const encrypted = await encryptFile(selectedFile, passphrase)
|
||||
setUploadProgress(50)
|
||||
|
||||
// Step 3: Create form data
|
||||
generateSalt(); setUploadProgress(20)
|
||||
const encrypted = await encryptFile(selectedFile, passphrase); setUploadProgress(50)
|
||||
const formData = new FormData()
|
||||
const encryptedBlob = new Blob([encrypted.encryptedData], { type: 'application/octet-stream' })
|
||||
formData.append('file', encryptedBlob, 'encrypted.bin')
|
||||
|
||||
const metadataJson = JSON.stringify({
|
||||
metadata: {
|
||||
title: metadata.title,
|
||||
subject: metadata.subject,
|
||||
niveau: metadata.niveau,
|
||||
year: metadata.year,
|
||||
aufgaben_nummer: metadata.aufgaben_nummer || null
|
||||
},
|
||||
encryption_key_hash: encrypted.keyHash,
|
||||
salt: encrypted.salt,
|
||||
rights_confirmed: true,
|
||||
original_filename: selectedFile.name
|
||||
})
|
||||
formData.append('metadata_json', metadataJson)
|
||||
|
||||
formData.append('file', new Blob([encrypted.encryptedData], { type: 'application/octet-stream' }), 'encrypted.bin')
|
||||
formData.append('metadata_json', JSON.stringify({
|
||||
metadata: { title: metadata.title, subject: metadata.subject, niveau: metadata.niveau, year: metadata.year, aufgaben_nummer: metadata.aufgaben_nummer || null },
|
||||
encryption_key_hash: encrypted.keyHash, salt: encrypted.salt, rights_confirmed: true, original_filename: selectedFile.name
|
||||
}))
|
||||
setUploadProgress(70)
|
||||
|
||||
// Step 4: Upload to server
|
||||
const response = await ehApi.uploadEH(formData)
|
||||
setUploadProgress(90)
|
||||
|
||||
// Step 5: Link to Klausur if klausurId provided
|
||||
if (klausurId && response.id) {
|
||||
try {
|
||||
await ehApi.linkToKlausur(response.id, klausurId)
|
||||
} catch (linkError) {
|
||||
console.warn('Failed to auto-link EH to Klausur:', linkError)
|
||||
// Don't fail the whole upload if linking fails
|
||||
}
|
||||
}
|
||||
|
||||
setUploadProgress(100)
|
||||
|
||||
// Success!
|
||||
handleComplete(response.id)
|
||||
} catch (error) {
|
||||
console.error('Upload failed:', error)
|
||||
setUploadError(error instanceof Error ? error.message : 'Upload fehlgeschlagen')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
const response = await ehApi.uploadEH(formData); setUploadProgress(90)
|
||||
if (klausurId && response.id) { try { await ehApi.linkToKlausur(response.id, klausurId) } catch (linkError) { console.warn('Failed to auto-link EH:', linkError) } }
|
||||
setUploadProgress(100); handleComplete(response.id)
|
||||
} catch (error) { console.error('Upload failed:', error); setUploadError(error instanceof Error ? error.message : 'Upload fehlgeschlagen') }
|
||||
finally { setUploading(false) }
|
||||
}
|
||||
|
||||
// Render step content
|
||||
const renderStepContent = () => {
|
||||
switch (currentStep) {
|
||||
case 'file':
|
||||
return (
|
||||
<div className="eh-wizard-step">
|
||||
<h3>Erwartungshorizont hochladen</h3>
|
||||
<p className="eh-wizard-description">
|
||||
Waehlen Sie die PDF-Datei Ihres Erwartungshorizonts aus.
|
||||
Die Datei wird verschluesselt und kann nur von Ihnen entschluesselt werden.
|
||||
</p>
|
||||
|
||||
<div className="eh-file-drop">
|
||||
<input
|
||||
type="file"
|
||||
accept=".pdf"
|
||||
onChange={handleFileSelect}
|
||||
id="eh-file-input"
|
||||
/>
|
||||
<label htmlFor="eh-file-input" className="eh-file-label">
|
||||
{selectedFile ? (
|
||||
<>
|
||||
<span className="eh-file-icon">📄</span>
|
||||
<span className="eh-file-name">{selectedFile.name}</span>
|
||||
<span className="eh-file-size">
|
||||
({(selectedFile.size / 1024 / 1024).toFixed(2)} MB)
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="eh-file-icon">📂</span>
|
||||
<span>PDF-Datei hier ablegen oder klicken</span>
|
||||
</>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{fileError && <p className="eh-error">{fileError}</p>}
|
||||
|
||||
{!encryptionSupported && (
|
||||
<p className="eh-warning">
|
||||
Ihr Browser unterstuetzt keine Verschluesselung.
|
||||
Bitte verwenden Sie einen modernen Browser (Chrome, Firefox, Safari, Edge).
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'metadata':
|
||||
return (
|
||||
<div className="eh-wizard-step">
|
||||
<h3>Metadaten</h3>
|
||||
<p className="eh-wizard-description">
|
||||
Geben Sie Informationen zum Erwartungshorizont ein.
|
||||
</p>
|
||||
|
||||
<div className="eh-form-group">
|
||||
<label htmlFor="eh-title">Titel *</label>
|
||||
<input
|
||||
id="eh-title"
|
||||
type="text"
|
||||
value={metadata.title}
|
||||
onChange={e => setMetadata(prev => ({ ...prev, title: e.target.value }))}
|
||||
placeholder="z.B. Deutsch Abitur 2024 Aufgabe 1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="eh-form-row">
|
||||
<div className="eh-form-group">
|
||||
<label htmlFor="eh-subject">Fach *</label>
|
||||
<select
|
||||
id="eh-subject"
|
||||
value={metadata.subject}
|
||||
onChange={e => setMetadata(prev => ({ ...prev, subject: e.target.value }))}
|
||||
>
|
||||
{SUBJECTS.map(s => (
|
||||
<option key={s} value={s}>
|
||||
{s.charAt(0).toUpperCase() + s.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="eh-form-group">
|
||||
<label htmlFor="eh-niveau">Niveau *</label>
|
||||
<select
|
||||
id="eh-niveau"
|
||||
value={metadata.niveau}
|
||||
onChange={e => setMetadata(prev => ({ ...prev, niveau: e.target.value as 'eA' | 'gA' }))}
|
||||
>
|
||||
<option value="eA">Erhoehtes Anforderungsniveau (eA)</option>
|
||||
<option value="gA">Grundlegendes Anforderungsniveau (gA)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="eh-form-row">
|
||||
<div className="eh-form-group">
|
||||
<label htmlFor="eh-year">Jahr *</label>
|
||||
<input
|
||||
id="eh-year"
|
||||
type="number"
|
||||
min={2000}
|
||||
max={2050}
|
||||
value={metadata.year}
|
||||
onChange={e => setMetadata(prev => ({ ...prev, year: parseInt(e.target.value) }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="eh-form-group">
|
||||
<label htmlFor="eh-aufgabe">Aufgabennummer</label>
|
||||
<input
|
||||
id="eh-aufgabe"
|
||||
type="text"
|
||||
value={metadata.aufgaben_nummer || ''}
|
||||
onChange={e => setMetadata(prev => ({ ...prev, aufgaben_nummer: e.target.value }))}
|
||||
placeholder="z.B. 1a, 2.1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'rights':
|
||||
return (
|
||||
<div className="eh-wizard-step">
|
||||
<h3>Rechte-Bestaetigung</h3>
|
||||
<p className="eh-wizard-description">
|
||||
Bitte lesen und bestaetigen Sie die folgenden Bedingungen.
|
||||
</p>
|
||||
|
||||
<div className="eh-rights-box">
|
||||
<pre>{RIGHTS_TEXT}</pre>
|
||||
</div>
|
||||
|
||||
<div className="eh-checkbox-group">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="eh-rights-confirm"
|
||||
checked={rightsConfirmed}
|
||||
onChange={e => setRightsConfirmed(e.target.checked)}
|
||||
/>
|
||||
<label htmlFor="eh-rights-confirm">
|
||||
Ich habe die Bedingungen gelesen und stimme ihnen zu.
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="eh-info-box">
|
||||
<strong>Wichtig:</strong> Ihr Erwartungshorizont wird niemals fuer
|
||||
KI-Training verwendet. Er dient ausschliesslich als Referenz fuer
|
||||
Ihre persoenlichen Korrekturvorschlaege.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'encryption':
|
||||
return (
|
||||
<div className="eh-wizard-step">
|
||||
<h3>Verschluesselung</h3>
|
||||
<p className="eh-wizard-description">
|
||||
Waehlen Sie ein sicheres Passwort fuer Ihren Erwartungshorizont.
|
||||
Dieses Passwort wird <strong>niemals</strong> an den Server gesendet.
|
||||
</p>
|
||||
|
||||
<div className="eh-form-group">
|
||||
<label htmlFor="eh-passphrase">Passwort *</label>
|
||||
<div className="eh-password-input">
|
||||
<input
|
||||
id="eh-passphrase"
|
||||
type={showPassphrase ? 'text' : 'password'}
|
||||
value={passphrase}
|
||||
onChange={e => setPassphrase(e.target.value)}
|
||||
placeholder="Mindestens 8 Zeichen"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="eh-toggle-password"
|
||||
onClick={() => setShowPassphrase(!showPassphrase)}
|
||||
>
|
||||
{showPassphrase ? '👁' : '👀'}
|
||||
</button>
|
||||
</div>
|
||||
<div className={`eh-password-strength eh-strength-${passphraseStrength}`}>
|
||||
Staerke: {passphraseStrength === 'weak' ? 'Schwach' : passphraseStrength === 'medium' ? 'Mittel' : 'Stark'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="eh-form-group">
|
||||
<label htmlFor="eh-passphrase-confirm">Passwort bestaetigen *</label>
|
||||
<input
|
||||
id="eh-passphrase-confirm"
|
||||
type={showPassphrase ? 'text' : 'password'}
|
||||
value={passphraseConfirm}
|
||||
onChange={e => setPassphraseConfirm(e.target.value)}
|
||||
placeholder="Passwort wiederholen"
|
||||
/>
|
||||
{passphraseConfirm && passphrase !== passphraseConfirm && (
|
||||
<p className="eh-error">Passwoerter stimmen nicht ueberein</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="eh-warning-box">
|
||||
<strong>Achtung:</strong> Merken Sie sich dieses Passwort gut!
|
||||
Ohne das Passwort kann der Erwartungshorizont nicht fuer
|
||||
Korrekturvorschlaege verwendet werden. Breakpilot kann Ihr
|
||||
Passwort nicht wiederherstellen.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'summary':
|
||||
return (
|
||||
<div className="eh-wizard-step">
|
||||
<h3>Zusammenfassung</h3>
|
||||
<p className="eh-wizard-description">
|
||||
Pruefen Sie Ihre Eingaben und starten Sie den Upload.
|
||||
</p>
|
||||
|
||||
<div className="eh-summary-table">
|
||||
<div className="eh-summary-row">
|
||||
<span className="eh-summary-label">Datei:</span>
|
||||
<span className="eh-summary-value">{selectedFile?.name}</span>
|
||||
</div>
|
||||
<div className="eh-summary-row">
|
||||
<span className="eh-summary-label">Titel:</span>
|
||||
<span className="eh-summary-value">{metadata.title}</span>
|
||||
</div>
|
||||
<div className="eh-summary-row">
|
||||
<span className="eh-summary-label">Fach:</span>
|
||||
<span className="eh-summary-value">
|
||||
{metadata.subject.charAt(0).toUpperCase() + metadata.subject.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="eh-summary-row">
|
||||
<span className="eh-summary-label">Niveau:</span>
|
||||
<span className="eh-summary-value">{metadata.niveau}</span>
|
||||
</div>
|
||||
<div className="eh-summary-row">
|
||||
<span className="eh-summary-label">Jahr:</span>
|
||||
<span className="eh-summary-value">{metadata.year}</span>
|
||||
</div>
|
||||
<div className="eh-summary-row">
|
||||
<span className="eh-summary-label">Verschluesselung:</span>
|
||||
<span className="eh-summary-value">AES-256-GCM</span>
|
||||
</div>
|
||||
<div className="eh-summary-row">
|
||||
<span className="eh-summary-label">Rechte bestaetigt:</span>
|
||||
<span className="eh-summary-value">Ja</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{uploading && (
|
||||
<div className="eh-upload-progress">
|
||||
<div
|
||||
className="eh-progress-bar"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
/>
|
||||
<span>{uploadProgress}%</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploadError && (
|
||||
<p className="eh-error">{uploadError}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
default:
|
||||
return null
|
||||
case 'file': return <FileStep selectedFile={selectedFile} fileError={fileError} encryptionSupported={encryptionSupported} onFileSelect={handleFileSelect} />
|
||||
case 'metadata': return <MetadataStep metadata={metadata} onMetadataChange={setMetadata} />
|
||||
case 'rights': return <RightsStep rightsConfirmed={rightsConfirmed} onRightsConfirmedChange={setRightsConfirmed} />
|
||||
case 'encryption': return <EncryptionStep passphrase={passphrase} passphraseConfirm={passphraseConfirm} showPassphrase={showPassphrase} passphraseStrength={passphraseStrength} onPassphraseChange={setPassphrase} onPassphraseConfirmChange={setPassphraseConfirm} onToggleShow={() => setShowPassphrase(!showPassphrase)} />
|
||||
case 'summary': return <SummaryStep selectedFile={selectedFile} metadata={metadata} uploading={uploading} uploadProgress={uploadProgress} uploadError={uploadError} />
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="eh-wizard-overlay">
|
||||
<div className="eh-wizard-modal">
|
||||
{/* Header */}
|
||||
<div className="eh-wizard-header">
|
||||
<h2>Erwartungshorizont hochladen</h2>
|
||||
<button className="eh-wizard-close" onClick={onClose}>×</button>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<div className="eh-wizard-progress">
|
||||
{WIZARD_STEPS.map((step, index) => (
|
||||
<div
|
||||
key={step}
|
||||
className={`eh-progress-step ${
|
||||
index < currentStepIndex ? 'completed' :
|
||||
index === currentStepIndex ? 'active' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="eh-progress-dot">
|
||||
{index < currentStepIndex ? '\u2713' : index + 1}
|
||||
</div>
|
||||
<div key={step} className={`eh-progress-step ${index < currentStepIndex ? 'completed' : index === currentStepIndex ? 'active' : ''}`}>
|
||||
<div className="eh-progress-dot">{index < currentStepIndex ? '\u2713' : index + 1}</div>
|
||||
<span className="eh-progress-label">{STEP_LABELS[step]}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="eh-wizard-content">
|
||||
{renderStepContent()}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="eh-wizard-content">{renderStepContent()}</div>
|
||||
<div className="eh-wizard-footer">
|
||||
<button
|
||||
className="eh-btn eh-btn-secondary"
|
||||
onClick={isFirstStep ? onClose : goBack}
|
||||
disabled={uploading}
|
||||
>
|
||||
{isFirstStep ? 'Abbrechen' : 'Zurueck'}
|
||||
</button>
|
||||
|
||||
<button className="eh-btn eh-btn-secondary" onClick={isFirstStep ? onClose : goBack} disabled={uploading}>{isFirstStep ? 'Abbrechen' : 'Zurueck'}</button>
|
||||
{isLastStep ? (
|
||||
<button
|
||||
className="eh-btn eh-btn-primary"
|
||||
onClick={handleUpload}
|
||||
disabled={uploading || !isStepValid(currentStep)}
|
||||
>
|
||||
{uploading ? 'Wird hochgeladen...' : 'Hochladen'}
|
||||
</button>
|
||||
<button className="eh-btn eh-btn-primary" onClick={handleUpload} disabled={uploading || !isStepValid(currentStep)}>{uploading ? 'Wird hochgeladen...' : 'Hochladen'}</button>
|
||||
) : (
|
||||
<button
|
||||
className="eh-btn eh-btn-primary"
|
||||
onClick={goNext}
|
||||
disabled={!isStepValid(currentStep)}
|
||||
>
|
||||
Weiter
|
||||
</button>
|
||||
<button className="eh-btn eh-btn-primary" onClick={goNext} disabled={!isStepValid(currentStep)}>Weiter</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* EH Upload Wizard Steps - Individual step content renderers
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
interface EHMetadata {
|
||||
title: string
|
||||
subject: string
|
||||
niveau: 'eA' | 'gA'
|
||||
year: number
|
||||
aufgaben_nummer?: string
|
||||
}
|
||||
|
||||
const SUBJECTS = [
|
||||
'deutsch', 'englisch', 'mathematik', 'physik', 'chemie', 'biologie',
|
||||
'geschichte', 'politik', 'erdkunde', 'kunst', 'musik', 'sport',
|
||||
'informatik', 'latein', 'franzoesisch', 'spanisch'
|
||||
]
|
||||
|
||||
const RIGHTS_TEXT = `Ich bestaetige hiermit, dass:
|
||||
|
||||
1. Ich das Urheberrecht oder die notwendigen Nutzungsrechte an diesem
|
||||
Erwartungshorizont besitze.
|
||||
|
||||
2. Breakpilot diesen Erwartungshorizont NICHT fuer KI-Training verwendet,
|
||||
sondern ausschliesslich fuer RAG-gestuetzte Korrekturvorschlaege
|
||||
in meinem persoenlichen Arbeitsbereich.
|
||||
|
||||
3. Der Inhalt verschluesselt gespeichert wird und Breakpilot-Mitarbeiter
|
||||
keinen Zugriff auf den Klartext haben.
|
||||
|
||||
4. Ich diesen Erwartungshorizont jederzeit loeschen kann.`
|
||||
|
||||
// Step 1: File Selection
|
||||
export function FileStep({ selectedFile, fileError, encryptionSupported, onFileSelect }: {
|
||||
selectedFile: File | null; fileError: string | null; encryptionSupported: boolean;
|
||||
onFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="eh-wizard-step">
|
||||
<h3>Erwartungshorizont hochladen</h3>
|
||||
<p className="eh-wizard-description">Waehlen Sie die PDF-Datei Ihres Erwartungshorizonts aus. Die Datei wird verschluesselt und kann nur von Ihnen entschluesselt werden.</p>
|
||||
<div className="eh-file-drop">
|
||||
<input type="file" accept=".pdf" onChange={onFileSelect} id="eh-file-input" />
|
||||
<label htmlFor="eh-file-input" className="eh-file-label">
|
||||
{selectedFile ? (<><span className="eh-file-icon">📄</span><span className="eh-file-name">{selectedFile.name}</span><span className="eh-file-size">({(selectedFile.size / 1024 / 1024).toFixed(2)} MB)</span></>) : (<><span className="eh-file-icon">📂</span><span>PDF-Datei hier ablegen oder klicken</span></>)}
|
||||
</label>
|
||||
</div>
|
||||
{fileError && <p className="eh-error">{fileError}</p>}
|
||||
{!encryptionSupported && <p className="eh-warning">Ihr Browser unterstuetzt keine Verschluesselung. Bitte verwenden Sie einen modernen Browser.</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Step 2: Metadata
|
||||
export function MetadataStep({ metadata, onMetadataChange }: {
|
||||
metadata: EHMetadata; onMetadataChange: (metadata: EHMetadata) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="eh-wizard-step">
|
||||
<h3>Metadaten</h3>
|
||||
<p className="eh-wizard-description">Geben Sie Informationen zum Erwartungshorizont ein.</p>
|
||||
<div className="eh-form-group">
|
||||
<label htmlFor="eh-title">Titel *</label>
|
||||
<input id="eh-title" type="text" value={metadata.title} onChange={e => onMetadataChange({ ...metadata, title: e.target.value })} placeholder="z.B. Deutsch Abitur 2024 Aufgabe 1" />
|
||||
</div>
|
||||
<div className="eh-form-row">
|
||||
<div className="eh-form-group">
|
||||
<label htmlFor="eh-subject">Fach *</label>
|
||||
<select id="eh-subject" value={metadata.subject} onChange={e => onMetadataChange({ ...metadata, subject: e.target.value })}>
|
||||
{SUBJECTS.map(s => (<option key={s} value={s}>{s.charAt(0).toUpperCase() + s.slice(1)}</option>))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="eh-form-group">
|
||||
<label htmlFor="eh-niveau">Niveau *</label>
|
||||
<select id="eh-niveau" value={metadata.niveau} onChange={e => onMetadataChange({ ...metadata, niveau: e.target.value as 'eA' | 'gA' })}>
|
||||
<option value="eA">Erhoehtes Anforderungsniveau (eA)</option>
|
||||
<option value="gA">Grundlegendes Anforderungsniveau (gA)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="eh-form-row">
|
||||
<div className="eh-form-group">
|
||||
<label htmlFor="eh-year">Jahr *</label>
|
||||
<input id="eh-year" type="number" min={2000} max={2050} value={metadata.year} onChange={e => onMetadataChange({ ...metadata, year: parseInt(e.target.value) })} />
|
||||
</div>
|
||||
<div className="eh-form-group">
|
||||
<label htmlFor="eh-aufgabe">Aufgabennummer</label>
|
||||
<input id="eh-aufgabe" type="text" value={metadata.aufgaben_nummer || ''} onChange={e => onMetadataChange({ ...metadata, aufgaben_nummer: e.target.value })} placeholder="z.B. 1a, 2.1" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Step 3: Rights Confirmation
|
||||
export function RightsStep({ rightsConfirmed, onRightsConfirmedChange }: {
|
||||
rightsConfirmed: boolean; onRightsConfirmedChange: (confirmed: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="eh-wizard-step">
|
||||
<h3>Rechte-Bestaetigung</h3>
|
||||
<p className="eh-wizard-description">Bitte lesen und bestaetigen Sie die folgenden Bedingungen.</p>
|
||||
<div className="eh-rights-box"><pre>{RIGHTS_TEXT}</pre></div>
|
||||
<div className="eh-checkbox-group">
|
||||
<input type="checkbox" id="eh-rights-confirm" checked={rightsConfirmed} onChange={e => onRightsConfirmedChange(e.target.checked)} />
|
||||
<label htmlFor="eh-rights-confirm">Ich habe die Bedingungen gelesen und stimme ihnen zu.</label>
|
||||
</div>
|
||||
<div className="eh-info-box"><strong>Wichtig:</strong> Ihr Erwartungshorizont wird niemals fuer KI-Training verwendet. Er dient ausschliesslich als Referenz fuer Ihre persoenlichen Korrekturvorschlaege.</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Step 4: Encryption
|
||||
export function EncryptionStep({ passphrase, passphraseConfirm, showPassphrase, passphraseStrength, onPassphraseChange, onPassphraseConfirmChange, onToggleShow }: {
|
||||
passphrase: string; passphraseConfirm: string; showPassphrase: boolean;
|
||||
passphraseStrength: 'weak' | 'medium' | 'strong';
|
||||
onPassphraseChange: (v: string) => void; onPassphraseConfirmChange: (v: string) => void; onToggleShow: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="eh-wizard-step">
|
||||
<h3>Verschluesselung</h3>
|
||||
<p className="eh-wizard-description">Waehlen Sie ein sicheres Passwort fuer Ihren Erwartungshorizont. Dieses Passwort wird <strong>niemals</strong> an den Server gesendet.</p>
|
||||
<div className="eh-form-group">
|
||||
<label htmlFor="eh-passphrase">Passwort *</label>
|
||||
<div className="eh-password-input">
|
||||
<input id="eh-passphrase" type={showPassphrase ? 'text' : 'password'} value={passphrase} onChange={e => onPassphraseChange(e.target.value)} placeholder="Mindestens 8 Zeichen" />
|
||||
<button type="button" className="eh-toggle-password" onClick={onToggleShow}>{showPassphrase ? '👁' : '👀'}</button>
|
||||
</div>
|
||||
<div className={`eh-password-strength eh-strength-${passphraseStrength}`}>Staerke: {passphraseStrength === 'weak' ? 'Schwach' : passphraseStrength === 'medium' ? 'Mittel' : 'Stark'}</div>
|
||||
</div>
|
||||
<div className="eh-form-group">
|
||||
<label htmlFor="eh-passphrase-confirm">Passwort bestaetigen *</label>
|
||||
<input id="eh-passphrase-confirm" type={showPassphrase ? 'text' : 'password'} value={passphraseConfirm} onChange={e => onPassphraseConfirmChange(e.target.value)} placeholder="Passwort wiederholen" />
|
||||
{passphraseConfirm && passphrase !== passphraseConfirm && <p className="eh-error">Passwoerter stimmen nicht ueberein</p>}
|
||||
</div>
|
||||
<div className="eh-warning-box"><strong>Achtung:</strong> Merken Sie sich dieses Passwort gut! Ohne das Passwort kann der Erwartungshorizont nicht fuer Korrekturvorschlaege verwendet werden.</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Step 5: Summary
|
||||
export function SummaryStep({ selectedFile, metadata, uploading, uploadProgress, uploadError }: {
|
||||
selectedFile: File | null; metadata: EHMetadata; uploading: boolean;
|
||||
uploadProgress: number; uploadError: string | null
|
||||
}) {
|
||||
return (
|
||||
<div className="eh-wizard-step">
|
||||
<h3>Zusammenfassung</h3>
|
||||
<p className="eh-wizard-description">Pruefen Sie Ihre Eingaben und starten Sie den Upload.</p>
|
||||
<div className="eh-summary-table">
|
||||
<div className="eh-summary-row"><span className="eh-summary-label">Datei:</span><span className="eh-summary-value">{selectedFile?.name}</span></div>
|
||||
<div className="eh-summary-row"><span className="eh-summary-label">Titel:</span><span className="eh-summary-value">{metadata.title}</span></div>
|
||||
<div className="eh-summary-row"><span className="eh-summary-label">Fach:</span><span className="eh-summary-value">{metadata.subject.charAt(0).toUpperCase() + metadata.subject.slice(1)}</span></div>
|
||||
<div className="eh-summary-row"><span className="eh-summary-label">Niveau:</span><span className="eh-summary-value">{metadata.niveau}</span></div>
|
||||
<div className="eh-summary-row"><span className="eh-summary-label">Jahr:</span><span className="eh-summary-value">{metadata.year}</span></div>
|
||||
<div className="eh-summary-row"><span className="eh-summary-label">Verschluesselung:</span><span className="eh-summary-value">AES-256-GCM</span></div>
|
||||
<div className="eh-summary-row"><span className="eh-summary-label">Rechte bestaetigt:</span><span className="eh-summary-value">Ja</span></div>
|
||||
</div>
|
||||
{uploading && (<div className="eh-upload-progress"><div className="eh-progress-bar" style={{ width: `${uploadProgress}%` }} /><span>{uploadProgress}%</span></div>)}
|
||||
{uploadError && <p className="eh-error">{uploadError}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { SUBJECTS, RIGHTS_TEXT }
|
||||
export type { EHMetadata }
|
||||
Reference in New Issue
Block a user