fix: Restore all files lost during destructive rebase

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

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

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

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

View File

@@ -0,0 +1,232 @@
# BreakPilot Studio - Komponenten-Refactoring
## Überblick
Die monolithische `studio.py` (11.703 Zeilen) wurde erfolgreich in 7 modulare Komponenten aufgeteilt:
| Komponente | Zeilen | Beschreibung |
|------------|--------|--------------|
| `base.py` | ~300 | CSS Variables, Base Styles, Theme Toggle |
| `legal_modal.py` | ~1.200 | Legal/Consent Modal (AGB, Datenschutz, Cookies, etc.) |
| `auth_modal.py` | ~1.500 | Auth/Login/Register Modal, 2FA |
| `admin_panel.py` | ~3.000 | Admin Panel (Documents, Versions, Approval) |
| `admin_email.py` | ~1.000 | E-Mail Template Management |
| `admin_dsms.py` | ~1.500 | DSMS/IPFS WebUI, Archive Management |
| `admin_stats.py` | ~700 | Statistics & GDPR Export |
**Gesamt:** ~9.200 Zeilen in Komponenten (78% der ursprünglichen Datei)
## Struktur
```
backend/frontend/
├── studio.py (ursprünglich 11.703 Zeilen)
├── studio.py.backup (Backup der Original-Datei)
├── studio_refactored_demo.py (Demo der Integration)
└── components/
├── __init__.py
├── base.py
├── legal_modal.py
├── auth_modal.py
├── admin_panel.py
├── admin_email.py
├── admin_dsms.py
└── admin_stats.py
```
## Komponenten-API
Jede Komponente exportiert 3 Funktionen:
```python
def get_[component]_css() -> str:
"""Gibt das CSS für die Komponente zurück"""
def get_[component]_html() -> str:
"""Gibt das HTML für die Komponente zurück"""
def get_[component]_js() -> str:
"""Gibt das JavaScript für die Komponente zurück"""
```
## Integration in studio.py
### 1. Imports hinzufügen
```python
from fastapi import APIRouter
from fastapi.responses import HTMLResponse
from .components import (
base,
legal_modal,
auth_modal,
admin_panel,
admin_email,
admin_dsms,
admin_stats,
)
router = APIRouter()
```
### 2. F-String verwenden
```python
@router.get("/app", response_class=HTMLResponse)
def app_ui():
return f""" # F-String statt normaler String
<!DOCTYPE html>
...
"""
```
### 3. Komponenten-Aufrufe einsetzen
#### CSS (im `<style>` Tag):
```python
<style>
{base.get_base_css()}
/* Gemeinsame Styles (Buttons, Forms, etc.) bleiben hier */
{legal_modal.get_legal_modal_css()}
{auth_modal.get_auth_modal_css()}
{admin_panel.get_admin_panel_css()}
{admin_dsms.get_admin_dsms_css()}
</style>
```
#### HTML (im `<body>`):
```python
<body>
<!-- Main Content (Topbar, Sidebar, etc.) bleibt hier -->
{legal_modal.get_legal_modal_html()}
{auth_modal.get_auth_modal_html()}
{admin_panel.get_admin_panel_html()}
{admin_dsms.get_admin_dsms_html()}
</body>
```
#### JavaScript (im `<script>` Tag):
```python
<script>
{base.get_base_js()}
/* Gemeinsames JS (i18n, notifications, etc.) bleibt hier */
{legal_modal.get_legal_modal_js()}
{auth_modal.get_auth_modal_js()}
{admin_panel.get_admin_panel_js()}
{admin_email.get_admin_email_js()}
{admin_stats.get_admin_stats_js()}
{admin_dsms.get_admin_dsms_js()}
</script>
```
## Vorteile des Refactorings
### Wartbarkeit
- Jede Komponente ist eigenständig und fokussiert
- Änderungen sind isoliert und einfacher zu testen
- Code-Reviews sind übersichtlicher
### Team-Zusammenarbeit
- Mehrere Entwickler können parallel an verschiedenen Komponenten arbeiten
- Reduzierte Merge-Konflikte
- Klare Zuständigkeiten
### Performance
- IDE-Performance deutlich verbessert
- Schnelleres Laden in Editoren
- Bessere Syntax-Highlighting
### Testbarkeit
- Komponenten können einzeln getestet werden
- Mock-Daten für isolierte Tests
- Einfacheres Unit-Testing
## Nächste Schritte
### Vollständige Integration (geschätzt 2-3 Stunden)
1. **Backup sichern** (bereits erledigt ✓)
```bash
cp studio.py studio.py.backup
```
2. **Neue studio.py erstellen**
- Kopiere Header aus `studio_refactored_demo.py`
- Übernimm Main Content aus `studio.py.backup`
- Ersetze Modal-Bereiche durch Komponenten-Aufrufe
3. **Testen**
```bash
cd backend
python -c "from frontend.studio import app_ui; print('OK')"
```
4. **Funktionstest**
- Starte die Anwendung
- Teste alle Modals (Legal, Auth, Admin, DSMS)
- Teste Theme Toggle
- Teste Admin-Funktionen
5. **Cleanup** (optional)
- Entferne `studio.py.backup` nach erfolgreichen Tests
- Entferne `studio_refactored_demo.py`
- Aktualisiere Dokumentation
## Fehlerbehandlung
Falls nach der Integration Probleme auftreten:
1. **Syntax-Fehler**
- Prüfe F-String-Syntax: `return f"""...`
- Prüfe geschweifte Klammern in CSS/JS (escapen mit `{{` und `}}`)
2. **Import-Fehler**
- Prüfe `__init__.py` in components/
- Prüfe relative Imports: `from .components import ...`
3. **Fehlende Styles/JS**
- Vergleiche mit `studio.py.backup`
- Prüfe, ob gemeinsame Styles übernommen wurden
4. **Rollback**
```bash
cp studio.py.backup studio.py
```
## Wartung
### Neue Komponente hinzufügen
1. Erstelle `components/new_component.py`
2. Implementiere die 3 Funktionen (css, html, js)
3. Exportiere in `components/__init__.py`
4. Importiere in `studio.py`
5. Rufe in `app_ui()` auf
### Komponente ändern
1. Öffne die entsprechende Datei in `components/`
2. Ändere CSS/HTML/JS
3. Speichern - Änderung wird automatisch übernommen
## Performance-Metriken
| Metrik | Vorher | Nachher | Verbesserung |
|--------|--------|---------|--------------|
| Dateigröße studio.py | 454 KB | ~50 KB | -89% |
| Zeilen studio.py | 11.703 | ~2.500 | -78% |
| IDE-Ladezeit | ~3s | ~0.5s | -83% |
| Größte Datei | 11.703 Z. | 3.000 Z. | -74% |
## Support
Bei Fragen oder Problemen:
- Siehe `docs/architecture/studio-refactoring-proposal.md`
- Backup ist in `studio.py.backup`
- Demo ist in `studio_refactored_demo.py`

View File

@@ -0,0 +1,227 @@
# Studio.py Refactoring - Status Report
**Datum:** 2025-12-14
**Status:** Komponenten-Struktur erfolgreich erstellt
**Nächster Schritt:** Manuelle Vervollständigung der HTML-Extraktion
---
## Zusammenfassung
Das Refactoring der monolithischen `studio.py` (11.703 Zeilen) wurde gemäß dem Plan in `docs/architecture/studio-refactoring-proposal.md` implementiert. Die Komponenten-Struktur (Option A) ist vollständig aufgesetzt.
## Was wurde erreicht ✓
### 1. Verzeichnisstruktur
```
backend/frontend/
├── studio.py (Original - unverändert)
├── studio.py.backup (Backup)
├── studio_refactored_demo.py (Demo)
└── components/
├── __init__.py
├── README.md
├── base.py
├── legal_modal.py
├── auth_modal.py
├── admin_panel.py
├── admin_email.py
├── admin_dsms.py
└── admin_stats.py
```
### 2. Komponenten erstellt
| Komponente | Status | CSS | HTML | JS |
|------------|--------|-----|------|-----|
| `base.py` | ✓ Komplett | ✓ | ✓ | ✓ |
| `legal_modal.py` | ⚠ Partial | ✓ | ○ | ✓ |
| `auth_modal.py` | ⚠ Partial | ✓ | ○ | ✓ |
| `admin_panel.py` | ⚠ Partial | ✓ | ○ | ✓ |
| `admin_email.py` | ✓ Komplett | - | - | ✓ |
| `admin_dsms.py` | ⚠ Partial | ✓ | ○ | ✓ |
| `admin_stats.py` | ✓ Komplett | - | - | ✓ |
**Legende:**
✓ = Vollständig extrahiert
⚠ = CSS und JS extrahiert, HTML teilweise
○ = Nicht/nur teilweise extrahiert
\- = Nicht erforderlich (in anderen Komponenten enthalten)
### 3. Automatische Extraktion
Ein automatisches Extraktions-Skript wurde erstellt und ausgeführt:
- **Erfolgreich:** CSS und JavaScript für alle Komponenten
- **Teilweise:** HTML-Extraktion (Regex-Pattern haben nicht alle HTML-Bereiche erfasst)
### 4. Dokumentation
-`components/README.md` - Vollständige Integrations-Anleitung
-`studio_refactored_demo.py` - Funktionierendes Demo-Beispiel
-`REFACTORING_STATUS.md` - Dieser Statusbericht
## Was funktioniert
### Komponenten-Import
```python
from frontend.components import (
base, legal_modal, auth_modal,
admin_panel, admin_email, admin_dsms, admin_stats
)
# ✓ Alle Imports funktionieren
```
### CSS-Extraktion
```python
base.get_base_css() # ✓ 7.106 Zeichen
legal_modal.get_legal_modal_css() # ✓ Funktioniert
auth_modal.get_auth_modal_css() # ✓ Funktioniert
# ... alle CSS-Funktionen funktionieren
```
### JavaScript-Extraktion
```python
base.get_base_js() # ✓ Theme Toggle JS
legal_modal.get_legal_modal_js() # ✓ Legal Modal Functions
# ... alle JS-Funktionen funktionieren
```
## Was noch zu tun ist
### HTML-Extraktion vervollständigen
Die HTML-Bereiche müssen manuell aus `studio.py.backup` extrahiert werden:
#### 1. Legal Modal HTML (ca. Zeilen 6800-7000)
```python
# In components/legal_modal.py
def get_legal_modal_html() -> str:
return """
<div id="legal-modal" class="legal-modal">
<!-- Kopiere HTML aus studio.py.backup -->
</div>
"""
```
#### 2. Auth Modal HTML (ca. Zeilen 7000-7040)
```python
# In components/auth_modal.py
def get_auth_modal_html() -> str:
return """
<div id="auth-modal" class="auth-modal">
<!-- Kopiere HTML aus studio.py.backup -->
</div>
"""
```
#### 3. Admin Panel HTML (ca. Zeilen 7040-7500)
```python
# In components/admin_panel.py
def get_admin_panel_html() -> str:
return """
<div id="admin-modal" class="admin-modal">
<!-- Kopiere HTML aus studio.py.backup -->
</div>
"""
```
#### 4. DSMS WebUI HTML (ca. Zeilen 7500-7800)
```python
# In components/admin_dsms.py
def get_admin_dsms_html() -> str:
return """
<!-- DSMS WebUI Modal -->
<div id="dsms-webui-modal" class="admin-modal">
<!-- Kopiere HTML aus studio.py.backup -->
</div>
"""
```
### Anleitung zur manuellen Vervollständigung
```bash
# 1. Öffne studio.py.backup in einem Editor
code /Users/benjaminadmin/Projekte/breakpilot-pwa/backend/frontend/studio.py.backup
# 2. Suche nach den HTML-Bereichen:
# - Suche: "<div id="legal-modal""
# - Kopiere bis: "<!-- /legal-modal -->"
# - Füge in components/legal_modal.py ein
# 3. Wiederhole für alle anderen Modals
# 4. Teste die Integration:
cd /Users/benjaminadmin/Projekte/breakpilot-pwa/backend
python3 -c "from frontend.components import legal_modal; print(len(legal_modal.get_legal_modal_html()))"
```
## Alternative: Verwendung der Backup-Datei
Statt die HTML-Bereiche zu extrahieren, kann auch die Backup-Datei direkt verwendet werden:
1. Kopiere `studio.py.backup` zu `studio.py`
2. Füge die Imports hinzu (siehe `studio_refactored_demo.py`)
3. Ändere `return """` zu `return f"""`
4. Ersetze die extrahierten CSS/JS-Bereiche durch Komponenten-Aufrufe
**Vorteil:** Die Anwendung funktioniert sofort
**Nachteil:** Noch nicht vollständig modular (HTML noch inline)
## Zeitschätzung
| Aufgabe | Geschätzte Zeit |
|---------|-----------------|
| HTML manuell extrahieren | 1-2 Stunden |
| Integration testen | 30 Minuten |
| Vollständige Integration in studio.py | 1-2 Stunden |
| **Gesamt** | **2,5-4,5 Stunden** |
## Empfehlung
### Kurzfristig (heute):
Verwende `studio.py.backup` als neue `studio.py` und füge nur die Imports hinzu. Die Anwendung funktioniert dann sofort weiter.
### Mittelfristig (nächste Woche):
Vervollständige die HTML-Extraktion manuell (2-4 Stunden Arbeit).
### Langfristig (nächster Sprint):
Führe die vollständige Integration durch und teste alle Features.
## Ergebnis
**Struktur:** Vollständig implementiert
**CSS:** 100% in Komponenten extrahiert
**JavaScript:** 100% in Komponenten extrahiert
**HTML:** 20% extrahiert (base.py), 80% noch zu tun
**Dokumentation:** Vollständig
**Geschätzter Fortschritt:** 75% abgeschlossen
## Nächste Schritte
1. Entscheide zwischen:
- **Option A:** HTML manuell vervollständigen (empfohlen, 2-4h)
- **Option B:** Mit teilweise modularem Ansatz weiterarbeiten
2. Teste die Demo:
```bash
# Backup wiederherstellen
cp studio.py.backup studio.py
# Server starten
cd backend
uvicorn main:app --reload
# Browser öffnen
# http://localhost:8000/app
```
3. Bei Problemen:
- Siehe `components/README.md`
- Backup ist gesichert in `studio.py.backup`
- Demo ist in `studio_refactored_demo.py`
---
**Erstellt von:** Claude (Automated Refactoring)
**Basis:** `docs/architecture/studio-refactoring-proposal.md`

View File

@@ -0,0 +1,30 @@
"""
Frontend Components for BreakPilot Studio
This package contains modular components extracted from the monolithic studio.py:
- base: CSS Variables, Base Styles, Theme Toggle
- legal_modal: AGB, Datenschutz, Cookies, Community Guidelines, GDPR Rights
- auth_modal: Login, Register, 2FA
- admin_panel: Admin Panel Core (Documents, Versions, Approval)
- admin_email: E-Mail Template Management
- admin_dsms: DSMS/IPFS WebUI, Archive Management
- admin_stats: Statistics & GDPR Export
"""
from . import base
from . import legal_modal
from . import auth_modal
from . import admin_panel
from . import admin_email
from . import admin_dsms
from . import admin_stats
__all__ = [
'base',
'legal_modal',
'auth_modal',
'admin_panel',
'admin_email',
'admin_dsms',
'admin_stats',
]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,507 @@
"""
Admin Email Component - E-Mail Template Management
"""
def get_admin_email_css() -> str:
"""CSS für E-Mail Templates (inkludiert in admin_panel.css)"""
return ""
def get_admin_email_html() -> str:
"""HTML für E-Mail Templates (inkludiert in admin_panel.html)"""
return ""
def get_admin_email_js() -> str:
"""JavaScript für E-Mail Template Management zurückgeben"""
return """
let emailTemplates = [];
let emailTemplateVersions = [];
let currentEmailTemplateId = null;
let currentEmailVersionId = null;
// E-Mail-Template-Typen mit deutschen Namen
const emailTypeNames = {
'welcome': 'Willkommens-E-Mail',
'email_verification': 'E-Mail-Verifizierung',
'password_reset': 'Passwort zurücksetzen',
'password_changed': 'Passwort geändert',
'2fa_enabled': '2FA aktiviert',
'2fa_disabled': '2FA deaktiviert',
'new_device_login': 'Neues Gerät Login',
'suspicious_activity': 'Verdächtige Aktivität',
'account_locked': 'Account gesperrt',
'account_unlocked': 'Account entsperrt',
'deletion_requested': 'Löschung angefordert',
'deletion_confirmed': 'Löschung bestätigt',
'data_export_ready': 'Datenexport bereit',
'email_changed': 'E-Mail geändert',
'new_version_published': 'Neue Version veröffentlicht',
'consent_reminder': 'Consent Erinnerung',
'consent_deadline_warning': 'Consent Frist Warnung',
'account_suspended': 'Account suspendiert'
};
// Load E-Mail Templates when tab is clicked
document.querySelector('.admin-tab[data-tab="emails"]')?.addEventListener('click', loadEmailTemplates);
async function loadEmailTemplates() {
try {
const res = await fetch('/api/consent/admin/email-templates');
if (!res.ok) throw new Error('Fehler beim Laden der Templates');
const data = await res.json();
emailTemplates = data.templates || [];
populateEmailTemplateSelect();
} catch (e) {
console.error('Error loading email templates:', e);
showToast('Fehler beim Laden der E-Mail-Templates', 'error');
}
}
function populateEmailTemplateSelect() {
const select = document.getElementById('email-template-select');
select.innerHTML = '<option value="">-- E-Mail-Vorlage auswählen --</option>';
emailTemplates.forEach(template => {
const opt = document.createElement('option');
opt.value = template.id;
opt.textContent = emailTypeNames[template.type] || template.name;
select.appendChild(opt);
});
}
async function loadEmailTemplateVersions() {
const select = document.getElementById('email-template-select');
const templateId = select.value;
const newVersionBtn = document.getElementById('btn-new-email-version');
const infoCard = document.getElementById('email-template-info');
const container = document.getElementById('email-version-table-container');
if (!templateId) {
newVersionBtn.disabled = true;
infoCard.style.display = 'none';
container.innerHTML = '<div class="admin-empty">Wählen Sie eine E-Mail-Vorlage aus, um deren Versionen anzuzeigen.</div>';
currentEmailTemplateId = null;
return;
}
currentEmailTemplateId = templateId;
newVersionBtn.disabled = false;
// Finde das Template
const template = emailTemplates.find(t => t.id === templateId);
if (template) {
infoCard.style.display = 'block';
document.getElementById('email-template-name').textContent = emailTypeNames[template.type] || template.name;
document.getElementById('email-template-description').textContent = template.description || 'Keine Beschreibung';
document.getElementById('email-template-type-badge').textContent = template.type;
// Variablen anzeigen (wird aus dem Default-Inhalt ermittelt)
try {
const defaultRes = await fetch(`/api/consent/admin/email-templates/default/${template.type}`);
if (defaultRes.ok) {
const defaultData = await defaultRes.json();
const variables = extractVariables(defaultData.body_html || '');
document.getElementById('email-template-variables').textContent = variables.join(', ') || 'Keine';
}
} catch (e) {
document.getElementById('email-template-variables').textContent = '-';
}
}
// Lade Versionen
container.innerHTML = '<div class="admin-loading">Lade Versionen...</div>';
try {
const res = await fetch(`/api/consent/admin/email-templates/${templateId}/versions`);
if (!res.ok) throw new Error('Fehler beim Laden');
const data = await res.json();
emailTemplateVersions = data.versions || [];
renderEmailVersionsTable();
} catch (e) {
container.innerHTML = '<div class="admin-empty">Fehler beim Laden der Versionen.</div>';
}
}
function extractVariables(content) {
const matches = content.match(/\\{\\{([^}]+)\\}\\}/g) || [];
return [...new Set(matches.map(m => m.replace(/[{}]/g, '')))];
}
function renderEmailVersionsTable() {
const container = document.getElementById('email-version-table-container');
if (emailTemplateVersions.length === 0) {
container.innerHTML = '<div class="admin-empty">Keine Versionen vorhanden. Erstellen Sie eine neue Version.</div>';
return;
}
const statusColors = {
'draft': 'draft',
'review': 'review',
'approved': 'approved',
'published': 'published',
'archived': 'archived'
};
const statusNames = {
'draft': 'Entwurf',
'review': 'In Prüfung',
'approved': 'Genehmigt',
'published': 'Veröffentlicht',
'archived': 'Archiviert'
};
container.innerHTML = `
<table class="admin-table">
<thead>
<tr>
<th>Version</th>
<th>Sprache</th>
<th>Betreff</th>
<th>Status</th>
<th>Aktualisiert</th>
<th style="text-align: right;">Aktionen</th>
</tr>
</thead>
<tbody>
${emailTemplateVersions.map(v => `
<tr>
<td><strong>${v.version}</strong></td>
<td>${v.language === 'de' ? '🇩🇪 DE' : '🇬🇧 EN'}</td>
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${v.subject}</td>
<td><span class="admin-badge badge-${statusColors[v.status]}">${statusNames[v.status] || v.status}</span></td>
<td>${new Date(v.updated_at).toLocaleDateString('de-DE')}</td>
<td style="text-align: right;">
<button class="btn btn-ghost btn-xs" onclick="previewEmailVersionById('${v.id}')" title="Vorschau">👁️</button>
${v.status === 'draft' ? `
<button class="btn btn-ghost btn-xs" onclick="editEmailVersion('${v.id}')" title="Bearbeiten">✏️</button>
<button class="btn btn-ghost btn-xs" onclick="submitEmailForReview('${v.id}')" title="Zur Prüfung">📤</button>
<button class="btn btn-ghost btn-xs" onclick="deleteEmailVersion('${v.id}')" title="Löschen">🗑️</button>
` : ''}
${v.status === 'review' ? `
<button class="btn btn-ghost btn-xs" onclick="showEmailApprovalDialogFor('${v.id}')" title="Genehmigen">✅</button>
<button class="btn btn-ghost btn-xs" onclick="rejectEmailVersion('${v.id}')" title="Ablehnen">❌</button>
` : ''}
${v.status === 'approved' ? `
<button class="btn btn-primary btn-xs" onclick="publishEmailVersion('${v.id}')" title="Veröffentlichen">🚀</button>
` : ''}
</td>
</tr>
`).join('')}
</tbody>
</table>
`;
}
function showEmailVersionForm() {
document.getElementById('email-version-form').style.display = 'block';
document.getElementById('email-version-form-title').textContent = 'Neue E-Mail-Version erstellen';
document.getElementById('email-version-id').value = '';
document.getElementById('email-version-number').value = '';
document.getElementById('email-version-subject').value = '';
document.getElementById('email-version-editor').innerHTML = '';
document.getElementById('email-version-text').value = '';
// Lade Default-Inhalt
const template = emailTemplates.find(t => t.id === currentEmailTemplateId);
if (template) {
loadDefaultEmailContent(template.type);
}
}
async function loadDefaultEmailContent(templateType) {
try {
const res = await fetch(`/api/consent/admin/email-templates/default/${templateType}`);
if (res.ok) {
const data = await res.json();
document.getElementById('email-version-subject').value = data.subject || '';
document.getElementById('email-version-editor').innerHTML = data.body_html || '';
document.getElementById('email-version-text').value = data.body_text || '';
}
} catch (e) {
console.error('Error loading default content:', e);
}
}
function hideEmailVersionForm() {
document.getElementById('email-version-form').style.display = 'none';
}
async function saveEmailVersion() {
const versionId = document.getElementById('email-version-id').value;
const templateId = currentEmailTemplateId;
const version = document.getElementById('email-version-number').value.trim();
const language = document.getElementById('email-version-lang').value;
const subject = document.getElementById('email-version-subject').value.trim();
const bodyHtml = document.getElementById('email-version-editor').innerHTML;
const bodyText = document.getElementById('email-version-text').value.trim();
if (!version || !subject || !bodyHtml) {
showToast('Bitte füllen Sie alle Pflichtfelder aus', 'error');
return;
}
const data = {
template_id: templateId,
version: version,
language: language,
subject: subject,
body_html: bodyHtml,
body_text: bodyText || stripHtml(bodyHtml)
};
try {
let res;
if (versionId) {
// Update existing version
res = await fetch(`/api/consent/admin/email-template-versions/${versionId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
} else {
// Create new version
res = await fetch('/api/consent/admin/email-template-versions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
}
if (!res.ok) {
const error = await res.json();
throw new Error(error.detail || 'Fehler beim Speichern');
}
showToast('E-Mail-Version gespeichert!', 'success');
hideEmailVersionForm();
loadEmailTemplateVersions();
} catch (e) {
showToast('Fehler: ' + e.message, 'error');
}
}
function stripHtml(html) {
const div = document.createElement('div');
div.innerHTML = html;
return div.textContent || div.innerText || '';
}
async function editEmailVersion(versionId) {
try {
const res = await fetch(`/api/consent/admin/email-template-versions/${versionId}`);
if (!res.ok) throw new Error('Version nicht gefunden');
const version = await res.json();
document.getElementById('email-version-form').style.display = 'block';
document.getElementById('email-version-form-title').textContent = 'E-Mail-Version bearbeiten';
document.getElementById('email-version-id').value = versionId;
document.getElementById('email-version-number').value = version.version;
document.getElementById('email-version-lang').value = version.language;
document.getElementById('email-version-subject').value = version.subject;
document.getElementById('email-version-editor').innerHTML = version.body_html;
document.getElementById('email-version-text').value = version.body_text || '';
} catch (e) {
showToast('Fehler beim Laden der Version', 'error');
}
}
async function deleteEmailVersion(versionId) {
if (!confirm('Möchten Sie diese Version wirklich löschen?')) return;
try {
const res = await fetch(`/api/consent/admin/email-template-versions/${versionId}`, {
method: 'DELETE'
});
if (!res.ok) throw new Error('Fehler beim Löschen');
showToast('Version gelöscht', 'success');
loadEmailTemplateVersions();
} catch (e) {
showToast('Fehler beim Löschen', 'error');
}
}
async function submitEmailForReview(versionId) {
try {
const res = await fetch(`/api/consent/admin/email-template-versions/${versionId}/submit`, {
method: 'POST'
});
if (!res.ok) throw new Error('Fehler');
showToast('Zur Prüfung eingereicht', 'success');
loadEmailTemplateVersions();
} catch (e) {
showToast('Fehler beim Einreichen', 'error');
}
}
function showEmailApprovalDialogFor(versionId) {
currentEmailVersionId = versionId;
document.getElementById('email-approval-dialog').style.display = 'flex';
document.getElementById('email-approval-comment').value = '';
}
function hideEmailApprovalDialog() {
document.getElementById('email-approval-dialog').style.display = 'none';
currentEmailVersionId = null;
}
async function submitEmailApproval() {
if (!currentEmailVersionId) return;
const comment = document.getElementById('email-approval-comment').value.trim();
try {
const res = await fetch(`/api/consent/admin/email-template-versions/${currentEmailVersionId}/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ comment: comment })
});
if (!res.ok) throw new Error('Fehler');
showToast('Version genehmigt', 'success');
hideEmailApprovalDialog();
loadEmailTemplateVersions();
} catch (e) {
showToast('Fehler bei der Genehmigung', 'error');
}
}
async function rejectEmailVersion(versionId) {
const reason = prompt('Ablehnungsgrund:');
if (!reason) return;
try {
const res = await fetch(`/api/consent/admin/email-template-versions/${versionId}/reject`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reason: reason })
});
if (!res.ok) throw new Error('Fehler');
showToast('Version abgelehnt', 'success');
loadEmailTemplateVersions();
} catch (e) {
showToast('Fehler bei der Ablehnung', 'error');
}
}
async function publishEmailVersion(versionId) {
if (!confirm('Möchten Sie diese Version veröffentlichen? Die vorherige Version wird archiviert.')) return;
try {
const res = await fetch(`/api/consent/admin/email-template-versions/${versionId}/publish`, {
method: 'POST'
});
if (!res.ok) throw new Error('Fehler');
showToast('Version veröffentlicht!', 'success');
loadEmailTemplateVersions();
} catch (e) {
showToast('Fehler beim Veröffentlichen', 'error');
}
}
async function previewEmailVersionById(versionId) {
try {
const res = await fetch(`/api/consent/admin/email-template-versions/${versionId}/preview`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({})
});
if (!res.ok) throw new Error('Fehler');
const data = await res.json();
document.getElementById('email-preview-subject').textContent = data.subject;
document.getElementById('email-preview-content').innerHTML = data.body_html;
document.getElementById('email-preview-dialog').style.display = 'flex';
currentEmailVersionId = versionId;
} catch (e) {
showToast('Fehler bei der Vorschau', 'error');
}
}
function previewEmailVersion() {
const subject = document.getElementById('email-version-subject').value;
const bodyHtml = document.getElementById('email-version-editor').innerHTML;
document.getElementById('email-preview-subject').textContent = subject;
document.getElementById('email-preview-content').innerHTML = bodyHtml;
document.getElementById('email-preview-dialog').style.display = 'flex';
}
function hideEmailPreview() {
document.getElementById('email-preview-dialog').style.display = 'none';
}
async function sendTestEmail() {
const email = document.getElementById('email-test-address').value.trim();
if (!email) {
showToast('Bitte geben Sie eine E-Mail-Adresse ein', 'error');
return;
}
if (!currentEmailVersionId) {
showToast('Keine Version ausgewählt', 'error');
return;
}
try {
const res = await fetch(`/api/consent/admin/email-template-versions/${currentEmailVersionId}/send-test`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email })
});
if (!res.ok) throw new Error('Fehler');
showToast('Test-E-Mail gesendet!', 'success');
} catch (e) {
showToast('Fehler beim Senden der Test-E-Mail', 'error');
}
}
async function initializeEmailTemplates() {
if (!confirm('Möchten Sie alle Standard-E-Mail-Templates initialisieren?')) return;
try {
const res = await fetch('/api/consent/admin/email-templates/initialize', {
method: 'POST'
});
if (!res.ok) throw new Error('Fehler');
showToast('Templates initialisiert!', 'success');
loadEmailTemplates();
} catch (e) {
showToast('Fehler bei der Initialisierung', 'error');
}
}
// E-Mail Editor Helpers
function formatEmailDoc(command) {
document.execCommand(command, false, null);
document.getElementById('email-version-editor').focus();
}
function formatEmailBlock(tag) {
document.execCommand('formatBlock', false, '<' + tag + '>');
document.getElementById('email-version-editor').focus();
}
function insertEmailVariable() {
const variable = prompt('Variablenname eingeben (z.B. user_name, reset_link):');
if (variable) {
document.execCommand('insertText', false, '{{' + variable + '}}');
}
}
function insertEmailLink() {
const url = prompt('Link-URL:');
if (url) {
const text = prompt('Link-Text:', url);
document.execCommand('insertHTML', false, `<a href="${url}" style="color: #5B21B6;">${text}</a>`);
}
}
function insertEmailButton() {
const url = prompt('Button-Link:');
if (url) {
const text = prompt('Button-Text:', 'Klicken');
const buttonHtml = `<table cellpadding="0" cellspacing="0" style="margin: 16px 0;"><tr><td style="background: #5B21B6; border-radius: 6px; padding: 12px 24px;"><a href="${url}" style="color: white; text-decoration: none; font-weight: 600;">${text}</a></td></tr></table>`;
document.execCommand('insertHTML', false, buttonHtml);
}
}
"""

View File

@@ -0,0 +1,640 @@
"""
Admin GPU Infrastructure Component.
Provides UI controls for vast.ai GPU management:
- Start/Stop buttons
- Status display with GPU info
- Cost tracking
- Auto-shutdown timer
"""
def get_admin_gpu_css() -> str:
"""CSS fuer GPU Control Panel."""
return """
/* ==========================================
GPU INFRASTRUCTURE STYLES
========================================== */
.gpu-control-panel {
background: var(--bp-surface-elevated);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
border: 1px solid var(--bp-border);
}
.gpu-status-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.gpu-status-badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border-radius: 20px;
font-size: 13px;
font-weight: 600;
}
.gpu-status-badge.running {
background: rgba(34, 197, 94, 0.15);
color: #22c55e;
}
.gpu-status-badge.stopped {
background: rgba(156, 163, 175, 0.15);
color: #9ca3af;
}
.gpu-status-badge.loading {
background: rgba(59, 130, 246, 0.15);
color: #3b82f6;
}
.gpu-status-badge.error {
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
}
.gpu-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
}
.gpu-status-dot.running {
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.gpu-info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 12px;
margin-bottom: 20px;
}
.gpu-info-card {
background: var(--bp-surface);
border-radius: 8px;
padding: 12px;
border: 1px solid var(--bp-border-subtle);
}
.gpu-info-label {
font-size: 11px;
color: var(--bp-text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 4px;
}
.gpu-info-value {
font-size: 16px;
font-weight: 600;
color: var(--bp-text);
}
.gpu-info-value.cost {
color: #f59e0b;
}
.gpu-info-value.time {
color: #3b82f6;
}
.gpu-controls {
display: flex;
gap: 12px;
margin-top: 20px;
}
.gpu-btn {
flex: 1;
padding: 12px 20px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: all 0.2s ease;
}
.gpu-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.gpu-btn-start {
background: #22c55e;
color: white;
}
.gpu-btn-start:hover:not(:disabled) {
background: #16a34a;
}
.gpu-btn-stop {
background: #ef4444;
color: white;
}
.gpu-btn-stop:hover:not(:disabled) {
background: #dc2626;
}
.gpu-btn-refresh {
background: var(--bp-surface);
color: var(--bp-text);
border: 1px solid var(--bp-border);
flex: 0 0 auto;
padding: 12px;
}
.gpu-btn-refresh:hover:not(:disabled) {
background: var(--bp-surface-elevated);
}
.gpu-shutdown-warning {
background: rgba(251, 191, 36, 0.1);
border: 1px solid rgba(251, 191, 36, 0.3);
border-radius: 8px;
padding: 12px;
margin-top: 16px;
display: flex;
align-items: center;
gap: 10px;
color: #fbbf24;
font-size: 13px;
}
.gpu-shutdown-warning svg {
flex-shrink: 0;
}
.gpu-cost-summary {
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid var(--bp-border);
}
.gpu-cost-summary h4 {
margin: 0 0 12px 0;
font-size: 14px;
color: var(--bp-text-muted);
}
.gpu-audit-log {
margin-top: 20px;
max-height: 200px;
overflow-y: auto;
background: var(--bp-surface);
border-radius: 8px;
border: 1px solid var(--bp-border-subtle);
}
.gpu-audit-entry {
padding: 8px 12px;
border-bottom: 1px solid var(--bp-border-subtle);
font-size: 12px;
font-family: monospace;
}
.gpu-audit-entry:last-child {
border-bottom: none;
}
.gpu-audit-time {
color: var(--bp-text-muted);
margin-right: 8px;
}
.gpu-audit-event {
color: var(--bp-text);
}
.gpu-endpoint-url {
margin-top: 12px;
padding: 10px 12px;
background: var(--bp-surface);
border-radius: 8px;
font-family: monospace;
font-size: 12px;
color: var(--bp-text-muted);
word-break: break-all;
}
.gpu-endpoint-url.active {
color: #22c55e;
}
.gpu-spinner {
width: 16px;
height: 16px;
border: 2px solid transparent;
border-top-color: currentColor;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
[data-theme="light"] .gpu-control-panel {
background: #fafafa;
}
[data-theme="light"] .gpu-info-card {
background: #ffffff;
}
"""
def get_admin_gpu_html() -> str:
"""HTML fuer GPU Control Tab."""
return """
<!-- GPU Infrastructure Tab Content -->
<div id="admin-content-gpu" class="admin-content">
<div class="gpu-control-panel">
<div class="gpu-status-header">
<h3 style="margin: 0; font-size: 16px;">vast.ai GPU Instance</h3>
<div id="gpu-status-badge" class="gpu-status-badge stopped">
<span class="gpu-status-dot"></span>
<span id="gpu-status-text">Unbekannt</span>
</div>
</div>
<div class="gpu-info-grid">
<div class="gpu-info-card">
<div class="gpu-info-label">GPU</div>
<div id="gpu-name" class="gpu-info-value">-</div>
</div>
<div class="gpu-info-card">
<div class="gpu-info-label">Kosten/Stunde</div>
<div id="gpu-dph" class="gpu-info-value cost">-</div>
</div>
<div class="gpu-info-card">
<div class="gpu-info-label">Session</div>
<div id="gpu-session-time" class="gpu-info-value time">-</div>
</div>
<div class="gpu-info-card">
<div class="gpu-info-label">Gesamt</div>
<div id="gpu-total-cost" class="gpu-info-value cost">-</div>
</div>
</div>
<div id="gpu-endpoint" class="gpu-endpoint-url" style="display: none;">
Endpoint: <span id="gpu-endpoint-url">-</span>
</div>
<div id="gpu-shutdown-warning" class="gpu-shutdown-warning" style="display: none;">
<svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
<span>Auto-Shutdown in <strong id="gpu-shutdown-minutes">0</strong> Minuten (bei Inaktivitaet)</span>
</div>
<div class="gpu-controls">
<button id="gpu-btn-start" class="gpu-btn gpu-btn-start" onclick="gpuPowerOn()">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3l14 9-14 9V3z"/>
</svg>
Starten
</button>
<button id="gpu-btn-stop" class="gpu-btn gpu-btn-stop" onclick="gpuPowerOff()" disabled>
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z"/>
</svg>
Stoppen
</button>
<button class="gpu-btn gpu-btn-refresh" onclick="gpuRefreshStatus()" title="Status aktualisieren">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
</button>
</div>
<div class="gpu-cost-summary">
<h4>Kosten-Zusammenfassung</h4>
<div class="gpu-info-grid">
<div class="gpu-info-card">
<div class="gpu-info-label">Laufzeit Gesamt</div>
<div id="gpu-total-runtime" class="gpu-info-value time">0h 0m</div>
</div>
<div class="gpu-info-card">
<div class="gpu-info-label">Kosten Gesamt</div>
<div id="gpu-total-cost-all" class="gpu-info-value cost">$0.00</div>
</div>
</div>
</div>
<details style="margin-top: 20px;">
<summary style="cursor: pointer; color: var(--bp-text-muted); font-size: 13px;">
Audit Log (letzte Aktionen)
</summary>
<div id="gpu-audit-log" class="gpu-audit-log">
<div class="gpu-audit-entry">Keine Eintraege</div>
</div>
</details>
</div>
<div style="padding: 12px; background: var(--bp-surface-elevated); border-radius: 8px; font-size: 12px; color: var(--bp-text-muted);">
<strong>Hinweis:</strong> Die GPU-Instanz wird automatisch nach 30 Minuten Inaktivitaet gestoppt.
Bei jedem LLM-Request wird die Aktivitaet aufgezeichnet und der Timer zurueckgesetzt.
</div>
</div>
"""
def get_admin_gpu_js() -> str:
"""JavaScript fuer GPU Control."""
return """
// ==========================================
// GPU INFRASTRUCTURE CONTROLS
// ==========================================
let gpuStatusInterval = null;
const GPU_CONTROL_KEY = window.CONTROL_API_KEY || '';
async function gpuFetch(endpoint, options = {}) {
const headers = {
'Content-Type': 'application/json',
'X-API-Key': GPU_CONTROL_KEY,
...options.headers,
};
try {
const response = await fetch(`/infra/vast${endpoint}`, {
...options,
headers,
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: response.statusText }));
throw new Error(error.detail || 'Request failed');
}
return await response.json();
} catch (error) {
console.error('GPU API Error:', error);
throw error;
}
}
async function gpuRefreshStatus() {
const btnRefresh = document.querySelector('.gpu-btn-refresh');
const btnStart = document.getElementById('gpu-btn-start');
const btnStop = document.getElementById('gpu-btn-stop');
try {
if (btnRefresh) btnRefresh.disabled = true;
const status = await gpuFetch('/status');
// Update status badge
const badge = document.getElementById('gpu-status-badge');
const statusText = document.getElementById('gpu-status-text');
const statusDot = badge.querySelector('.gpu-status-dot');
badge.className = 'gpu-status-badge ' + status.status;
statusText.textContent = formatGpuStatus(status.status);
statusDot.className = 'gpu-status-dot ' + status.status;
// Update info cards
document.getElementById('gpu-name').textContent = status.gpu_name || '-';
document.getElementById('gpu-dph').textContent = status.dph_total
? `$${status.dph_total.toFixed(2)}/h`
: '-';
// Update endpoint
const endpointDiv = document.getElementById('gpu-endpoint');
const endpointUrl = document.getElementById('gpu-endpoint-url');
if (status.endpoint_base_url && status.status === 'running') {
endpointDiv.style.display = 'block';
endpointDiv.className = 'gpu-endpoint-url active';
endpointUrl.textContent = status.endpoint_base_url;
} else {
endpointDiv.style.display = 'none';
}
// Update shutdown warning
const warningDiv = document.getElementById('gpu-shutdown-warning');
const shutdownMinutes = document.getElementById('gpu-shutdown-minutes');
if (status.auto_shutdown_in_minutes !== null && status.status === 'running') {
warningDiv.style.display = 'flex';
shutdownMinutes.textContent = status.auto_shutdown_in_minutes;
} else {
warningDiv.style.display = 'none';
}
// Update totals
document.getElementById('gpu-total-runtime').textContent = formatRuntime(status.total_runtime_hours || 0);
document.getElementById('gpu-total-cost-all').textContent = `$${(status.total_cost_usd || 0).toFixed(2)}`;
// Update buttons
const isRunning = status.status === 'running';
const isLoading = ['loading', 'scheduling', 'creating'].includes(status.status);
btnStart.disabled = isRunning || isLoading;
btnStop.disabled = !isRunning;
if (isLoading) {
btnStart.innerHTML = '<span class="gpu-spinner"></span> Startet...';
} else {
btnStart.innerHTML = `
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3l14 9-14 9V3z"/>
</svg>
Starten
`;
}
// Load audit log
loadGpuAuditLog();
} catch (error) {
console.error('Failed to refresh GPU status:', error);
document.getElementById('gpu-status-text').textContent = 'Fehler';
document.getElementById('gpu-status-badge').className = 'gpu-status-badge error';
} finally {
if (btnRefresh) btnRefresh.disabled = false;
}
}
async function gpuPowerOn() {
const btnStart = document.getElementById('gpu-btn-start');
const btnStop = document.getElementById('gpu-btn-stop');
if (!confirm('GPU-Instanz starten? Es fallen Kosten an solange sie laeuft.')) {
return;
}
try {
btnStart.disabled = true;
btnStop.disabled = true;
btnStart.innerHTML = '<span class="gpu-spinner"></span> Startet...';
const result = await gpuFetch('/power/on', {
method: 'POST',
body: JSON.stringify({
wait_for_health: true,
}),
});
showNotification('GPU gestartet: ' + (result.message || result.status), 'success');
await gpuRefreshStatus();
// Start polling for status updates
startGpuStatusPolling();
} catch (error) {
showNotification('GPU Start fehlgeschlagen: ' + error.message, 'error');
await gpuRefreshStatus();
}
}
async function gpuPowerOff() {
const btnStart = document.getElementById('gpu-btn-start');
const btnStop = document.getElementById('gpu-btn-stop');
if (!confirm('GPU-Instanz stoppen?')) {
return;
}
try {
btnStart.disabled = true;
btnStop.disabled = true;
btnStop.innerHTML = '<span class="gpu-spinner"></span> Stoppt...';
const result = await gpuFetch('/power/off', {
method: 'POST',
body: JSON.stringify({}),
});
const msg = result.session_runtime_minutes
? `GPU gestoppt. Session: ${result.session_runtime_minutes.toFixed(1)} min, $${result.session_cost_usd.toFixed(3)}`
: 'GPU gestoppt';
showNotification(msg, 'success');
await gpuRefreshStatus();
// Stop polling
stopGpuStatusPolling();
} catch (error) {
showNotification('GPU Stop fehlgeschlagen: ' + error.message, 'error');
await gpuRefreshStatus();
} finally {
btnStop.innerHTML = `
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z"/>
</svg>
Stoppen
`;
}
}
async function loadGpuAuditLog() {
try {
const entries = await gpuFetch('/audit?limit=20');
const container = document.getElementById('gpu-audit-log');
if (!entries || entries.length === 0) {
container.innerHTML = '<div class="gpu-audit-entry">Keine Eintraege</div>';
return;
}
container.innerHTML = entries.map(entry => {
const time = new Date(entry.ts).toLocaleString('de-DE', {
hour: '2-digit',
minute: '2-digit',
day: '2-digit',
month: '2-digit',
});
return `
<div class="gpu-audit-entry">
<span class="gpu-audit-time">${time}</span>
<span class="gpu-audit-event">${entry.event}</span>
</div>
`;
}).join('');
} catch (error) {
console.error('Failed to load audit log:', error);
}
}
function formatGpuStatus(status) {
const statusMap = {
'running': 'Laeuft',
'stopped': 'Gestoppt',
'exited': 'Beendet',
'loading': 'Laedt...',
'scheduling': 'Plane...',
'creating': 'Erstelle...',
'unconfigured': 'Nicht konfiguriert',
'not_found': 'Nicht gefunden',
'unknown': 'Unbekannt',
'error': 'Fehler',
};
return statusMap[status] || status;
}
function formatRuntime(hours) {
const h = Math.floor(hours);
const m = Math.round((hours - h) * 60);
return `${h}h ${m}m`;
}
function startGpuStatusPolling() {
stopGpuStatusPolling();
gpuStatusInterval = setInterval(gpuRefreshStatus, 30000); // Every 30s
}
function stopGpuStatusPolling() {
if (gpuStatusInterval) {
clearInterval(gpuStatusInterval);
gpuStatusInterval = null;
}
}
function showNotification(message, type = 'info') {
// Use existing notification system if available
if (window.showToast) {
window.showToast(message, type);
return;
}
// Fallback
alert(message);
}
// Initialize when GPU tab is activated
document.addEventListener('DOMContentLoaded', () => {
const gpuTab = document.querySelector('.admin-tab[data-tab="gpu"]');
if (gpuTab) {
gpuTab.addEventListener('click', () => {
gpuRefreshStatus();
startGpuStatusPolling();
});
}
});
"""

View File

@@ -0,0 +1,629 @@
"""
Admin Klausurkorrektur Documentation Component.
Provides audit-ready documentation for education ministries and data protection officers.
Written in non-technical language for compliance review.
This component explains:
- Privacy-by-Design architecture
- DSGVO compliance measures
- Data flow and processing
- Security guarantees
"""
def get_admin_klausur_docs_css() -> str:
"""CSS for Klausur Documentation Panel."""
return """
/* ==========================================
KLAUSUR DOCUMENTATION STYLES (Audit-Ready)
========================================== */
.klausur-docs-panel {
background: var(--bp-surface-elevated);
border-radius: 12px;
padding: 24px;
margin-bottom: 20px;
border: 1px solid var(--bp-border);
}
.klausur-docs-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid var(--bp-border);
}
.klausur-docs-header h2 {
margin: 0;
font-size: 20px;
color: var(--bp-text);
display: flex;
align-items: center;
gap: 12px;
}
.audit-badge {
background: #065f46;
color: #6ee7b7;
font-size: 11px;
padding: 4px 10px;
border-radius: 4px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.doc-section {
margin-bottom: 32px;
}
.doc-section h3 {
color: var(--bp-text);
font-size: 16px;
margin: 0 0 16px 0;
padding-bottom: 8px;
border-bottom: 1px solid var(--bp-border-subtle);
display: flex;
align-items: center;
gap: 8px;
}
.doc-section h3 .section-number {
background: var(--bp-primary);
color: white;
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
}
.doc-content {
color: var(--bp-text-muted);
font-size: 14px;
line-height: 1.8;
}
.doc-content p {
margin: 0 0 12px 0;
}
.doc-content ul, .doc-content ol {
margin: 12px 0;
padding-left: 24px;
}
.doc-content li {
margin-bottom: 8px;
}
.highlight-box {
background: rgba(6, 95, 70, 0.15);
border: 1px solid #065f46;
border-radius: 8px;
padding: 16px;
margin: 16px 0;
}
.highlight-box.green {
background: rgba(6, 95, 70, 0.15);
border-color: #065f46;
}
.highlight-box.blue {
background: rgba(59, 130, 246, 0.1);
border-color: #3b82f6;
}
.highlight-box.yellow {
background: rgba(251, 191, 36, 0.1);
border-color: #fbbf24;
}
.highlight-box h4 {
color: inherit;
margin: 0 0 8px 0;
font-size: 14px;
}
.highlight-box p, .highlight-box ul {
margin: 0;
color: inherit;
}
.highlight-box.green { color: #6ee7b7; }
.highlight-box.blue { color: #60a5fa; }
.highlight-box.yellow { color: #fbbf24; }
.data-flow-diagram {
background: var(--bp-surface);
border: 1px solid var(--bp-border);
border-radius: 8px;
padding: 20px;
margin: 16px 0;
font-family: monospace;
font-size: 12px;
overflow-x: auto;
white-space: pre;
color: var(--bp-text);
line-height: 1.6;
}
.legal-reference {
background: var(--bp-surface);
border-left: 3px solid var(--bp-primary);
padding: 12px 16px;
margin: 16px 0;
font-style: italic;
color: var(--bp-text-muted);
}
.legal-reference cite {
display: block;
margin-top: 8px;
font-style: normal;
font-weight: 600;
color: var(--bp-text);
}
.comparison-table {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
font-size: 13px;
}
.comparison-table th,
.comparison-table td {
padding: 12px;
text-align: left;
border: 1px solid var(--bp-border);
}
.comparison-table th {
background: var(--bp-surface);
color: var(--bp-text);
font-weight: 600;
}
.comparison-table td {
color: var(--bp-text-muted);
}
.comparison-table .good {
color: #22c55e;
}
.comparison-table .bad {
color: #ef4444;
}
.tech-specs {
background: var(--bp-surface);
border-radius: 8px;
padding: 16px;
margin: 16px 0;
}
.tech-specs h4 {
margin: 0 0 12px 0;
color: var(--bp-text);
font-size: 14px;
}
.tech-specs table {
width: 100%;
font-size: 12px;
}
.tech-specs td {
padding: 6px 0;
color: var(--bp-text-muted);
}
.tech-specs td:first-child {
color: var(--bp-text);
font-weight: 500;
width: 40%;
}
.print-button {
background: var(--bp-surface);
border: 1px solid var(--bp-border);
color: var(--bp-text);
padding: 10px 20px;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
display: flex;
align-items: center;
gap: 8px;
}
.print-button:hover {
background: var(--bp-border);
}
@media print {
.klausur-docs-panel {
background: white;
color: black;
border: none;
}
.doc-content, .highlight-box, .tech-specs td {
color: black !important;
}
.print-button {
display: none;
}
}
"""
def get_admin_klausur_docs_html() -> str:
"""HTML for Klausur Documentation (Audit-Ready)."""
return """
<!-- Klausur Documentation Tab Content (Audit-Ready) -->
<div id="admin-content-klausur-docs" class="admin-content">
<div class="klausur-docs-panel">
<div class="klausur-docs-header">
<h2>
Datenschutz-Dokumentation Klausurkorrektur
<span class="audit-badge">Audit-Ready</span>
</h2>
<button class="print-button" onclick="window.print()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M6 9V2h12v7M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/>
<rect x="6" y="14" width="12" height="8"/>
</svg>
Drucken / PDF
</button>
</div>
<!-- Section 1: Overview -->
<div class="doc-section">
<h3>
<span class="section-number">1</span>
Zusammenfassung fuer Entscheidungstraeger
</h3>
<div class="doc-content">
<div class="highlight-box green">
<h4>Kernaussage</h4>
<p>
Die KI-gestuetzte Klausurkorrektur verarbeitet <strong>keine personenbezogenen Daten</strong>
ausserhalb des Geraets der Lehrkraft. Die Verarbeitung ist datenschutzrechtlich
vergleichbar mit einer Korrektur auf dem eigenen PC der Lehrkraft.
</p>
</div>
<p>
Dieses System wurde nach dem Prinzip <strong>"Privacy by Design"</strong> entwickelt.
Schueler-Namen werden niemals an Server oder KI-Systeme uebermittelt.
Stattdessen werden zufaellige Dokumenten-Tokens (Pseudonyme) verwendet,
die nur die Lehrkraft lokal wieder aufloesen kann.
</p>
</div>
</div>
<!-- Section 2: Legal Basis -->
<div class="doc-section">
<h3>
<span class="section-number">2</span>
Rechtsgrundlage und DSGVO-Konformitaet
</h3>
<div class="doc-content">
<div class="legal-reference">
"Pseudonymisierung bezeichnet die Verarbeitung personenbezogener Daten in einer Weise,
dass die personenbezogenen Daten ohne Hinzuziehung zusaetzlicher Informationen nicht
mehr einer spezifischen betroffenen Person zugeordnet werden koennen."
<cite>— Art. 4 Nr. 5 DSGVO (Begriffsbestimmung Pseudonymisierung)</cite>
</div>
<p><strong>Anwendung auf unser System:</strong></p>
<ul>
<li>
<strong>Pseudonymisierung:</strong> Jedes Klausur-Dokument erhaelt einen
zufaelligen 128-Bit Token (UUID), der keinerlei Beziehung zur Schueleridentitaet hat.
</li>
<li>
<strong>Getrennte Speicherung:</strong> Die Zuordnung Token → Name wird
ausschliesslich lokal beim Lehrer gespeichert (verschluesselt mit AES-256).
</li>
<li>
<strong>Kein Zugriff:</strong> Der Server und die KI haben keinen Zugang
zur Zuordnungstabelle und koennen somit keine Re-Identifizierung durchfuehren.
</li>
</ul>
<div class="highlight-box blue">
<h4>Rechtliche Einordnung</h4>
<p>
Da die KI nur pseudonymisierte Texte ohne jeglichen Personenbezug verarbeitet,
handelt es sich aus Sicht der KI-Verarbeitung um <strong>anonyme Daten</strong>.
Die DSGVO gilt nicht fuer anonyme Daten (Erwaegungsgrund 26).
</p>
</div>
</div>
</div>
<!-- Section 3: Data Flow -->
<div class="doc-section">
<h3>
<span class="section-number">3</span>
Datenfluss und Verarbeitungsschritte
</h3>
<div class="doc-content">
<div class="data-flow-diagram">
┌─────────────────────────────────────────────────────────────────────────────────┐
│ DATENFLUSS KLAUSURKORREKTUR │
├─────────────────────────────────────────────────────────────────────────────────┤
│ │
│ LEHRER-GERAET (Browser) SERVER (BreakPilot) │
│ ──────────────────────── ───────────────────── │
│ │
│ 1. Schuelerliste eingeben │
│ [Max, Anna, Tim, ...] │
│ │ │
│ ▼ │
│ 2. Lokale Verschluesselung ─────► Verschluesselte Zuordnung │
│ (AES-256, Passwort) (Server kann nicht lesen) │
│ │ │
│ ▼ │
│ 3. QR-Codes generieren ◄───── Zufaellige doc_tokens │
│ [abc123..., def456..., ...] (128-bit UUID) │
│ │ │
│ ▼ │
│ 4. QR-Bogen drucken │
│ │ │
│ ══════════╪══════════════════════════════════════════════════════════════ │
│ │ (Physisch: Klausur mit QR) │
│ ══════════╪══════════════════════════════════════════════════════════════ │
│ │ │
│ 5. Scan hochladen ─────► 6. QR erkennen │
│ (Bild der Klausur) │ │
│ ▼ │
│ 7. Kopfzeile entfernen │
│ (Name/Klasse redacted) │
│ │ │
│ ▼ │
│ 8. OCR + KI-Korrektur │
│ (Nur Token + Text) │
│ │ │
│ ▼ │
│ 10. Lokal entschluesseln ◄───── 9. Pseudonymisierte Ergebnisse │
│ + Namen zuordnen [abc123: Note 2+, ...] │
│ │ │
│ ▼ │
│ 11. Ergebnis: Max = 2+ │
│ │
└─────────────────────────────────────────────────────────────────────────────────┘
LEGENDE:
─────► Verschluesselte oder pseudonymisierte Daten
═══════ Physischer Medienbruch (Papier)
Server kann NIEMALS: Schuelernamen sehen, Zuordnung aufloesen, Identitaet bestimmen
</div>
<p><strong>Erklaerung der Schritte:</strong></p>
<ol>
<li><strong>Schuelerliste:</strong> Der Lehrer gibt die Namen seiner Schueler ein. Diese verlassen das Geraet nicht.</li>
<li><strong>Verschluesselung:</strong> Die Zuordnung wird mit dem Passwort des Lehrers verschluesselt (AES-256-GCM).</li>
<li><strong>QR-Codes:</strong> Fuer jeden Schueler wird ein zufaelliger Token generiert. Kein Zusammenhang zum Namen.</li>
<li><strong>Drucken:</strong> Der QR-Bogen wird ausgedruckt. Schueler kleben ihren QR auf die Klausur.</li>
<li><strong>Scan:</strong> Die korrigierten Klausuren werden gescannt.</li>
<li><strong>QR-Erkennung:</strong> Der QR-Code wird automatisch erkannt, um das Dokument zuzuordnen.</li>
<li><strong>Redaction:</strong> Die Kopfzeile mit Name/Klasse wird automatisch geschwärzt.</li>
<li><strong>KI-Korrektur:</strong> Die KI sieht nur den anonymen Text und den Token - niemals einen Namen.</li>
<li><strong>Ergebnis:</strong> Das Ergebnis wird mit dem Token gespeichert (z.B. "abc123: 85 Punkte").</li>
<li><strong>Entschluesselung:</strong> Nur der Lehrer kann mit seinem Passwort die Zuordnung wiederherstellen.</li>
</ol>
</div>
</div>
<!-- Section 4: Comparison -->
<div class="doc-section">
<h3>
<span class="section-number">4</span>
Vergleich mit anderen Loesungen
</h3>
<div class="doc-content">
<table class="comparison-table">
<thead>
<tr>
<th>Kriterium</th>
<th>BreakPilot (diese Loesung)</th>
<th>Typische Cloud-KI (z.B. NovaGrade)</th>
</tr>
</thead>
<tbody>
<tr>
<td>Schuelernamen an Server</td>
<td class="good">Nein (nur Tokens)</td>
<td class="bad">Ja</td>
</tr>
<tr>
<td>Schuelernamen an KI</td>
<td class="good">Nein</td>
<td class="bad">Ja (OpenAI/Claude API)</td>
</tr>
<tr>
<td>Datenspeicherung</td>
<td class="good">Self-Hosted (SysEleven, Deutschland)</td>
<td class="bad">US-Cloud (OpenAI, AWS)</td>
</tr>
<tr>
<td>Zuordnung abrufbar durch</td>
<td class="good">Nur Lehrer (verschluesselt)</td>
<td class="bad">Anbieter, evtl. Dritte</td>
</tr>
<tr>
<td>DSGVO-Risiko</td>
<td class="good">Minimal (pseudonymisiert)</td>
<td class="bad">Hoch (personenbezogen)</td>
</tr>
<tr>
<td>Vergleichbar mit</td>
<td class="good">Lokale PC-Korrektur</td>
<td class="bad">Cloud-Datenuebermittlung</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Section 5: Technical Security -->
<div class="doc-section">
<h3>
<span class="section-number">5</span>
Technische Sicherheitsmassnahmen
</h3>
<div class="doc-content">
<div class="tech-specs">
<h4>Kryptographische Parameter</h4>
<table>
<tr><td>Token-Generierung</td><td>UUID v4 (128-bit kryptographisch zufaellig)</td></tr>
<tr><td>Verschluesselung</td><td>AES-256-GCM (authentifizierte Verschluesselung)</td></tr>
<tr><td>Schluesselableitung</td><td>PBKDF2-SHA256, 100.000 Iterationen</td></tr>
<tr><td>Salt</td><td>128-bit zufaellig pro Sitzung</td></tr>
<tr><td>IV (Initialisierungsvektor)</td><td>96-bit zufaellig pro Verschluesselung</td></tr>
</table>
</div>
<div class="tech-specs">
<h4>Infrastruktur</h4>
<table>
<tr><td>LLM-Hosting</td><td>SysEleven, Berlin (DE)</td></tr>
<tr><td>Datenbank</td><td>PostgreSQL mit Verschluesselung at rest</td></tr>
<tr><td>Netzwerk</td><td>TLS 1.3, kein Datenverkehr zu US-Servern</td></tr>
<tr><td>Datenloeschung</td><td>Automatisch nach 30 Tagen</td></tr>
</table>
</div>
<div class="tech-specs">
<h4>Zugangskontrolle</h4>
<table>
<tr><td>Lehrer-Isolation</td><td>Strikte Mandantentrennung (teacher_id Filter)</td></tr>
<tr><td>Kein Admin-Zugriff auf Zuordnung</td><td>Zero-Knowledge-Design</td></tr>
<tr><td>Audit-Log</td><td>Alle Zugriffe protokolliert (ohne PII)</td></tr>
</table>
</div>
</div>
</div>
<!-- Section 6: Risk Assessment -->
<div class="doc-section">
<h3>
<span class="section-number">6</span>
Risikobewertung
</h3>
<div class="doc-content">
<p><strong>Potenzielle Risiken und Gegenmassnahmen:</strong></p>
<table class="comparison-table">
<thead>
<tr>
<th>Risiko</th>
<th>Bewertung</th>
<th>Gegenmassnahme</th>
</tr>
</thead>
<tbody>
<tr>
<td>Re-Identifizierung durch Server</td>
<td class="good">Technisch unmoeglich</td>
<td>Server hat keinen Zugang zur Zuordnung (Zero-Knowledge)</td>
</tr>
<tr>
<td>Datenverlust</td>
<td class="good">Kein Personenbezug</td>
<td>Gestohlene Daten enthalten nur Tokens, keine Namen</td>
</tr>
<tr>
<td>Passwort-Verlust durch Lehrer</td>
<td style="color: #fbbf24;">Mittel</td>
<td>Lokale Backup-Kopie im Browser (localStorage), Neustart moeglich</td>
</tr>
<tr>
<td>KI-Halluzination</td>
<td class="good">Kein Datenschutzrisiko</td>
<td>Lehrer prueft und korrigiert Ergebnisse manuell</td>
</tr>
</tbody>
</table>
<div class="highlight-box yellow">
<h4>Wichtiger Hinweis</h4>
<p>
Bei Verlust des Vault-Passworts kann die Zuordnung Token → Name nicht wiederhergestellt werden.
Die Lehrkraft sollte das Passwort sicher aufbewahren oder eine lokale Sicherung erstellen.
</p>
</div>
</div>
</div>
<!-- Section 7: Conclusion -->
<div class="doc-section">
<h3>
<span class="section-number">7</span>
Fazit und Empfehlung
</h3>
<div class="doc-content">
<div class="highlight-box green">
<h4>Bewertung: Datenschutzkonform</h4>
<p>
Die Klausurkorrektur mit BreakPilot ist <strong>vergleichbar mit einer lokalen Verarbeitung
auf dem PC der Lehrkraft</strong>. Personenbezogene Daten verlassen das Geraet der Lehrkraft
nicht. Die KI-Verarbeitung erfolgt ausschliesslich auf pseudonymisierten Daten.
</p>
<ul>
<li>Keine Uebermittlung personenbezogener Daten an Dritte</li>
<li>Kein US-Cloud-Transfer (Schrems II konform)</li>
<li>Volle Kontrolle der Lehrkraft ueber die Identitaets-Zuordnung</li>
<li>Automatische Datenloeschung nach 30 Tagen</li>
</ul>
</div>
<p>
<strong>Empfehlung:</strong> Das System kann ohne zusaetzliche Genehmigung oder
Datenschutz-Folgenabschaetzung (DSFA) eingesetzt werden, da keine hohen Risiken
fuer die Rechte und Freiheiten der Betroffenen bestehen.
</p>
</div>
</div>
<!-- Version Info -->
<div style="margin-top: 32px; padding-top: 16px; border-top: 1px solid var(--bp-border); color: var(--bp-text-muted); font-size: 12px;">
<p>
<strong>Dokumentversion:</strong> 1.0 |
<strong>Stand:</strong> <span id="doc-date"></span> |
<strong>System:</strong> BreakPilot Klausurkorrektur |
<strong>Kontakt:</strong> datenschutz@breakpilot.de
</p>
</div>
</div>
</div>
<script>
// Set current date
document.getElementById('doc-date').textContent = new Date().toLocaleDateString('de-DE');
</script>
"""
def get_admin_klausur_docs_js() -> str:
"""JavaScript for Klausur Documentation Panel."""
return """
// Klausur Documentation Panel
// No interactive JS needed - documentation is static
// Print functionality uses native window.print()
console.log('Klausur Documentation loaded - Audit-ready version');
"""

View File

@@ -0,0 +1,24 @@
"""
Admin Panel Component - Legacy Compatibility Wrapper
This file provides backward compatibility for code importing from admin_panel.py.
All functionality has been moved to the admin_panel/ module.
For new code, import directly from:
from frontend.components.admin_panel import get_admin_panel_css
from frontend.components.admin_panel import get_admin_panel_html
from frontend.components.admin_panel import get_admin_panel_js
"""
# Re-export all public APIs from the modular structure
from .admin_panel import (
get_admin_panel_css,
get_admin_panel_html,
get_admin_panel_js,
)
__all__ = [
"get_admin_panel_css",
"get_admin_panel_html",
"get_admin_panel_js",
]

View File

@@ -0,0 +1,22 @@
"""
Admin Panel Module
Modular structure for the Admin Panel component.
Contains CSS, HTML, and JavaScript for the consent admin panel.
Modular Refactoring (2026-02-03):
- Split into sub-modules for maintainability
- Original file: admin_panel.py (2,977 lines)
- Now split into: styles.py, markup.py, scripts.py
"""
# Re-export the main functions for backward compatibility
from .styles import get_admin_panel_css
from .markup import get_admin_panel_html
from .scripts import get_admin_panel_js
__all__ = [
"get_admin_panel_css",
"get_admin_panel_html",
"get_admin_panel_js",
]

View File

@@ -0,0 +1,831 @@
"""
Admin Panel Component - HTML Markup
Extracted from admin_panel.py for maintainability.
Contains all HTML templates for the admin panel modal, tabs, forms, and dialogs.
"""
def get_admin_panel_html() -> str:
"""HTML fuer Admin Panel zurueckgeben"""
return """
<!-- Admin Panel Modal -->
<div id="admin-modal" class="admin-modal">
<div class="admin-modal-content">
<div class="admin-modal-header">
<h2><span>⚙️</span> Consent Admin Panel</h2>
<button id="admin-modal-close" class="legal-modal-close">&times;</button>
</div>
<div class="admin-tabs">
<button class="admin-tab active" data-tab="documents">Dokumente</button>
<button class="admin-tab" data-tab="versions">Versionen</button>
<button class="admin-tab" data-tab="cookies">Cookie-Kategorien</button>
<button class="admin-tab" data-tab="stats">Statistiken</button>
<button class="admin-tab" data-tab="emails">E-Mail Vorlagen</button>
<button class="admin-tab" data-tab="dsms">DSMS</button>
<button class="admin-tab" data-tab="gpu">GPU Infra</button>
</div>
<div class="admin-body">
<!-- Documents Tab -->
<div id="admin-documents" class="admin-content active">
<div class="admin-toolbar">
<div class="admin-toolbar-left">
<input type="text" class="admin-search" placeholder="Dokumente suchen..." id="admin-doc-search">
</div>
<button class="btn btn-primary btn-sm" onclick="showDocumentForm()">+ Neues Dokument</button>
</div>
<!-- Document Creation Form -->
<div id="admin-document-form" class="admin-form" style="display: none;">
<h3 class="admin-form-title" id="admin-document-form-title">Neues Dokument erstellen</h3>
<input type="hidden" id="admin-document-id">
<div class="admin-form-row">
<div class="admin-form-group">
<label class="admin-form-label">Dokumenttyp *</label>
<select class="admin-form-select" id="admin-document-type">
<option value="">-- Typ auswählen --</option>
<option value="terms">AGB (Allgemeine Geschäftsbedingungen)</option>
<option value="privacy">Datenschutzerklärung</option>
<option value="cookies">Cookie-Richtlinie</option>
<option value="community">Community Guidelines</option>
<option value="imprint">Impressum</option>
</select>
</div>
<div class="admin-form-group">
<label class="admin-form-label">Name *</label>
<input type="text" class="admin-form-input" id="admin-document-name" placeholder="z.B. Allgemeine Geschäftsbedingungen">
</div>
</div>
<div class="admin-form-row">
<div class="admin-form-group full-width">
<label class="admin-form-label">Beschreibung</label>
<input type="text" class="admin-form-input" id="admin-document-description" placeholder="Kurze Beschreibung des Dokuments">
</div>
</div>
<div class="admin-form-row">
<div class="admin-form-group">
<label class="admin-form-label">
<input type="checkbox" id="admin-document-mandatory" style="margin-right: 8px;">
Pflichtdokument (Nutzer müssen zustimmen)
</label>
</div>
</div>
<div class="admin-form-actions">
<button class="btn btn-ghost btn-sm" onclick="hideDocumentForm()">Abbrechen</button>
<button class="btn btn-primary btn-sm" onclick="saveDocument()">Dokument erstellen</button>
</div>
</div>
<div id="admin-doc-table-container">
<div class="admin-loading">Lade Dokumente...</div>
</div>
</div>
<!-- Versions Tab -->
<div id="admin-versions" class="admin-content">
<div class="admin-toolbar">
<div class="admin-toolbar-left">
<select class="admin-form-select" id="admin-version-doc-select" onchange="loadVersionsForDocument()">
<option value="">-- Dokument auswählen --</option>
</select>
</div>
<button class="btn btn-primary btn-sm" onclick="showVersionForm()" id="btn-new-version" disabled>+ Neue Version</button>
</div>
<div id="admin-version-form" class="admin-form">
<h3 class="admin-form-title" id="admin-version-form-title">Neue Version erstellen</h3>
<input type="hidden" id="admin-version-id">
<div class="admin-form-row">
<div class="admin-form-group">
<label class="admin-form-label">Version *</label>
<input type="text" class="admin-form-input" id="admin-version-number" placeholder="z.B. 1.0.0">
</div>
<div class="admin-form-group">
<label class="admin-form-label">Sprache *</label>
<select class="admin-form-select" id="admin-version-lang">
<option value="de">Deutsch</option>
<option value="en">English</option>
</select>
</div>
</div>
<div class="admin-form-row">
<div class="admin-form-group full-width">
<label class="admin-form-label">Titel *</label>
<input type="text" class="admin-form-input" id="admin-version-title" placeholder="Titel der Version">
</div>
</div>
<div class="admin-form-row">
<div class="admin-form-group full-width">
<label class="admin-form-label">Zusammenfassung</label>
<input type="text" class="admin-form-input" id="admin-version-summary" placeholder="Kurze Zusammenfassung der Änderungen">
</div>
</div>
<div class="admin-form-row">
<div class="admin-form-group full-width">
<label class="admin-form-label">Inhalt *</label>
<div class="editor-container">
<div class="editor-toolbar">
<div class="editor-toolbar-group">
<button type="button" class="editor-btn" onclick="formatDoc('bold')" title="Fett (Strg+B)"><b>B</b></button>
<button type="button" class="editor-btn" onclick="formatDoc('italic')" title="Kursiv (Strg+I)"><i>I</i></button>
<button type="button" class="editor-btn" onclick="formatDoc('underline')" title="Unterstrichen (Strg+U)"><u>U</u></button>
</div>
<div class="editor-toolbar-group">
<button type="button" class="editor-btn" onclick="formatBlock('h1')" title="Überschrift 1">H1</button>
<button type="button" class="editor-btn" onclick="formatBlock('h2')" title="Überschrift 2">H2</button>
<button type="button" class="editor-btn" onclick="formatBlock('h3')" title="Überschrift 3">H3</button>
<button type="button" class="editor-btn" onclick="formatBlock('p')" title="Absatz">P</button>
</div>
<div class="editor-toolbar-group">
<button type="button" class="editor-btn" onclick="formatDoc('insertUnorderedList')" title="Aufzählung">• Liste</button>
<button type="button" class="editor-btn" onclick="formatDoc('insertOrderedList')" title="Nummerierung">1. Liste</button>
</div>
<div class="editor-toolbar-group">
<button type="button" class="editor-btn" onclick="insertLink()" title="Link einfügen">🔗</button>
<button type="button" class="editor-btn" onclick="formatDoc('formatBlock', 'blockquote')" title="Zitat">❝</button>
<button type="button" class="editor-btn" onclick="formatDoc('insertHorizontalRule')" title="Trennlinie">—</button>
</div>
<div class="editor-toolbar-group">
<button type="button" class="editor-btn editor-btn-upload" onclick="document.getElementById('word-upload').click()" title="Word-Dokument importieren">📄 Word Import</button>
<input type="file" id="word-upload" class="word-upload-input" accept=".docx,.doc" onchange="handleWordUpload(event)">
</div>
</div>
<div id="admin-version-editor" class="editor-content" contenteditable="true" placeholder="Schreiben Sie hier den Inhalt..."></div>
<div class="editor-status">
<span id="editor-char-count">0 Zeichen</span> |
<span style="color: var(--bp-text-muted);">Tipp: Sie können direkt aus Word kopieren und einfügen!</span>
</div>
</div>
<input type="hidden" id="admin-version-content">
</div>
</div>
<div class="admin-form-actions">
<button class="btn btn-ghost btn-sm" onclick="hideVersionForm()">Abbrechen</button>
<button class="btn btn-primary btn-sm" onclick="saveVersion()">Speichern</button>
</div>
</div>
<div id="admin-version-table-container">
<div class="admin-empty">Wählen Sie ein Dokument aus, um dessen Versionen anzuzeigen.</div>
</div>
<!-- Approval Dialog -->
<div id="approval-dialog" class="admin-dialog">
<div class="admin-dialog-content">
<h3>Version genehmigen</h3>
<p class="admin-dialog-info">
Legen Sie einen Veröffentlichungszeitpunkt fest. Die Version wird automatisch zum gewählten Zeitpunkt veröffentlicht.
</p>
<div class="admin-form-row">
<div class="admin-form-group">
<label class="admin-form-label">Veröffentlichungsdatum *</label>
<input type="date" class="admin-form-input" id="approval-date" required>
</div>
<div class="admin-form-group">
<label class="admin-form-label">Uhrzeit</label>
<input type="time" class="admin-form-input" id="approval-time" value="00:00">
</div>
</div>
<div class="admin-form-row">
<div class="admin-form-group full-width">
<label class="admin-form-label">Kommentar (optional)</label>
<input type="text" class="admin-form-input" id="approval-comment" placeholder="z.B. Genehmigt nach rechtlicher Prüfung">
</div>
</div>
<div class="admin-dialog-actions">
<button class="btn btn-ghost btn-sm" onclick="hideApprovalDialog()">Abbrechen</button>
<button class="btn btn-primary btn-sm" onclick="submitApproval()">Genehmigen & Planen</button>
</div>
</div>
</div>
</div>
<!-- Version Compare View (Full Screen Overlay) -->
<div id="version-compare-view" class="version-compare-overlay">
<div class="version-compare-header">
<h2>Versionsvergleich</h2>
<div class="version-compare-info">
<span id="compare-published-info"></span>
<span class="compare-vs">vs</span>
<span id="compare-draft-info"></span>
</div>
<button class="btn btn-ghost" onclick="hideCompareView()">Schließen</button>
</div>
<div class="version-compare-container">
<div class="version-compare-panel">
<div class="version-compare-panel-header">
<span class="compare-label compare-label-published">Veröffentlichte Version</span>
<span id="compare-published-version"></span>
</div>
<div class="version-compare-content" id="compare-content-left"></div>
</div>
<div class="version-compare-panel">
<div class="version-compare-panel-header">
<span class="compare-label compare-label-draft">Neue Version</span>
<span id="compare-draft-version"></span>
</div>
<div class="version-compare-content" id="compare-content-right"></div>
</div>
</div>
<div class="version-compare-footer">
<div id="compare-history-container"></div>
<div id="compare-actions-container" style="display: flex; gap: 12px; justify-content: flex-end; margin-top: 12px;"></div>
</div>
</div>
<!-- Cookie Categories Tab -->
<div id="admin-cookies" class="admin-content">
<div class="admin-toolbar">
<div class="admin-toolbar-left">
<span style="color: var(--bp-text-muted);">Cookie-Kategorien verwalten</span>
</div>
<button class="btn btn-primary btn-sm" onclick="showCookieForm()">+ Neue Kategorie</button>
</div>
<div id="admin-cookie-form" class="admin-form">
<h3 class="admin-form-title">Neue Cookie-Kategorie</h3>
<input type="hidden" id="admin-cookie-id">
<div class="admin-form-row">
<div class="admin-form-group">
<label class="admin-form-label">Technischer Name *</label>
<input type="text" class="admin-form-input" id="admin-cookie-name" placeholder="z.B. analytics">
</div>
<div class="admin-form-group">
<label class="admin-form-label">Anzeigename (DE) *</label>
<input type="text" class="admin-form-input" id="admin-cookie-display-de" placeholder="z.B. Analyse-Cookies">
</div>
</div>
<div class="admin-form-row">
<div class="admin-form-group">
<label class="admin-form-label">Anzeigename (EN)</label>
<input type="text" class="admin-form-input" id="admin-cookie-display-en" placeholder="z.B. Analytics Cookies">
</div>
<div class="admin-form-group">
<label class="admin-form-label">
<input type="checkbox" id="admin-cookie-mandatory"> Notwendig (kann nicht deaktiviert werden)
</label>
</div>
</div>
<div class="admin-form-row">
<div class="admin-form-group full-width">
<label class="admin-form-label">Beschreibung (DE)</label>
<input type="text" class="admin-form-input" id="admin-cookie-desc-de" placeholder="Beschreibung auf Deutsch">
</div>
</div>
<div class="admin-form-actions">
<button class="btn btn-ghost btn-sm" onclick="hideCookieForm()">Abbrechen</button>
<button class="btn btn-primary btn-sm" onclick="saveCookieCategory()">Speichern</button>
</div>
</div>
<div id="admin-cookie-table-container">
<div class="admin-loading">Lade Cookie-Kategorien...</div>
</div>
</div>
<!-- Statistics Tab -->
<div id="admin-stats" class="admin-content">
<div id="admin-stats-container">
<div class="admin-loading">Lade Statistiken...</div>
</div>
</div>
""" + _get_email_templates_html() + """
""" + _get_dsms_html() + """
""" + _get_gpu_html() + """
</div>
</div>
</div>
""" + _get_dsms_webui_modal_html() + """
"""
def _get_email_templates_html() -> str:
"""HTML fuer E-Mail Templates Tab"""
return """
<!-- E-Mail Templates Tab -->
<div id="admin-emails" class="admin-content">
<div class="admin-toolbar">
<div class="admin-toolbar-left">
<select class="admin-form-select" id="email-template-select" onchange="loadEmailTemplateVersions()">
<option value="">-- E-Mail-Vorlage auswählen --</option>
</select>
</div>
<button class="btn btn-ghost btn-sm" onclick="initializeEmailTemplates()">Templates initialisieren</button>
<button class="btn btn-primary btn-sm" onclick="showEmailVersionForm()" id="btn-new-email-version" disabled>+ Neue Version</button>
</div>
<!-- E-Mail Template Info Card -->
<div id="email-template-info" style="display: none; margin-bottom: 16px;">
<div style="background: var(--bp-surface-elevated); border-radius: 8px; padding: 16px; border: 1px solid var(--bp-border);">
<div style="display: flex; justify-content: space-between; align-items: start;">
<div>
<h3 style="margin: 0 0 8px 0; font-size: 16px;" id="email-template-name">-</h3>
<p style="margin: 0; color: var(--bp-text-muted); font-size: 13px;" id="email-template-description">-</p>
</div>
<div style="text-align: right;">
<div class="admin-badge" id="email-template-type-badge">-</div>
</div>
</div>
<div style="margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--bp-border);">
<span style="font-size: 12px; color: var(--bp-text-muted);">Variablen: </span>
<span id="email-template-variables" style="font-size: 12px; font-family: monospace; color: var(--bp-primary);"></span>
</div>
</div>
</div>
<!-- E-Mail Version Form -->
<div id="email-version-form" class="admin-form" style="display: none;">
<h3 class="admin-form-title" id="email-version-form-title">Neue E-Mail-Version erstellen</h3>
<input type="hidden" id="email-version-id">
<div class="admin-form-row">
<div class="admin-form-group">
<label class="admin-form-label">Version *</label>
<input type="text" class="admin-form-input" id="email-version-number" placeholder="z.B. 1.0.0">
</div>
<div class="admin-form-group">
<label class="admin-form-label">Sprache *</label>
<select class="admin-form-select" id="email-version-lang">
<option value="de">Deutsch</option>
<option value="en">English</option>
</select>
</div>
</div>
<div class="admin-form-row">
<div class="admin-form-group full-width">
<label class="admin-form-label">Betreff *</label>
<input type="text" class="admin-form-input" id="email-version-subject" placeholder="E-Mail Betreff (kann Variablen enthalten)">
</div>
</div>
<div class="admin-form-row">
<div class="admin-form-group full-width">
<label class="admin-form-label">HTML-Inhalt *</label>
<div class="editor-container">
<div class="editor-toolbar">
<div class="editor-toolbar-group">
<button type="button" class="editor-btn" onclick="formatEmailDoc('bold')" title="Fett"><b>B</b></button>
<button type="button" class="editor-btn" onclick="formatEmailDoc('italic')" title="Kursiv"><i>I</i></button>
<button type="button" class="editor-btn" onclick="formatEmailDoc('underline')" title="Unterstrichen"><u>U</u></button>
</div>
<div class="editor-toolbar-group">
<button type="button" class="editor-btn" onclick="formatEmailBlock('h1')" title="Überschrift 1">H1</button>
<button type="button" class="editor-btn" onclick="formatEmailBlock('h2')" title="Überschrift 2">H2</button>
<button type="button" class="editor-btn" onclick="formatEmailBlock('p')" title="Absatz">P</button>
</div>
<div class="editor-toolbar-group">
<button type="button" class="editor-btn" onclick="insertEmailVariable()" title="Variable einfügen">{{var}}</button>
<button type="button" class="editor-btn" onclick="insertEmailLink()" title="Link einfügen">🔗</button>
<button type="button" class="editor-btn" onclick="insertEmailButton()" title="Button einfügen">🔘</button>
</div>
</div>
<div id="email-version-editor" class="editor-content" contenteditable="true" placeholder="HTML-Inhalt der E-Mail..." style="min-height: 200px;"></div>
</div>
</div>
</div>
<div class="admin-form-row">
<div class="admin-form-group full-width">
<label class="admin-form-label">Text-Version (Plain Text)</label>
<textarea class="admin-form-input" id="email-version-text" rows="5" placeholder="Plain-Text-Version der E-Mail (optional, wird aus HTML generiert falls leer)"></textarea>
</div>
</div>
<div class="admin-form-actions">
<button class="btn btn-ghost btn-sm" onclick="hideEmailVersionForm()">Abbrechen</button>
<button class="btn btn-ghost btn-sm" onclick="previewEmailVersion()">Vorschau</button>
<button class="btn btn-primary btn-sm" onclick="saveEmailVersion()">Speichern</button>
</div>
</div>
<!-- E-Mail Versions Table -->
<div id="email-version-table-container">
<div class="admin-empty">Wählen Sie eine E-Mail-Vorlage aus, um deren Versionen anzuzeigen.</div>
</div>
<!-- E-Mail Preview Dialog -->
<div id="email-preview-dialog" class="admin-dialog" style="display: none;">
<div class="admin-dialog-content" style="max-width: 700px; max-height: 80vh; overflow-y: auto;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
<h3 style="margin: 0;">E-Mail Vorschau</h3>
<button class="btn btn-ghost btn-sm" onclick="hideEmailPreview()">Schließen</button>
</div>
<div style="margin-bottom: 12px;">
<strong>Betreff:</strong> <span id="email-preview-subject"></span>
</div>
<div style="border: 1px solid var(--bp-border); border-radius: 8px; padding: 16px; background: white; color: #333;">
<div id="email-preview-content"></div>
</div>
<div style="margin-top: 16px; display: flex; gap: 8px;">
<input type="email" class="admin-form-input" id="email-test-address" placeholder="Test-E-Mail-Adresse" style="flex: 1;">
<button class="btn btn-primary btn-sm" onclick="sendTestEmail()">Test-E-Mail senden</button>
</div>
</div>
</div>
<!-- E-Mail Approval Dialog -->
<div id="email-approval-dialog" class="admin-dialog" style="display: none;">
<div class="admin-dialog-content">
<h3>E-Mail-Version genehmigen</h3>
<div class="admin-form-row">
<div class="admin-form-group full-width">
<label class="admin-form-label">Kommentar (optional)</label>
<input type="text" class="admin-form-input" id="email-approval-comment" placeholder="z.B. Genehmigt nach Marketing-Prüfung">
</div>
</div>
<div class="admin-dialog-actions">
<button class="btn btn-ghost btn-sm" onclick="hideEmailApprovalDialog()">Abbrechen</button>
<button class="btn btn-primary btn-sm" onclick="submitEmailApproval()">Genehmigen</button>
</div>
</div>
</div>
</div>
"""
def _get_dsms_html() -> str:
"""HTML fuer DSMS Tab"""
return """
<!-- DSMS Tab -->
<div id="admin-dsms" class="admin-content">
<div class="admin-toolbar">
<div class="admin-toolbar-left">
<span style="font-weight: 600; color: var(--bp-primary);">Dezentrales Speichersystem (IPFS)</span>
</div>
<div style="display: flex; gap: 8px;">
<button class="btn btn-ghost btn-sm" onclick="openDsmsWebUI()">DSMS WebUI</button>
<button class="btn btn-primary btn-sm" onclick="loadDsmsData()">Aktualisieren</button>
</div>
</div>
<!-- DSMS Status Cards -->
<div id="dsms-status-cards" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px;">
<div class="admin-loading">Lade DSMS Status...</div>
</div>
<!-- DSMS Tabs -->
<div style="display: flex; gap: 8px; margin-bottom: 16px; border-bottom: 1px solid var(--bp-border); padding-bottom: 8px;">
<button class="dsms-subtab active" data-dsms-tab="archives" onclick="switchDsmsTab('archives')">Archivierte Dokumente</button>
<button class="dsms-subtab" data-dsms-tab="verify" onclick="switchDsmsTab('verify')">Verifizierung</button>
<button class="dsms-subtab" data-dsms-tab="settings" onclick="switchDsmsTab('settings')">Einstellungen</button>
</div>
<!-- Archives Sub-Tab -->
<div id="dsms-archives" class="dsms-content active">
<div class="admin-toolbar" style="margin-bottom: 16px;">
<div class="admin-toolbar-left">
<input type="text" class="admin-search" placeholder="CID suchen..." id="dsms-cid-search" style="width: 300px;">
</div>
<button class="btn btn-primary btn-sm" onclick="showArchiveForm()">+ Dokument archivieren</button>
</div>
<!-- Archive Form -->
<div id="dsms-archive-form" class="admin-form" style="display: none; margin-bottom: 16px;">
<h3 class="admin-form-title">Dokument im DSMS archivieren</h3>
<div class="admin-form-row">
<div class="admin-form-group">
<label class="admin-form-label">Dokument auswählen *</label>
<select class="admin-form-select" id="dsms-archive-doc-select">
<option value="">-- Dokument wählen --</option>
</select>
</div>
<div class="admin-form-group">
<label class="admin-form-label">Version *</label>
<select class="admin-form-select" id="dsms-archive-version-select" disabled>
<option value="">-- Erst Dokument wählen --</option>
</select>
</div>
</div>
<div class="admin-form-actions">
<button class="btn btn-ghost btn-sm" onclick="hideArchiveForm()">Abbrechen</button>
<button class="btn btn-primary btn-sm" onclick="archiveDocumentToDsms()">Archivieren</button>
</div>
</div>
<div id="dsms-archives-table">
<div class="admin-loading">Lade archivierte Dokumente...</div>
</div>
</div>
<!-- Verify Sub-Tab -->
<div id="dsms-verify" class="dsms-content" style="display: none;">
<div style="background: var(--bp-surface-elevated); border-radius: 8px; padding: 24px; border: 1px solid var(--bp-border);">
<h3 style="margin: 0 0 16px 0; font-size: 16px;">Dokumentenintegrität prüfen</h3>
<p style="color: var(--bp-text-muted); margin-bottom: 16px; font-size: 14px;">
Geben Sie einen CID (Content Identifier) ein, um die Integrität eines archivierten Dokuments zu verifizieren.
</p>
<div style="display: flex; gap: 12px; margin-bottom: 16px;">
<input type="text" class="admin-form-input" id="dsms-verify-cid" placeholder="Qm... oder bafy..." style="flex: 1;">
<button class="btn btn-primary btn-sm" onclick="verifyDsmsDocument()">Verifizieren</button>
</div>
<div id="dsms-verify-result" style="display: none;"></div>
</div>
</div>
<!-- Settings Sub-Tab -->
<div id="dsms-settings" class="dsms-content" style="display: none;">
<div style="display: grid; gap: 16px;">
<!-- Node Info -->
<div style="background: var(--bp-surface-elevated); border-radius: 8px; padding: 24px; border: 1px solid var(--bp-border);">
<h3 style="margin: 0 0 16px 0; font-size: 16px;">Node-Informationen</h3>
<div id="dsms-node-info">
<div class="admin-loading">Lade Node-Info...</div>
</div>
</div>
<!-- Quick Links -->
<div style="background: var(--bp-surface-elevated); border-radius: 8px; padding: 24px; border: 1px solid var(--bp-border);">
<h3 style="margin: 0 0 16px 0; font-size: 16px;">Schnellzugriff</h3>
<div style="display: flex; flex-wrap: wrap; gap: 12px;">
<button class="btn btn-primary btn-sm" onclick="openDsmsWebUI()">DSMS WebUI</button>
<a href="http://localhost:8082/docs" target="_blank" class="btn btn-ghost btn-sm">DSMS API Docs</a>
<a href="http://localhost:8085" target="_blank" class="btn btn-ghost btn-sm">IPFS Gateway</a>
</div>
</div>
<!-- Lizenzhinweise -->
<div style="background: var(--bp-surface-elevated); border-radius: 8px; padding: 24px; border: 1px solid var(--bp-border);">
<h3 style="margin: 0 0 16px 0; font-size: 16px;">Open Source Lizenzen</h3>
<p style="color: var(--bp-text-muted); font-size: 13px; margin-bottom: 12px;">
DSMS verwendet folgende Open-Source-Komponenten:
</p>
<ul style="color: var(--bp-text-muted); font-size: 13px; margin: 0; padding-left: 20px;">
<li><strong>IPFS Kubo</strong> - MIT + Apache 2.0 (Dual License) - Protocol Labs, Inc.</li>
<li><strong>IPFS WebUI</strong> - MIT License - Protocol Labs, Inc.</li>
<li><strong>FastAPI</strong> - MIT License</li>
</ul>
<p style="color: var(--bp-text-muted); font-size: 12px; margin-top: 12px; font-style: italic;">
Alle Komponenten erlauben kommerzielle Nutzung.
</p>
</div>
</div>
</div>
</div>
"""
def _get_gpu_html() -> str:
"""HTML fuer GPU Infrastructure Tab"""
return """
<!-- GPU Infrastructure Tab -->
<div id="admin-content-gpu" class="admin-content">
<div class="gpu-control-panel">
<div class="gpu-status-header">
<h3 style="margin: 0; font-size: 16px;">vast.ai GPU Instance</h3>
<div id="gpu-status-badge" class="gpu-status-badge stopped">
<span class="gpu-status-dot"></span>
<span id="gpu-status-text">Unbekannt</span>
</div>
</div>
<div class="gpu-info-grid">
<div class="gpu-info-card">
<div class="gpu-info-label">GPU</div>
<div id="gpu-name" class="gpu-info-value">-</div>
</div>
<div class="gpu-info-card">
<div class="gpu-info-label">Kosten/Stunde</div>
<div id="gpu-dph" class="gpu-info-value cost">-</div>
</div>
<div class="gpu-info-card">
<div class="gpu-info-label">Session</div>
<div id="gpu-session-time" class="gpu-info-value time">-</div>
</div>
<div class="gpu-info-card">
<div class="gpu-info-label">Gesamt</div>
<div id="gpu-total-cost" class="gpu-info-value cost">-</div>
</div>
</div>
<div id="gpu-endpoint" class="gpu-endpoint-url" style="display: none;">
Endpoint: <span id="gpu-endpoint-url">-</span>
</div>
<div id="gpu-shutdown-warning" class="gpu-shutdown-warning" style="display: none;">
<svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
<span>Auto-Shutdown in <strong id="gpu-shutdown-minutes">0</strong> Minuten (bei Inaktivitaet)</span>
</div>
<div class="gpu-controls">
<button id="gpu-btn-start" class="gpu-btn gpu-btn-start" onclick="gpuPowerOn()">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3l14 9-14 9V3z"/>
</svg>
Starten
</button>
<button id="gpu-btn-stop" class="gpu-btn gpu-btn-stop" onclick="gpuPowerOff()" disabled>
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z"/>
</svg>
Stoppen
</button>
<button class="gpu-btn gpu-btn-refresh" onclick="gpuRefreshStatus()" title="Status aktualisieren">
<svg width="16" height="16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
</button>
</div>
<div class="gpu-cost-summary">
<h4>Kosten-Zusammenfassung</h4>
<div class="gpu-info-grid">
<div class="gpu-info-card">
<div class="gpu-info-label">Laufzeit Gesamt</div>
<div id="gpu-total-runtime" class="gpu-info-value time">0h 0m</div>
</div>
<div class="gpu-info-card">
<div class="gpu-info-label">Kosten Gesamt</div>
<div id="gpu-total-cost-all" class="gpu-info-value cost">$0.00</div>
</div>
</div>
</div>
<details style="margin-top: 20px;">
<summary style="cursor: pointer; color: var(--bp-text-muted); font-size: 13px;">
Audit Log (letzte Aktionen)
</summary>
<div id="gpu-audit-log" class="gpu-audit-log">
<div class="gpu-audit-entry">Keine Eintraege</div>
</div>
</details>
</div>
<div style="padding: 12px; background: var(--bp-surface-elevated); border-radius: 8px; font-size: 12px; color: var(--bp-text-muted);">
<strong>Hinweis:</strong> Die GPU-Instanz wird automatisch nach 30 Minuten Inaktivitaet gestoppt.
Bei jedem LLM-Request wird die Aktivitaet aufgezeichnet und der Timer zurueckgesetzt.
</div>
</div>
"""
def _get_dsms_webui_modal_html() -> str:
"""HTML fuer DSMS WebUI Modal"""
return """
<!-- DSMS WebUI Modal -->
<div id="dsms-webui-modal" class="legal-modal" style="display: none;">
<div class="legal-modal-content" style="max-width: 1200px; width: 95%; height: 90vh;">
<div class="legal-modal-header" style="border-bottom: 1px solid var(--bp-border);">
<h2 style="display: flex; align-items: center; gap: 10px;">
<span style="font-size: 24px;">&#127760;</span> DSMS WebUI
</h2>
<button id="dsms-webui-modal-close" class="legal-modal-close" onclick="closeDsmsWebUI()">&times;</button>
</div>
<div style="display: flex; height: calc(100% - 60px);">
<!-- Sidebar -->
<div style="width: 200px; background: var(--bp-surface); border-right: 1px solid var(--bp-border); padding: 16px;">
<nav style="display: flex; flex-direction: column; gap: 4px;">
<button class="dsms-webui-nav active" data-section="overview" onclick="switchDsmsWebUISection('overview')">
<span>&#128200;</span> Übersicht
</button>
<button class="dsms-webui-nav" data-section="files" onclick="switchDsmsWebUISection('files')">
<span>&#128193;</span> Dateien
</button>
<button class="dsms-webui-nav" data-section="explore" onclick="switchDsmsWebUISection('explore')">
<span>&#128269;</span> Erkunden
</button>
<button class="dsms-webui-nav" data-section="peers" onclick="switchDsmsWebUISection('peers')">
<span>&#127760;</span> Peers
</button>
<button class="dsms-webui-nav" data-section="config" onclick="switchDsmsWebUISection('config')">
<span>&#9881;</span> Konfiguration
</button>
</nav>
</div>
<!-- Main Content -->
<div style="flex: 1; overflow-y: auto; padding: 24px;" id="dsms-webui-content">
<!-- Overview Section (default) -->
<div id="dsms-webui-overview" class="dsms-webui-section active">
<h3 style="margin: 0 0 24px 0;">Node Übersicht</h3>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px;">
<div class="dsms-webui-stat-card">
<div class="dsms-webui-stat-label">Status</div>
<div class="dsms-webui-stat-value" id="webui-status">--</div>
</div>
<div class="dsms-webui-stat-card">
<div class="dsms-webui-stat-label">Node ID</div>
<div class="dsms-webui-stat-value" id="webui-node-id" style="font-size: 11px; word-break: break-all;">--</div>
</div>
<div class="dsms-webui-stat-card">
<div class="dsms-webui-stat-label">Protokoll</div>
<div class="dsms-webui-stat-value" id="webui-protocol">--</div>
</div>
<div class="dsms-webui-stat-card">
<div class="dsms-webui-stat-label">Agent</div>
<div class="dsms-webui-stat-value" id="webui-agent">--</div>
</div>
</div>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 16px;">
<div class="dsms-webui-stat-card">
<div class="dsms-webui-stat-label">Repo Größe</div>
<div class="dsms-webui-stat-value" id="webui-repo-size">--</div>
<div class="dsms-webui-stat-sub" id="webui-storage-info">--</div>
</div>
<div class="dsms-webui-stat-card">
<div class="dsms-webui-stat-label">Objekte</div>
<div class="dsms-webui-stat-value" id="webui-num-objects">--</div>
</div>
<div class="dsms-webui-stat-card">
<div class="dsms-webui-stat-label">Gepinnte Dokumente</div>
<div class="dsms-webui-stat-value" id="webui-pinned-count">--</div>
</div>
</div>
<div style="margin-top: 24px;">
<h4 style="margin: 0 0 12px 0;">Adressen</h4>
<div id="webui-addresses" style="background: var(--bp-input-bg); border-radius: 8px; padding: 12px; font-family: monospace; font-size: 12px; max-height: 150px; overflow-y: auto;">
Lade...
</div>
</div>
</div>
<!-- Files Section -->
<div id="dsms-webui-files" class="dsms-webui-section" style="display: none;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px;">
<h3 style="margin: 0;">Dateien hochladen</h3>
</div>
<div class="dsms-webui-upload-zone" id="dsms-upload-zone" ondrop="handleDsmsFileDrop(event)" ondragover="handleDsmsDragOver(event)" ondragleave="handleDsmsDragLeave(event)">
<div style="text-align: center;">
<div style="font-size: 48px; margin-bottom: 16px;">&#128229;</div>
<p style="color: var(--bp-text); margin-bottom: 8px;">Dateien hierher ziehen</p>
<p style="color: var(--bp-text-muted); font-size: 13px;">oder</p>
<input type="file" id="dsms-file-input" style="display: none;" onchange="handleDsmsFileSelect(event)" multiple>
<button class="btn btn-primary btn-sm" onclick="document.getElementById('dsms-file-input').click()">Dateien auswählen</button>
</div>
</div>
<div id="dsms-upload-progress" style="display: none; margin-top: 16px;">
<div style="background: var(--bp-surface-elevated); border-radius: 8px; padding: 16px;">
<div id="dsms-upload-status">Hochladen...</div>
<div style="background: var(--bp-border); border-radius: 4px; height: 8px; margin-top: 8px; overflow: hidden;">
<div id="dsms-upload-bar" style="background: var(--bp-primary); height: 100%; width: 0%; transition: width 0.3s;"></div>
</div>
</div>
</div>
<div id="dsms-upload-results" style="margin-top: 24px;"></div>
</div>
<!-- Explore Section -->
<div id="dsms-webui-explore" class="dsms-webui-section" style="display: none;">
<h3 style="margin: 0 0 24px 0;">IPFS Explorer</h3>
<div style="display: flex; gap: 12px; margin-bottom: 24px;">
<input type="text" class="admin-search" placeholder="CID eingeben (z.B. QmXyz...)" id="webui-explore-cid" style="flex: 1;">
<button class="btn btn-primary btn-sm" onclick="exploreDsmsCid()">Erkunden</button>
</div>
<div id="dsms-explore-result" style="display: none;">
<div style="background: var(--bp-surface-elevated); border-radius: 8px; padding: 16px;">
<div id="dsms-explore-content"></div>
</div>
</div>
</div>
<!-- Peers Section -->
<div id="dsms-webui-peers" class="dsms-webui-section" style="display: none;">
<h3 style="margin: 0 0 24px 0;">Verbundene Peers</h3>
<p style="color: var(--bp-text-muted); margin-bottom: 16px;">
Hinweis: In einem privaten DSMS-Netzwerk sind normalerweise keine externen Peers verbunden.
</p>
<div id="webui-peers-list">
<div class="admin-loading">Lade Peers...</div>
</div>
</div>
<!-- Config Section -->
<div id="dsms-webui-config" class="dsms-webui-section" style="display: none;">
<h3 style="margin: 0 0 24px 0;">Konfiguration</h3>
<div style="display: grid; gap: 16px;">
<div style="background: var(--bp-surface-elevated); border-radius: 8px; padding: 16px;">
<h4 style="margin: 0 0 12px 0;">API Endpoints</h4>
<table style="width: 100%; font-size: 13px;">
<tr>
<td style="padding: 8px 0; color: var(--bp-text-muted);">IPFS API</td>
<td style="padding: 8px 0; font-family: monospace;">http://localhost:5001</td>
</tr>
<tr>
<td style="padding: 8px 0; color: var(--bp-text-muted);">DSMS Gateway</td>
<td style="padding: 8px 0; font-family: monospace;">http://localhost:8082</td>
</tr>
<tr>
<td style="padding: 8px 0; color: var(--bp-text-muted);">IPFS Gateway</td>
<td style="padding: 8px 0; font-family: monospace;">http://localhost:8085</td>
</tr>
<tr>
<td style="padding: 8px 0; color: var(--bp-text-muted);">Swarm P2P</td>
<td style="padding: 8px 0; font-family: monospace;">:4001</td>
</tr>
</table>
</div>
<div style="background: var(--bp-surface-elevated); border-radius: 8px; padding: 16px;">
<h4 style="margin: 0 0 12px 0;">Aktionen</h4>
<div style="display: flex; flex-wrap: wrap; gap: 12px;">
<button class="btn btn-ghost btn-sm" onclick="runDsmsGarbageCollection()">&#128465; Garbage Collection</button>
<button class="btn btn-ghost btn-sm" onclick="loadDsmsWebUIData()">&#8635; Daten aktualisieren</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
"""

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,803 @@
"""
Admin Panel Component - CSS Styles
Extracted from admin_panel.py for maintainability.
Contains all CSS styles for the admin panel modal, tabs, forms, and GPU controls.
"""
def get_admin_panel_css() -> str:
"""CSS fuer Admin Panel zurueckgeben"""
return """
/* ==========================================
ADMIN PANEL STYLES
========================================== */
.admin-modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
z-index: 10000;
justify-content: center;
align-items: center;
backdrop-filter: blur(4px);
}
.admin-modal.active {
display: flex;
}
.admin-modal-content {
background: var(--bp-surface);
border-radius: 16px;
width: 95%;
max-width: 1000px;
max-height: 90vh;
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(0,0,0,0.4);
border: 1px solid var(--bp-border);
}
.admin-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid var(--bp-border);
}
.admin-modal-header h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
display: flex;
align-items: center;
gap: 10px;
}
.admin-tabs {
display: flex;
gap: 4px;
padding: 12px 24px;
border-bottom: 1px solid var(--bp-border);
background: var(--bp-surface-elevated);
}
.admin-tab {
padding: 8px 16px;
border: none;
background: transparent;
color: var(--bp-text-muted);
cursor: pointer;
border-radius: 8px;
font-size: 13px;
transition: all 0.2s ease;
}
.admin-tab:hover {
background: var(--bp-border-subtle);
color: var(--bp-text);
}
.admin-tab.active {
background: var(--bp-primary);
color: white;
}
.admin-body {
padding: 24px;
overflow-y: auto;
flex: 1;
}
.admin-content {
display: none;
}
.admin-content.active {
display: block;
}
.admin-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
gap: 12px;
}
.admin-toolbar-left {
display: flex;
gap: 12px;
align-items: center;
}
.admin-search {
padding: 8px 12px;
border: 1px solid var(--bp-border);
border-radius: 8px;
background: var(--bp-surface-elevated);
color: var(--bp-text);
width: 250px;
}
.admin-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.admin-table th,
.admin-table td {
padding: 12px;
text-align: left;
border-bottom: 1px solid var(--bp-border);
}
.admin-table th {
background: var(--bp-surface-elevated);
font-weight: 600;
color: var(--bp-text);
}
.admin-table tr:hover {
background: var(--bp-surface-elevated);
}
.admin-table td {
color: var(--bp-text-muted);
}
.admin-badge {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
}
.admin-badge-published {
background: rgba(74, 222, 128, 0.2);
color: #4ADE80;
}
.admin-badge-draft {
background: rgba(251, 191, 36, 0.2);
color: #FBBF24;
}
.admin-badge-archived {
background: rgba(156, 163, 175, 0.2);
color: #9CA3AF;
}
.admin-badge-rejected {
background: rgba(239, 68, 68, 0.2);
color: #EF4444;
}
.admin-badge-review {
background: rgba(147, 51, 234, 0.2);
color: #A855F7;
}
.admin-badge-approved {
background: rgba(34, 197, 94, 0.2);
color: #22C55E;
}
.admin-badge-submitted {
background: rgba(59, 130, 246, 0.2);
color: #3B82F6;
}
.admin-badge-mandatory {
background: rgba(239, 68, 68, 0.2);
color: #EF4444;
}
.admin-badge-optional {
background: rgba(96, 165, 250, 0.2);
color: #60A5FA;
}
.admin-actions {
display: flex;
gap: 8px;
}
.admin-btn {
padding: 6px 10px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
transition: all 0.2s ease;
}
.admin-btn-edit {
background: var(--bp-primary);
color: white;
}
.admin-btn-edit:hover {
filter: brightness(1.1);
}
.admin-btn-delete {
background: rgba(239, 68, 68, 0.2);
color: #EF4444;
}
.admin-btn-delete:hover {
background: rgba(239, 68, 68, 0.3);
}
.admin-btn-publish {
background: rgba(74, 222, 128, 0.2);
color: #4ADE80;
}
.admin-btn-publish:hover {
background: rgba(74, 222, 128, 0.3);
}
.admin-form {
display: none;
background: var(--bp-surface-elevated);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
border: 1px solid var(--bp-border);
}
.admin-form.active {
display: block;
}
/* Info Text in Toolbar */
.admin-info-text {
font-size: 12px;
color: var(--bp-text-muted);
font-style: italic;
}
/* Dialog Overlay */
.admin-dialog {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1000;
align-items: center;
justify-content: center;
}
.admin-dialog.active {
display: flex;
}
.admin-dialog-content {
background: var(--bp-surface);
border-radius: 12px;
padding: 24px;
max-width: 500px;
width: 90%;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
}
.admin-dialog-content h3 {
margin: 0 0 12px 0;
font-size: 18px;
}
.admin-dialog-info {
font-size: 13px;
color: var(--bp-text-muted);
margin-bottom: 20px;
line-height: 1.5;
}
.admin-dialog-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
margin-top: 20px;
}
/* Scheduled Badge */
.admin-badge-scheduled {
background: #f59e0b;
color: white;
}
/* Version Compare Overlay */
.version-compare-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--bp-bg);
z-index: 2000;
flex-direction: column;
}
.version-compare-overlay.active {
display: flex;
}
.version-compare-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
background: var(--bp-surface);
border-bottom: 1px solid var(--bp-border);
}
.version-compare-header h2 {
margin: 0;
font-size: 18px;
}
.version-compare-info {
display: flex;
align-items: center;
gap: 12px;
font-size: 14px;
}
.compare-vs {
color: var(--bp-text-muted);
font-weight: 600;
}
.version-compare-container {
display: grid;
grid-template-columns: 1fr 1fr;
flex: 1;
overflow: hidden;
gap: 0;
}
.version-compare-panel {
display: flex;
flex-direction: column;
overflow: hidden;
border-right: 1px solid var(--bp-border);
}
.version-compare-panel:last-child {
border-right: none;
}
.version-compare-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
background: var(--bp-surface);
border-bottom: 1px solid var(--bp-border);
}
.compare-label {
padding: 4px 12px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
}
.compare-label-published {
background: rgba(34, 197, 94, 0.15);
color: #22c55e;
}
.compare-label-draft {
background: rgba(59, 130, 246, 0.15);
color: #3b82f6;
}
.version-compare-content {
flex: 1;
overflow-y: auto;
padding: 24px;
font-size: 14px;
line-height: 1.7;
}
.version-compare-content h1,
.version-compare-content h2,
.version-compare-content h3 {
margin-top: 24px;
margin-bottom: 12px;
}
.version-compare-content p {
margin-bottom: 12px;
}
.version-compare-content ul,
.version-compare-content ol {
margin-bottom: 12px;
padding-left: 24px;
}
.version-compare-content .no-content {
color: var(--bp-text-muted);
font-style: italic;
text-align: center;
padding: 40px;
}
.version-compare-footer {
padding: 12px 24px;
background: var(--bp-surface);
border-top: 1px solid var(--bp-border);
max-height: 150px;
overflow-y: auto;
}
.compare-history-title {
font-size: 13px;
font-weight: 600;
margin-bottom: 8px;
color: var(--bp-text-muted);
}
.compare-history-item {
font-size: 12px;
padding: 4px 0;
border-bottom: 1px solid var(--bp-border-subtle);
}
.compare-history-item:last-child {
border-bottom: none;
}
.compare-history-list {
display: flex;
flex-wrap: wrap;
gap: 4px 8px;
font-size: 12px;
color: var(--bp-text-muted);
}
.admin-form-title {
margin: 0 0 16px 0;
font-size: 16px;
font-weight: 600;
}
.admin-form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 16px;
}
.admin-form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.admin-form-group.full-width {
grid-column: 1 / -1;
}
.admin-form-label {
font-size: 12px;
font-weight: 600;
color: var(--bp-text);
}
.admin-form-input,
.admin-form-select,
.admin-form-textarea {
padding: 10px 12px;
border: 1px solid var(--bp-border);
border-radius: 8px;
background: var(--bp-surface);
color: var(--bp-text);
font-size: 13px;
}
.admin-form-textarea {
min-height: 150px;
resize: vertical;
font-family: inherit;
}
.admin-form-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--bp-border);
}
.admin-empty {
text-align: center;
padding: 40px;
color: var(--bp-text-muted);
}
.admin-loading {
text-align: center;
padding: 40px;
color: var(--bp-text-muted);
}
[data-theme="light"] .admin-modal-content {
background: #FFFFFF;
border-color: #E0E0E0;
}
[data-theme="light"] .admin-tabs {
background: #F5F5F5;
}
[data-theme="light"] .admin-table th {
background: #F5F5F5;
}
[data-theme="light"] .admin-form {
background: #F8F8F8;
border-color: #E0E0E0;
}
/* GPU Infrastructure Styles */
.gpu-control-panel {
background: var(--bp-surface-elevated);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
border: 1px solid var(--bp-border);
}
.gpu-status-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.gpu-status-badge {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border-radius: 20px;
font-size: 13px;
font-weight: 600;
}
.gpu-status-badge.running {
background: rgba(34, 197, 94, 0.15);
color: #22c55e;
}
.gpu-status-badge.stopped,
.gpu-status-badge.exited {
background: rgba(156, 163, 175, 0.15);
color: #9ca3af;
}
.gpu-status-badge.loading,
.gpu-status-badge.scheduling,
.gpu-status-badge.creating {
background: rgba(59, 130, 246, 0.15);
color: #3b82f6;
}
.gpu-status-badge.error,
.gpu-status-badge.unconfigured {
background: rgba(239, 68, 68, 0.15);
color: #ef4444;
}
.gpu-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
}
.gpu-status-dot.running {
animation: gpu-pulse 2s infinite;
}
@keyframes gpu-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.gpu-info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 12px;
margin-bottom: 20px;
}
.gpu-info-card {
background: var(--bp-surface);
border-radius: 8px;
padding: 12px;
border: 1px solid var(--bp-border-subtle);
}
.gpu-info-label {
font-size: 11px;
color: var(--bp-text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 4px;
}
.gpu-info-value {
font-size: 16px;
font-weight: 600;
color: var(--bp-text);
}
.gpu-info-value.cost {
color: #f59e0b;
}
.gpu-info-value.time {
color: #3b82f6;
}
.gpu-controls {
display: flex;
gap: 12px;
margin-top: 20px;
}
.gpu-btn {
flex: 1;
padding: 12px 20px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
transition: all 0.2s ease;
}
.gpu-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.gpu-btn-start {
background: #22c55e;
color: white;
}
.gpu-btn-start:hover:not(:disabled) {
background: #16a34a;
}
.gpu-btn-stop {
background: #ef4444;
color: white;
}
.gpu-btn-stop:hover:not(:disabled) {
background: #dc2626;
}
.gpu-btn-refresh {
background: var(--bp-surface);
color: var(--bp-text);
border: 1px solid var(--bp-border);
flex: 0 0 auto;
padding: 12px;
}
.gpu-btn-refresh:hover:not(:disabled) {
background: var(--bp-surface-elevated);
}
.gpu-shutdown-warning {
background: rgba(251, 191, 36, 0.1);
border: 1px solid rgba(251, 191, 36, 0.3);
border-radius: 8px;
padding: 12px;
margin-top: 16px;
display: flex;
align-items: center;
gap: 10px;
color: #fbbf24;
font-size: 13px;
}
.gpu-cost-summary {
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid var(--bp-border);
}
.gpu-cost-summary h4 {
margin: 0 0 12px 0;
font-size: 14px;
color: var(--bp-text-muted);
}
.gpu-audit-log {
margin-top: 12px;
max-height: 200px;
overflow-y: auto;
background: var(--bp-surface);
border-radius: 8px;
border: 1px solid var(--bp-border-subtle);
}
.gpu-audit-entry {
padding: 8px 12px;
border-bottom: 1px solid var(--bp-border-subtle);
font-size: 12px;
font-family: monospace;
}
.gpu-audit-entry:last-child {
border-bottom: none;
}
.gpu-audit-time {
color: var(--bp-text-muted);
margin-right: 8px;
}
.gpu-endpoint-url {
margin-top: 12px;
padding: 10px 12px;
background: var(--bp-surface);
border-radius: 8px;
font-family: monospace;
font-size: 12px;
color: var(--bp-text-muted);
word-break: break-all;
}
.gpu-endpoint-url.active {
color: #22c55e;
}
.gpu-spinner {
width: 16px;
height: 16px;
border: 2px solid transparent;
border-top-color: currentColor;
border-radius: 50%;
animation: gpu-spin 1s linear infinite;
}
@keyframes gpu-spin {
to { transform: rotate(360deg); }
}
[data-theme="light"] .gpu-control-panel {
background: #fafafa;
}
[data-theme="light"] .gpu-info-card {
background: #ffffff;
}
"""

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,214 @@
"""
Admin Stats Component - Statistics & GDPR Export
"""
def get_admin_stats_css() -> str:
"""CSS für Statistics (inkludiert in admin_panel.css)"""
return ""
def get_admin_stats_html() -> str:
"""HTML für Statistics (inkludiert in admin_panel.html)"""
return ""
def get_admin_stats_js() -> str:
"""JavaScript für Statistics & GDPR Export zurückgeben"""
return """
let dataCategories = [];
async function loadAdminStats() {
const container = document.getElementById('admin-stats-container');
container.innerHTML = '<div class="admin-loading">Lade Statistiken & DSGVO-Informationen...</div>';
try {
// Lade Datenkategorien
const catRes = await fetch('/api/consent/privacy/data-categories');
if (catRes.ok) {
const catData = await catRes.json();
dataCategories = catData.categories || [];
}
renderStatsPanel();
} catch(e) {
container.innerHTML = '<div class="admin-empty">Fehler beim Laden: ' + e.message + '</div>';
}
}
function renderStatsPanel() {
const container = document.getElementById('admin-stats-container');
// Kategorisiere Daten
const essential = dataCategories.filter(c => c.is_essential);
const optional = dataCategories.filter(c => !c.is_essential);
const html = `
<div style="display: grid; gap: 24px;">
<!-- GDPR Export Section -->
<div class="admin-form" style="padding: 20px;">
<h3 style="margin: 0 0 16px 0; font-size: 16px; color: var(--bp-text);">
<span style="margin-right: 8px;">📋</span> DSGVO-Datenauskunft (Art. 15)
</h3>
<p style="color: var(--bp-text-muted); font-size: 13px; margin-bottom: 16px;">
Exportieren Sie alle personenbezogenen Daten eines Nutzers als PDF-Dokument.
Dies erfüllt die Anforderungen der DSGVO Art. 15 (Auskunftsrecht).
</p>
<div style="display: flex; gap: 12px; align-items: center; flex-wrap: wrap;">
<input type="text" id="gdpr-export-user-id" class="admin-form-input"
placeholder="Benutzer-ID (optional für eigene Daten)"
style="flex: 1; min-width: 200px;">
<button class="admin-btn admin-btn-primary" onclick="exportUserDataPdf()">
PDF exportieren
</button>
<button class="admin-btn" onclick="previewUserDataHtml()">
HTML-Vorschau
</button>
</div>
<div id="gdpr-export-status" style="margin-top: 12px; font-size: 13px;"></div>
</div>
<!-- Data Retention Overview -->
<div class="admin-form" style="padding: 20px;">
<h3 style="margin: 0 0 16px 0; font-size: 16px; color: var(--bp-text);">
<span style="margin-right: 8px;">🗄️</span> Datenkategorien & Löschfristen
</h3>
<div style="margin-bottom: 20px;">
<h4 style="font-size: 14px; color: var(--bp-primary); margin: 0 0 12px 0;">
Essentielle Daten (Pflicht für Betrieb)
</h4>
<table class="admin-table">
<thead>
<tr>
<th>Kategorie</th>
<th>Beschreibung</th>
<th>Löschfrist</th>
<th>Rechtsgrundlage</th>
</tr>
</thead>
<tbody>
${essential.map(cat => `
<tr>
<td><strong>${cat.name_de}</strong></td>
<td>${cat.description_de}</td>
<td><span class="admin-badge admin-badge-published">${cat.retention_period}</span></td>
<td style="font-size: 11px; color: var(--bp-text-muted);">${cat.legal_basis}</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
<div>
<h4 style="font-size: 14px; color: var(--bp-warning); margin: 0 0 12px 0;">
Optionale Daten (nur bei Einwilligung)
</h4>
<table class="admin-table">
<thead>
<tr>
<th>Kategorie</th>
<th>Beschreibung</th>
<th>Cookie-Kategorie</th>
<th>Löschfrist</th>
</tr>
</thead>
<tbody>
${optional.map(cat => `
<tr>
<td><strong>${cat.name_de}</strong></td>
<td>${cat.description_de}</td>
<td><span class="admin-badge">${cat.cookie_category || '-'}</span></td>
<td><span class="admin-badge admin-badge-optional">${cat.retention_period}</span></td>
</tr>
`).join('')}
</tbody>
</table>
</div>
</div>
<!-- Quick Stats -->
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 16px;">
<div class="admin-form" style="padding: 16px; text-align: center;">
<div style="font-size: 28px; font-weight: bold; color: var(--bp-primary);">${dataCategories.length}</div>
<div style="color: var(--bp-text-muted); font-size: 13px;">Datenkategorien</div>
</div>
<div class="admin-form" style="padding: 16px; text-align: center;">
<div style="font-size: 28px; font-weight: bold; color: var(--bp-success);">${essential.length}</div>
<div style="color: var(--bp-text-muted); font-size: 13px;">Essentiell</div>
</div>
<div class="admin-form" style="padding: 16px; text-align: center;">
<div style="font-size: 28px; font-weight: bold; color: var(--bp-warning);">${optional.length}</div>
<div style="color: var(--bp-text-muted); font-size: 13px;">Optional (Opt-in)</div>
</div>
</div>
</div>
`;
container.innerHTML = html;
}
async function exportUserDataPdf() {
const userIdInput = document.getElementById('gdpr-export-user-id');
const statusDiv = document.getElementById('gdpr-export-status');
const userId = userIdInput?.value?.trim();
statusDiv.innerHTML = '<span style="color: var(--bp-primary);">Generiere PDF...</span>';
try {
let url = '/api/consent/privacy/export-pdf';
// Wenn eine User-ID angegeben wurde, verwende den Admin-Endpoint
if (userId) {
url = `/api/consent/admin/privacy/export-pdf/${userId}`;
}
const res = await fetch(url, { method: 'POST' });
if (!res.ok) {
const error = await res.json();
throw new Error(error.detail?.message || error.detail || 'Export fehlgeschlagen');
}
// PDF herunterladen
const blob = await res.blob();
const downloadUrl = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = downloadUrl;
a.download = userId ? `datenauskunft_${userId.slice(0,8)}.pdf` : 'breakpilot_datenauskunft.pdf';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(downloadUrl);
statusDiv.innerHTML = '<span style="color: var(--bp-success);">✓ PDF erfolgreich generiert!</span>';
} catch(e) {
statusDiv.innerHTML = `<span style="color: var(--bp-danger);">Fehler: ${e.message}</span>`;
}
}
async function previewUserDataHtml() {
const statusDiv = document.getElementById('gdpr-export-status');
statusDiv.innerHTML = '<span style="color: var(--bp-primary);">Lade Vorschau...</span>';
try {
const res = await fetch('/api/consent/privacy/export-html');
if (!res.ok) {
throw new Error('Vorschau konnte nicht geladen werden');
}
const html = await res.text();
// In neuem Tab öffnen
const win = window.open('', '_blank');
win.document.write(html);
win.document.close();
statusDiv.innerHTML = '<span style="color: var(--bp-success);">✓ Vorschau in neuem Tab geöffnet</span>';
} catch(e) {
statusDiv.innerHTML = `<span style="color: var(--bp-danger);">Fehler: ${e.message}</span>`;
}
}
"""

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,320 @@
"""
Base Component - CSS Variables, Base Styles, Theme Toggle
"""
def get_base_css() -> str:
"""CSS für Base Styles (Variables, Scrollbar, Layout) zurückgeben"""
return """
/* ==========================================
DARK MODE (Default) - Original Design
========================================== */
:root {
--bp-primary: #0f766e;
--bp-primary-soft: #ccfbf1;
--bp-bg: #020617;
--bp-surface: #020617;
--bp-surface-elevated: rgba(15,23,42,0.9);
--bp-border: #1f2937;
--bp-border-subtle: rgba(148,163,184,0.25);
--bp-accent: #22c55e;
--bp-accent-soft: rgba(34,197,94,0.2);
--bp-text: #e5e7eb;
--bp-text-muted: #9ca3af;
--bp-danger: #ef4444;
--bp-gradient-bg: radial-gradient(circle at top left, #1f2937 0, #020617 50%, #000 100%);
--bp-gradient-surface: radial-gradient(circle at top left, rgba(15,23,42,0.9) 0, #020617 50%, #000 100%);
--bp-gradient-sidebar: radial-gradient(circle at top, #020617 0, #020617 40%, #000 100%);
--bp-gradient-topbar: linear-gradient(to right, rgba(15,23,42,0.9), rgba(15,23,42,0.6));
--bp-btn-primary-bg: linear-gradient(to right, var(--bp-primary), #15803d);
--bp-btn-primary-hover: linear-gradient(to right, #0f766e, #166534);
--bp-card-bg: var(--bp-gradient-surface);
--bp-input-bg: rgba(255,255,255,0.05);
--bp-scrollbar-track: rgba(15,23,42,0.5);
--bp-scrollbar-thumb: rgba(148,163,184,0.5);
}
/* ==========================================
LIGHT MODE - Website Design
Farben aus BreakPilot Website:
- Primary: Sky Blue #0ea5e9 (Modern, Vertrauen)
- Accent: Fuchsia #d946ef (Kreativ, Energie)
- Text: Slate #0f172a (Klarheit)
- Background: White/Slate-50 (Clean, Professional)
========================================== */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
[data-theme="light"] {
--bp-primary: #0ea5e9;
--bp-primary-soft: rgba(14, 165, 233, 0.1);
--bp-bg: #f8fafc;
--bp-surface: #FFFFFF;
--bp-surface-elevated: #FFFFFF;
--bp-border: #e2e8f0;
--bp-border-subtle: rgba(14, 165, 233, 0.2);
--bp-accent: #d946ef;
--bp-accent-soft: rgba(217, 70, 239, 0.15);
--bp-text: #0f172a;
--bp-text-muted: #64748b;
--bp-danger: #ef4444;
--bp-gradient-bg: linear-gradient(145deg, #f8fafc 0%, #f1f5f9 100%);
--bp-gradient-surface: linear-gradient(145deg, #FFFFFF 0%, #f8fafc 100%);
--bp-gradient-sidebar: linear-gradient(180deg, #FFFFFF 0%, #f1f5f9 100%);
--bp-gradient-topbar: linear-gradient(to right, #FFFFFF, #f8fafc);
--bp-btn-primary-bg: linear-gradient(135deg, #0ea5e9 0%, #d946ef 100%);
--bp-btn-primary-hover: linear-gradient(135deg, #0284c7 0%, #c026d3 100%);
--bp-card-bg: linear-gradient(145deg, #FFFFFF 0%, #f8fafc 100%);
--bp-input-bg: #FFFFFF;
--bp-scrollbar-track: rgba(0,0,0,0.05);
--bp-scrollbar-thumb: rgba(14, 165, 233, 0.3);
--bp-gold: #eab308;
}
[data-theme="light"] body {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
}
* { box-sizing: border-box; }
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bp-scrollbar-track);
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: var(--bp-scrollbar-thumb);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--bp-scrollbar-thumb);
opacity: 0.8;
}
html, body {
height: 100%;
}
body {
margin: 0;
padding: 0;
font-family: system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Text", sans-serif;
background: var(--bp-gradient-bg);
color: var(--bp-text);
min-height: 100vh;
display: flex;
flex-direction: column;
transition: background 0.3s ease, color 0.3s ease;
}
.app-root {
display: grid;
grid-template-rows: 56px 1fr 32px;
flex: 1;
min-height: 0;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20px;
border-bottom: 1px solid var(--bp-border-subtle);
backdrop-filter: blur(18px);
background: var(--bp-gradient-topbar);
transition: background 0.3s ease, border-color 0.3s ease;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
}
.brand-logo {
width: 28px;
height: 28px;
border-radius: 999px;
border: 2px solid var(--bp-accent);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 14px;
color: var(--bp-accent);
}
.brand-text-main {
font-weight: 600;
font-size: 16px;
}
.brand-text-sub {
font-size: 12px;
color: var(--bp-text-muted);
}
.top-nav {
display: flex;
align-items: center;
gap: 18px;
font-size: 13px;
}
.top-nav-item {
cursor: pointer;
padding: 4px 10px;
border-radius: 999px;
color: var(--bp-text-muted);
border: 1px solid transparent;
}
.top-nav-item.active {
color: var(--bp-primary);
border-color: var(--bp-accent-soft);
background: var(--bp-surface-elevated);
}
[data-theme="light"] .top-nav-item.active {
color: var(--bp-primary);
background: var(--bp-primary-soft);
border-color: var(--bp-primary);
}
.top-actions {
display: flex;
align-items: center;
gap: 10px;
}
.pill {
font-size: 11px;
padding: 4px 10px;
border-radius: 999px;
border: 1px solid var(--bp-border-subtle);
color: var(--bp-text-muted);
}
/* Theme Toggle Button */
.theme-toggle {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 999px;
border: 1px solid var(--bp-border-subtle);
background: var(--bp-surface-elevated);
color: var(--bp-text-muted);
cursor: pointer;
font-size: 11px;
transition: all 0.2s ease;
}
.theme-toggle:hover {
border-color: var(--bp-primary);
color: var(--bp-primary);
}
.theme-toggle-icon {
font-size: 14px;
}
"""
def get_base_html() -> str:
"""HTML für Base Layout zurückgeben"""
return """
<div class="app-root">
<div class="topbar">
<div class="brand">
<div class="brand-logo">BP</div>
<div>
<div class="brand-text-main">BreakPilot</div>
<div class="brand-text-sub" data-i18n="brand_sub">Studio</div>
</div>
</div>
<div class="top-nav">
<span class="top-nav-item" data-i18n="nav_compare">Arbeitsblätter</span>
<span class="top-nav-item" data-i18n="nav_tiles">Lern-Kacheln</span>
</div>
<div class="top-actions">
<span class="pill" data-i18n="mvp_local">MVP · Lokal auf deinem Mac</span>
<button id="theme-toggle" class="theme-toggle">
<span id="theme-icon" class="theme-toggle-icon">🌙</span>
<span id="theme-label">Dark</span>
</button>
<button id="auth-toggle" class="pill" style="cursor: pointer;" data-i18n="login">Login / Anmeldung</button>
</div>
</div>
<!-- Main content area will be inserted here -->
<div class="main-content"></div>
<div class="footer">
<span>© 2025 BreakPilot</span>
<span style="display: flex; gap: 12px; font-size: 11px;">
<a id="open-imprint" style="cursor: pointer; color: var(--bp-text-muted);">Impressum</a>
<a id="open-terms" style="cursor: pointer; color: var(--bp-text-muted);">AGB</a>
<a id="open-privacy" style="cursor: pointer; color: var(--bp-text-muted);">Datenschutz</a>
<a id="open-cookies" style="cursor: pointer; color: var(--bp-text-muted);">Cookies</a>
<a id="open-community" style="cursor: pointer; color: var(--bp-text-muted);">Community</a>
<a id="open-gdpr" style="cursor: pointer; color: var(--bp-text-muted);">Deine Rechte</a>
<a id="open-settings" style="cursor: pointer; color: var(--bp-text-muted);">Einstellungen</a>
</span>
</div>
</div>
"""
def get_base_js() -> str:
"""JavaScript für Theme Toggle zurückgeben"""
return """
// ==========================================
// THEME TOGGLE (Dark/Light Mode)
// ==========================================
(function() {
const savedTheme = localStorage.getItem('bp-theme') || 'dark';
if (savedTheme === 'light') {
document.documentElement.setAttribute('data-theme', 'light');
}
})();
function initThemeToggle() {
const toggle = document.getElementById('theme-toggle');
const icon = document.getElementById('theme-icon');
const label = document.getElementById('theme-label');
function updateToggleUI(theme) {
if (theme === 'light') {
icon.textContent = '☀️';
label.textContent = 'Light';
} else {
icon.textContent = '🌙';
label.textContent = 'Dark';
}
}
// Initialize UI based on current theme
const currentTheme = document.documentElement.getAttribute('data-theme') || 'dark';
updateToggleUI(currentTheme);
toggle.addEventListener('click', function() {
const current = document.documentElement.getAttribute('data-theme') || 'dark';
const newTheme = current === 'dark' ? 'light' : 'dark';
if (newTheme === 'light') {
document.documentElement.setAttribute('data-theme', 'light');
} else {
document.documentElement.removeAttribute('data-theme');
}
localStorage.setItem('bp-theme', newTheme);
updateToggleUI(newTheme);
});
}
"""

View File

@@ -0,0 +1,191 @@
#!/usr/bin/env python3
"""
Script to extract components from the monolithic studio.py file.
This script analyzes studio.py and extracts sections into separate component files.
"""
import re
from pathlib import Path
def read_file_lines(filepath: str, start: int, end: int) -> str:
"""Read specific lines from a file."""
with open(filepath, 'r', encoding='utf-8') as f:
lines = f.readlines()
return ''.join(lines[start-1:end])
def find_section_boundaries(filepath: str):
"""Find all section boundaries in the file."""
with open(filepath, 'r', encoding='utf-8') as f:
lines = f.readlines()
css_sections = []
html_sections = []
js_sections = []
for i, line in enumerate(lines, 1):
# Find CSS section markers
if '/* ==========================================' in line:
if i + 1 < len(lines):
title = lines[i].strip()
css_sections.append((i, title))
# Find JS section markers
if '// ==========================================' in line:
if i + 1 < len(lines):
title = lines[i].strip()
js_sections.append((i, title))
# Find HTML sections (major modal divs)
if '<div id="legal-modal"' in line or '<div id="auth-modal"' in line or '<div id="admin-panel"' in line:
html_sections.append((i, line.strip()))
return css_sections, html_sections, js_sections
def extract_legal_modal(source_file: str, output_dir: Path):
"""Extract Legal Modal component."""
with open(source_file, 'r', encoding='utf-8') as f:
content = f.read()
# Extract CSS (between LEGAL MODAL STYLES and AUTH MODAL STYLES)
css_match = re.search(
r'/\* ={40,}\s+LEGAL MODAL STYLES\s+={40,} \*/\s+(.*?)\s+/\* ={40,}\s+AUTH MODAL STYLES',
content,
re.DOTALL
)
# Extract HTML (legal-modal div)
html_match = re.search(
r'(<div id="legal-modal".*?</div>\s*<!-- /legal-modal -->)',
content,
re.DOTALL
)
# Extract JS (LEGAL MODAL section)
js_match = re.search(
r'// ={40,}\s+// LEGAL MODAL.*?\s+// ={40,}\s+(.*?)(?=\s+// ={40,})',
content,
re.DOTALL
)
css_content = css_match.group(1).strip() if css_match else ""
html_content = html_match.group(1).strip() if html_match else ""
js_content = js_match.group(1).strip() if js_match else ""
component_code = f'''"""
Legal Modal Component - AGB, Datenschutz, Cookies, Community Guidelines, GDPR Rights
"""
def get_legal_modal_css() -> str:
"""CSS für Legal Modal zurückgeben"""
return """
{css_content}
"""
def get_legal_modal_html() -> str:
"""HTML für Legal Modal zurückgeben"""
return """
{html_content}
"""
def get_legal_modal_js() -> str:
"""JavaScript für Legal Modal zurückgeben"""
return """
{js_content}
"""
'''
output_file = output_dir / 'legal_modal.py'
with open(output_file, 'w', encoding='utf-8') as f:
f.write(component_code)
print(f"✓ Created {output_file}")
return output_file
def extract_auth_modal(source_file: str, output_dir: Path):
"""Extract Auth Modal component."""
with open(source_file, 'r', encoding='utf-8') as f:
content = f.read()
# Extract CSS (between AUTH MODAL STYLES and next section)
css_match = re.search(
r'/\* ={40,}\s+AUTH MODAL STYLES\s+={40,} \*/\s+(.*?)(?=/\* ={40,})',
content,
re.DOTALL
)
# Extract HTML (auth-modal div)
html_match = re.search(
r'(<div id="auth-modal".*?</div>\s*<!-- /auth-modal -->)',
content,
re.DOTALL
)
# Extract JS (AUTH MODAL section)
js_match = re.search(
r'// ={40,}\s+// AUTH MODAL\s+// ={40,}\s+(.*?)(?=\s+// ={40,})',
content,
re.DOTALL
)
css_content = css_match.group(1).strip() if css_match else ""
html_content = html_match.group(1).strip() if html_match else ""
js_content = js_match.group(1).strip() if js_match else ""
component_code = f'''"""
Auth Modal Component - Login, Register, 2FA
"""
def get_auth_modal_css() -> str:
"""CSS für Auth Modal zurückgeben"""
return """
{css_content}
"""
def get_auth_modal_html() -> str:
"""HTML für Auth Modal zurückgeben"""
return """
{html_content}
"""
def get_auth_modal_js() -> str:
"""JavaScript für Auth Modal zurückgeben"""
return """
{js_content}
"""
'''
output_file = output_dir / 'auth_modal.py'
with open(output_file, 'w', encoding='utf-8') as f:
f.write(component_code)
print(f"✓ Created {output_file}")
return output_file
if __name__ == '__main__':
source_file = Path(__file__).parent.parent / 'studio.py'
output_dir = Path(__file__).parent
print(f"Extracting components from {source_file}...")
print(f"Output directory: {output_dir}")
# Find boundaries
css_sections, html_sections, js_sections = find_section_boundaries(str(source_file))
print(f"\nFound {len(css_sections)} CSS sections")
print(f"Found {len(html_sections)} HTML sections")
print(f"Found {len(js_sections)} JS sections")
# Extract components
# extract_legal_modal(str(source_file), output_dir)
# extract_auth_modal(str(source_file), output_dir)
print("\nDone!")

View File

@@ -0,0 +1,514 @@
"""
Legal Modal Component - AGB, Datenschutz, Cookies, Community Guidelines, GDPR Rights
"""
def get_legal_modal_css() -> str:
"""CSS für Legal Modal zurückgeben"""
return """
/* ==========================================
LEGAL MODAL STYLES
========================================== */
.legal-modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
z-index: 10000;
justify-content: center;
align-items: center;
backdrop-filter: blur(4px);
}
.legal-modal.active {
display: flex;
}
.legal-modal-content {
background: var(--bp-surface);
border-radius: 16px;
width: 90%;
max-width: 700px;
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 20px 60px rgba(0,0,0,0.4);
border: 1px solid var(--bp-border);
}
.legal-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid var(--bp-border);
}
.legal-modal-header h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.legal-modal-close {
background: none;
border: none;
font-size: 28px;
cursor: pointer;
color: var(--bp-text-muted);
padding: 0;
line-height: 1;
}
.legal-modal-close:hover {
color: var(--bp-text);
}
.legal-tabs {
display: flex;
gap: 4px;
padding: 12px 24px;
border-bottom: 1px solid var(--bp-border);
background: var(--bp-surface-elevated);
}
.legal-tab {
padding: 8px 16px;
border: none;
background: transparent;
color: var(--bp-text-muted);
cursor: pointer;
border-radius: 8px;
font-size: 13px;
transition: all 0.2s ease;
}
.legal-tab:hover {
background: var(--bp-border-subtle);
color: var(--bp-text);
}
.legal-tab.active {
background: var(--bp-primary);
color: white;
}
.legal-body {
padding: 24px;
overflow-y: auto;
flex: 1;
}
.legal-content {
display: none;
}
.legal-content.active {
display: block;
}
.legal-content h3 {
margin-top: 0;
color: var(--bp-text);
}
.legal-content p {
line-height: 1.6;
color: var(--bp-text-muted);
}
.legal-content ul {
color: var(--bp-text-muted);
line-height: 1.8;
}
.cookie-categories {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 16px;
}
.cookie-category {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px;
background: var(--bp-surface-elevated);
border-radius: 8px;
border: 1px solid var(--bp-border);
cursor: pointer;
}
.cookie-category input {
margin-top: 4px;
}
.cookie-category span {
flex: 1;
font-size: 13px;
color: var(--bp-text);
}
.gdpr-actions {
display: flex;
flex-direction: column;
gap: 16px;
margin-top: 16px;
}
.gdpr-action {
padding: 16px;
background: var(--bp-surface-elevated);
border-radius: 12px;
border: 1px solid var(--bp-border);
}
.gdpr-action h4 {
margin: 0 0 8px 0;
font-size: 14px;
color: var(--bp-text);
}
.gdpr-action p {
margin: 0 0 12px 0;
font-size: 13px;
}
.btn-danger {
background: var(--bp-danger) !important;
border-color: var(--bp-danger) !important;
}
.btn-danger:hover {
filter: brightness(1.1);
}
[data-theme="light"] .legal-modal-content {
background: #FFFFFF;
border-color: #E0E0E0;
}
[data-theme="light"] .legal-tabs {
background: #F5F5F5;
}
[data-theme="light"] .legal-tab.active {
background: var(--bp-primary);
color: white;
}
[data-theme="light"] .cookie-category,
[data-theme="light"] .gdpr-action {
background: #F8F8F8;
border-color: #E0E0E0;
}
"""
def get_legal_modal_html() -> str:
"""HTML für Legal Modal zurückgeben"""
return """
<!-- Legal Modal -->
<div id="legal-modal" class="legal-modal">
<div class="legal-modal-content">
<div class="legal-modal-header">
<h2>Rechtliches</h2>
<button id="legal-modal-close" class="legal-modal-close">&times;</button>
</div>
<div class="legal-tabs">
<button class="legal-tab active" data-tab="terms">AGB</button>
<button class="legal-tab" data-tab="privacy">Datenschutz</button>
<button class="legal-tab" data-tab="community">Community Guidelines</button>
<button class="legal-tab" data-tab="cookies">Cookie-Einstellungen</button>
<button class="legal-tab" data-tab="gdpr">DSGVO-Rechte</button>
</div>
<div class="legal-body">
<div id="legal-terms" class="legal-content active">
<div id="legal-terms-content">
<div class="legal-loading">Lade AGB...</div>
</div>
</div>
<div id="legal-privacy" class="legal-content">
<div id="legal-privacy-content">
<div class="legal-loading">Lade Datenschutzerklärung...</div>
</div>
</div>
<div id="legal-community" class="legal-content">
<div id="legal-community-content">
<div class="legal-loading">Lade Community Guidelines...</div>
</div>
</div>
<div id="legal-cookies" class="legal-content">
<h3>Cookie-Einstellungen</h3>
<p>Wir verwenden Cookies, um Ihnen die bestmögliche Erfahrung zu bieten. Hier können Sie Ihre Präferenzen jederzeit anpassen.</p>
<div id="cookie-categories-container" class="cookie-categories">
<div class="legal-loading">Lade Cookie-Kategorien...</div>
</div>
<button class="btn btn-primary" style="margin-top:16px" onclick="saveCookiePreferences()">Einstellungen speichern</button>
</div>
<div id="legal-gdpr" class="legal-content">
<h3>Ihre DSGVO-Rechte</h3>
<p>Nach der Datenschutz-Grundverordnung haben Sie folgende Rechte:</p>
<div class="gdpr-actions">
<div class="gdpr-action">
<h4>📋 Datenauskunft (Art. 15)</h4>
<p>Erfahren Sie, welche Daten wir über Sie gespeichert haben.</p>
<button class="btn btn-sm" onclick="requestDataExport()">Meine Daten anfordern</button>
</div>
<div class="gdpr-action">
<h4>📤 Datenübertragbarkeit (Art. 20)</h4>
<p>Exportieren Sie Ihre Daten in einem maschinenlesbaren Format.</p>
<button class="btn btn-sm" onclick="requestDataDownload()">Daten exportieren</button>
</div>
<div class="gdpr-action">
<h4>🗑️ Recht auf Löschung (Art. 17)</h4>
<p>Beantragen Sie die vollständige Löschung Ihrer Daten.</p>
<button class="btn btn-sm btn-danger" onclick="requestDataDeletion()">Löschung beantragen</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Imprint Modal (Impressum - muss direkt erreichbar sein) -->
<div id="imprint-modal" class="legal-modal">
<div class="legal-modal-content">
<div class="legal-modal-header">
<h2>Impressum</h2>
<button id="imprint-modal-close" class="legal-modal-close">&times;</button>
</div>
<div class="legal-body" style="padding: 24px;">
<div id="imprint-content">
<div class="legal-loading">Lade Impressum...</div>
</div>
</div>
</div>
</div>
"""
def get_legal_modal_js() -> str:
"""JavaScript für Legal Modal zurückgeben"""
return """
const legalModal = document.getElementById('legal-modal');
const legalModalClose = document.getElementById('legal-modal-close');
const legalTabs = document.querySelectorAll('.legal-tab');
const legalContents = document.querySelectorAll('.legal-content');
const btnLegal = document.getElementById('btn-legal');
// Imprint Modal
const imprintModal = document.getElementById('imprint-modal');
const imprintModalClose = document.getElementById('imprint-modal-close');
// Open legal modal from footer
function openLegalModal(tab = 'terms') {
legalModal.classList.add('active');
// Switch to specified tab
if (tab) {
legalTabs.forEach(t => t.classList.remove('active'));
legalContents.forEach(c => c.classList.remove('active'));
const targetTab = document.querySelector(`.legal-tab[data-tab="${tab}"]`);
if (targetTab) targetTab.classList.add('active');
document.getElementById(`legal-${tab}`)?.classList.add('active');
}
loadLegalDocuments();
}
// Open imprint modal from footer
function openImprintModal() {
imprintModal.classList.add('active');
loadImprintContent();
}
// Open legal modal
btnLegal?.addEventListener('click', async () => {
openLegalModal();
});
// Close legal modal
legalModalClose?.addEventListener('click', () => {
legalModal.classList.remove('active');
});
// Close imprint modal
imprintModalClose?.addEventListener('click', () => {
imprintModal.classList.remove('active');
});
// Close on background click
legalModal?.addEventListener('click', (e) => {
if (e.target === legalModal) {
legalModal.classList.remove('active');
}
});
imprintModal?.addEventListener('click', (e) => {
if (e.target === imprintModal) {
imprintModal.classList.remove('active');
}
});
// Tab switching
legalTabs.forEach(tab => {
tab.addEventListener('click', () => {
const tabId = tab.dataset.tab;
legalTabs.forEach(t => t.classList.remove('active'));
legalContents.forEach(c => c.classList.remove('active'));
tab.classList.add('active');
document.getElementById(`legal-${tabId}`)?.classList.add('active');
// Load cookie categories when switching to cookies tab
if (tabId === 'cookies') {
loadCookieCategories();
}
});
});
// Load legal documents from consent service
async function loadLegalDocuments() {
const lang = document.getElementById('language-select')?.value || 'de';
// Load all documents in parallel
await Promise.all([
loadDocumentContent('terms', 'legal-terms-content', getDefaultTerms, lang),
loadDocumentContent('privacy', 'legal-privacy-content', getDefaultPrivacy, lang),
loadDocumentContent('community_guidelines', 'legal-community-content', getDefaultCommunityGuidelines, lang)
]);
}
// Load imprint content
async function loadImprintContent() {
const lang = document.getElementById('language-select')?.value || 'de';
await loadDocumentContent('imprint', 'imprint-content', getDefaultImprint, lang);
}
// Generic function to load document content
async function loadDocumentContent(docType, containerId, defaultFn, lang) {
const container = document.getElementById(containerId);
if (!container) return;
try {
const res = await fetch(`/api/consent/documents/${docType}/latest?language=${lang}`);
if (res.ok) {
const data = await res.json();
if (data.content) {
container.innerHTML = data.content;
return;
}
}
} catch(e) {
console.log(`Could not load ${docType}:`, e);
}
// Fallback to default
container.innerHTML = defaultFn(lang);
}
// Load cookie categories for the cookie settings tab
async function loadCookieCategories() {
const container = document.getElementById('cookie-categories-container');
if (!container) return;
try {
const res = await fetch('/api/consent/cookies/categories');
if (res.ok) {
const data = await res.json();
const categories = data.categories || [];
if (categories.length === 0) {
container.innerHTML = getDefaultCookieCategories();
return;
}
// Get current preferences from localStorage
const savedPrefs = JSON.parse(localStorage.getItem('bp_cookie_consent') || '{}');
container.innerHTML = categories.map(cat => `
<label class="cookie-category">
<input type="checkbox" id="cookie-${cat.name}"
${cat.is_mandatory ? 'checked disabled' : (savedPrefs[cat.name] ? 'checked' : '')}>
<span>
<strong>${cat.display_name_de || cat.name}</strong>
${cat.is_mandatory ? ' (erforderlich)' : ''}
${cat.description_de ? ` - ${cat.description_de}` : ''}
</span>
</label>
`).join('');
} else {
container.innerHTML = getDefaultCookieCategories();
}
} catch(e) {
container.innerHTML = getDefaultCookieCategories();
}
}
function getDefaultCookieCategories() {
return `
<label class="cookie-category">
<input type="checkbox" checked disabled>
<span><strong>Notwendig</strong> (erforderlich) - Erforderlich für die Grundfunktionen</span>
</label>
<label class="cookie-category">
<input type="checkbox" id="cookie-functional">
<span><strong>Funktional</strong> - Erweiterte Funktionen und Personalisierung</span>
</label>
<label class="cookie-category">
<input type="checkbox" id="cookie-analytics">
<span><strong>Analyse</strong> - Hilft uns, die Nutzung zu verstehen</span>
</label>
`;
}
function getDefaultTerms(lang) {
const terms = {
de: '<h3>Allgemeine Geschäftsbedingungen</h3><p>Die BreakPilot-Plattform wird von der BreakPilot UG bereitgestellt.</p><p><strong>Nutzung:</strong> Die Plattform dient zur Erstellung und Verwaltung von Lernmaterialien für Bildungszwecke.</p><p><strong>Haftung:</strong> Die Nutzung erfolgt auf eigene Verantwortung.</p><p><strong>Änderungen:</strong> Wir behalten uns vor, diese AGB jederzeit zu ändern.</p>',
en: '<h3>Terms of Service</h3><p>The BreakPilot platform is provided by BreakPilot UG.</p><p><strong>Usage:</strong> The platform is designed for creating and managing learning materials for educational purposes.</p><p><strong>Liability:</strong> Use at your own risk.</p><p><strong>Changes:</strong> We reserve the right to modify these terms at any time.</p>'
};
return terms[lang] || terms.de;
}
function getDefaultPrivacy(lang) {
const privacy = {
de: '<h3>Datenschutzerklärung</h3><p><strong>Verantwortlicher:</strong> BreakPilot UG</p><p><strong>Erhobene Daten:</strong> Bei der Nutzung werden technische Daten (IP-Adresse, Browser-Typ) sowie von Ihnen eingegebene Inhalte verarbeitet.</p><p><strong>Zweck:</strong> Die Daten werden zur Bereitstellung der Plattform und zur Verbesserung unserer Dienste genutzt.</p><p><strong>Ihre Rechte (DSGVO):</strong></p><ul><li>Auskunftsrecht (Art. 15)</li><li>Recht auf Berichtigung (Art. 16)</li><li>Recht auf Löschung (Art. 17)</li><li>Recht auf Datenübertragbarkeit (Art. 20)</li></ul><p><strong>Kontakt:</strong> datenschutz@breakpilot.app</p>',
en: '<h3>Privacy Policy</h3><p><strong>Controller:</strong> BreakPilot UG</p><p><strong>Data Collected:</strong> Technical data (IP address, browser type) and content you provide are processed.</p><p><strong>Purpose:</strong> Data is used to provide the platform and improve our services.</p><p><strong>Your Rights (GDPR):</strong></p><ul><li>Right of access (Art. 15)</li><li>Right to rectification (Art. 16)</li><li>Right to erasure (Art. 17)</li><li>Right to data portability (Art. 20)</li></ul><p><strong>Contact:</strong> privacy@breakpilot.app</p>'
};
return privacy[lang] || privacy.de;
}
function getDefaultCommunityGuidelines(lang) {
const guidelines = {
de: '<h3>Community Guidelines</h3><p>Willkommen bei BreakPilot! Um eine positive und respektvolle Umgebung zu gewährleisten, bitten wir alle Nutzer, diese Richtlinien zu befolgen.</p><p><strong>Respektvoller Umgang:</strong> Behandeln Sie andere Nutzer mit Respekt und Höflichkeit.</p><p><strong>Keine illegalen Inhalte:</strong> Das Erstellen oder Teilen von illegalen Inhalten ist streng untersagt.</p><p><strong>Urheberrecht:</strong> Respektieren Sie das geistige Eigentum anderer. Verwenden Sie nur Inhalte, für die Sie die Rechte besitzen.</p><p><strong>Datenschutz:</strong> Teilen Sie keine persönlichen Daten anderer ohne deren ausdrückliche Zustimmung.</p><p><strong>Qualität:</strong> Bemühen Sie sich um qualitativ hochwertige Lerninhalte.</p><p>Verstöße gegen diese Richtlinien können zur Sperrung des Accounts führen.</p>',
en: '<h3>Community Guidelines</h3><p>Welcome to BreakPilot! To ensure a positive and respectful environment, we ask all users to follow these guidelines.</p><p><strong>Respectful Behavior:</strong> Treat other users with respect and courtesy.</p><p><strong>No Illegal Content:</strong> Creating or sharing illegal content is strictly prohibited.</p><p><strong>Copyright:</strong> Respect the intellectual property of others. Only use content you have rights to.</p><p><strong>Privacy:</strong> Do not share personal data of others without their explicit consent.</p><p><strong>Quality:</strong> Strive for high-quality learning content.</p><p>Violations of these guidelines may result in account suspension.</p>'
};
return guidelines[lang] || guidelines.de;
}
function getDefaultImprint(lang) {
const imprint = {
de: '<h3>Impressum</h3><p><strong>Angaben gemäß § 5 TMG:</strong></p><p>BreakPilot UG (haftungsbeschränkt)<br>Musterstraße 1<br>12345 Musterstadt<br>Deutschland</p><p><strong>Vertreten durch:</strong><br>Geschäftsführer: Max Mustermann</p><p><strong>Kontakt:</strong><br>Telefon: +49 (0) 123 456789<br>E-Mail: info@breakpilot.app</p><p><strong>Registereintrag:</strong><br>Eintragung im Handelsregister<br>Registergericht: Amtsgericht Musterstadt<br>Registernummer: HRB 12345</p><p><strong>Umsatzsteuer-ID:</strong><br>Umsatzsteuer-Identifikationsnummer gemäß § 27 a UStG: DE123456789</p><p><strong>Verantwortlich für den Inhalt nach § 55 Abs. 2 RStV:</strong><br>Max Mustermann<br>Musterstraße 1<br>12345 Musterstadt</p>',
en: '<h3>Legal Notice</h3><p><strong>Information according to § 5 TMG:</strong></p><p>BreakPilot UG (limited liability)<br>Musterstraße 1<br>12345 Musterstadt<br>Germany</p><p><strong>Represented by:</strong><br>Managing Director: Max Mustermann</p><p><strong>Contact:</strong><br>Phone: +49 (0) 123 456789<br>Email: info@breakpilot.app</p><p><strong>Register entry:</strong><br>Entry in the commercial register<br>Register court: Amtsgericht Musterstadt<br>Register number: HRB 12345</p><p><strong>VAT ID:</strong><br>VAT identification number according to § 27 a UStG: DE123456789</p><p><strong>Responsible for content according to § 55 Abs. 2 RStV:</strong><br>Max Mustermann<br>Musterstraße 1<br>12345 Musterstadt</p>'
};
return imprint[lang] || imprint.de;
}
// Save cookie preferences
function saveCookiePreferences() {
const prefs = {};
const checkboxes = document.querySelectorAll('#cookie-categories-container input[type="checkbox"]');
checkboxes.forEach(cb => {
const name = cb.id.replace('cookie-', '');
if (name && !cb.disabled) {
prefs[name] = cb.checked;
}
});
localStorage.setItem('bp_cookie_consent', JSON.stringify(prefs));
localStorage.setItem('bp_cookie_consent_date', new Date().toISOString());
// TODO: Send to consent service if user is logged in
alert('Cookie-Einstellungen gespeichert!');
}
"""

View File

@@ -0,0 +1,743 @@
"""
Local LLM Component - Transformers.js + ONNX Integration.
Ermoeglicht Header-Extraktion aus Klausuren direkt im Browser:
- Laeuft vollstaendig lokal (Privacy-by-Design)
- ONNX Modell wird beim PWA-Install gecacht
- Extrahiert: Namen, Klasse, Fach, Datum
Architektur:
1. Service Worker cacht ONNX Modell (~100MB)
2. Transformers.js laedt Modell aus Cache
3. Header-Region wird per Canvas extrahiert
4. Vision-Modell extrahiert strukturierte Daten
"""
class LocalLLMComponent:
"""PWA Local LLM Component fuer Header-Extraktion."""
# Modell-Konfiguration
MODEL_ID = "breakpilot/exam-header-extractor"
MODEL_CACHE_NAME = "breakpilot-llm-v1"
MODEL_SIZE_MB = 100
@staticmethod
def get_css() -> str:
"""CSS fuer Local LLM UI-Elemente."""
return """
/* Local LLM Status Indicator */
.local-llm-status {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--bp-surface-elevated);
border-radius: 8px;
font-size: 13px;
}
.local-llm-status.loading {
background: var(--bp-warning-bg);
}
.local-llm-status.ready {
background: var(--bp-success-bg);
}
.local-llm-status.error {
background: var(--bp-error-bg);
}
.llm-status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--bp-text-muted);
}
.local-llm-status.loading .llm-status-dot {
background: var(--bp-warning);
animation: pulse 1.5s infinite;
}
.local-llm-status.ready .llm-status-dot {
background: var(--bp-success);
}
.local-llm-status.error .llm-status-dot {
background: var(--bp-error);
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* Extraction Progress */
.extraction-progress {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
background: var(--bp-surface);
border-radius: 12px;
border: 1px solid var(--bp-border);
}
.extraction-progress-bar {
height: 6px;
background: var(--bp-border);
border-radius: 3px;
overflow: hidden;
}
.extraction-progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--bp-primary), var(--bp-accent));
border-radius: 3px;
transition: width 0.3s ease;
}
.extraction-stats {
display: flex;
justify-content: space-between;
font-size: 13px;
color: var(--bp-text-muted);
}
/* Instant Feedback Card */
.instant-feedback-card {
background: linear-gradient(135deg, var(--bp-primary-bg), var(--bp-accent-bg));
border-radius: 16px;
padding: 24px;
text-align: center;
animation: fadeInUp 0.5s ease;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.instant-feedback-card h3 {
margin: 0 0 16px 0;
font-size: 20px;
color: var(--bp-text);
}
.detected-info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 16px;
margin-top: 16px;
}
.detected-info-item {
background: var(--bp-surface);
border-radius: 12px;
padding: 16px;
text-align: center;
}
.detected-info-item .label {
font-size: 12px;
color: var(--bp-text-muted);
margin-bottom: 4px;
}
.detected-info-item .value {
font-size: 18px;
font-weight: 600;
color: var(--bp-text);
}
.detected-info-item .confidence {
font-size: 11px;
color: var(--bp-success);
margin-top: 4px;
}
"""
@staticmethod
def get_html() -> str:
"""HTML fuer Local LLM UI-Elemente (wird in Wizard eingebettet)."""
return """
<!-- Local LLM Status (oben im Wizard) -->
<div id="local-llm-status" class="local-llm-status" style="display: none;">
<span class="llm-status-dot"></span>
<span class="llm-status-text">KI-Modell wird geladen...</span>
</div>
<!-- Instant Feedback (nach Upload) -->
<div id="instant-feedback" class="instant-feedback-card" style="display: none;">
<h3>Automatisch erkannt</h3>
<div class="detected-info-grid">
<div class="detected-info-item">
<div class="label">Klasse</div>
<div class="value" id="detected-class">-</div>
<div class="confidence" id="detected-class-conf"></div>
</div>
<div class="detected-info-item">
<div class="label">Fach</div>
<div class="value" id="detected-subject">-</div>
<div class="confidence" id="detected-subject-conf"></div>
</div>
<div class="detected-info-item">
<div class="label">Schueler</div>
<div class="value" id="detected-count">-</div>
</div>
<div class="detected-info-item">
<div class="label">Datum</div>
<div class="value" id="detected-date">-</div>
</div>
</div>
</div>
<!-- Extraction Progress -->
<div id="extraction-progress" class="extraction-progress" style="display: none;">
<div class="extraction-progress-bar">
<div class="extraction-progress-fill" id="extraction-fill" style="width: 0%"></div>
</div>
<div class="extraction-stats">
<span id="extraction-current">0 / 0 analysiert</span>
<span id="extraction-time">~0 Sek verbleibend</span>
</div>
</div>
"""
@staticmethod
def get_js() -> str:
"""JavaScript fuer Transformers.js + ONNX Integration."""
return """
// ============================================================
// LOCAL LLM - Transformers.js + ONNX Header Extraction
// ============================================================
// Konfiguration
const LOCAL_LLM_CONFIG = {
// Transformers.js CDN
transformersUrl: 'https://cdn.jsdelivr.net/npm/@xenova/transformers@2.17.1',
// Modell fuer Header-Extraktion (Vision + Text)
// Option 1: Florence-2 (Microsoft) - gut fuer OCR + Strukturerkennung
// Option 2: PaddleOCR via ONNX - spezialisiert auf Handschrift
// Option 3: Custom fine-tuned Modell
modelId: 'Xenova/florence-2-base', // Fallback: Xenova/vit-gpt2-image-captioning
// Alternative: Lokaler OCR-basierter Ansatz
useOcrFallback: true,
// Cache
cacheName: 'breakpilot-llm-v1',
// Performance
maxParallelExtractions: 4,
headerRegionPercent: 0.20 // Top 20% der Seite
};
// State
let localLLMState = {
isLoading: false,
isReady: false,
error: null,
pipeline: null,
ocrWorker: null
};
// ============================================================
// INITIALIZATION
// ============================================================
/**
* Initialisiert das lokale LLM (Transformers.js).
* Wird beim ersten Magic-Onboarding-Start aufgerufen.
*/
async function initLocalLLM() {
if (localLLMState.isReady) return true;
if (localLLMState.isLoading) {
// Warten bis geladen
return new Promise((resolve) => {
const checkReady = setInterval(() => {
if (localLLMState.isReady) {
clearInterval(checkReady);
resolve(true);
}
}, 100);
});
}
localLLMState.isLoading = true;
updateLLMStatus('loading', 'KI-Modell wird geladen...');
try {
// Transformers.js dynamisch laden
if (!window.Transformers) {
await loadScript(LOCAL_LLM_CONFIG.transformersUrl);
}
// OCR Worker initialisieren (Tesseract.js als Fallback)
if (LOCAL_LLM_CONFIG.useOcrFallback) {
await initOCRWorker();
}
localLLMState.isReady = true;
localLLMState.isLoading = false;
updateLLMStatus('ready', 'KI bereit');
console.log('[LocalLLM] Initialisierung abgeschlossen');
return true;
} catch (error) {
console.error('[LocalLLM] Fehler bei Initialisierung:', error);
localLLMState.error = error;
localLLMState.isLoading = false;
updateLLMStatus('error', 'KI-Fehler: ' + error.message);
return false;
}
}
/**
* Initialisiert Tesseract.js als OCR-Fallback.
*/
async function initOCRWorker() {
// Tesseract.js fuer deutsche Handschrifterkennung
if (!window.Tesseract) {
await loadScript('https://cdn.jsdelivr.net/npm/tesseract.js@5/dist/tesseract.min.js');
}
localLLMState.ocrWorker = await Tesseract.createWorker('deu', 1, {
logger: m => {
if (m.status === 'recognizing text') {
updateExtractionProgress(m.progress * 100);
}
}
});
console.log('[LocalLLM] OCR Worker initialisiert (Deutsch)');
}
// ============================================================
// HEADER EXTRACTION
// ============================================================
/**
* Extrahiert Header-Daten aus mehreren Bildern.
* @param {File[]} files - Array von Bild-Dateien
* @returns {Promise<ExtractionResult>}
*/
async function extractExamHeaders(files) {
if (!localLLMState.isReady) {
await initLocalLLM();
}
const startTime = Date.now();
showExtractionProgress(true);
const results = {
students: [],
detectedClass: null,
detectedSubject: null,
detectedDate: null,
classConfidence: 0,
subjectConfidence: 0,
errors: []
};
// Parallele Verarbeitung mit Limit
const batchSize = LOCAL_LLM_CONFIG.maxParallelExtractions;
for (let i = 0; i < files.length; i += batchSize) {
const batch = files.slice(i, Math.min(i + batchSize, files.length));
const batchResults = await Promise.all(
batch.map(file => extractSingleHeader(file))
);
// Ergebnisse aggregieren
for (const result of batchResults) {
if (result.error) {
results.errors.push(result.error);
continue;
}
if (result.studentName) {
results.students.push({
firstName: result.firstName || result.studentName,
lastNameHint: result.lastNameHint,
fullName: result.studentName,
confidence: result.nameConfidence
});
}
// Klasse/Fach/Datum aggregieren (hoechste Konfidenz gewinnt)
if (result.className && result.classConfidence > results.classConfidence) {
results.detectedClass = result.className;
results.classConfidence = result.classConfidence;
}
if (result.subject && result.subjectConfidence > results.subjectConfidence) {
results.detectedSubject = result.subject;
results.subjectConfidence = result.subjectConfidence;
}
if (result.date && !results.detectedDate) {
results.detectedDate = result.date;
}
}
// Progress Update
const progress = Math.min(100, ((i + batch.length) / files.length) * 100);
const elapsed = (Date.now() - startTime) / 1000;
const remaining = (elapsed / (i + batch.length)) * (files.length - i - batch.length);
updateExtractionProgress(progress, i + batch.length, files.length, remaining);
}
showExtractionProgress(false);
return results;
}
/**
* Extrahiert Header-Daten aus einem einzelnen Bild.
*/
async function extractSingleHeader(file) {
try {
// 1. Bild laden
const img = await loadImageFromFile(file);
// 2. Header-Region extrahieren (Top 20%)
const headerCanvas = extractHeaderRegion(img);
// 3. OCR auf Header-Region
const ocrResult = await performOCR(headerCanvas);
// 4. Strukturierte Daten extrahieren
const parsed = parseHeaderText(ocrResult.text);
return {
...parsed,
nameConfidence: ocrResult.confidence,
rawText: ocrResult.text
};
} catch (error) {
console.error('[LocalLLM] Fehler bei Extraktion:', error);
return { error: error.message };
}
}
/**
* Laedt ein Bild aus einer File.
*/
function loadImageFromFile(file) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = URL.createObjectURL(file);
});
}
/**
* Extrahiert die Header-Region (Top X%) aus einem Bild.
*/
function extractHeaderRegion(img) {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const headerHeight = Math.floor(img.height * LOCAL_LLM_CONFIG.headerRegionPercent);
canvas.width = img.width;
canvas.height = headerHeight;
ctx.drawImage(img, 0, 0, img.width, headerHeight, 0, 0, img.width, headerHeight);
return canvas;
}
/**
* Fuehrt OCR auf einem Canvas aus.
*/
async function performOCR(canvas) {
if (!localLLMState.ocrWorker) {
throw new Error('OCR Worker nicht initialisiert');
}
const { data } = await localLLMState.ocrWorker.recognize(canvas);
return {
text: data.text,
confidence: data.confidence / 100,
words: data.words
};
}
/**
* Parst OCR-Text und extrahiert strukturierte Daten.
*/
function parseHeaderText(text) {
const result = {
studentName: null,
firstName: null,
lastNameHint: null,
className: null,
classConfidence: 0,
subject: null,
subjectConfidence: 0,
date: null
};
const lines = text.split('\\n').map(l => l.trim()).filter(l => l);
for (const line of lines) {
// Klassenname erkennen (z.B. "3a", "Klasse 10b", "Q1")
const classMatch = line.match(/\\b(Klasse\\s*)?(\\d{1,2}[a-zA-Z]?|Q[12]|E[1-2]|EF|[KG]\\d)\\b/i);
if (classMatch) {
result.className = classMatch[2] || classMatch[0];
result.classConfidence = 0.9;
}
// Fach erkennen
const subjects = {
'mathe': 'Mathematik',
'mathematik': 'Mathematik',
'deutsch': 'Deutsch',
'englisch': 'Englisch',
'english': 'Englisch',
'physik': 'Physik',
'chemie': 'Chemie',
'biologie': 'Biologie',
'bio': 'Biologie',
'geschichte': 'Geschichte',
'erdkunde': 'Erdkunde',
'geographie': 'Geographie',
'politik': 'Politik',
'kunst': 'Kunst',
'musik': 'Musik',
'sport': 'Sport',
'religion': 'Religion',
'ethik': 'Ethik',
'informatik': 'Informatik',
'latein': 'Latein',
'franzoesisch': 'Franzoesisch',
'französisch': 'Franzoesisch',
'spanisch': 'Spanisch'
};
const lowerLine = line.toLowerCase();
for (const [key, value] of Object.entries(subjects)) {
if (lowerLine.includes(key)) {
result.subject = value;
result.subjectConfidence = 0.95;
break;
}
}
// Datum erkennen (verschiedene Formate)
const datePatterns = [
/\\b(\\d{1,2})\\.(\\d{1,2})\\.(\\d{2,4})\\b/, // 12.01.2026
/\\b(\\d{1,2})\\s+(Januar|Februar|Maerz|April|Mai|Juni|Juli|August|September|Oktober|November|Dezember)\\s+(\\d{4})\\b/i
];
for (const pattern of datePatterns) {
const dateMatch = line.match(pattern);
if (dateMatch) {
result.date = dateMatch[0];
break;
}
}
// Name erkennen (erste Zeile mit "Name:" oder alleinstehender Name)
if (line.toLowerCase().includes('name')) {
const nameMatch = line.match(/name[:\\s]+(.+)/i);
if (nameMatch) {
result.studentName = nameMatch[1].trim();
parseStudentName(result);
}
} else if (!result.studentName && lines.indexOf(line) < 3) {
// Erste Zeilen koennen Namen sein
const potentialName = line.replace(/[^a-zA-ZaeoeueAeOeUess\\s.-]/g, '').trim();
if (potentialName.length >= 2 && potentialName.length <= 40) {
// Pruefen ob es wie ein Name aussieht (Gross-/Kleinschreibung)
if (/^[A-ZAEOEUE][a-zaeoeue]+/.test(potentialName)) {
result.studentName = potentialName;
parseStudentName(result);
}
}
}
}
return result;
}
/**
* Parst einen Schuelernamen in Vor- und Nachname.
*/
function parseStudentName(result) {
if (!result.studentName) return;
const name = result.studentName.trim();
const parts = name.split(/\\s+/);
if (parts.length === 1) {
// Nur Vorname
result.firstName = parts[0];
} else if (parts.length === 2) {
// Vorname Nachname oder Nachname, Vorname
if (parts[0].endsWith(',')) {
result.firstName = parts[1];
result.lastNameHint = parts[0].replace(',', '');
} else {
result.firstName = parts[0];
result.lastNameHint = parts[1];
}
} else {
// Mehrere Teile - erster ist Vorname
result.firstName = parts[0];
result.lastNameHint = parts.slice(1).join(' ');
}
// Abkuerzungen erkennen (z.B. "M." fuer Nachname)
if (result.lastNameHint && result.lastNameHint.match(/^[A-Z]\\.?$/)) {
result.lastNameHint = result.lastNameHint.replace('.', '') + '.';
}
}
// ============================================================
// UI HELPERS
// ============================================================
/**
* Aktualisiert den LLM-Status-Indikator.
*/
function updateLLMStatus(state, text) {
const statusEl = document.getElementById('local-llm-status');
if (!statusEl) return;
statusEl.style.display = 'flex';
statusEl.className = 'local-llm-status ' + state;
statusEl.querySelector('.llm-status-text').textContent = text;
}
/**
* Zeigt das Instant-Feedback-Panel.
*/
function showInstantFeedback(data) {
const feedbackEl = document.getElementById('instant-feedback');
if (!feedbackEl) return;
feedbackEl.style.display = 'block';
document.getElementById('detected-class').textContent = data.detectedClass || '-';
document.getElementById('detected-subject').textContent = data.detectedSubject || '-';
document.getElementById('detected-count').textContent = data.studentCount || '0';
document.getElementById('detected-date').textContent = data.detectedDate || '-';
// Konfidenz anzeigen
if (data.classConfidence) {
document.getElementById('detected-class-conf').textContent =
Math.round(data.classConfidence * 100) + '% sicher';
}
if (data.subjectConfidence) {
document.getElementById('detected-subject-conf').textContent =
Math.round(data.subjectConfidence * 100) + '% sicher';
}
}
/**
* Zeigt/versteckt den Extraktions-Fortschritt.
*/
function showExtractionProgress(show) {
const progressEl = document.getElementById('extraction-progress');
if (progressEl) {
progressEl.style.display = show ? 'flex' : 'none';
}
}
/**
* Aktualisiert den Extraktions-Fortschritt.
*/
function updateExtractionProgress(percent, current, total, remainingSec) {
const fillEl = document.getElementById('extraction-fill');
const currentEl = document.getElementById('extraction-current');
const timeEl = document.getElementById('extraction-time');
if (fillEl) fillEl.style.width = percent + '%';
if (currentEl && current !== undefined) {
currentEl.textContent = current + ' / ' + total + ' analysiert';
}
if (timeEl && remainingSec !== undefined) {
timeEl.textContent = '~' + Math.ceil(remainingSec) + ' Sek verbleibend';
}
}
/**
* Laedt ein Script dynamisch.
*/
function loadScript(url) {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = url;
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
// ============================================================
// MAGIC ONBOARDING INTEGRATION
// ============================================================
/**
* Hauptfunktion fuer Magic-Analyse beim Upload.
* Wird von klausur_korrektur.py aufgerufen.
*/
async function magicAnalyzeExams(files) {
console.log('[MagicOnboarding] Starte Analyse von', files.length, 'Dateien');
// 1. LLM initialisieren
await initLocalLLM();
// 2. Quick Preview (erste 5 Dateien)
const quickFiles = files.slice(0, 5);
const quickResults = await extractExamHeaders(quickFiles);
// 3. Sofort Feedback zeigen (WOW-Effekt!)
showInstantFeedback({
detectedClass: quickResults.detectedClass,
detectedSubject: quickResults.detectedSubject,
studentCount: files.length,
detectedDate: quickResults.detectedDate,
classConfidence: quickResults.classConfidence,
subjectConfidence: quickResults.subjectConfidence
});
// 4. Rest im Hintergrund verarbeiten
if (files.length > 5) {
const remainingFiles = files.slice(5);
const remainingResults = await extractExamHeaders(remainingFiles);
// Ergebnisse mergen
quickResults.students = [
...quickResults.students,
...remainingResults.students
];
}
console.log('[MagicOnboarding] Analyse abgeschlossen:', quickResults);
return quickResults;
}
// Export fuer globalen Zugriff
window.LocalLLM = {
init: initLocalLLM,
extractHeaders: extractExamHeaders,
magicAnalyze: magicAnalyzeExams,
getStatus: () => localLLMState
};
"""