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:
232
backend/frontend/components/README.md
Normal file
232
backend/frontend/components/README.md
Normal 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`
|
||||
227
backend/frontend/components/REFACTORING_STATUS.md
Normal file
227
backend/frontend/components/REFACTORING_STATUS.md
Normal 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`
|
||||
30
backend/frontend/components/__init__.py
Normal file
30
backend/frontend/components/__init__.py
Normal 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',
|
||||
]
|
||||
1632
backend/frontend/components/admin_dsms.py
Normal file
1632
backend/frontend/components/admin_dsms.py
Normal file
File diff suppressed because it is too large
Load Diff
507
backend/frontend/components/admin_email.py
Normal file
507
backend/frontend/components/admin_email.py
Normal 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);
|
||||
}
|
||||
}
|
||||
"""
|
||||
640
backend/frontend/components/admin_gpu.py
Normal file
640
backend/frontend/components/admin_gpu.py
Normal 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();
|
||||
});
|
||||
}
|
||||
});
|
||||
"""
|
||||
629
backend/frontend/components/admin_klausur_docs.py
Normal file
629
backend/frontend/components/admin_klausur_docs.py
Normal 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');
|
||||
"""
|
||||
24
backend/frontend/components/admin_panel.py
Normal file
24
backend/frontend/components/admin_panel.py
Normal 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",
|
||||
]
|
||||
22
backend/frontend/components/admin_panel/__init__.py
Normal file
22
backend/frontend/components/admin_panel/__init__.py
Normal 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",
|
||||
]
|
||||
831
backend/frontend/components/admin_panel/markup.py
Normal file
831
backend/frontend/components/admin_panel/markup.py
Normal 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">×</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;">🌐</span> DSMS WebUI
|
||||
</h2>
|
||||
<button id="dsms-webui-modal-close" class="legal-modal-close" onclick="closeDsmsWebUI()">×</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>📈</span> Übersicht
|
||||
</button>
|
||||
<button class="dsms-webui-nav" data-section="files" onclick="switchDsmsWebUISection('files')">
|
||||
<span>📁</span> Dateien
|
||||
</button>
|
||||
<button class="dsms-webui-nav" data-section="explore" onclick="switchDsmsWebUISection('explore')">
|
||||
<span>🔍</span> Erkunden
|
||||
</button>
|
||||
<button class="dsms-webui-nav" data-section="peers" onclick="switchDsmsWebUISection('peers')">
|
||||
<span>🌐</span> Peers
|
||||
</button>
|
||||
<button class="dsms-webui-nav" data-section="config" onclick="switchDsmsWebUISection('config')">
|
||||
<span>⚙</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;">📥</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()">🗑 Garbage Collection</button>
|
||||
<button class="btn btn-ghost btn-sm" onclick="loadDsmsWebUIData()">↻ Daten aktualisieren</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
1428
backend/frontend/components/admin_panel/scripts.py
Normal file
1428
backend/frontend/components/admin_panel/scripts.py
Normal file
File diff suppressed because it is too large
Load Diff
803
backend/frontend/components/admin_panel/styles.py
Normal file
803
backend/frontend/components/admin_panel/styles.py
Normal 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;
|
||||
}
|
||||
"""
|
||||
1066
backend/frontend/components/admin_panel_css.py
Normal file
1066
backend/frontend/components/admin_panel_css.py
Normal file
File diff suppressed because it is too large
Load Diff
1280
backend/frontend/components/admin_panel_html.py
Normal file
1280
backend/frontend/components/admin_panel_html.py
Normal file
File diff suppressed because it is too large
Load Diff
1710
backend/frontend/components/admin_panel_js.py
Normal file
1710
backend/frontend/components/admin_panel_js.py
Normal file
File diff suppressed because it is too large
Load Diff
214
backend/frontend/components/admin_stats.py
Normal file
214
backend/frontend/components/admin_stats.py
Normal 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>`;
|
||||
}
|
||||
}
|
||||
"""
|
||||
1405
backend/frontend/components/auth_modal.py
Normal file
1405
backend/frontend/components/auth_modal.py
Normal file
File diff suppressed because it is too large
Load Diff
320
backend/frontend/components/base.py
Normal file
320
backend/frontend/components/base.py
Normal 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);
|
||||
});
|
||||
}
|
||||
"""
|
||||
191
backend/frontend/components/extract_components.py
Normal file
191
backend/frontend/components/extract_components.py
Normal 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!")
|
||||
514
backend/frontend/components/legal_modal.py
Normal file
514
backend/frontend/components/legal_modal.py
Normal 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">×</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">×</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!');
|
||||
}
|
||||
"""
|
||||
743
backend/frontend/components/local_llm.py
Normal file
743
backend/frontend/components/local_llm.py
Normal 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
|
||||
};
|
||||
"""
|
||||
Reference in New Issue
Block a user