Add event preview with two-step download flow

This commit is contained in:
2026-03-02 22:03:11 +01:00
parent f46763ace7
commit 9e5127a867
2 changed files with 317 additions and 15 deletions

View File

@@ -11,7 +11,7 @@ import os
import secrets
from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, UploadFile, status
from fastapi.responses import FileResponse, HTMLResponse
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from fastapi.templating import Jinja2Templates
from starlette.background import BackgroundTask
@@ -55,6 +55,58 @@ def index(request: Request, _: None = Depends(require_auth)):
)
@app.post("/preview")
async def preview_pdf(
file: UploadFile = File(...),
_: None = Depends(require_auth),
):
filename = file.filename or "dienstplan.pdf"
if not filename.lower().endswith(".pdf"):
return JSONResponse(
{"error": "Bitte eine PDF-Datei hochladen."},
status_code=400,
)
data = await file.read()
if not data:
return JSONResponse(
{"error": "Die Datei ist leer."},
status_code=400,
)
with tempfile.TemporaryDirectory(prefix="pdf_to_ics_web_preview_") as temp_dir:
temp_dir_path = Path(temp_dir)
pdf_path = temp_dir_path / "upload.pdf"
pdf_path.write_bytes(data)
dienstplan = extract_dienstplan_data(str(pdf_path))
if not dienstplan.get("events"):
return JSONResponse(
{"error": "Keine Dienstplan-Events in der PDF gefunden."},
status_code=422,
)
events_preview = []
for event in dienstplan["events"]:
events_preview.append({
"date": event["date"].strftime("%d.%m.%Y") if event["date"] else "",
"service": event["service"] or "",
"time": f"{event['start_time']}-{event['end_time']}" if event["start_time"] and event["end_time"] else "",
})
return JSONResponse({
"success": True,
"filename": filename,
"metadata": {
"name": f"{dienstplan.get('vorname', '')} {dienstplan.get('name', '')}".strip(),
"personalnummer": dienstplan.get("personalnummer", ""),
"betriebshof": dienstplan.get("betriebshof", ""),
"count": len(dienstplan["events"]),
},
"events": events_preview,
})
@app.post("/convert")
async def convert_pdf(
request: Request,

View File

@@ -6,6 +6,7 @@
<title>PDF zu ICS (Web)</title>
<style>
:root { color-scheme: light; }
* { box-sizing: border-box; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
@@ -13,7 +14,7 @@
color: #1f2937;
}
.wrap {
max-width: 520px;
max-width: 600px;
margin: 0 auto;
padding: 20px 14px 28px;
}
@@ -24,7 +25,7 @@
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
}
h1 {
margin: 0 0 10px;
margin: 0 0 6px;
font-size: 1.3rem;
}
p {
@@ -43,7 +44,6 @@
border: 1px solid #d1d5db;
border-radius: 10px;
background: #fff;
box-sizing: border-box;
}
.options {
margin: 12px 0;
@@ -68,8 +68,14 @@
color: #fff;
margin-top: 6px;
cursor: pointer;
transition: background 0.2s;
}
button:hover { background: #1d4ed8; }
button:active { transform: translateY(1px); }
button:disabled {
background: #9ca3af;
cursor: not-allowed;
}
.error {
margin: 0 0 12px;
padding: 10px;
@@ -83,38 +89,282 @@
font-size: 0.9rem;
color: #6b7280;
}
.hidden { display: none; }
.preview-header {
margin: 0 0 12px;
padding: 12px;
background: #f0f9ff;
border-radius: 10px;
border-left: 3px solid #2563eb;
}
.preview-meta {
font-size: 0.9rem;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin: 8px 0 0;
}
.preview-meta-item {
color: #4b5563;
}
.preview-meta-label {
font-weight: 600;
color: #1f2937;
}
.preview-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
margin: 12px 0;
}
.preview-table thead {
background: #f3f4f6;
}
.preview-table th, .preview-table td {
padding: 8px;
text-align: left;
border-bottom: 1px solid #e5e7eb;
}
.preview-table th {
font-weight: 600;
color: #374151;
}
.preview-table tbody tr:hover {
background: #fafafa;
}
.actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.actions button {
margin-top: 0;
}
.btn-secondary {
background: #6b7280;
}
.btn-secondary:hover {
background: #4b5563;
}
.spinner {
display: inline-block;
width: 12px;
height: 12px;
border: 2px solid #e5e7eb;
border-top-color: #2563eb;
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
</head>
<body>
<main class="wrap">
<section class="card">
<h1>PDF zu ICS Konverter</h1>
<p>PDF hochladen, konvertieren und die ICS-Datei direkt herunterladen.</p>
<p>PDF hochladen, Vorschau prüfen und herunterladen.</p>
{% if error %}
<div class="error">{{ error }}</div>
{% endif %}
<div id="error" class="error hidden"></div>
<form action="/convert" method="post" enctype="multipart/form-data">
<!-- UPLOAD-FORMULAR -->
<div id="step-init">
<label for="file">Dienstplan-PDF</label>
<input id="file" name="file" type="file" accept="application/pdf,.pdf" required />
<input id="file" type="file" accept="application/pdf,.pdf" />
<div class="options">
<label class="option">
<input type="checkbox" name="exclude_rest" value="true" />
<input id="exclude_rest" type="checkbox" />
Ruhetage ausschließen
</label>
<label class="option">
<input type="checkbox" name="exclude_vacation" value="true" />
<input id="exclude_vacation" type="checkbox" />
Urlaub ausschließen
</label>
</div>
<button type="submit">ICS erstellen</button>
</form>
<button type="button" onclick="uploadAndPreview()">
Vorschau laden
</button>
<div class="hint">Hinweis: Die Datei wird nur temporär für die Konvertierung verarbeitet.</div>
<div class="hint">Hinweis: Die Datei wird nur temporär verarbeitet.</div>
</div>
<!-- PREVIEW -->
<div id="step2" class="hidden">
<div class="preview-header">
<div style="font-weight: 600; font-size: 0.95rem;" id="preview-name"></div>
<div class="preview-meta">
<div class="preview-meta-item">
<div class="preview-meta-label">Personalnummer</div>
<div id="preview-personalnummer"></div>
</div>
<div class="preview-meta-item">
<div class="preview-meta-label">Betriebshof</div>
<div id="preview-betriebshof"></div>
</div>
</div>
<div style="margin-top: 8px; color: #4b5563;">
<strong id="preview-count"></strong> Ereignisse gefunden
</div>
</div>
<table class="preview-table">
<thead>
<tr>
<th>Datum</th>
<th>Dienstart</th>
<th>Zeit</th>
</tr>
</thead>
<tbody id="preview-events"></tbody>
</table>
<div class="actions">
<button type="button" class="btn-secondary" onclick="resetForm()">Erneut hochladen</button>
<button type="button" onclick="downloadICS()">ICS herunterladen</button>
</div>
</div>
</section>
</main>
<script>
let currentFile = null;
let currentFilename = null;
async function uploadAndPreview() {
const fileInput = document.getElementById("file");
currentFile = fileInput.files[0];
currentFilename = currentFile?.name;
if (!currentFile) {
showError("Bitte eine PDF-Datei auswählen.");
return;
}
const btn = event.target;
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> Lädt Vorschau...';
try {
const formData = new FormData();
formData.append("file", currentFile);
const response = await fetch("/preview", {
method: "POST",
body: formData,
});
if (!response.ok) {
const data = await response.json();
showError(data.error || "Fehler beim Hochladen");
btn.disabled = false;
btn.textContent = "Vorschau laden";
return;
}
const data = await response.json();
if (!data.success) {
showError(data.error || "Fehler beim Vorschau-Laden");
btn.disabled = false;
btn.textContent = "Vorschau laden";
return;
}
showPreview(data);
hide("step-init");
show("step2");
} catch (error) {
showError(`Fehler: ${error.message}`);
btn.disabled = false;
btn.textContent = "Vorschau laden";
}
}
function showPreview(data) {
const { metadata, events } = data;
document.getElementById("preview-name").textContent = metadata.name;
document.getElementById("preview-personalnummer").textContent = metadata.personalnummer;
document.getElementById("preview-betriebshof").textContent = metadata.betriebshof;
document.getElementById("preview-count").textContent = metadata.count;
const tbody = document.getElementById("preview-events");
tbody.innerHTML = "";
events.forEach((event) => {
const tr = document.createElement("tr");
tr.innerHTML = `<td>${event.date}</td><td>${event.service}</td><td>${event.time}</td>`;
tbody.appendChild(tr);
});
}
async function downloadICS() {
const btn = event.target;
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> Download...';
try {
const formData = new FormData();
formData.append("file", currentFile);
formData.append("exclude_rest", document.getElementById("exclude_rest").checked);
formData.append("exclude_vacation", document.getElementById("exclude_vacation").checked);
const response = await fetch("/convert", {
method: "POST",
body: formData,
});
if (!response.ok) {
showError("Fehler beim Download");
btn.disabled = false;
btn.textContent = "ICS herunterladen";
return;
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = currentFilename.replace(/\.pdf$/i, ".ics");
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
a.remove();
resetForm();
} catch (error) {
showError(`Fehler: ${error.message}`);
btn.disabled = false;
btn.textContent = "ICS herunterladen";
}
}
function resetForm() {
document.getElementById("file").value = "";
document.getElementById("exclude_rest").checked = false;
document.getElementById("exclude_vacation").checked = false;
currentFile = null;
currentFilename = null;
hide("step2");
show("step-init");
hide("error");
}
function showError(message) {
const el = document.getElementById("error");
el.textContent = message;
show("error");
}
function show(id) {
document.getElementById(id).classList.remove("hidden");
}
function hide(id) {
document.getElementById(id).classList.add("hidden");
}
</script>
</body>
</html>