Add event preview with two-step download flow
This commit is contained in:
54
web/app.py
54
web/app.py
@@ -11,7 +11,7 @@ import os
|
|||||||
import secrets
|
import secrets
|
||||||
|
|
||||||
from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, UploadFile, status
|
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.security import HTTPBasic, HTTPBasicCredentials
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from starlette.background import BackgroundTask
|
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")
|
@app.post("/convert")
|
||||||
async def convert_pdf(
|
async def convert_pdf(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<title>PDF zu ICS (Web)</title>
|
<title>PDF zu ICS (Web)</title>
|
||||||
<style>
|
<style>
|
||||||
:root { color-scheme: light; }
|
:root { color-scheme: light; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
|
||||||
@@ -13,7 +14,7 @@
|
|||||||
color: #1f2937;
|
color: #1f2937;
|
||||||
}
|
}
|
||||||
.wrap {
|
.wrap {
|
||||||
max-width: 520px;
|
max-width: 600px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 20px 14px 28px;
|
padding: 20px 14px 28px;
|
||||||
}
|
}
|
||||||
@@ -24,7 +25,7 @@
|
|||||||
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
|
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
|
||||||
}
|
}
|
||||||
h1 {
|
h1 {
|
||||||
margin: 0 0 10px;
|
margin: 0 0 6px;
|
||||||
font-size: 1.3rem;
|
font-size: 1.3rem;
|
||||||
}
|
}
|
||||||
p {
|
p {
|
||||||
@@ -43,7 +44,6 @@
|
|||||||
border: 1px solid #d1d5db;
|
border: 1px solid #d1d5db;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
}
|
||||||
.options {
|
.options {
|
||||||
margin: 12px 0;
|
margin: 12px 0;
|
||||||
@@ -68,8 +68,14 @@
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
}
|
}
|
||||||
|
button:hover { background: #1d4ed8; }
|
||||||
button:active { transform: translateY(1px); }
|
button:active { transform: translateY(1px); }
|
||||||
|
button:disabled {
|
||||||
|
background: #9ca3af;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
.error {
|
.error {
|
||||||
margin: 0 0 12px;
|
margin: 0 0 12px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
@@ -83,38 +89,282 @@
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
color: #6b7280;
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main class="wrap">
|
<main class="wrap">
|
||||||
<section class="card">
|
<section class="card">
|
||||||
<h1>PDF zu ICS Konverter</h1>
|
<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 id="error" class="error hidden"></div>
|
||||||
<div class="error">{{ error }}</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<form action="/convert" method="post" enctype="multipart/form-data">
|
<!-- UPLOAD-FORMULAR -->
|
||||||
|
<div id="step-init">
|
||||||
<label for="file">Dienstplan-PDF</label>
|
<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">
|
<div class="options">
|
||||||
<label class="option">
|
<label class="option">
|
||||||
<input type="checkbox" name="exclude_rest" value="true" />
|
<input id="exclude_rest" type="checkbox" />
|
||||||
Ruhetage ausschließen
|
Ruhetage ausschließen
|
||||||
</label>
|
</label>
|
||||||
<label class="option">
|
<label class="option">
|
||||||
<input type="checkbox" name="exclude_vacation" value="true" />
|
<input id="exclude_vacation" type="checkbox" />
|
||||||
Urlaub ausschließen
|
Urlaub ausschließen
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit">ICS erstellen</button>
|
<button type="button" onclick="uploadAndPreview()">
|
||||||
</form>
|
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>
|
</section>
|
||||||
</main>
|
</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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user