feat: implement export engine
Add async ZIP and HTML offline viewer export workers, download endpoints,
and a guest-facing /export page.
Backend — export workers (tokio::spawn, run after gallery release):
- ZIP worker: streams all non-deleted originals into Gallery.zip via
async_zip (Stored compression), organised into Photos/ and Videos/
with {date}_{uploader}_{id}.{ext} filenames; updates progress_pct in DB
- HTML worker: renders Memories.html via minijinja template (self-contained:
inlined CSS + JS, relative media paths); packs it with README.txt and
all media into Memories.zip (Deflate for text, Stored for media)
- Both workers mark export_job status (running → done/failed), update
export_zip_ready / export_html_ready on the event, and broadcast SSE
export-progress + export-available when both complete
Backend — new endpoints (AuthUser):
- GET /export/zip → streams Gallery.zip if export_zip_ready
- GET /export/html → streams Memories.zip if export_html_ready
- GET /export/status → released flag + per-type status/progress (moved from admin)
Memories.html features: warm keepsake aesthetic, responsive grid, fullscreen
lightbox with captions/comments/likes, client-side hashtag filter chips,
XSS-safe JS, fully offline (no external deps)
Frontend — /export page:
- Locked state: padlock illustration + message
- Released state: ZIP and HTML cards with progress bars (SSE-driven),
download buttons enabled only when ready
- HTML guide modal (unzip instructions + Wi-Fi tip) before download begins
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
195
frontend/src/routes/export/+page.svelte
Normal file
195
frontend/src/routes/export/+page.svelte
Normal file
@@ -0,0 +1,195 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { getToken } from '$lib/auth';
|
||||
import { api } from '$lib/api';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { connectSse, disconnectSse, onSseEvent } from '$lib/sse';
|
||||
|
||||
interface JobStatus {
|
||||
status: 'locked' | 'pending' | 'running' | 'done' | 'failed';
|
||||
progress_pct: number;
|
||||
}
|
||||
|
||||
interface ExportStatus {
|
||||
released: boolean;
|
||||
zip: JobStatus;
|
||||
html: JobStatus;
|
||||
}
|
||||
|
||||
let status = $state<ExportStatus | null>(null);
|
||||
let showHtmlGuide = $state(false);
|
||||
let loading = $state(true);
|
||||
|
||||
let unsubscribers: (() => void)[] = [];
|
||||
|
||||
onMount(async () => {
|
||||
if (!getToken()) {
|
||||
goto('/join');
|
||||
return;
|
||||
}
|
||||
|
||||
await loadStatus();
|
||||
connectSse();
|
||||
|
||||
unsubscribers.push(
|
||||
onSseEvent('export-progress', async () => {
|
||||
await loadStatus();
|
||||
}),
|
||||
onSseEvent('export-available', async () => {
|
||||
await loadStatus();
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
disconnectSse();
|
||||
for (const unsub of unsubscribers) unsub();
|
||||
});
|
||||
|
||||
async function loadStatus() {
|
||||
try {
|
||||
status = await api.get<ExportStatus>('/export/status');
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function jobLabel(type: 'zip' | 'html'): string {
|
||||
return type === 'zip' ? 'ZIP-Archiv (Gallery.zip)' : 'HTML-Viewer (Memories.zip)';
|
||||
}
|
||||
|
||||
function statusText(job: JobStatus): string {
|
||||
switch (job.status) {
|
||||
case 'locked': return 'Noch nicht freigegeben';
|
||||
case 'pending': return 'Wird vorbereitet…';
|
||||
case 'running': return `Wird erstellt (${job.progress_pct} %)`;
|
||||
case 'done': return 'Bereit zum Download';
|
||||
case 'failed': return 'Fehlgeschlagen';
|
||||
}
|
||||
}
|
||||
|
||||
function downloadZip() {
|
||||
window.location.href = '/api/v1/export/zip';
|
||||
}
|
||||
|
||||
function downloadHtml() {
|
||||
showHtmlGuide = true;
|
||||
}
|
||||
|
||||
function confirmHtmlDownload() {
|
||||
showHtmlGuide = false;
|
||||
window.location.href = '/api/v1/export/html';
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- HTML guide modal -->
|
||||
{#if showHtmlGuide}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div class="w-full max-w-sm rounded-2xl bg-white p-6 shadow-xl">
|
||||
<h2 class="mb-3 text-lg font-bold text-gray-900">Hinweis zum HTML-Viewer</h2>
|
||||
<ol class="mb-4 space-y-2 text-sm text-gray-700">
|
||||
<li class="flex gap-2"><span class="font-bold text-blue-600">1.</span> ZIP-Datei entpacken (Windows: Rechtsklick → "Alle extrahieren"; Mac: Doppelklick).</li>
|
||||
<li class="flex gap-2"><span class="font-bold text-blue-600">2.</span> <strong>Memories.html</strong> im Browser öffnen.</li>
|
||||
<li class="flex gap-2"><span class="font-bold text-blue-600">3.</span> Kein Internet nötig — alles ist lokal gespeichert.</li>
|
||||
</ol>
|
||||
<p class="mb-4 rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-700">
|
||||
Tipp: Am besten im WLAN herunterladen — die Datei kann mehrere GB groß sein.
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onclick={() => (showHtmlGuide = false)}
|
||||
class="flex-1 rounded-lg border border-gray-300 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onclick={confirmHtmlDownload}
|
||||
class="flex-1 rounded-lg bg-blue-600 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
Herunterladen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<div class="border-b border-gray-200 bg-white">
|
||||
<div class="mx-auto flex max-w-lg items-center justify-between px-4 py-4">
|
||||
<h1 class="text-xl font-bold text-gray-900">Export</h1>
|
||||
<a href="/feed" class="text-sm text-blue-600 hover:underline">Zur Galerie</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto max-w-lg space-y-4 p-4">
|
||||
{#if loading}
|
||||
<div class="py-16 text-center text-gray-400">Laden…</div>
|
||||
{:else if !status?.released}
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-6 text-center">
|
||||
<svg class="mx-auto mb-3 h-12 w-12 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
<p class="font-medium text-gray-700">Export noch nicht verfügbar</p>
|
||||
<p class="mt-1 text-sm text-gray-500">Schau nach der Veranstaltung noch einmal vorbei.</p>
|
||||
</div>
|
||||
{:else if status}
|
||||
<p class="text-sm text-gray-500">Wähle dein bevorzugtes Format:</p>
|
||||
|
||||
<!-- ZIP card -->
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-5">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<h2 class="font-semibold text-gray-900">ZIP-Archiv</h2>
|
||||
<p class="mt-0.5 text-sm text-gray-500">Alle Original-Fotos und Videos in strukturierten Ordnern.</p>
|
||||
<p class="mt-1 text-xs {status.zip.status === 'done' ? 'text-green-600' : status.zip.status === 'failed' ? 'text-red-500' : 'text-gray-400'}">
|
||||
{statusText(status.zip)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={downloadZip}
|
||||
disabled={status.zip.status !== 'done'}
|
||||
class="shrink-0 rounded-lg px-4 py-2 text-sm font-medium {status.zip.status === 'done' ? 'bg-blue-600 text-white hover:bg-blue-700' : 'bg-gray-100 text-gray-400 cursor-not-allowed'}"
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
{#if status.zip.status === 'running'}
|
||||
<div class="mt-3">
|
||||
<div class="h-1.5 overflow-hidden rounded-full bg-gray-200">
|
||||
<div class="h-full rounded-full bg-blue-500 transition-all" style="width: {status.zip.progress_pct}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- HTML card -->
|
||||
<div class="rounded-xl border border-gray-200 bg-white p-5">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<h2 class="font-semibold text-gray-900">HTML-Viewer</h2>
|
||||
<p class="mt-0.5 text-sm text-gray-500">Schöne Offline-Galerie mit Filterung, Kommentaren und Likes — kein Internet nötig.</p>
|
||||
<p class="mt-1 text-xs {status.html.status === 'done' ? 'text-green-600' : status.html.status === 'failed' ? 'text-red-500' : 'text-gray-400'}">
|
||||
{statusText(status.html)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onclick={downloadHtml}
|
||||
disabled={status.html.status !== 'done'}
|
||||
class="shrink-0 rounded-lg px-4 py-2 text-sm font-medium {status.html.status === 'done' ? 'bg-blue-600 text-white hover:bg-blue-700' : 'bg-gray-100 text-gray-400 cursor-not-allowed'}"
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
{#if status.html.status === 'running'}
|
||||
<div class="mt-3">
|
||||
<div class="h-1.5 overflow-hidden rounded-full bg-gray-200">
|
||||
<div class="h-full rounded-full bg-blue-500 transition-all" style="width: {status.html.progress_pct}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user