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:
23
klausur-service/frontend/src/App.tsx
Normal file
23
klausur-service/frontend/src/App.tsx
Normal 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
|
||||
591
klausur-service/frontend/src/components/EHUploadWizard.tsx
Normal file
591
klausur-service/frontend/src/components/EHUploadWizard.tsx
Normal 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">📄</span>
|
||||
<span className="eh-file-name">{selectedFile.name}</span>
|
||||
<span className="eh-file-size">
|
||||
({(selectedFile.size / 1024 / 1024).toFixed(2)} MB)
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="eh-file-icon">📂</span>
|
||||
<span>PDF-Datei hier ablegen oder klicken</span>
|
||||
</>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{fileError && <p className="eh-error">{fileError}</p>}
|
||||
|
||||
{!encryptionSupported && (
|
||||
<p className="eh-warning">
|
||||
Ihr Browser unterstuetzt keine Verschluesselung.
|
||||
Bitte verwenden Sie einen modernen Browser (Chrome, Firefox, Safari, Edge).
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'metadata':
|
||||
return (
|
||||
<div className="eh-wizard-step">
|
||||
<h3>Metadaten</h3>
|
||||
<p className="eh-wizard-description">
|
||||
Geben Sie Informationen zum Erwartungshorizont ein.
|
||||
</p>
|
||||
|
||||
<div className="eh-form-group">
|
||||
<label htmlFor="eh-title">Titel *</label>
|
||||
<input
|
||||
id="eh-title"
|
||||
type="text"
|
||||
value={metadata.title}
|
||||
onChange={e => setMetadata(prev => ({ ...prev, title: e.target.value }))}
|
||||
placeholder="z.B. Deutsch Abitur 2024 Aufgabe 1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="eh-form-row">
|
||||
<div className="eh-form-group">
|
||||
<label htmlFor="eh-subject">Fach *</label>
|
||||
<select
|
||||
id="eh-subject"
|
||||
value={metadata.subject}
|
||||
onChange={e => setMetadata(prev => ({ ...prev, subject: e.target.value }))}
|
||||
>
|
||||
{SUBJECTS.map(s => (
|
||||
<option key={s} value={s}>
|
||||
{s.charAt(0).toUpperCase() + s.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="eh-form-group">
|
||||
<label htmlFor="eh-niveau">Niveau *</label>
|
||||
<select
|
||||
id="eh-niveau"
|
||||
value={metadata.niveau}
|
||||
onChange={e => setMetadata(prev => ({ ...prev, niveau: e.target.value as 'eA' | 'gA' }))}
|
||||
>
|
||||
<option value="eA">Erhoehtes Anforderungsniveau (eA)</option>
|
||||
<option value="gA">Grundlegendes Anforderungsniveau (gA)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="eh-form-row">
|
||||
<div className="eh-form-group">
|
||||
<label htmlFor="eh-year">Jahr *</label>
|
||||
<input
|
||||
id="eh-year"
|
||||
type="number"
|
||||
min={2000}
|
||||
max={2050}
|
||||
value={metadata.year}
|
||||
onChange={e => setMetadata(prev => ({ ...prev, year: parseInt(e.target.value) }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="eh-form-group">
|
||||
<label htmlFor="eh-aufgabe">Aufgabennummer</label>
|
||||
<input
|
||||
id="eh-aufgabe"
|
||||
type="text"
|
||||
value={metadata.aufgaben_nummer || ''}
|
||||
onChange={e => setMetadata(prev => ({ ...prev, aufgaben_nummer: e.target.value }))}
|
||||
placeholder="z.B. 1a, 2.1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'rights':
|
||||
return (
|
||||
<div className="eh-wizard-step">
|
||||
<h3>Rechte-Bestaetigung</h3>
|
||||
<p className="eh-wizard-description">
|
||||
Bitte lesen und bestaetigen Sie die folgenden Bedingungen.
|
||||
</p>
|
||||
|
||||
<div className="eh-rights-box">
|
||||
<pre>{RIGHTS_TEXT}</pre>
|
||||
</div>
|
||||
|
||||
<div className="eh-checkbox-group">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="eh-rights-confirm"
|
||||
checked={rightsConfirmed}
|
||||
onChange={e => setRightsConfirmed(e.target.checked)}
|
||||
/>
|
||||
<label htmlFor="eh-rights-confirm">
|
||||
Ich habe die Bedingungen gelesen und stimme ihnen zu.
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="eh-info-box">
|
||||
<strong>Wichtig:</strong> Ihr Erwartungshorizont wird niemals fuer
|
||||
KI-Training verwendet. Er dient ausschliesslich als Referenz fuer
|
||||
Ihre persoenlichen Korrekturvorschlaege.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'encryption':
|
||||
return (
|
||||
<div className="eh-wizard-step">
|
||||
<h3>Verschluesselung</h3>
|
||||
<p className="eh-wizard-description">
|
||||
Waehlen Sie ein sicheres Passwort fuer Ihren Erwartungshorizont.
|
||||
Dieses Passwort wird <strong>niemals</strong> an den Server gesendet.
|
||||
</p>
|
||||
|
||||
<div className="eh-form-group">
|
||||
<label htmlFor="eh-passphrase">Passwort *</label>
|
||||
<div className="eh-password-input">
|
||||
<input
|
||||
id="eh-passphrase"
|
||||
type={showPassphrase ? 'text' : 'password'}
|
||||
value={passphrase}
|
||||
onChange={e => setPassphrase(e.target.value)}
|
||||
placeholder="Mindestens 8 Zeichen"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="eh-toggle-password"
|
||||
onClick={() => setShowPassphrase(!showPassphrase)}
|
||||
>
|
||||
{showPassphrase ? '👁' : '👀'}
|
||||
</button>
|
||||
</div>
|
||||
<div className={`eh-password-strength eh-strength-${passphraseStrength}`}>
|
||||
Staerke: {passphraseStrength === 'weak' ? 'Schwach' : passphraseStrength === 'medium' ? 'Mittel' : 'Stark'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="eh-form-group">
|
||||
<label htmlFor="eh-passphrase-confirm">Passwort bestaetigen *</label>
|
||||
<input
|
||||
id="eh-passphrase-confirm"
|
||||
type={showPassphrase ? 'text' : 'password'}
|
||||
value={passphraseConfirm}
|
||||
onChange={e => setPassphraseConfirm(e.target.value)}
|
||||
placeholder="Passwort wiederholen"
|
||||
/>
|
||||
{passphraseConfirm && passphrase !== passphraseConfirm && (
|
||||
<p className="eh-error">Passwoerter stimmen nicht ueberein</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="eh-warning-box">
|
||||
<strong>Achtung:</strong> Merken Sie sich dieses Passwort gut!
|
||||
Ohne das Passwort kann der Erwartungshorizont nicht fuer
|
||||
Korrekturvorschlaege verwendet werden. Breakpilot kann Ihr
|
||||
Passwort nicht wiederherstellen.
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
case 'summary':
|
||||
return (
|
||||
<div className="eh-wizard-step">
|
||||
<h3>Zusammenfassung</h3>
|
||||
<p className="eh-wizard-description">
|
||||
Pruefen Sie Ihre Eingaben und starten Sie den Upload.
|
||||
</p>
|
||||
|
||||
<div className="eh-summary-table">
|
||||
<div className="eh-summary-row">
|
||||
<span className="eh-summary-label">Datei:</span>
|
||||
<span className="eh-summary-value">{selectedFile?.name}</span>
|
||||
</div>
|
||||
<div className="eh-summary-row">
|
||||
<span className="eh-summary-label">Titel:</span>
|
||||
<span className="eh-summary-value">{metadata.title}</span>
|
||||
</div>
|
||||
<div className="eh-summary-row">
|
||||
<span className="eh-summary-label">Fach:</span>
|
||||
<span className="eh-summary-value">
|
||||
{metadata.subject.charAt(0).toUpperCase() + metadata.subject.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="eh-summary-row">
|
||||
<span className="eh-summary-label">Niveau:</span>
|
||||
<span className="eh-summary-value">{metadata.niveau}</span>
|
||||
</div>
|
||||
<div className="eh-summary-row">
|
||||
<span className="eh-summary-label">Jahr:</span>
|
||||
<span className="eh-summary-value">{metadata.year}</span>
|
||||
</div>
|
||||
<div className="eh-summary-row">
|
||||
<span className="eh-summary-label">Verschluesselung:</span>
|
||||
<span className="eh-summary-value">AES-256-GCM</span>
|
||||
</div>
|
||||
<div className="eh-summary-row">
|
||||
<span className="eh-summary-label">Rechte bestaetigt:</span>
|
||||
<span className="eh-summary-value">Ja</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{uploading && (
|
||||
<div className="eh-upload-progress">
|
||||
<div
|
||||
className="eh-progress-bar"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
/>
|
||||
<span>{uploadProgress}%</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{uploadError && (
|
||||
<p className="eh-error">{uploadError}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="eh-wizard-overlay">
|
||||
<div className="eh-wizard-modal">
|
||||
{/* Header */}
|
||||
<div className="eh-wizard-header">
|
||||
<h2>Erwartungshorizont hochladen</h2>
|
||||
<button className="eh-wizard-close" onClick={onClose}>×</button>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<div className="eh-wizard-progress">
|
||||
{WIZARD_STEPS.map((step, index) => (
|
||||
<div
|
||||
key={step}
|
||||
className={`eh-progress-step ${
|
||||
index < currentStepIndex ? 'completed' :
|
||||
index === currentStepIndex ? 'active' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="eh-progress-dot">
|
||||
{index < currentStepIndex ? '\u2713' : index + 1}
|
||||
</div>
|
||||
<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
|
||||
38
klausur-service/frontend/src/components/Layout.tsx
Normal file
38
klausur-service/frontend/src/components/Layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
255
klausur-service/frontend/src/components/RAGSearchPanel.tsx
Normal file
255
klausur-service/frontend/src/components/RAGSearchPanel.tsx
Normal 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}>×</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">✓</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">🔍</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>
|
||||
)
|
||||
}
|
||||
175
klausur-service/frontend/src/hooks/useKlausur.tsx
Normal file
175
klausur-service/frontend/src/hooks/useKlausur.tsx
Normal 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
|
||||
}
|
||||
19
klausur-service/frontend/src/main.tsx
Normal file
19
klausur-service/frontend/src/main.tsx
Normal 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>,
|
||||
)
|
||||
956
klausur-service/frontend/src/pages/KorrekturPage.tsx
Normal file
956
klausur-service/frontend/src/pages/KorrekturPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
247
klausur-service/frontend/src/pages/OnboardingPage.tsx
Normal file
247
klausur-service/frontend/src/pages/OnboardingPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
620
klausur-service/frontend/src/services/api.ts
Normal file
620
klausur-service/frontend/src/services/api.ts
Normal 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`)
|
||||
}
|
||||
298
klausur-service/frontend/src/services/encryption.ts
Normal file
298
klausur-service/frontend/src/services/encryption.ts
Normal 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'
|
||||
}
|
||||
468
klausur-service/frontend/src/styles/eh-wizard.css
Normal file
468
klausur-service/frontend/src/styles/eh-wizard.css
Normal 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;
|
||||
}
|
||||
}
|
||||
924
klausur-service/frontend/src/styles/global.css
Normal file
924
klausur-service/frontend/src/styles/global.css
Normal 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;
|
||||
}
|
||||
}
|
||||
407
klausur-service/frontend/src/styles/rag-search.css
Normal file
407
klausur-service/frontend/src/styles/rag-search.css
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user