fix: Restore all files lost during destructive rebase

A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.

This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).

Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-02-09 09:51:32 +01:00
parent f7487ee240
commit bfdaf63ba9
2009 changed files with 749983 additions and 1731 deletions

View File

@@ -0,0 +1,23 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import { KlausurProvider } from './hooks/useKlausur'
import Layout from './components/Layout'
import OnboardingPage from './pages/OnboardingPage'
import KorrekturPage from './pages/KorrekturPage'
function App() {
return (
<BrowserRouter>
<KlausurProvider>
<Layout>
<Routes>
<Route path="/" element={<OnboardingPage />} />
<Route path="/korrektur" element={<KorrekturPage />} />
<Route path="/korrektur/:klausurId" element={<KorrekturPage />} />
</Routes>
</Layout>
</KlausurProvider>
</BrowserRouter>
)
}
export default App

View File

@@ -0,0 +1,591 @@
/**
* 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
*/
import { useState, useEffect, useCallback } from 'react'
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
}
interface EHUploadWizardProps {
onClose: () => void
onComplete?: (ehId: string) => void
onSuccess?: (ehId: string) => void // Legacy alias for onComplete
klausurSubject?: string
klausurYear?: number
defaultSubject?: string // Alias for klausurSubject
defaultYear?: number // Alias for klausurYear
klausurId?: string // If provided, automatically link EH to this Klausur
}
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 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
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 [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')
}
}, [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 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
}
}, [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 }))
}
}
}
// Upload handler
const handleUpload = async () => {
if (!selectedFile || !encryptionSupported) return
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
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)
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)
}
}
// 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">&#128196;</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">&#128194;</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 ? '&#128065;' : '&#128064;'}
</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
}
}
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}>&times;</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>
<span className="eh-progress-label">{STEP_LABELS[step]}</span>
</div>
))}
</div>
{/* Content */}
<div className="eh-wizard-content">
{renderStepContent()}
</div>
{/* Footer */}
<div className="eh-wizard-footer">
<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={goNext}
disabled={!isStepValid(currentStep)}
>
Weiter
</button>
)}
</div>
</div>
</div>
)
}
export default EHUploadWizard

View File

@@ -0,0 +1,38 @@
import { ReactNode } from 'react'
interface LayoutProps {
children: ReactNode
}
export default function Layout({ children }: LayoutProps) {
const handleBackToStudio = () => {
// Navigate back to Studio
if (window.parent !== window) {
window.parent.postMessage({ type: 'CLOSE_KLAUSUR_SERVICE' }, '*')
} else {
window.location.href = '/studio'
}
}
return (
<div className="app-layout">
<div className="main-content">
<header className="topbar">
<div className="brand">
<div className="brand-logo">BP</div>
<div className="brand-text">
<span className="brand-text-main">BreakPilot</span>
<span className="brand-text-sub">Klausur-Korrektur</span>
</div>
</div>
<div className="topbar-actions">
<button className="btn btn-ghost" onClick={handleBackToStudio}>
Zurueck zum Studio
</button>
</div>
</header>
{children}
</div>
</div>
)
}

View File

@@ -0,0 +1,255 @@
/**
* RAGSearchPanel Component
*
* Enhanced RAG search panel for teachers to query their Erwartungshorizonte.
* Features:
* - search_info display (which RAG features were active)
* - Confidence indicator based on re-ranking scores
* - Toggle for "Enhanced Search" with advanced options
*/
import { useState, useCallback } from 'react'
import { ehApi, EHRAGResult } from '../services/api'
interface RAGSearchPanelProps {
onClose: () => void
defaultSubject?: string
passphrase: string
}
interface SearchOptions {
rerank: boolean
limit: number
}
// Confidence level based on score
type ConfidenceLevel = 'high' | 'medium' | 'low'
function getConfidenceLevel(score: number): ConfidenceLevel {
if (score >= 0.8) return 'high'
if (score >= 0.5) return 'medium'
return 'low'
}
function getConfidenceColor(level: ConfidenceLevel): string {
switch (level) {
case 'high': return 'var(--bp-success, #22c55e)'
case 'medium': return 'var(--bp-warning, #f59e0b)'
case 'low': return 'var(--bp-danger, #ef4444)'
}
}
function getConfidenceLabel(level: ConfidenceLevel): string {
switch (level) {
case 'high': return 'Hohe Relevanz'
case 'medium': return 'Mittlere Relevanz'
case 'low': return 'Geringe Relevanz'
}
}
export default function RAGSearchPanel({ onClose, defaultSubject, passphrase }: RAGSearchPanelProps) {
const [query, setQuery] = useState('')
const [searching, setSearching] = useState(false)
const [results, setResults] = useState<EHRAGResult | null>(null)
const [error, setError] = useState<string | null>(null)
// Enhanced search options
const [showAdvancedOptions, setShowAdvancedOptions] = useState(false)
const [options, setOptions] = useState<SearchOptions>({
rerank: true, // Default: enabled for better results
limit: 5
})
const handleSearch = useCallback(async () => {
if (!query.trim() || !passphrase) return
setSearching(true)
setError(null)
try {
const result = await ehApi.ragQuery({
query_text: query,
passphrase: passphrase,
subject: defaultSubject,
limit: options.limit,
rerank: options.rerank
})
setResults(result)
} catch (err) {
console.error('RAG search failed:', err)
setError(err instanceof Error ? err.message : 'Suche fehlgeschlagen')
} finally {
setSearching(false)
}
}, [query, passphrase, defaultSubject, options])
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSearch()
}
}
return (
<div className="rag-search-overlay">
<div className="rag-search-modal">
{/* Header */}
<div className="rag-search-header">
<h2>Erwartungshorizont durchsuchen</h2>
<button className="rag-search-close" onClick={onClose}>&times;</button>
</div>
{/* Search Input */}
<div className="rag-search-input-container">
<textarea
className="rag-search-input"
placeholder="Suchen Sie nach relevanten Abschnitten im Erwartungshorizont..."
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
rows={2}
/>
<button
className="rag-search-btn"
onClick={handleSearch}
disabled={searching || !query.trim()}
>
{searching ? 'Sucht...' : 'Suchen'}
</button>
</div>
{/* Advanced Options Toggle */}
<div className="rag-advanced-toggle">
<button
className="rag-toggle-btn"
onClick={() => setShowAdvancedOptions(!showAdvancedOptions)}
>
{showAdvancedOptions ? '▼' : '▶'} Erweiterte Optionen
</button>
</div>
{/* Advanced Options Panel */}
{showAdvancedOptions && (
<div className="rag-advanced-options">
<div className="rag-option-row">
<label className="rag-option-label">
<input
type="checkbox"
checked={options.rerank}
onChange={(e) => setOptions(prev => ({ ...prev, rerank: e.target.checked }))}
/>
<span className="rag-option-text">
Re-Ranking aktivieren
<span className="rag-option-hint">
(Cross-Encoder fuer hoehere Genauigkeit)
</span>
</span>
</label>
</div>
<div className="rag-option-row">
<label className="rag-option-label">
Anzahl Ergebnisse:
<select
value={options.limit}
onChange={(e) => setOptions(prev => ({ ...prev, limit: Number(e.target.value) }))}
className="rag-option-select"
>
<option value={3}>3</option>
<option value={5}>5</option>
<option value={10}>10</option>
</select>
</label>
</div>
</div>
)}
{/* Error Display */}
{error && (
<div className="rag-error">
{error}
</div>
)}
{/* Results */}
{results && (
<div className="rag-results">
{/* Search Info Badge */}
{results.search_info && (
<div className="rag-search-info">
<span className="rag-info-badge">
{results.search_info.rerank_applied && (
<span className="rag-info-tag rag-info-rerank">Re-Ranked</span>
)}
{results.search_info.hybrid_search_applied && (
<span className="rag-info-tag rag-info-hybrid">Hybrid Search</span>
)}
{results.search_info.embedding_model && (
<span className="rag-info-tag rag-info-model">
{results.search_info.embedding_model.split('/').pop()}
</span>
)}
</span>
<span className="rag-info-count">
{results.search_info.total_candidates} Kandidaten gefiltert
</span>
</div>
)}
{/* Context Summary */}
{results.context && (
<div className="rag-context-summary">
<h4>Zusammengefasster Kontext</h4>
<p>{results.context}</p>
</div>
)}
{/* Source Results */}
<div className="rag-sources">
<h4>Relevante Abschnitte ({results.sources.length})</h4>
{results.sources.map((source, index) => {
const confidence = getConfidenceLevel(source.score)
return (
<div key={`${source.eh_id}-${source.chunk_index}`} className="rag-source-item">
<div className="rag-source-header">
<span className="rag-source-rank">#{index + 1}</span>
<span className="rag-source-title">{source.eh_title}</span>
<span
className="rag-confidence-badge"
style={{
backgroundColor: getConfidenceColor(confidence),
color: 'white'
}}
title={`Score: ${(source.score * 100).toFixed(1)}%`}
>
{getConfidenceLabel(confidence)}
{source.reranked && <span className="rag-reranked-icon" title="Re-ranked">&#x2713;</span>}
</span>
</div>
<div className="rag-source-text">
{source.text}
</div>
<div className="rag-source-meta">
<span>Chunk #{source.chunk_index}</span>
<span>Score: {(source.score * 100).toFixed(1)}%</span>
</div>
</div>
)
})}
</div>
</div>
)}
{/* Empty State */}
{!results && !error && !searching && (
<div className="rag-empty-state">
<div className="rag-empty-icon">&#128269;</div>
<p>Geben Sie eine Suchanfrage ein, um relevante Abschnitte aus Ihren Erwartungshorizonten zu finden.</p>
<p className="rag-empty-hint">
Tipp: Aktivieren Sie "Re-Ranking" fuer praezisere Ergebnisse.
</p>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,175 @@
import { createContext, useContext, useState, useCallback, ReactNode } from 'react'
import { klausurApi, Klausur, StudentKlausur } from '../services/api'
interface KlausurContextType {
klausuren: Klausur[]
currentKlausur: Klausur | null
currentStudent: StudentKlausur | null
loading: boolean
error: string | null
loadKlausuren: () => Promise<void>
selectKlausur: (id: string, keepStudent?: boolean) => Promise<void>
selectStudent: (id: string) => void
setStudentById: (id: string) => void
refreshAndSelectStudent: (klausurId: string, studentId: string) => Promise<void>
createKlausur: (data: Partial<Klausur>) => Promise<Klausur>
deleteKlausur: (id: string) => Promise<void>
updateCriteria: (studentId: string, criterion: string, score: number) => Promise<void>
}
const KlausurContext = createContext<KlausurContextType | null>(null)
export function KlausurProvider({ children }: { children: ReactNode }) {
const [klausuren, setKlausuren] = useState<Klausur[]>([])
const [currentKlausur, setCurrentKlausur] = useState<Klausur | null>(null)
const [currentStudent, setCurrentStudent] = useState<StudentKlausur | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const loadKlausuren = useCallback(async () => {
setLoading(true)
setError(null)
try {
const data = await klausurApi.listKlausuren()
setKlausuren(data)
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to load Klausuren')
} finally {
setLoading(false)
}
}, [])
const selectKlausur = useCallback(async (id: string, keepStudent = false) => {
setLoading(true)
setError(null)
try {
const klausur = await klausurApi.getKlausur(id)
setCurrentKlausur(klausur)
// Optionally keep the current student selection (for refresh after save)
if (keepStudent && currentStudent) {
const updatedStudent = klausur.students.find(s => s.id === currentStudent.id)
setCurrentStudent(updatedStudent || null)
} else if (!keepStudent) {
setCurrentStudent(null)
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to load Klausur')
} finally {
setLoading(false)
}
}, [currentStudent])
const selectStudent = useCallback((id: string) => {
if (!currentKlausur) return
const student = currentKlausur.students.find(s => s.id === id)
setCurrentStudent(student || null)
}, [currentKlausur])
// Set student directly by ID (for cases where we need to set before state updates)
const setStudentById = useCallback((id: string) => {
if (!currentKlausur) return
const student = currentKlausur.students.find(s => s.id === id)
if (student) {
setCurrentStudent(student)
}
}, [currentKlausur])
// Combined refresh and select - useful after upload
const refreshAndSelectStudent = useCallback(async (klausurId: string, studentId: string) => {
setLoading(true)
setError(null)
try {
const klausur = await klausurApi.getKlausur(klausurId)
setCurrentKlausur(klausur)
const student = klausur.students.find(s => s.id === studentId)
if (student) {
setCurrentStudent(student)
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to load Klausur')
} finally {
setLoading(false)
}
}, [])
const createKlausur = useCallback(async (data: Partial<Klausur>) => {
setLoading(true)
setError(null)
try {
const klausur = await klausurApi.createKlausur(data)
setKlausuren(prev => [...prev, klausur])
return klausur
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to create Klausur')
throw e
} finally {
setLoading(false)
}
}, [])
const deleteKlausur = useCallback(async (id: string) => {
setLoading(true)
setError(null)
try {
await klausurApi.deleteKlausur(id)
setKlausuren(prev => prev.filter(k => k.id !== id))
if (currentKlausur?.id === id) {
setCurrentKlausur(null)
setCurrentStudent(null)
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to delete Klausur')
throw e
} finally {
setLoading(false)
}
}, [currentKlausur])
const updateCriteria = useCallback(async (studentId: string, criterion: string, score: number) => {
try {
const updated = await klausurApi.updateCriteria(studentId, criterion, score)
if (currentKlausur) {
setCurrentKlausur({
...currentKlausur,
students: currentKlausur.students.map(s =>
s.id === studentId ? updated : s
)
})
}
if (currentStudent?.id === studentId) {
setCurrentStudent(updated)
}
} catch (e) {
setError(e instanceof Error ? e.message : 'Failed to update criteria')
throw e
}
}, [currentKlausur, currentStudent])
return (
<KlausurContext.Provider value={{
klausuren,
currentKlausur,
currentStudent,
loading,
error,
loadKlausuren,
selectKlausur,
selectStudent,
setStudentById,
refreshAndSelectStudent,
createKlausur,
deleteKlausur,
updateCriteria
}}>
{children}
</KlausurContext.Provider>
)
}
export function useKlausur() {
const context = useContext(KlausurContext)
if (!context) {
throw new Error('useKlausur must be used within a KlausurProvider')
}
return context
}

View File

@@ -0,0 +1,19 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './styles/global.css'
// Listen for auth token from parent Studio window
window.addEventListener('message', (event) => {
const data = event.data as { type?: string; token?: string }
if (data?.type === 'AUTH_TOKEN' && data?.token) {
localStorage.setItem('auth_token', data.token)
console.log('Auth token received from Studio')
}
})
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@@ -0,0 +1,956 @@
import { useState, useEffect, useRef } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { useKlausur } from '../hooks/useKlausur'
import { klausurApi, uploadStudentWork, StudentKlausur, klausurEHApi, LinkedEHInfo } from '../services/api'
import EHUploadWizard from '../components/EHUploadWizard'
// Grade calculation
const GRADE_THRESHOLDS: Record<number, number> = {
15: 95, 14: 90, 13: 85, 12: 80, 11: 75, 10: 70,
9: 65, 8: 60, 7: 55, 6: 50, 5: 45, 4: 40,
3: 33, 2: 27, 1: 20, 0: 0
}
const GRADE_LABELS: Record<number, string> = {
15: '1+', 14: '1', 13: '1-', 12: '2+', 11: '2', 10: '2-',
9: '3+', 8: '3', 7: '3-', 6: '4+', 5: '4', 4: '4-',
3: '5+', 2: '5', 1: '5-', 0: '6'
}
const CRITERIA = [
{ key: 'inhalt', label: 'Inhaltliche Leistung', weight: 0.40 },
{ key: 'struktur', label: 'Aufbau & Struktur', weight: 0.15 },
{ key: 'stil', label: 'Ausdruck & Stil', weight: 0.15 },
{ key: 'grammatik', label: 'Grammatik', weight: 0.15 },
{ key: 'rechtschreibung', label: 'Rechtschreibung', weight: 0.15 }
]
// Wizard steps
type WizardStep = 'korrektur' | 'bewertung' | 'gutachten'
function calculateGradePoints(percentage: number): number {
for (const [points, threshold] of Object.entries(GRADE_THRESHOLDS).sort((a, b) => Number(b[0]) - Number(a[0]))) {
if (percentage >= threshold) {
return Number(points)
}
}
return 0
}
export default function KorrekturPage() {
const { klausurId } = useParams<{ klausurId: string }>()
const navigate = useNavigate()
const { currentKlausur, currentStudent, selectKlausur, selectStudent, refreshAndSelectStudent, loading, error } = useKlausur()
const fileInputRef = useRef<HTMLInputElement>(null)
// Wizard state
const [wizardStep, setWizardStep] = useState<WizardStep>('korrektur')
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
// Upload state
const [uploading, setUploading] = useState(false)
const [uploadModalOpen, setUploadModalOpen] = useState(false)
const [studentName, setStudentName] = useState('')
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [classStudents, setClassStudents] = useState<Array<{id: string, name: string}>>([])
const [useStudentDropdown, setUseStudentDropdown] = useState(true)
// Korrektur state (Step 1)
const [korrekturNotes, setKorrekturNotes] = useState('')
// Bewertung state (Step 2)
const [localScores, setLocalScores] = useState<Record<string, number>>({})
const [savingCriteria, setSavingCriteria] = useState(false)
// Gutachten state (Step 3)
const [generatingGutachten, setGeneratingGutachten] = useState(false)
const [localGutachten, setLocalGutachten] = useState<{
einleitung: string
hauptteil: string
fazit: string
}>({ einleitung: '', hauptteil: '', fazit: '' })
const [savingGutachten, setSavingGutachten] = useState(false)
const [finalizingStudent, setFinalizingStudent] = useState(false)
// BYOEH state - Erwartungshorizont integration
const [showEHPrompt, setShowEHPrompt] = useState(false)
const [showEHWizard, setShowEHWizard] = useState(false)
const [linkedEHs, setLinkedEHs] = useState<LinkedEHInfo[]>([])
const [ehPromptDismissed, setEhPromptDismissed] = useState(false)
const [_loadingEHs, setLoadingEHs] = useState(false)
// Load klausur on mount
useEffect(() => {
if (klausurId) {
selectKlausur(klausurId)
}
}, [klausurId, selectKlausur])
// Load class students when upload modal opens
useEffect(() => {
if (uploadModalOpen && currentKlausur?.class_id) {
loadClassStudents(currentKlausur.class_id)
}
}, [uploadModalOpen, currentKlausur?.class_id])
const loadClassStudents = async (classId: string) => {
try {
const resp = await fetch(`/api/school/classes/${classId}/students`)
if (resp.ok) {
const data = await resp.json()
setClassStudents(data)
}
} catch (e) {
console.error('Failed to load class students:', e)
}
}
// Load linked Erwartungshorizonte for this Klausur
const loadLinkedEHs = async () => {
if (!klausurId) return
setLoadingEHs(true)
try {
const ehs = await klausurEHApi.getLinkedEH(klausurId)
setLinkedEHs(ehs)
} catch (e) {
console.error('Failed to load linked EHs:', e)
} finally {
setLoadingEHs(false)
}
}
// Load linked EHs when klausur changes
useEffect(() => {
if (klausurId) {
loadLinkedEHs()
}
}, [klausurId])
// Show EH prompt after first student upload (if no EH is linked yet)
useEffect(() => {
// After upload is complete and modal is closed
if (
currentKlausur &&
currentKlausur.students.length === 1 &&
linkedEHs.length === 0 &&
!ehPromptDismissed &&
!uploadModalOpen &&
!showEHWizard
) {
// Check localStorage to see if prompt was already shown for this klausur
const dismissedKey = `eh_prompt_dismissed_${klausurId}`
if (!localStorage.getItem(dismissedKey)) {
setShowEHPrompt(true)
}
}
}, [currentKlausur?.students.length, linkedEHs.length, uploadModalOpen, showEHWizard, ehPromptDismissed])
// Handle EH prompt responses
const handleEHPromptUpload = () => {
setShowEHPrompt(false)
setShowEHWizard(true)
}
const handleEHPromptDismiss = () => {
setShowEHPrompt(false)
setEhPromptDismissed(true)
if (klausurId) {
localStorage.setItem(`eh_prompt_dismissed_${klausurId}`, 'true')
}
}
// Handle EH wizard completion
const handleEHWizardComplete = async () => {
setShowEHWizard(false)
// Reload linked EHs
await loadLinkedEHs()
}
// Sync local state with current student
useEffect(() => {
if (currentStudent) {
const scores: Record<string, number> = {}
for (const c of CRITERIA) {
scores[c.key] = currentStudent.criteria_scores?.[c.key]?.score ?? 0
}
setLocalScores(scores)
setLocalGutachten({
einleitung: currentStudent.gutachten?.einleitung || '',
hauptteil: currentStudent.gutachten?.hauptteil || '',
fazit: currentStudent.gutachten?.fazit || ''
})
// Reset wizard to first step when selecting new student
setWizardStep('korrektur')
setKorrekturNotes('')
}
}, [currentStudent?.id])
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files?.[0]) {
setSelectedFile(e.target.files[0])
}
}
const handleUpload = async () => {
if (!klausurId || !studentName || !selectedFile) return
setUploading(true)
try {
const newStudent = await uploadStudentWork(klausurId, studentName, selectedFile)
// Refresh klausur and auto-select the newly uploaded student
await refreshAndSelectStudent(klausurId, newStudent.id)
setUploadModalOpen(false)
setStudentName('')
setSelectedFile(null)
} catch (e) {
console.error('Upload failed:', e)
alert('Fehler beim Hochladen')
} finally {
setUploading(false)
}
}
const handleDeleteStudent = async (studentId: string, e: React.MouseEvent) => {
e.stopPropagation()
if (!confirm('Schuelerarbeit wirklich loeschen?')) return
try {
await klausurApi.deleteStudent(studentId)
if (klausurId) {
await selectKlausur(klausurId, true)
}
} catch (e) {
console.error('Failed to delete student:', e)
}
}
// Step 1: Complete Korrektur and go to Bewertung
const handleKorrekturComplete = () => {
setWizardStep('bewertung')
}
// Step 2: Save criteria scores
const handleCriteriaChange = async (criterion: string, value: number) => {
setLocalScores(prev => ({ ...prev, [criterion]: value }))
if (!currentStudent) return
setSavingCriteria(true)
try {
await klausurApi.updateCriteria(currentStudent.id, criterion, value)
if (klausurId) {
await selectKlausur(klausurId, true)
}
} catch (e) {
console.error('Failed to update criteria:', e)
} finally {
setSavingCriteria(false)
}
}
// Check if all criteria are filled
const allCriteriaFilled = CRITERIA.every(c => (localScores[c.key] || 0) > 0)
// Step 2: Complete Bewertung and go to Gutachten
const handleBewertungComplete = () => {
if (!allCriteriaFilled) {
alert('Bitte alle Bewertungskriterien ausfuellen')
return
}
setWizardStep('gutachten')
}
// Step 3: Generate Gutachten
const handleGenerateGutachten = async () => {
if (!currentStudent) return
setGeneratingGutachten(true)
try {
const generated = await klausurApi.generateGutachten(currentStudent.id, {
include_strengths: true,
include_weaknesses: true,
tone: 'formal'
})
setLocalGutachten({
einleitung: generated.einleitung,
hauptteil: generated.hauptteil,
fazit: generated.fazit
})
} catch (e) {
console.error('Failed to generate gutachten:', e)
alert('Fehler bei der KI-Generierung')
} finally {
setGeneratingGutachten(false)
}
}
// Step 3: Save Gutachten
const handleSaveGutachten = async () => {
if (!currentStudent) return
setSavingGutachten(true)
try {
await klausurApi.updateGutachten(currentStudent.id, {
einleitung: localGutachten.einleitung,
hauptteil: localGutachten.hauptteil,
fazit: localGutachten.fazit
})
if (klausurId) {
await selectKlausur(klausurId, true)
}
} catch (e) {
console.error('Failed to save gutachten:', e)
alert('Fehler beim Speichern des Gutachtens')
} finally {
setSavingGutachten(false)
}
}
// Finalize the correction
const handleFinalizeStudent = async () => {
if (!currentStudent) return
if (!confirm('Bewertung wirklich abschliessen? Dies kann nicht rueckgaengig gemacht werden.')) return
setFinalizingStudent(true)
try {
await klausurApi.finalizeStudent(currentStudent.id)
if (klausurId) {
await selectKlausur(klausurId, true)
}
} catch (e) {
console.error('Failed to finalize:', e)
alert('Fehler beim Abschliessen der Bewertung')
} finally {
setFinalizingStudent(false)
}
}
const calculateTotalPercentage = (): number => {
let total = 0
for (const c of CRITERIA) {
total += (localScores[c.key] || 0) * c.weight
}
return Math.round(total)
}
if (loading && !currentKlausur) {
return (
<div className="loading">
<div className="spinner" />
<div className="loading-text">Klausur wird geladen...</div>
</div>
)
}
if (error) {
return (
<div className="loading">
<div style={{ color: 'var(--bp-danger)', marginBottom: 16 }}>Fehler: {error}</div>
<button className="btn btn-secondary" onClick={() => navigate('/')}>
Zurueck zur Startseite
</button>
</div>
)
}
if (!currentKlausur) {
return (
<div className="loading">
<div style={{ marginBottom: 16 }}>Klausur nicht gefunden</div>
<button className="btn btn-secondary" onClick={() => navigate('/')}>
Zurueck zur Startseite
</button>
</div>
)
}
const totalPercentage = calculateTotalPercentage()
const gradePoints = calculateGradePoints(totalPercentage)
// Render right panel content based on wizard step
const renderWizardContent = () => {
if (!currentStudent) {
return (
<div className="panel-section" style={{ textAlign: 'center', padding: 40 }}>
<div style={{ fontSize: 48, marginBottom: 16, opacity: 0.5 }}>📋</div>
<div style={{ color: 'var(--bp-text-muted)' }}>
Waehlen Sie eine Schuelerarbeit aus, um die Bewertung zu beginnen
</div>
</div>
)
}
switch (wizardStep) {
case 'korrektur':
return (
<>
{/* Step indicator */}
<div className="wizard-steps">
<div className="wizard-step active">
<span className="wizard-step-number">1</span>
<span className="wizard-step-label">Korrektur</span>
</div>
<div className="wizard-step">
<span className="wizard-step-number">2</span>
<span className="wizard-step-label">Bewertung</span>
</div>
<div className="wizard-step">
<span className="wizard-step-number">3</span>
<span className="wizard-step-label">Gutachten</span>
</div>
</div>
<div className="panel-section">
<div className="panel-section-title"> Korrektur durchfuehren</div>
<p style={{ color: 'var(--bp-text-muted)', fontSize: 13, marginBottom: 16 }}>
Lesen Sie die Arbeit sorgfaeltig und machen Sie Anmerkungen direkt im Dokument.
Notieren Sie hier Ihre wichtigsten Beobachtungen.
</p>
<div className="form-group">
<label className="form-label">Korrektur-Notizen</label>
<textarea
className="textarea"
placeholder="Ihre Notizen waehrend der Korrektur..."
style={{ minHeight: 200 }}
value={korrekturNotes}
onChange={(e) => setKorrekturNotes(e.target.value)}
/>
</div>
</div>
<div className="panel-section">
<button
className="btn btn-primary"
style={{ width: '100%' }}
onClick={handleKorrekturComplete}
>
Weiter zur Bewertung
</button>
</div>
</>
)
case 'bewertung':
return (
<>
{/* Step indicator */}
<div className="wizard-steps">
<div className="wizard-step completed">
<span className="wizard-step-number"></span>
<span className="wizard-step-label">Korrektur</span>
</div>
<div className="wizard-step active">
<span className="wizard-step-number">2</span>
<span className="wizard-step-label">Bewertung</span>
</div>
<div className="wizard-step">
<span className="wizard-step-number">3</span>
<span className="wizard-step-label">Gutachten</span>
</div>
</div>
<div className="panel-section">
<div className="panel-section-title">
📊 Gesamtnote
</div>
<div className="grade-display">
<div className="grade-points">{gradePoints}</div>
<div className="grade-label">
{GRADE_LABELS[gradePoints]} ({totalPercentage}%)
</div>
</div>
</div>
<div className="panel-section">
<div className="panel-section-title">
Bewertungskriterien
{savingCriteria && <span style={{ fontSize: 11, color: 'var(--bp-text-muted)' }}> (Speichert...)</span>}
</div>
{CRITERIA.map(c => (
<div key={c.key} className="criterion-item">
<div className="criterion-header">
<span className="criterion-label">{c.label} ({Math.round(c.weight * 100)}%)</span>
<span className="criterion-score">{localScores[c.key] || 0}%</span>
</div>
<input
type="range"
className="criterion-slider"
min="0"
max="100"
value={localScores[c.key] || 0}
onChange={(e) => handleCriteriaChange(c.key, Number(e.target.value))}
/>
</div>
))}
</div>
<div className="panel-section">
<div style={{ display: 'flex', gap: 8 }}>
<button
className="btn btn-secondary"
style={{ flex: 1 }}
onClick={() => setWizardStep('korrektur')}
>
Zurueck
</button>
<button
className="btn btn-primary"
style={{ flex: 1 }}
onClick={handleBewertungComplete}
disabled={!allCriteriaFilled}
>
Weiter
</button>
</div>
{!allCriteriaFilled && (
<p style={{ color: 'var(--bp-warning)', fontSize: 12, marginTop: 8, textAlign: 'center' }}>
Bitte alle Kriterien bewerten
</p>
)}
</div>
</>
)
case 'gutachten':
return (
<>
{/* Step indicator */}
<div className="wizard-steps">
<div className="wizard-step completed">
<span className="wizard-step-number"></span>
<span className="wizard-step-label">Korrektur</span>
</div>
<div className="wizard-step completed">
<span className="wizard-step-number"></span>
<span className="wizard-step-label">Bewertung</span>
</div>
<div className="wizard-step active">
<span className="wizard-step-number">3</span>
<span className="wizard-step-label">Gutachten</span>
</div>
</div>
<div className="panel-section">
<div className="panel-section-title">
📊 Endergebnis: {gradePoints} Punkte ({GRADE_LABELS[gradePoints]})
</div>
</div>
<div className="panel-section">
<div className="panel-section-title">
📝 Gutachten
{savingGutachten && <span style={{ fontSize: 11, color: 'var(--bp-text-muted)' }}> (Speichert...)</span>}
</div>
<button
className="btn btn-secondary"
style={{ width: '100%', marginBottom: 16 }}
onClick={handleGenerateGutachten}
disabled={generatingGutachten}
>
{generatingGutachten ? '⏳ KI generiert...' : '🤖 KI-Gutachten generieren'}
</button>
<div className="form-group">
<label className="form-label">Einleitung</label>
<textarea
className="textarea"
placeholder="Allgemeine Einordnung der Arbeit..."
value={localGutachten.einleitung}
onChange={(e) => setLocalGutachten(prev => ({ ...prev, einleitung: e.target.value }))}
/>
</div>
<div className="form-group">
<label className="form-label">Hauptteil</label>
<textarea
className="textarea"
placeholder="Detaillierte Bewertung..."
style={{ minHeight: 120 }}
value={localGutachten.hauptteil}
onChange={(e) => setLocalGutachten(prev => ({ ...prev, hauptteil: e.target.value }))}
/>
</div>
<div className="form-group">
<label className="form-label">Fazit</label>
<textarea
className="textarea"
placeholder="Zusammenfassung und Empfehlungen..."
value={localGutachten.fazit}
onChange={(e) => setLocalGutachten(prev => ({ ...prev, fazit: e.target.value }))}
/>
</div>
<button
className="btn btn-secondary"
style={{ width: '100%', marginBottom: 8 }}
onClick={handleSaveGutachten}
disabled={savingGutachten}
>
{savingGutachten ? '💾 Speichert...' : '💾 Gutachten speichern'}
</button>
</div>
<div className="panel-section">
<div style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
<button
className="btn btn-secondary"
style={{ flex: 1 }}
onClick={() => setWizardStep('bewertung')}
>
Zurueck
</button>
</div>
<button
className="btn btn-primary"
style={{ width: '100%' }}
onClick={handleFinalizeStudent}
disabled={finalizingStudent || currentStudent.status === 'completed'}
>
{currentStudent.status === 'completed'
? '✓ Abgeschlossen'
: finalizingStudent
? 'Wird abgeschlossen...'
: '✓ Bewertung abschliessen'}
</button>
</div>
</>
)
}
}
return (
<div className={`korrektur-layout ${sidebarCollapsed ? 'sidebar-collapsed' : ''}`}>
{/* Collapsible Left Sidebar */}
<div className={`korrektur-sidebar ${sidebarCollapsed ? 'collapsed' : ''}`}>
<button
className="sidebar-toggle"
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
title={sidebarCollapsed ? 'Sidebar einblenden' : 'Sidebar ausblenden'}
>
{sidebarCollapsed ? '→' : '←'}
</button>
{!sidebarCollapsed && (
<>
<div className="sidebar-section">
<div className="sidebar-section-title">Klausur</div>
<div className="klausur-item active">
<div className="klausur-icon">📋</div>
<div className="klausur-info">
<div className="klausur-name">{currentKlausur.title}</div>
<div className="klausur-meta">
{currentKlausur.modus === 'landes_abitur' ? 'Abitur' : 'Vorabitur'} {currentKlausur.students.length} Schueler
</div>
</div>
</div>
</div>
<div className="sidebar-section" style={{ flex: 1 }}>
<div className="sidebar-section-title">Schuelerarbeiten</div>
{currentKlausur.students.length === 0 ? (
<div style={{ color: 'var(--bp-text-muted)', fontSize: 13, padding: '8px 0' }}>
Noch keine Arbeiten hochgeladen
</div>
) : (
currentKlausur.students.map((student: StudentKlausur) => (
<div
key={student.id}
className={`klausur-item ${currentStudent?.id === student.id ? 'active' : ''}`}
onClick={() => selectStudent(student.id)}
>
<div className="klausur-icon">📄</div>
<div className="klausur-info">
<div className="klausur-name">{student.student_name}</div>
<div className="klausur-meta">
{student.status === 'completed' ? `${student.grade_points} Punkte` : student.status}
</div>
</div>
<button
className="delete-btn"
onClick={(e) => handleDeleteStudent(student.id, e)}
title="Loeschen"
>
🗑
</button>
</div>
))
)}
<button
className="btn btn-primary"
style={{ width: '100%', marginTop: 16 }}
onClick={() => setUploadModalOpen(true)}
>
+ Arbeit hochladen
</button>
</div>
</>
)}
</div>
{/* Center - Document Viewer (2/3) */}
<div className="korrektur-main">
<div className="viewer-container">
<div className="viewer-toolbar">
<div style={{ fontSize: 14, fontWeight: 500 }}>
{currentStudent ? currentStudent.student_name : 'Dokument-Ansicht'}
</div>
<div style={{ display: 'flex', gap: 8 }}>
{currentStudent && (
<>
<button className="btn btn-ghost" style={{ padding: '6px 12px' }}>
OCR-Text
</button>
<button className="btn btn-ghost" style={{ padding: '6px 12px' }}>
Original
</button>
</>
)}
</div>
</div>
<div className="viewer-content">
{!currentStudent ? (
<div className="document-placeholder">
<div className="document-placeholder-icon">📄</div>
<div style={{ fontSize: 16, marginBottom: 8 }}>Keine Arbeit ausgewaehlt</div>
<div style={{ fontSize: 14 }}>
Waehlen Sie eine Schuelerarbeit aus der Liste oder laden Sie eine neue hoch
</div>
</div>
) : currentStudent.file_path ? (
<div className="document-viewer">
<div className="document-info-bar">
<span className="file-name">📄 {currentStudent.student_name}</span>
<span className="file-status"> Hochgeladen</span>
</div>
<div className="document-frame">
{currentStudent.file_path.endsWith('.pdf') ? (
<iframe
src={`/api/v1/students/${currentStudent.id}/file`}
title="Schuelerarbeit"
style={{ width: '100%', height: '100%', minHeight: '600px' }}
/>
) : (
<img
src={`/api/v1/students/${currentStudent.id}/file`}
alt={`Arbeit von ${currentStudent.student_name}`}
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }}
/>
)}
</div>
</div>
) : (
<div className="document-placeholder">
<div className="document-placeholder-icon">📄</div>
<div style={{ fontSize: 16, marginBottom: 8 }}>Keine Datei vorhanden</div>
<div style={{ fontSize: 14 }}>
Laden Sie eine Schuelerarbeit hoch, um mit der Korrektur zu beginnen.
</div>
</div>
)}
</div>
</div>
</div>
{/* Right Panel - Wizard (1/3) */}
<div className="korrektur-panel">
{renderWizardContent()}
</div>
{/* EH Info in Sidebar - Show linked Erwartungshorizonte */}
{linkedEHs.length > 0 && (
<div className="eh-info-badge" style={{
position: 'fixed',
bottom: 20,
left: sidebarCollapsed ? 20 : 270,
background: 'var(--bp-primary)',
color: 'white',
padding: '8px 16px',
borderRadius: 20,
fontSize: 13,
display: 'flex',
alignItems: 'center',
gap: 8,
boxShadow: '0 2px 8px rgba(0,0,0,0.2)',
zIndex: 100,
cursor: 'pointer',
transition: 'left 0.3s ease'
}} onClick={() => setShowEHWizard(true)}>
<span>📋</span>
<span>{linkedEHs.length} Erwartungshorizont{linkedEHs.length > 1 ? 'e' : ''} verknuepft</span>
</div>
)}
{/* Upload Modal */}
{uploadModalOpen && (
<div className="modal-overlay" onClick={() => setUploadModalOpen(false)}>
<div className="modal" onClick={e => e.stopPropagation()}>
<div className="modal-header">
<div className="modal-title">Schuelerarbeit hochladen</div>
<button className="modal-close" onClick={() => setUploadModalOpen(false)}>×</button>
</div>
<div className="modal-body">
<div className="form-group">
<label className="form-label">Schueler zuweisen</label>
{classStudents.length > 0 && (
<div style={{ marginBottom: 12 }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer', fontSize: 13 }}>
<input
type="checkbox"
checked={useStudentDropdown}
onChange={(e) => setUseStudentDropdown(e.target.checked)}
/>
Aus Klassenliste waehlen
</label>
</div>
)}
{useStudentDropdown && classStudents.length > 0 ? (
<select
className="input"
value={studentName}
onChange={e => setStudentName(e.target.value)}
style={{ width: '100%' }}
>
<option value="">-- Schueler waehlen --</option>
{classStudents.map(s => (
<option key={s.id} value={s.name}>{s.name}</option>
))}
</select>
) : (
<input
type="text"
className="input"
placeholder="z.B. Max Mustermann"
value={studentName}
onChange={e => setStudentName(e.target.value)}
/>
)}
{classStudents.length === 0 && (
<p style={{ fontSize: 12, color: 'var(--bp-text-muted)', marginTop: 8 }}>
Keine Klassenliste verfuegbar. Bitte Namen manuell eingeben.
</p>
)}
</div>
<div className="form-group">
<label className="form-label">Datei (PDF oder Bild)</label>
<div
className={`upload-area ${selectedFile ? 'selected' : ''}`}
onClick={() => fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
accept=".pdf,.png,.jpg,.jpeg"
style={{ display: 'none' }}
onChange={handleFileSelect}
/>
{selectedFile ? (
<>
<div className="upload-icon">📄</div>
<div className="upload-text">{selectedFile.name}</div>
</>
) : (
<>
<div className="upload-icon">📁</div>
<div className="upload-text">
Klicken Sie hier oder ziehen Sie eine Datei hinein
</div>
</>
)}
</div>
</div>
</div>
<div className="modal-footer">
<button
className="btn btn-secondary"
onClick={() => setUploadModalOpen(false)}
>
Abbrechen
</button>
<button
className="btn btn-primary"
disabled={!studentName || !selectedFile || uploading}
onClick={handleUpload}
>
{uploading ? 'Wird hochgeladen...' : 'Hochladen'}
</button>
</div>
</div>
</div>
)}
{/* EH Prompt Modal - Shown after first student upload */}
{showEHPrompt && (
<div className="modal-overlay" onClick={handleEHPromptDismiss}>
<div className="modal" onClick={e => e.stopPropagation()} style={{ maxWidth: 500 }}>
<div className="modal-header">
<div className="modal-title">📋 Erwartungshorizont hochladen?</div>
<button className="modal-close" onClick={handleEHPromptDismiss}>×</button>
</div>
<div className="modal-body">
<p style={{ marginBottom: 16, lineHeight: 1.6 }}>
Sie haben die erste Schuelerarbeit hochgeladen. Moechten Sie jetzt einen
<strong> Erwartungshorizont</strong> hinzufuegen?
</p>
<div style={{
background: 'var(--bp-bg-light)',
border: '1px solid var(--bp-border)',
borderRadius: 8,
padding: 16,
marginBottom: 16
}}>
<div style={{ fontWeight: 500, marginBottom: 8 }}> Vorteile:</div>
<ul style={{ margin: 0, paddingLeft: 20, color: 'var(--bp-text-muted)', fontSize: 14, lineHeight: 1.8 }}>
<li>KI-gestuetzte Korrekturvorschlaege basierend auf Ihrem EH</li>
<li>Bessere und konsistentere Bewertungen</li>
<li>Automatisch fuer alle Korrektoren verfuegbar</li>
<li>Ende-zu-Ende verschluesselt - nur Sie haben den Schluessel</li>
</ul>
</div>
<p style={{ fontSize: 13, color: 'var(--bp-text-muted)' }}>
Sie koennen den Erwartungshorizont auch spaeter hochladen.
</p>
</div>
<div className="modal-footer">
<button
className="btn btn-secondary"
onClick={handleEHPromptDismiss}
>
Spaeter
</button>
<button
className="btn btn-primary"
onClick={handleEHPromptUpload}
>
Jetzt hochladen
</button>
</div>
</div>
</div>
)}
{/* EH Upload Wizard */}
{showEHWizard && currentKlausur && (
<EHUploadWizard
onClose={() => setShowEHWizard(false)}
onComplete={handleEHWizardComplete}
defaultSubject={currentKlausur.subject}
defaultYear={currentKlausur.year}
klausurId={klausurId}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,247 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useKlausur } from '../hooks/useKlausur'
type Step = 'action' | 'type' | 'class'
type Action = 'korrigieren' | 'erstellen'
type ExamType = 'vorabitur' | 'abitur'
interface SchoolClass {
id: string
name: string
student_count: number
}
export default function OnboardingPage() {
const navigate = useNavigate()
const { createKlausur, loadKlausuren, klausuren } = useKlausur()
const [step, setStep] = useState<Step>('action')
const [action, setAction] = useState<Action | null>(null)
const [examType, setExamType] = useState<ExamType | null>(null)
const [selectedClass, setSelectedClass] = useState<SchoolClass | null>(null)
const [classes, setClasses] = useState<SchoolClass[]>([])
const [loading, setLoading] = useState(false)
// Load existing klausuren on mount
useEffect(() => {
loadKlausuren()
}, [loadKlausuren])
// Load classes when reaching step 3
useEffect(() => {
if (step === 'class') {
loadClasses()
}
}, [step])
const loadClasses = async () => {
try {
// Try to load from school-service via backend proxy
const resp = await fetch('/api/school/classes')
if (resp.ok) {
const data = await resp.json()
setClasses(data)
} else {
// If no school service available, use demo data
setClasses([
{ id: 'demo-q1', name: 'Q1 Deutsch GK', student_count: 24 },
{ id: 'demo-q2', name: 'Q2 Deutsch LK', student_count: 18 },
{ id: 'demo-q3', name: 'Q3 Deutsch GK', student_count: 22 }
])
}
} catch (e) {
console.error('Failed to load classes:', e)
// Use demo data on error
setClasses([
{ id: 'demo-q1', name: 'Q1 Deutsch GK', student_count: 24 },
{ id: 'demo-q2', name: 'Q2 Deutsch LK', student_count: 18 },
{ id: 'demo-q3', name: 'Q3 Deutsch GK', student_count: 22 }
])
}
}
const handleSelectAction = (a: Action) => {
setAction(a)
if (a === 'erstellen') {
alert('Die Klausur-Erstellung wird in einer spaeteren Version verfuegbar sein.')
return
}
setStep('type')
}
const handleSelectType = (t: ExamType) => {
setExamType(t)
setStep('class')
}
const handleSelectClass = (c: SchoolClass) => {
setSelectedClass(c)
}
const handleBack = () => {
if (step === 'type') setStep('action')
else if (step === 'class') setStep('type')
}
const handleStartCorrection = async () => {
if (!selectedClass || !examType) return
setLoading(true)
try {
const klausur = await createKlausur({
title: `Klausur ${selectedClass.name}`,
subject: 'Deutsch',
modus: examType === 'abitur' ? 'landes_abitur' : 'vorabitur',
class_id: selectedClass.id,
year: new Date().getFullYear(),
semester: 'Q1'
})
navigate(`/korrektur/${klausur.id}`)
} catch (e) {
console.error('Failed to create klausur:', e)
alert('Fehler beim Erstellen der Klausur')
} finally {
setLoading(false)
}
}
const getStepClasses = (s: Step) => {
const steps: Step[] = ['action', 'type', 'class']
const currentIndex = steps.indexOf(step)
const stepIndex = steps.indexOf(s)
if (stepIndex < currentIndex) return 'onboarding-step completed'
if (stepIndex === currentIndex) return 'onboarding-step active'
return 'onboarding-step'
}
return (
<div className="onboarding">
{step !== 'action' && (
<div className="onboarding-back" onClick={handleBack}>
Zurueck
</div>
)}
<div className="onboarding-content">
<div className="onboarding-steps">
<div className={getStepClasses('action')} />
<div className={getStepClasses('type')} />
<div className={getStepClasses('class')} />
</div>
{step === 'action' && (
<>
<h1 className="onboarding-title">Was moechten Sie tun?</h1>
<p className="onboarding-subtitle">Waehlen Sie eine Option, um fortzufahren</p>
<div className="onboarding-options">
<div
className={`onboarding-card ${action === 'korrigieren' ? 'selected' : ''}`}
onClick={() => handleSelectAction('korrigieren')}
>
<div className="onboarding-card-icon"></div>
<div className="onboarding-card-title">Klausuren korrigieren</div>
<div className="onboarding-card-desc">
Schuelerarbeiten hochladen und mit KI-Unterstuetzung bewerten
</div>
</div>
<div
className={`onboarding-card ${action === 'erstellen' ? 'selected' : ''}`}
onClick={() => handleSelectAction('erstellen')}
>
<div className="onboarding-card-icon">📝</div>
<div className="onboarding-card-title">Klausur erstellen</div>
<div className="onboarding-card-desc">
Neue Klausur mit Erwartungshorizont anlegen
</div>
</div>
</div>
{klausuren.length > 0 && (
<div style={{ marginTop: 40, textAlign: 'center' }}>
<button
className="btn btn-secondary"
onClick={() => navigate('/korrektur')}
>
Zu bestehenden Klausuren ({klausuren.length})
</button>
</div>
)}
</>
)}
{step === 'type' && (
<>
<h1 className="onboarding-title">Welche Art von Klausur?</h1>
<p className="onboarding-subtitle">Waehlen Sie den Klausurtyp</p>
<div className="onboarding-options">
<div
className={`onboarding-card ${examType === 'vorabitur' ? 'selected' : ''}`}
onClick={() => handleSelectType('vorabitur')}
>
<div className="onboarding-card-icon">📋</div>
<div className="onboarding-card-title">Vorabitur / Klausur</div>
<div className="onboarding-card-desc">
Regulaere Klausuren waehrend des Schuljahres
</div>
</div>
<div
className={`onboarding-card ${examType === 'abitur' ? 'selected' : ''}`}
onClick={() => handleSelectType('abitur')}
>
<div className="onboarding-card-icon">🎓</div>
<div className="onboarding-card-title">Abiturklausur</div>
<div className="onboarding-card-desc">
Abiturpruefung mit 15-Punkte-System
</div>
</div>
</div>
</>
)}
{step === 'class' && (
<>
<h1 className="onboarding-title">Klasse waehlen</h1>
<p className="onboarding-subtitle">
Waehlen Sie die Klasse oder legen Sie eine neue Klausur an
</p>
{classes.length === 0 ? (
<div style={{ color: 'var(--bp-text-muted)', padding: '32px', textAlign: 'center' }}>
Keine Klassen gefunden. Bitte legen Sie Klassen im Studio Modul an.
</div>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))', gap: 16, marginBottom: 24 }}>
{classes.map(c => (
<div
key={c.id}
className={`onboarding-card ${selectedClass?.id === c.id ? 'selected' : ''}`}
style={{ padding: '20px 16px', minWidth: 'auto' }}
onClick={() => handleSelectClass(c)}
>
<div className="onboarding-card-title" style={{ fontSize: 16 }}>{c.name}</div>
<div className="onboarding-card-desc">{c.student_count} Schueler</div>
</div>
))}
</div>
)}
<div style={{ marginTop: 24 }}>
<button
className="btn btn-primary"
disabled={!selectedClass || loading}
onClick={handleStartCorrection}
>
{loading ? 'Wird erstellt...' : 'Weiter zur Korrektur'}
</button>
</div>
</>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,620 @@
// API Types
export interface StudentKlausur {
id: string
klausur_id: string
student_name: string
student_id: string | null
file_path: string | null
ocr_text: string | null
status: string
criteria_scores: Record<string, { score: number; annotations: string[] }>
gutachten: {
einleitung: string
hauptteil: string
fazit: string
staerken: string[]
schwaechen: string[]
} | null
raw_points: number
grade_points: number
created_at: string
}
export interface Klausur {
id: string
title: string
subject: string
modus: 'landes_abitur' | 'vorabitur'
class_id: string | null
year: number
semester: string
erwartungshorizont: Record<string, unknown> | null
student_count: number
students: StudentKlausur[]
created_at: string
teacher_id: string
}
export interface GradeInfo {
thresholds: Record<number, number>
labels: Record<number, string>
criteria: Record<string, { weight: number; label: string }>
}
// Get auth token from parent window or localStorage
function getAuthToken(): string | null {
// Try to get from parent window (iframe scenario)
try {
if (window.parent !== window) {
const parentToken = (window.parent as unknown as { authToken?: string }).authToken
if (parentToken) return parentToken
}
} catch {
// Cross-origin access denied
}
// Try localStorage
return localStorage.getItem('auth_token')
}
// Base API call
async function apiCall<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const token = getAuthToken()
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string> || {})
}
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
const response = await fetch(`/api/v1${endpoint}`, {
...options,
headers
})
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Request failed' }))
throw new Error(error.detail || `HTTP ${response.status}`)
}
return response.json()
}
// Klausuren API
export const klausurApi = {
listKlausuren: (): Promise<Klausur[]> =>
apiCall('/klausuren'),
getKlausur: (id: string): Promise<Klausur> =>
apiCall(`/klausuren/${id}`),
createKlausur: (data: Partial<Klausur>): Promise<Klausur> =>
apiCall('/klausuren', {
method: 'POST',
body: JSON.stringify(data)
}),
updateKlausur: (id: string, data: Partial<Klausur>): Promise<Klausur> =>
apiCall(`/klausuren/${id}`, {
method: 'PUT',
body: JSON.stringify(data)
}),
deleteKlausur: (id: string): Promise<{ success: boolean }> =>
apiCall(`/klausuren/${id}`, { method: 'DELETE' }),
// Students
listStudents: (klausurId: string): Promise<StudentKlausur[]> =>
apiCall(`/klausuren/${klausurId}/students`),
deleteStudent: (studentId: string): Promise<{ success: boolean }> =>
apiCall(`/students/${studentId}`, { method: 'DELETE' }),
// Grading
updateCriteria: (
studentId: string,
criterion: string,
score: number,
annotations?: string[]
): Promise<StudentKlausur> =>
apiCall(`/students/${studentId}/criteria`, {
method: 'PUT',
body: JSON.stringify({ criterion, score, annotations })
}),
updateGutachten: (
studentId: string,
gutachten: {
einleitung: string
hauptteil: string
fazit: string
staerken?: string[]
schwaechen?: string[]
}
): Promise<StudentKlausur> =>
apiCall(`/students/${studentId}/gutachten`, {
method: 'PUT',
body: JSON.stringify(gutachten)
}),
finalizeStudent: (studentId: string): Promise<StudentKlausur> =>
apiCall(`/students/${studentId}/finalize`, { method: 'POST' }),
// KI-Gutachten Generation
generateGutachten: (
studentId: string,
options: {
include_strengths?: boolean
include_weaknesses?: boolean
tone?: 'formal' | 'friendly' | 'constructive'
} = {}
): Promise<{
einleitung: string
hauptteil: string
fazit: string
staerken: string[]
schwaechen: string[]
generated_at: string
is_ki_generated: boolean
tone: string
}> =>
apiCall(`/students/${studentId}/gutachten/generate`, {
method: 'POST',
body: JSON.stringify({
include_strengths: options.include_strengths ?? true,
include_weaknesses: options.include_weaknesses ?? true,
tone: options.tone ?? 'formal'
})
}),
// Fairness Analysis
getFairnessAnalysis: (klausurId: string): Promise<{
klausur_id: string
students_count: number
graded_count: number
statistics: {
average_grade: number
average_raw_points: number
min_grade: number
max_grade: number
spread: number
standard_deviation: number
}
criteria_breakdown: Record<string, { average: number; min: number; max: number; count: number }>
outliers: Array<{
student_id: string
student_name: string
grade_points: number
deviation: number
direction: 'above' | 'below'
}>
fairness_score: number
warnings: string[]
recommendation: string
}> =>
apiCall(`/klausuren/${klausurId}/fairness`),
// Audit Log
getStudentAuditLog: (studentId: string): Promise<Array<{
id: string
timestamp: string
user_id: string
action: string
entity_type: string
entity_id: string
field: string | null
old_value: string | null
new_value: string | null
details: Record<string, unknown> | null
}>> =>
apiCall(`/students/${studentId}/audit-log`),
// Utilities
getGradeInfo: (): Promise<GradeInfo> =>
apiCall('/grade-info')
}
// File upload (special handling for multipart)
export async function uploadStudentWork(
klausurId: string,
studentName: string,
file: File
): Promise<StudentKlausur> {
const token = getAuthToken()
const formData = new FormData()
formData.append('file', file)
formData.append('student_name', studentName)
const headers: Record<string, string> = {}
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
const response = await fetch(`/api/v1/klausuren/${klausurId}/students`, {
method: 'POST',
headers,
body: formData
})
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Upload failed' }))
throw new Error(error.detail || `HTTP ${response.status}`)
}
return response.json()
}
// =============================================
// BYOEH (Erwartungshorizont) Types & API
// =============================================
export interface Erwartungshorizont {
id: string
tenant_id: string
teacher_id: string
title: string
subject: string
niveau: 'eA' | 'gA'
year: number
aufgaben_nummer: string | null
status: 'pending_rights' | 'processing' | 'indexed' | 'error'
chunk_count: number
rights_confirmed: boolean
rights_confirmed_at: string | null
indexed_at: string | null
file_size_bytes: number
original_filename: string
training_allowed: boolean
created_at: string
deleted_at: string | null
}
export interface EHRAGResult {
context: string
sources: Array<{
text: string
eh_id: string
eh_title: string
chunk_index: number
score: number
reranked?: boolean
}>
query: string
search_info?: {
retrieval_time_ms?: number
rerank_time_ms?: number
total_time_ms?: number
reranked?: boolean
rerank_applied?: boolean
hybrid_search_applied?: boolean
embedding_model?: string
total_candidates?: number
original_count?: number
}
}
export interface EHAuditEntry {
id: string
eh_id: string | null
tenant_id: string
user_id: string
action: string
details: Record<string, unknown> | null
created_at: string
}
export interface EHKeyShare {
id: string
eh_id: string
user_id: string
passphrase_hint: string
granted_by: string
granted_at: string
role: 'second_examiner' | 'third_examiner' | 'supervisor' | 'department_head'
klausur_id: string | null
active: boolean
}
export interface EHKlausurLink {
id: string
eh_id: string
klausur_id: string
linked_by: string
linked_at: string
}
export interface SharedEHInfo {
eh: Erwartungshorizont
share: EHKeyShare
}
export interface LinkedEHInfo {
eh: Erwartungshorizont
link: EHKlausurLink
is_owner: boolean
share: EHKeyShare | null
}
// Invitation types for Invite/Accept/Revoke flow
export interface EHShareInvitation {
id: string
eh_id: string
inviter_id: string
invitee_id: string
invitee_email: string
role: 'second_examiner' | 'third_examiner' | 'supervisor' | 'department_head' | 'fachvorsitz'
klausur_id: string | null
message: string | null
status: 'pending' | 'accepted' | 'declined' | 'expired' | 'revoked'
expires_at: string
created_at: string
accepted_at: string | null
declined_at: string | null
}
export interface PendingInvitationInfo {
invitation: EHShareInvitation
eh: {
id: string
title: string
subject: string
niveau: string
year: number
} | null
}
export interface SentInvitationInfo {
invitation: EHShareInvitation
eh: {
id: string
title: string
subject: string
} | null
}
export interface EHAccessChain {
eh_id: string
eh_title: string
owner: {
user_id: string
role: string
}
active_shares: EHKeyShare[]
pending_invitations: EHShareInvitation[]
revoked_shares: EHKeyShare[]
}
// Erwartungshorizont API
export const ehApi = {
// List all EH for current teacher
listEH: (params?: { subject?: string; year?: number }): Promise<Erwartungshorizont[]> => {
const query = new URLSearchParams()
if (params?.subject) query.append('subject', params.subject)
if (params?.year) query.append('year', params.year.toString())
const queryStr = query.toString()
return apiCall(`/eh${queryStr ? `?${queryStr}` : ''}`)
},
// Get single EH by ID
getEH: (id: string): Promise<Erwartungshorizont> =>
apiCall(`/eh/${id}`),
// Upload encrypted EH (special handling for FormData)
uploadEH: async (formData: FormData): Promise<Erwartungshorizont> => {
const token = getAuthToken()
const headers: Record<string, string> = {}
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
const response = await fetch('/api/v1/eh/upload', {
method: 'POST',
headers,
body: formData
})
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: 'Upload failed' }))
throw new Error(error.detail || `HTTP ${response.status}`)
}
return response.json()
},
// Delete EH (soft delete)
deleteEH: (id: string): Promise<{ status: string; id: string }> =>
apiCall(`/eh/${id}`, { method: 'DELETE' }),
// Index EH for RAG (requires passphrase)
indexEH: (id: string, passphrase: string): Promise<{ status: string; id: string; chunk_count: number }> =>
apiCall(`/eh/${id}/index`, {
method: 'POST',
body: JSON.stringify({ passphrase })
}),
// RAG query against EH
ragQuery: (params: {
query_text: string
passphrase: string
subject?: string
limit?: number
rerank?: boolean
}): Promise<EHRAGResult> =>
apiCall('/eh/rag-query', {
method: 'POST',
body: JSON.stringify({
query_text: params.query_text,
passphrase: params.passphrase,
subject: params.subject,
limit: params.limit ?? 5,
rerank: params.rerank ?? false
})
}),
// Get audit log
getAuditLog: (ehId?: string, limit?: number): Promise<EHAuditEntry[]> => {
const query = new URLSearchParams()
if (ehId) query.append('eh_id', ehId)
if (limit) query.append('limit', limit.toString())
const queryStr = query.toString()
return apiCall(`/eh/audit-log${queryStr ? `?${queryStr}` : ''}`)
},
// Get rights confirmation text
getRightsText: (): Promise<{ text: string; version: string }> =>
apiCall('/eh/rights-text'),
// Get Qdrant status (admin only)
getQdrantStatus: (): Promise<{
name: string
vectors_count: number
points_count: number
status: string
}> =>
apiCall('/eh/qdrant-status'),
// =============================================
// KEY SHARING
// =============================================
// Share EH with another examiner
shareEH: (
ehId: string,
params: {
user_id: string
role: 'second_examiner' | 'third_examiner' | 'supervisor' | 'department_head'
encrypted_passphrase: string
passphrase_hint?: string
klausur_id?: string
}
): Promise<{
status: string
share_id: string
eh_id: string
shared_with: string
role: string
}> =>
apiCall(`/eh/${ehId}/share`, {
method: 'POST',
body: JSON.stringify(params)
}),
// List shares for an EH (owner only)
listShares: (ehId: string): Promise<EHKeyShare[]> =>
apiCall(`/eh/${ehId}/shares`),
// Revoke a share
revokeShare: (ehId: string, shareId: string): Promise<{ status: string; share_id: string }> =>
apiCall(`/eh/${ehId}/shares/${shareId}`, { method: 'DELETE' }),
// Get EH shared with current user
getSharedWithMe: (): Promise<SharedEHInfo[]> =>
apiCall('/eh/shared-with-me'),
// Link EH to a Klausur
linkToKlausur: (ehId: string, klausurId: string): Promise<{
status: string
link_id: string
eh_id: string
klausur_id: string
}> =>
apiCall(`/eh/${ehId}/link-klausur`, {
method: 'POST',
body: JSON.stringify({ klausur_id: klausurId })
}),
// Unlink EH from a Klausur
unlinkFromKlausur: (ehId: string, klausurId: string): Promise<{
status: string
eh_id: string
klausur_id: string
}> =>
apiCall(`/eh/${ehId}/link-klausur/${klausurId}`, { method: 'DELETE' }),
// =============================================
// INVITATION FLOW (Invite / Accept / Revoke)
// =============================================
// Send invitation to share EH
inviteToEH: (
ehId: string,
params: {
invitee_email: string
invitee_id?: string
role: 'second_examiner' | 'third_examiner' | 'supervisor' | 'department_head' | 'fachvorsitz'
klausur_id?: string
message?: string
expires_in_days?: number
}
): Promise<{
status: string
invitation_id: string
eh_id: string
invitee_email: string
role: string
expires_at: string
eh_title: string
}> =>
apiCall(`/eh/${ehId}/invite`, {
method: 'POST',
body: JSON.stringify(params)
}),
// Get pending invitations for current user
getPendingInvitations: (): Promise<PendingInvitationInfo[]> =>
apiCall('/eh/invitations/pending'),
// Get sent invitations (as inviter)
getSentInvitations: (): Promise<SentInvitationInfo[]> =>
apiCall('/eh/invitations/sent'),
// Accept an invitation
acceptInvitation: (
invitationId: string,
encryptedPassphrase: string
): Promise<{
status: string
share_id: string
eh_id: string
role: string
klausur_id: string | null
}> =>
apiCall(`/eh/invitations/${invitationId}/accept`, {
method: 'POST',
body: JSON.stringify({ encrypted_passphrase: encryptedPassphrase })
}),
// Decline an invitation
declineInvitation: (invitationId: string): Promise<{
status: string
invitation_id: string
eh_id: string
}> =>
apiCall(`/eh/invitations/${invitationId}/decline`, { method: 'POST' }),
// Revoke an invitation (as inviter)
revokeInvitation: (invitationId: string): Promise<{
status: string
invitation_id: string
eh_id: string
}> =>
apiCall(`/eh/invitations/${invitationId}`, { method: 'DELETE' }),
// Get the complete access chain for an EH
getAccessChain: (ehId: string): Promise<EHAccessChain> =>
apiCall(`/eh/${ehId}/access-chain`)
}
// Get linked EH for a Klausur (separate from ehApi for clarity)
export const klausurEHApi = {
// Get all EH linked to a Klausur that the user has access to
getLinkedEH: (klausurId: string): Promise<LinkedEHInfo[]> =>
apiCall(`/klausuren/${klausurId}/linked-eh`)
}

View File

@@ -0,0 +1,298 @@
/**
* BYOEH Client-Side Encryption Service
*
* Provides AES-256-GCM encryption for Erwartungshorizonte.
* The passphrase NEVER leaves the browser - only the encrypted data
* and a hash of the derived key are sent to the server.
*
* Security Flow:
* 1. User enters passphrase
* 2. PBKDF2 derives a 256-bit key from passphrase + random salt
* 3. AES-256-GCM encrypts the file content
* 4. SHA-256 hash of derived key is created for server-side verification
* 5. Encrypted blob + key hash + salt are uploaded (NOT the passphrase!)
*/
export interface EncryptionResult {
encryptedData: ArrayBuffer
keyHash: string
salt: string
iv: string
}
export interface DecryptionResult {
decryptedData: ArrayBuffer
success: boolean
error?: string
}
/**
* Convert ArrayBuffer to hex string
*/
function bufferToHex(buffer: Uint8Array): string {
return Array.from(buffer)
.map(b => b.toString(16).padStart(2, '0'))
.join('')
}
/**
* Convert hex string to ArrayBuffer
*/
function hexToBuffer(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2)
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substr(i, 2), 16)
}
return bytes
}
/**
* Derive an AES-256 key from passphrase using PBKDF2
*/
async function deriveKey(
passphrase: string,
salt: Uint8Array
): Promise<CryptoKey> {
// Import passphrase as key material
const keyMaterial = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(passphrase),
'PBKDF2',
false,
['deriveKey']
)
// Derive AES-256-GCM key using PBKDF2
return crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: salt.buffer as ArrayBuffer,
iterations: 100000, // High iteration count for security
hash: 'SHA-256'
},
keyMaterial,
{ name: 'AES-GCM', length: 256 },
true, // extractable for hashing
['encrypt', 'decrypt']
)
}
/**
* Create SHA-256 hash of the derived key for server verification
*/
async function hashKey(key: CryptoKey): Promise<string> {
const rawKey = await crypto.subtle.exportKey('raw', key)
const hashBuffer = await crypto.subtle.digest('SHA-256', rawKey)
return bufferToHex(new Uint8Array(hashBuffer))
}
/**
* Encrypt a file using AES-256-GCM
*
* @param file - File to encrypt
* @param passphrase - User's passphrase (never sent to server)
* @returns Encrypted data + metadata for upload
*/
export async function encryptFile(
file: File,
passphrase: string
): Promise<EncryptionResult> {
// 1. Generate random 16-byte salt
const salt = crypto.getRandomValues(new Uint8Array(16))
// 2. Derive key from passphrase
const key = await deriveKey(passphrase, salt)
// 3. Generate random 12-byte IV (required for AES-GCM)
const iv = crypto.getRandomValues(new Uint8Array(12))
// 4. Read file content
const fileBuffer = await file.arrayBuffer()
// 5. Encrypt using AES-256-GCM
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
fileBuffer
)
// 6. Create key hash for server-side verification
const keyHash = await hashKey(key)
// 7. Combine IV + ciphertext (IV is needed for decryption)
const combined = new Uint8Array(iv.length + encrypted.byteLength)
combined.set(iv, 0)
combined.set(new Uint8Array(encrypted), iv.length)
return {
encryptedData: combined.buffer,
keyHash,
salt: bufferToHex(salt),
iv: bufferToHex(iv)
}
}
/**
* Encrypt text content using AES-256-GCM
*
* @param text - Text to encrypt
* @param passphrase - User's passphrase
* @param saltHex - Salt as hex string
* @returns Base64-encoded encrypted content
*/
export async function encryptText(
text: string,
passphrase: string,
saltHex: string
): Promise<string> {
const salt = hexToBuffer(saltHex)
const key = await deriveKey(passphrase, salt)
const iv = crypto.getRandomValues(new Uint8Array(12))
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
new TextEncoder().encode(text)
)
// Combine IV + ciphertext
const combined = new Uint8Array(iv.length + encrypted.byteLength)
combined.set(iv, 0)
combined.set(new Uint8Array(encrypted), iv.length)
// Return as base64
return btoa(String.fromCharCode(...combined))
}
/**
* Decrypt text content using AES-256-GCM
*
* @param encryptedBase64 - Base64-encoded encrypted content (IV + ciphertext)
* @param passphrase - User's passphrase
* @param saltHex - Salt as hex string
* @returns Decrypted text
*/
export async function decryptText(
encryptedBase64: string,
passphrase: string,
saltHex: string
): Promise<string> {
const salt = hexToBuffer(saltHex)
const key = await deriveKey(passphrase, salt)
// Decode base64
const combined = Uint8Array.from(atob(encryptedBase64), c => c.charCodeAt(0))
// Extract IV (first 12 bytes) and ciphertext
const iv = combined.slice(0, 12)
const ciphertext = combined.slice(12)
// Decrypt
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
key,
ciphertext
)
return new TextDecoder().decode(decrypted)
}
/**
* Decrypt a file using AES-256-GCM
*
* @param encryptedData - Encrypted data (IV + ciphertext)
* @param passphrase - User's passphrase
* @param saltHex - Salt as hex string
* @returns Decrypted file content
*/
export async function decryptFile(
encryptedData: ArrayBuffer,
passphrase: string,
saltHex: string
): Promise<DecryptionResult> {
try {
const salt = hexToBuffer(saltHex)
const key = await deriveKey(passphrase, salt)
const combined = new Uint8Array(encryptedData)
// Extract IV (first 12 bytes) and ciphertext
const iv = combined.slice(0, 12)
const ciphertext = combined.slice(12)
// Decrypt
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
key,
ciphertext
)
return {
decryptedData: decrypted,
success: true
}
} catch (error) {
return {
decryptedData: new ArrayBuffer(0),
success: false,
error: error instanceof Error ? error.message : 'Decryption failed'
}
}
}
/**
* Verify a passphrase against stored key hash
*
* @param passphrase - Passphrase to verify
* @param saltHex - Salt as hex string
* @param expectedHash - Expected key hash
* @returns true if passphrase is correct
*/
export async function verifyPassphrase(
passphrase: string,
saltHex: string,
expectedHash: string
): Promise<boolean> {
try {
const salt = hexToBuffer(saltHex)
const key = await deriveKey(passphrase, salt)
const computedHash = await hashKey(key)
return computedHash === expectedHash
} catch {
return false
}
}
/**
* Generate a key hash for a given passphrase and salt
* Used when creating a new encrypted document
*
* @param passphrase - User's passphrase
* @param saltHex - Salt as hex string
* @returns Key hash for storage
*/
export async function generateKeyHash(
passphrase: string,
saltHex: string
): Promise<string> {
const salt = hexToBuffer(saltHex)
const key = await deriveKey(passphrase, salt)
return hashKey(key)
}
/**
* Generate a random salt for encryption
*
* @returns 16-byte salt as hex string
*/
export function generateSalt(): string {
const salt = crypto.getRandomValues(new Uint8Array(16))
return bufferToHex(salt)
}
/**
* Check if Web Crypto API is available
*/
export function isEncryptionSupported(): boolean {
return typeof crypto !== 'undefined' && typeof crypto.subtle !== 'undefined'
}

View File

@@ -0,0 +1,468 @@
/**
* BYOEH Upload Wizard Styles
*/
/* Overlay */
.eh-wizard-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
}
/* Modal */
.eh-wizard-modal {
background: white;
border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
width: 100%;
max-width: 600px;
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* Header */
.eh-wizard-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid #e5e7eb;
}
.eh-wizard-header h2 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: #111827;
}
.eh-wizard-close {
background: none;
border: none;
font-size: 24px;
color: #6b7280;
cursor: pointer;
padding: 4px;
line-height: 1;
}
.eh-wizard-close:hover {
color: #111827;
}
/* Progress */
.eh-wizard-progress {
display: flex;
justify-content: space-between;
padding: 20px 24px;
background: #f9fafb;
border-bottom: 1px solid #e5e7eb;
}
.eh-progress-step {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
position: relative;
}
.eh-progress-step:not(:last-child)::after {
content: '';
position: absolute;
top: 12px;
left: 50%;
width: 100%;
height: 2px;
background: #e5e7eb;
}
.eh-progress-step.completed:not(:last-child)::after {
background: #10b981;
}
.eh-progress-dot {
width: 24px;
height: 24px;
border-radius: 50%;
background: #e5e7eb;
color: #6b7280;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
z-index: 1;
}
.eh-progress-step.active .eh-progress-dot {
background: #3b82f6;
color: white;
}
.eh-progress-step.completed .eh-progress-dot {
background: #10b981;
color: white;
}
.eh-progress-label {
margin-top: 8px;
font-size: 11px;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.eh-progress-step.active .eh-progress-label {
color: #3b82f6;
font-weight: 600;
}
/* Content */
.eh-wizard-content {
padding: 24px;
overflow-y: auto;
flex: 1;
}
.eh-wizard-step h3 {
margin: 0 0 8px 0;
font-size: 1.125rem;
font-weight: 600;
color: #111827;
}
.eh-wizard-description {
margin: 0 0 24px 0;
color: #6b7280;
line-height: 1.5;
}
/* File Drop */
.eh-file-drop {
margin-bottom: 16px;
}
.eh-file-drop input[type="file"] {
display: none;
}
.eh-file-label {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
border: 2px dashed #d1d5db;
border-radius: 8px;
cursor: pointer;
transition: border-color 0.2s, background-color 0.2s;
}
.eh-file-label:hover {
border-color: #3b82f6;
background: #f0f9ff;
}
.eh-file-icon {
font-size: 48px;
margin-bottom: 12px;
}
.eh-file-name {
font-weight: 600;
color: #111827;
margin-bottom: 4px;
}
.eh-file-size {
font-size: 14px;
color: #6b7280;
}
/* Form Groups */
.eh-form-group {
margin-bottom: 16px;
}
.eh-form-group label {
display: block;
margin-bottom: 6px;
font-size: 14px;
font-weight: 500;
color: #374151;
}
.eh-form-group input,
.eh-form-group select {
width: 100%;
padding: 10px 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 14px;
transition: border-color 0.2s, box-shadow 0.2s;
}
.eh-form-group input:focus,
.eh-form-group select:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.eh-form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
/* Password Input */
.eh-password-input {
position: relative;
}
.eh-password-input input {
padding-right: 44px;
}
.eh-toggle-password {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
cursor: pointer;
padding: 4px;
font-size: 18px;
}
.eh-password-strength {
margin-top: 8px;
font-size: 12px;
font-weight: 500;
}
.eh-strength-weak {
color: #ef4444;
}
.eh-strength-medium {
color: #f59e0b;
}
.eh-strength-strong {
color: #10b981;
}
/* Rights Box */
.eh-rights-box {
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
max-height: 200px;
overflow-y: auto;
}
.eh-rights-box pre {
margin: 0;
white-space: pre-wrap;
font-family: inherit;
font-size: 13px;
line-height: 1.6;
color: #374151;
}
/* Checkbox Group */
.eh-checkbox-group {
display: flex;
align-items: flex-start;
gap: 10px;
margin-bottom: 16px;
}
.eh-checkbox-group input[type="checkbox"] {
margin-top: 2px;
width: 18px;
height: 18px;
cursor: pointer;
}
.eh-checkbox-group label {
font-size: 14px;
color: #374151;
cursor: pointer;
}
/* Info & Warning Boxes */
.eh-info-box,
.eh-warning-box {
padding: 12px 16px;
border-radius: 8px;
font-size: 14px;
line-height: 1.5;
}
.eh-info-box {
background: #dbeafe;
color: #1e40af;
}
.eh-warning-box {
background: #fef3c7;
color: #92400e;
}
.eh-info-box strong,
.eh-warning-box strong {
display: block;
margin-bottom: 4px;
}
/* Summary Table */
.eh-summary-table {
border: 1px solid #e5e7eb;
border-radius: 8px;
overflow: hidden;
margin-bottom: 16px;
}
.eh-summary-row {
display: flex;
padding: 12px 16px;
border-bottom: 1px solid #e5e7eb;
}
.eh-summary-row:last-child {
border-bottom: none;
}
.eh-summary-row:nth-child(odd) {
background: #f9fafb;
}
.eh-summary-label {
font-weight: 500;
color: #374151;
width: 160px;
flex-shrink: 0;
}
.eh-summary-value {
color: #111827;
}
/* Upload Progress */
.eh-upload-progress {
background: #e5e7eb;
border-radius: 4px;
height: 24px;
overflow: hidden;
position: relative;
margin-bottom: 16px;
}
.eh-progress-bar {
background: #3b82f6;
height: 100%;
transition: width 0.3s ease;
}
.eh-upload-progress span {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 12px;
font-weight: 600;
color: #111827;
}
/* Error & Warning Messages */
.eh-error {
color: #dc2626;
font-size: 14px;
margin-top: 8px;
}
.eh-warning {
color: #d97706;
font-size: 14px;
margin-top: 8px;
}
/* Footer */
.eh-wizard-footer {
display: flex;
justify-content: space-between;
padding: 16px 24px;
border-top: 1px solid #e5e7eb;
background: #f9fafb;
}
/* Buttons */
.eh-btn {
padding: 10px 20px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s, opacity 0.2s;
}
.eh-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.eh-btn-primary {
background: #3b82f6;
color: white;
border: none;
}
.eh-btn-primary:hover:not(:disabled) {
background: #2563eb;
}
.eh-btn-secondary {
background: white;
color: #374151;
border: 1px solid #d1d5db;
}
.eh-btn-secondary:hover:not(:disabled) {
background: #f9fafb;
}
/* Responsive */
@media (max-width: 640px) {
.eh-wizard-modal {
max-height: 100vh;
border-radius: 0;
}
.eh-form-row {
grid-template-columns: 1fr;
}
.eh-wizard-progress {
padding: 16px;
}
.eh-progress-label {
display: none;
}
}

View File

@@ -0,0 +1,924 @@
/* ==========================================
BREAKPILOT KLAUSUR-SERVICE - DESIGN SYSTEM
========================================== */
:root {
/* Primary Colors - Weinrot */
--bp-primary: #6C1B1B;
--bp-primary-hover: #8B2323;
--bp-primary-soft: rgba(108, 27, 27, 0.1);
/* Background */
--bp-bg: #0f172a;
--bp-surface: #1e293b;
--bp-surface-elevated: #334155;
/* Borders */
--bp-border: #475569;
--bp-border-subtle: rgba(255, 255, 255, 0.1);
/* Accent Colors */
--bp-accent: #5ABF60;
--bp-accent-soft: rgba(90, 191, 96, 0.15);
/* Text */
--bp-text: #e5e7eb;
--bp-text-muted: #9ca3af;
/* Status Colors */
--bp-danger: #ef4444;
--bp-warning: #f59e0b;
--bp-success: #22c55e;
--bp-info: #3b82f6;
/* Gold Accent */
--bp-gold: #F1C40F;
/* Sidebar */
--sidebar-width: 280px;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Manrope', system-ui, -apple-system, sans-serif;
background: var(--bp-bg);
color: var(--bp-text);
min-height: 100vh;
line-height: 1.5;
}
a {
color: inherit;
text-decoration: none;
}
button {
cursor: pointer;
font-family: inherit;
}
/* ==========================================
LAYOUT
========================================== */
.app-layout {
display: flex;
min-height: 100vh;
}
.main-content {
flex: 1;
display: flex;
flex-direction: column;
}
/* ==========================================
TOPBAR
========================================== */
.topbar {
height: 56px;
background: var(--bp-surface);
border-bottom: 1px solid var(--bp-border);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
}
.brand {
display: flex;
align-items: center;
gap: 12px;
}
.brand-logo {
width: 36px;
height: 36px;
background: var(--bp-primary);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 700;
font-size: 14px;
}
.brand-text {
display: flex;
flex-direction: column;
}
.brand-text-main {
font-size: 18px;
font-weight: 700;
color: var(--bp-primary);
}
.brand-text-sub {
font-size: 11px;
color: var(--bp-text-muted);
}
.topbar-actions {
display: flex;
align-items: center;
gap: 12px;
}
/* ==========================================
BUTTONS
========================================== */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 20px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
border: none;
transition: all 0.2s;
}
.btn-primary {
background: var(--bp-primary);
color: white;
}
.btn-primary:hover {
background: var(--bp-primary-hover);
}
.btn-secondary {
background: var(--bp-surface-elevated);
color: var(--bp-text);
border: 1px solid var(--bp-border);
}
.btn-secondary:hover {
background: var(--bp-border);
}
.btn-ghost {
background: transparent;
color: var(--bp-text-muted);
}
.btn-ghost:hover {
background: var(--bp-surface-elevated);
color: var(--bp-text);
}
.btn-danger {
background: var(--bp-danger);
color: white;
}
.btn-danger:hover {
opacity: 0.9;
}
/* ==========================================
CARDS
========================================== */
.card {
background: var(--bp-surface);
border: 1px solid var(--bp-border);
border-radius: 12px;
padding: 24px;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.card-title {
font-size: 18px;
font-weight: 600;
}
/* ==========================================
ONBOARDING WIZARD
========================================== */
.onboarding {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px;
background: var(--bp-bg);
}
.onboarding-content {
max-width: 600px;
text-align: center;
}
.onboarding-steps {
display: flex;
gap: 8px;
justify-content: center;
margin-bottom: 32px;
}
.onboarding-step {
width: 40px;
height: 4px;
background: var(--bp-border);
border-radius: 2px;
transition: background 0.3s;
}
.onboarding-step.active {
background: var(--bp-primary);
}
.onboarding-step.completed {
background: var(--bp-accent);
}
.onboarding-title {
font-size: 28px;
font-weight: 700;
margin-bottom: 12px;
}
.onboarding-subtitle {
color: var(--bp-text-muted);
margin-bottom: 40px;
}
.onboarding-options {
display: flex;
gap: 24px;
justify-content: center;
flex-wrap: wrap;
}
.onboarding-card {
flex: 1;
min-width: 220px;
max-width: 280px;
padding: 32px 24px;
background: var(--bp-surface);
border: 2px solid var(--bp-border);
border-radius: 16px;
cursor: pointer;
transition: all 0.2s;
text-align: center;
}
.onboarding-card:hover {
border-color: var(--bp-primary);
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
}
.onboarding-card.selected {
border-color: var(--bp-primary);
background: var(--bp-primary-soft);
}
.onboarding-card-icon {
font-size: 48px;
margin-bottom: 16px;
}
.onboarding-card-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 8px;
}
.onboarding-card-desc {
font-size: 13px;
color: var(--bp-text-muted);
line-height: 1.5;
}
.onboarding-back {
position: fixed;
top: 70px;
left: 20px;
color: var(--bp-text-muted);
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
z-index: 10;
padding: 8px 12px;
background: var(--bp-surface);
border-radius: 8px;
border: 1px solid var(--bp-border);
}
.onboarding-back:hover {
color: var(--bp-text);
background: var(--bp-surface-elevated);
}
/* ==========================================
TWO-COLUMN LAYOUT (2/3 Document + 1/3 Grading)
========================================== */
.korrektur-layout {
display: flex;
flex: 1;
overflow: hidden;
}
/* Left side: Student list (collapsible) */
.korrektur-sidebar {
width: 260px;
min-width: 260px;
background: var(--bp-surface);
border-right: 1px solid var(--bp-border);
display: flex;
flex-direction: column;
overflow-y: auto;
position: relative;
transition: width 0.3s ease, min-width 0.3s ease;
}
/* Collapsed sidebar */
.korrektur-sidebar.collapsed {
width: 48px;
min-width: 48px;
}
/* Sidebar toggle button */
.sidebar-toggle {
position: absolute;
top: 12px;
right: 8px;
width: 32px;
height: 32px;
background: var(--bp-surface-elevated);
border: 1px solid var(--bp-border);
border-radius: 6px;
color: var(--bp-text-muted);
font-size: 14px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
z-index: 10;
}
.sidebar-toggle:hover {
background: var(--bp-border);
color: var(--bp-text);
}
.korrektur-sidebar.collapsed .sidebar-toggle {
right: 8px;
}
/* Center: Document viewer (2/3 of remaining space) */
.korrektur-main {
flex: 2;
display: flex;
flex-direction: column;
background: var(--bp-bg);
overflow: hidden;
min-width: 0;
}
/* Sidebar collapsed -> main takes more space */
.korrektur-layout.sidebar-collapsed .korrektur-main {
flex: 2.5;
}
/* Right: Grading panel (1/3 of remaining space) */
.korrektur-panel {
flex: 1;
min-width: 340px;
max-width: 420px;
background: var(--bp-surface);
border-left: 1px solid var(--bp-border);
display: flex;
flex-direction: column;
overflow-y: auto;
}
/* ==========================================
WIZARD STEPS INDICATOR
========================================== */
.wizard-steps {
display: flex;
justify-content: space-between;
padding: 16px 20px;
background: var(--bp-bg);
border-bottom: 1px solid var(--bp-border);
gap: 8px;
}
.wizard-step {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
opacity: 0.5;
transition: opacity 0.2s;
}
.wizard-step.active {
opacity: 1;
}
.wizard-step.completed {
opacity: 0.8;
}
.wizard-step-number {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--bp-surface-elevated);
border: 2px solid var(--bp-border);
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
color: var(--bp-text-muted);
}
.wizard-step.active .wizard-step-number {
background: var(--bp-primary);
border-color: var(--bp-primary);
color: white;
}
.wizard-step.completed .wizard-step-number {
background: var(--bp-accent);
border-color: var(--bp-accent);
color: white;
}
.wizard-step-label {
font-size: 11px;
font-weight: 500;
color: var(--bp-text-muted);
text-align: center;
}
.wizard-step.active .wizard-step-label {
color: var(--bp-text);
}
/* ==========================================
SIDEBAR SECTIONS
========================================== */
.sidebar-section {
padding: 16px;
border-bottom: 1px solid var(--bp-border);
}
.sidebar-section-title {
font-size: 11px;
font-weight: 600;
color: var(--bp-text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 12px;
}
.klausur-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
border-radius: 8px;
cursor: pointer;
transition: background 0.2s;
}
.klausur-item:hover {
background: var(--bp-surface-elevated);
}
.klausur-item.active {
background: var(--bp-primary-soft);
border: 1px solid var(--bp-primary);
}
.klausur-icon {
font-size: 20px;
}
.klausur-info {
flex: 1;
min-width: 0;
}
.klausur-name {
font-size: 14px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.klausur-meta {
font-size: 12px;
color: var(--bp-text-muted);
}
.delete-btn {
opacity: 0;
background: none;
border: none;
color: var(--bp-danger);
padding: 4px;
border-radius: 4px;
transition: opacity 0.2s;
}
.klausur-item:hover .delete-btn {
opacity: 1;
}
/* ==========================================
DOCUMENT VIEWER
========================================== */
.viewer-container {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.viewer-toolbar {
height: 48px;
background: var(--bp-surface);
border-bottom: 1px solid var(--bp-border);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
}
.viewer-content {
flex: 1;
overflow: auto;
padding: 24px;
display: flex;
justify-content: center;
}
.document-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--bp-text-muted);
padding: 60px;
text-align: center;
}
.document-placeholder-icon {
font-size: 64px;
margin-bottom: 16px;
opacity: 0.5;
}
/* Document Display */
.document-viewer {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
background: var(--bp-surface-elevated);
border-radius: 8px;
overflow: hidden;
}
.document-viewer iframe {
width: 100%;
height: 100%;
border: none;
}
.document-viewer img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.document-info-bar {
width: 100%;
padding: 12px 16px;
background: var(--bp-surface);
border-bottom: 1px solid var(--bp-border);
display: flex;
align-items: center;
justify-content: space-between;
font-size: 13px;
}
.document-info-bar .file-name {
font-weight: 500;
color: var(--bp-text);
}
.document-info-bar .file-status {
color: var(--bp-accent);
display: flex;
align-items: center;
gap: 6px;
}
.document-frame {
flex: 1;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
overflow: auto;
}
/* ==========================================
GRADING PANEL
========================================== */
.panel-section {
padding: 20px;
border-bottom: 1px solid var(--bp-border);
}
.panel-section-title {
font-size: 14px;
font-weight: 600;
margin-bottom: 16px;
display: flex;
align-items: center;
gap: 8px;
}
.criterion-item {
margin-bottom: 16px;
}
.criterion-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.criterion-label {
font-size: 13px;
color: var(--bp-text-muted);
}
.criterion-score {
font-size: 14px;
font-weight: 600;
}
.criterion-slider {
width: 100%;
height: 6px;
-webkit-appearance: none;
background: var(--bp-border);
border-radius: 3px;
outline: none;
}
.criterion-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 18px;
height: 18px;
background: var(--bp-primary);
border-radius: 50%;
cursor: pointer;
}
/* Grade Display */
.grade-display {
text-align: center;
padding: 24px;
background: var(--bp-bg);
border-radius: 12px;
margin-bottom: 16px;
}
.grade-points {
font-size: 48px;
font-weight: 700;
color: var(--bp-accent);
}
.grade-label {
font-size: 14px;
color: var(--bp-text-muted);
margin-top: 4px;
}
/* ==========================================
FORMS & INPUTS
========================================== */
.input {
width: 100%;
padding: 10px 14px;
background: var(--bp-bg);
border: 1px solid var(--bp-border);
border-radius: 8px;
color: var(--bp-text);
font-size: 14px;
outline: none;
transition: border-color 0.2s;
}
.input:focus {
border-color: var(--bp-primary);
}
.textarea {
width: 100%;
padding: 12px 14px;
background: var(--bp-bg);
border: 1px solid var(--bp-border);
border-radius: 8px;
color: var(--bp-text);
font-size: 14px;
font-family: inherit;
resize: vertical;
min-height: 100px;
outline: none;
}
.textarea:focus {
border-color: var(--bp-primary);
}
.form-group {
margin-bottom: 16px;
}
.form-label {
display: block;
font-size: 13px;
font-weight: 500;
color: var(--bp-text-muted);
margin-bottom: 6px;
}
/* ==========================================
UPLOAD AREA
========================================== */
.upload-area {
border: 2px dashed var(--bp-border);
border-radius: 12px;
padding: 40px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
}
.upload-area:hover,
.upload-area.dragover {
border-color: var(--bp-primary);
background: var(--bp-primary-soft);
}
.upload-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
.upload-text {
font-size: 14px;
color: var(--bp-text-muted);
}
/* ==========================================
LOADING & SPINNER
========================================== */
.spinner {
width: 40px;
height: 40px;
border: 3px solid var(--bp-border);
border-top-color: var(--bp-primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px;
}
.loading-text {
margin-top: 16px;
color: var(--bp-text-muted);
}
/* ==========================================
MODALS
========================================== */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: var(--bp-surface);
border-radius: 16px;
max-width: 500px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
padding: 20px 24px;
border-bottom: 1px solid var(--bp-border);
display: flex;
align-items: center;
justify-content: space-between;
}
.modal-title {
font-size: 18px;
font-weight: 600;
}
.modal-close {
background: none;
border: none;
color: var(--bp-text-muted);
font-size: 24px;
cursor: pointer;
}
.modal-body {
padding: 24px;
}
.modal-footer {
padding: 16px 24px;
border-top: 1px solid var(--bp-border);
display: flex;
justify-content: flex-end;
gap: 12px;
}
/* ==========================================
RESPONSIVE
========================================== */
@media (max-width: 1024px) {
.korrektur-panel {
display: none;
}
}
@media (max-width: 768px) {
.korrektur-sidebar {
display: none;
}
}

View File

@@ -0,0 +1,407 @@
/* ==========================================
RAG SEARCH PANEL STYLES
========================================== */
/* Overlay */
.rag-search-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.75);
display: flex;
align-items: center;
justify-content: center;
z-index: 1100;
padding: 20px;
}
/* Modal */
.rag-search-modal {
background: var(--bp-surface);
border-radius: 16px;
max-width: 800px;
width: 100%;
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
}
/* Header */
.rag-search-header {
padding: 20px 24px;
border-bottom: 1px solid var(--bp-border);
display: flex;
align-items: center;
justify-content: space-between;
}
.rag-search-header h2 {
font-size: 18px;
font-weight: 600;
margin: 0;
}
.rag-search-close {
background: none;
border: none;
color: var(--bp-text-muted);
font-size: 28px;
cursor: pointer;
padding: 0;
line-height: 1;
}
.rag-search-close:hover {
color: var(--bp-text);
}
/* Search Input */
.rag-search-input-container {
padding: 20px 24px;
display: flex;
gap: 12px;
border-bottom: 1px solid var(--bp-border);
}
.rag-search-input {
flex: 1;
padding: 12px 16px;
background: var(--bp-bg);
border: 1px solid var(--bp-border);
border-radius: 8px;
color: var(--bp-text);
font-size: 14px;
font-family: inherit;
resize: none;
outline: none;
}
.rag-search-input:focus {
border-color: var(--bp-primary);
}
.rag-search-input::placeholder {
color: var(--bp-text-muted);
}
.rag-search-btn {
padding: 12px 24px;
background: var(--bp-primary);
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
white-space: nowrap;
}
.rag-search-btn:hover:not(:disabled) {
background: var(--bp-primary-hover);
}
.rag-search-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Advanced Options Toggle */
.rag-advanced-toggle {
padding: 12px 24px;
border-bottom: 1px solid var(--bp-border);
}
.rag-toggle-btn {
background: none;
border: none;
color: var(--bp-text-muted);
font-size: 13px;
cursor: pointer;
padding: 4px 0;
display: flex;
align-items: center;
gap: 8px;
}
.rag-toggle-btn:hover {
color: var(--bp-text);
}
/* Advanced Options Panel */
.rag-advanced-options {
padding: 16px 24px;
background: var(--bp-bg);
border-bottom: 1px solid var(--bp-border);
}
.rag-option-row {
margin-bottom: 12px;
}
.rag-option-row:last-child {
margin-bottom: 0;
}
.rag-option-label {
display: flex;
align-items: center;
gap: 10px;
font-size: 14px;
color: var(--bp-text);
cursor: pointer;
}
.rag-option-label input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: var(--bp-primary);
}
.rag-option-text {
display: flex;
flex-direction: column;
gap: 2px;
}
.rag-option-hint {
font-size: 12px;
color: var(--bp-text-muted);
}
.rag-option-select {
margin-left: 8px;
padding: 6px 12px;
background: var(--bp-surface);
border: 1px solid var(--bp-border);
border-radius: 6px;
color: var(--bp-text);
font-size: 13px;
}
/* Error Display */
.rag-error {
margin: 16px 24px;
padding: 12px 16px;
background: rgba(239, 68, 68, 0.1);
border: 1px solid var(--bp-danger);
border-radius: 8px;
color: var(--bp-danger);
font-size: 14px;
}
/* Results Container */
.rag-results {
flex: 1;
overflow-y: auto;
padding: 20px 24px;
}
/* Search Info Badge */
.rag-search-info {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
padding: 12px 16px;
background: var(--bp-bg);
border-radius: 8px;
}
.rag-info-badge {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.rag-info-tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.rag-info-rerank {
background: rgba(59, 130, 246, 0.2);
color: var(--bp-info);
}
.rag-info-hybrid {
background: rgba(168, 85, 247, 0.2);
color: #a855f7;
}
.rag-info-model {
background: var(--bp-surface-elevated);
color: var(--bp-text-muted);
}
.rag-info-count {
font-size: 12px;
color: var(--bp-text-muted);
}
/* Context Summary */
.rag-context-summary {
margin-bottom: 20px;
padding: 16px;
background: var(--bp-primary-soft);
border-radius: 8px;
border-left: 4px solid var(--bp-primary);
}
.rag-context-summary h4 {
font-size: 13px;
font-weight: 600;
color: var(--bp-primary);
margin-bottom: 8px;
}
.rag-context-summary p {
font-size: 14px;
line-height: 1.6;
color: var(--bp-text);
margin: 0;
}
/* Source Results */
.rag-sources h4 {
font-size: 14px;
font-weight: 600;
margin-bottom: 12px;
color: var(--bp-text);
}
.rag-source-item {
background: var(--bp-bg);
border: 1px solid var(--bp-border);
border-radius: 10px;
margin-bottom: 12px;
overflow: hidden;
transition: border-color 0.2s;
}
.rag-source-item:hover {
border-color: var(--bp-primary);
}
.rag-source-header {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: var(--bp-surface-elevated);
border-bottom: 1px solid var(--bp-border);
}
.rag-source-rank {
width: 28px;
height: 28px;
background: var(--bp-primary);
color: white;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
}
.rag-source-title {
flex: 1;
font-size: 14px;
font-weight: 500;
color: var(--bp-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rag-confidence-badge {
padding: 4px 10px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
display: flex;
align-items: center;
gap: 4px;
}
.rag-reranked-icon {
font-size: 10px;
margin-left: 2px;
}
.rag-source-text {
padding: 16px;
font-size: 14px;
line-height: 1.7;
color: var(--bp-text);
white-space: pre-wrap;
}
.rag-source-meta {
display: flex;
justify-content: space-between;
padding: 10px 16px;
background: var(--bp-surface-elevated);
border-top: 1px solid var(--bp-border);
font-size: 12px;
color: var(--bp-text-muted);
}
/* Empty State */
.rag-empty-state {
padding: 40px 24px;
text-align: center;
color: var(--bp-text-muted);
}
.rag-empty-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
.rag-empty-state p {
font-size: 14px;
line-height: 1.6;
margin-bottom: 8px;
}
.rag-empty-hint {
font-size: 13px;
color: var(--bp-text-muted);
opacity: 0.8;
}
/* Responsive */
@media (max-width: 640px) {
.rag-search-modal {
max-height: 95vh;
border-radius: 12px;
}
.rag-search-input-container {
flex-direction: column;
}
.rag-search-btn {
width: 100%;
}
.rag-search-info {
flex-direction: column;
gap: 8px;
align-items: flex-start;
}
}