klausur-service (11 files): - cv_gutter_repair, ocr_pipeline_regression, upload_api - ocr_pipeline_sessions, smart_spell, nru_worksheet_generator - ocr_pipeline_overlays, mail/aggregator, zeugnis_api - cv_syllable_detect, self_rag backend-lehrer (17 files): - classroom_engine/suggestions, generators/quiz_generator - worksheets_api, llm_gateway/comparison, state_engine_api - classroom/models (→ 4 submodules), services/file_processor - alerts_agent/api/wizard+digests+routes, content_generators/pdf - classroom/routes/sessions, llm_gateway/inference - classroom_engine/analytics, auth/keycloak_auth - alerts_agent/processing/rule_engine, ai_processor/print_versions agent-core (5 files): - brain/memory_store, brain/knowledge_graph, brain/context_manager - orchestrator/supervisor, sessions/session_manager admin-lehrer (5 components): - GridOverlay, StepGridReview, DevOpsPipelineSidebar - DataFlowDiagram, sbom/wizard/page website (2 files): - DependencyMap, lehrer/abitur-archiv Other: nibis_ingestion, grid_detection_service, export-doclayout-onnx Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
293 lines
13 KiB
Python
293 lines
13 KiB
Python
"""
|
|
Mobile Upload HTML Page — serves the mobile upload UI directly from klausur-service.
|
|
|
|
Extracted from upload_api.py for modularity.
|
|
|
|
DSGVO-konform: Data stays local in WLAN, no external transmission.
|
|
"""
|
|
|
|
from fastapi import APIRouter
|
|
from fastapi.responses import HTMLResponse
|
|
|
|
router = APIRouter(prefix="/api/v1/upload", tags=["Mobile Upload"])
|
|
|
|
|
|
@router.get("/mobile", response_class=HTMLResponse)
|
|
async def mobile_upload_page():
|
|
"""
|
|
Serve the mobile upload page directly from the klausur-service.
|
|
This allows mobile devices to upload without needing the Next.js website.
|
|
"""
|
|
html_content = '''<!DOCTYPE html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
<title>BreakPilot Upload</title>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
background: linear-gradient(135deg, #1e293b 0%, #0f172a 100%);
|
|
color: white;
|
|
min-height: 100vh;
|
|
padding: 16px;
|
|
}
|
|
.header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 16px;
|
|
border-bottom: 1px solid #334155;
|
|
margin-bottom: 24px;
|
|
}
|
|
.header h1 { font-size: 20px; color: #60a5fa; }
|
|
.badge { font-size: 10px; background: #1e293b; padding: 4px 8px; border-radius: 4px; color: #94a3b8; }
|
|
.destination-selector {
|
|
display: flex;
|
|
gap: 8px;
|
|
margin-bottom: 24px;
|
|
}
|
|
.dest-btn {
|
|
flex: 1;
|
|
padding: 14px;
|
|
border: none;
|
|
border-radius: 10px;
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
}
|
|
.dest-btn.active-klausur { background: #2563eb; color: white; box-shadow: 0 4px 15px rgba(37, 99, 235, 0.3); }
|
|
.dest-btn.active-rag { background: #7c3aed; color: white; box-shadow: 0 4px 15px rgba(124, 58, 237, 0.3); }
|
|
.dest-btn:not(.active-klausur):not(.active-rag) { background: #1e293b; color: #94a3b8; }
|
|
.upload-zone {
|
|
border: 2px dashed #475569;
|
|
border-radius: 16px;
|
|
padding: 40px 20px;
|
|
text-align: center;
|
|
margin-bottom: 24px;
|
|
transition: all 0.2s;
|
|
position: relative;
|
|
}
|
|
.upload-zone.dragover { border-color: #60a5fa; background: rgba(96, 165, 250, 0.1); transform: scale(1.02); }
|
|
.upload-zone input[type="file"] {
|
|
position: absolute;
|
|
inset: 0;
|
|
opacity: 0;
|
|
cursor: pointer;
|
|
}
|
|
.upload-icon {
|
|
width: 64px;
|
|
height: 64px;
|
|
background: #334155;
|
|
border-radius: 50%;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
margin: 0 auto 16px;
|
|
font-size: 28px;
|
|
}
|
|
.upload-title { font-size: 18px; margin-bottom: 8px; }
|
|
.upload-subtitle { font-size: 14px; color: #94a3b8; margin-bottom: 16px; }
|
|
.upload-hint { font-size: 12px; color: #64748b; }
|
|
.file-list { margin-bottom: 24px; }
|
|
.file-item {
|
|
background: #1e293b;
|
|
border-radius: 12px;
|
|
padding: 16px;
|
|
margin-bottom: 12px;
|
|
}
|
|
.file-item.error { border: 2px solid rgba(239, 68, 68, 0.5); }
|
|
.file-item.complete { border: 2px solid rgba(34, 197, 94, 0.3); }
|
|
.file-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 8px; }
|
|
.file-name { font-weight: 500; word-break: break-all; }
|
|
.file-size { font-size: 14px; color: #94a3b8; }
|
|
.remove-btn { background: none; border: none; color: #94a3b8; font-size: 20px; cursor: pointer; padding: 4px; }
|
|
.progress-bar { height: 6px; background: #334155; border-radius: 3px; overflow: hidden; margin-top: 12px; }
|
|
.progress-fill { height: 100%; background: linear-gradient(90deg, #3b82f6, #60a5fa); transition: width 0.3s; }
|
|
.progress-text { font-size: 12px; color: #94a3b8; margin-top: 4px; }
|
|
.status-complete { display: flex; align-items: center; gap: 8px; color: #22c55e; font-size: 14px; margin-top: 12px; }
|
|
.status-error { display: flex; align-items: center; gap: 8px; color: #ef4444; font-size: 14px; margin-top: 12px; }
|
|
.info-box {
|
|
background: rgba(30, 41, 59, 0.5);
|
|
border-radius: 12px;
|
|
padding: 16px;
|
|
font-size: 14px;
|
|
color: #94a3b8;
|
|
}
|
|
.info-box h3 { color: #cbd5e1; margin-bottom: 8px; font-size: 14px; }
|
|
.info-box ul { padding-left: 20px; }
|
|
.info-box li { margin-bottom: 4px; }
|
|
.server-info { text-align: center; font-size: 12px; color: #64748b; margin-top: 16px; }
|
|
.stats { display: flex; justify-content: space-between; font-size: 14px; color: #94a3b8; padding: 0 8px; margin-bottom: 12px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header class="header">
|
|
<h1>BreakPilot Upload</h1>
|
|
<span class="badge">DSGVO-konform</span>
|
|
</header>
|
|
|
|
<div class="destination-selector">
|
|
<button class="dest-btn active-klausur" id="btn-klausur" onclick="setDestination('klausur')">Klausuren</button>
|
|
<button class="dest-btn" id="btn-rag" onclick="setDestination('rag')">Erwartungshorizonte</button>
|
|
</div>
|
|
|
|
<div class="upload-zone" id="upload-zone">
|
|
<input type="file" accept=".pdf" multiple onchange="handleFiles(this.files)">
|
|
<div class="upload-icon">☁</div>
|
|
<div class="upload-title">PDF-Dateien hochladen</div>
|
|
<div class="upload-subtitle">Tippen zum Auswaehlen oder hierher ziehen</div>
|
|
<div class="upload-hint">Grosse Dateien bis 200 MB werden automatisch in Teilen hochgeladen</div>
|
|
</div>
|
|
|
|
<div class="stats" id="stats" style="display: none;">
|
|
<span id="completed-count">0 von 0 fertig</span>
|
|
<span id="total-size">0 B gesamt</span>
|
|
</div>
|
|
|
|
<div class="file-list" id="file-list"></div>
|
|
|
|
<div class="info-box">
|
|
<h3>Hinweise:</h3>
|
|
<ul>
|
|
<li>Die Dateien werden lokal im WLAN uebertragen</li>
|
|
<li>Keine Daten werden ins Internet gesendet</li>
|
|
<li>Unterstuetzte Formate: PDF</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div class="server-info" id="server-info">Server: wird ermittelt...</div>
|
|
|
|
<script>
|
|
const CHUNK_SIZE = 5 * 1024 * 1024;
|
|
let destination = 'klausur';
|
|
let files = [];
|
|
const serverUrl = window.location.origin;
|
|
document.getElementById('server-info').textContent = 'Server: ' + serverUrl;
|
|
|
|
function setDestination(dest) {
|
|
destination = dest;
|
|
document.querySelectorAll('.dest-btn').forEach(btn => {
|
|
btn.classList.remove('active-klausur', 'active-rag');
|
|
});
|
|
if (dest === 'klausur') {
|
|
document.getElementById('btn-klausur').classList.add('active-klausur');
|
|
} else {
|
|
document.getElementById('btn-rag').classList.add('active-rag');
|
|
}
|
|
}
|
|
|
|
function formatSize(bytes) {
|
|
if (bytes === 0) return '0 B';
|
|
const k = 1024;
|
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
|
}
|
|
|
|
function updateStats() {
|
|
const completed = files.filter(f => f.status === 'complete').length;
|
|
const total = files.reduce((sum, f) => sum + f.size, 0);
|
|
document.getElementById('completed-count').textContent = completed + ' von ' + files.length + ' fertig';
|
|
document.getElementById('total-size').textContent = formatSize(total) + ' gesamt';
|
|
document.getElementById('stats').style.display = files.length > 0 ? 'flex' : 'none';
|
|
}
|
|
|
|
function renderFiles() {
|
|
const list = document.getElementById('file-list');
|
|
list.innerHTML = files.map(f => {
|
|
let statusHtml = '';
|
|
if (f.status === 'uploading' || f.status === 'pending') {
|
|
statusHtml = '<div class="progress-bar"><div class="progress-fill" style="width: ' + f.progress + '%"></div></div><div class="progress-text">' + f.progress + '% hochgeladen</div>';
|
|
} else if (f.status === 'complete') {
|
|
statusHtml = '<div class="status-complete">✓ Erfolgreich hochgeladen</div>';
|
|
} else if (f.status === 'error') {
|
|
statusHtml = '<div class="status-error">⚠ ' + (f.error || 'Fehler beim Hochladen') + '</div>';
|
|
}
|
|
return '<div class="file-item ' + f.status + '"><div class="file-header"><div><div class="file-name">' + f.name + '</div><div class="file-size">' + formatSize(f.size) + '</div></div><button class="remove-btn" onclick="removeFile(\\'' + f.id + '\\')">×</button></div>' + statusHtml + '</div>';
|
|
}).join('');
|
|
updateStats();
|
|
}
|
|
|
|
function removeFile(id) {
|
|
files = files.filter(f => f.id !== id);
|
|
renderFiles();
|
|
}
|
|
|
|
async function uploadFile(file, fileId) {
|
|
const updateProgress = (progress) => {
|
|
const f = files.find(f => f.id === fileId);
|
|
if (f) { f.progress = progress; renderFiles(); }
|
|
};
|
|
const setStatus = (status, error) => {
|
|
const f = files.find(f => f.id === fileId);
|
|
if (f) { f.status = status; if (error) f.error = error; renderFiles(); }
|
|
};
|
|
|
|
try {
|
|
setStatus('uploading');
|
|
if (file.size > 10 * 1024 * 1024) {
|
|
// Chunked upload
|
|
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
|
|
const initRes = await fetch(serverUrl + '/api/v1/upload/init', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ filename: file.name, filesize: file.size, chunks: totalChunks, destination: destination })
|
|
});
|
|
if (!initRes.ok) throw new Error('Konnte Upload nicht starten');
|
|
const { upload_id } = await initRes.json();
|
|
|
|
for (let i = 0; i < totalChunks; i++) {
|
|
const start = i * CHUNK_SIZE;
|
|
const end = Math.min(start + CHUNK_SIZE, file.size);
|
|
const chunk = file.slice(start, end);
|
|
const formData = new FormData();
|
|
formData.append('chunk', chunk);
|
|
formData.append('upload_id', upload_id);
|
|
formData.append('chunk_index', i.toString());
|
|
const chunkRes = await fetch(serverUrl + '/api/v1/upload/chunk', { method: 'POST', body: formData });
|
|
if (!chunkRes.ok) throw new Error('Fehler bei Teil ' + (i + 1));
|
|
updateProgress(Math.round(((i + 1) / totalChunks) * 100));
|
|
}
|
|
const finalizeForm = new FormData();
|
|
finalizeForm.append('upload_id', upload_id);
|
|
const finalRes = await fetch(serverUrl + '/api/v1/upload/finalize', { method: 'POST', body: finalizeForm });
|
|
if (!finalRes.ok) throw new Error('Fehler beim Abschliessen');
|
|
} else {
|
|
// Simple upload
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
formData.append('destination', destination);
|
|
const res = await fetch(serverUrl + '/api/v1/upload/simple', { method: 'POST', body: formData });
|
|
if (!res.ok) throw new Error('Upload fehlgeschlagen');
|
|
updateProgress(100);
|
|
}
|
|
setStatus('complete');
|
|
} catch (e) {
|
|
setStatus('error', e.message);
|
|
}
|
|
}
|
|
|
|
function handleFiles(fileList) {
|
|
const newFiles = Array.from(fileList).filter(f => f.type === 'application/pdf');
|
|
newFiles.forEach(file => {
|
|
const id = Math.random().toString(36).substr(2, 9);
|
|
files.push({ id, name: file.name, size: file.size, progress: 0, status: 'pending', file });
|
|
renderFiles();
|
|
uploadFile(file, id);
|
|
});
|
|
}
|
|
|
|
// Drag & Drop
|
|
const zone = document.getElementById('upload-zone');
|
|
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('dragover'); });
|
|
zone.addEventListener('dragleave', e => { e.preventDefault(); zone.classList.remove('dragover'); });
|
|
zone.addEventListener('drop', e => { e.preventDefault(); zone.classList.remove('dragover'); handleFiles(e.dataTransfer.files); });
|
|
</script>
|
|
</body>
|
|
</html>'''
|
|
return HTMLResponse(content=html_content)
|