feat(export): single-file offline keepsake viewer + guest download

Rebuild the guest "keepsake" (offline HTML gallery) so it renders when the
extracted index.html is opened over file://. Browsers block external ES-module
scripts and fetch() at origin null, so a normal multi-file SvelteKit build shows
a blank window. Build the viewer as one self-contained index.html (inlined
JS/CSS via vite-plugin-singlefile, standalone non-SvelteKit entry) and inject
the data as window.__EXPORT_DATA__; the backend embeds the built viewer via
include_dir! and writes the data global into index.html when zipping.

Also surface the guest-facing download: a feed banner and the BottomNav Export
tab, both shown once the host releases the export.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-18 17:28:54 +02:00
parent f243bfe89a
commit 3654aca18b
26 changed files with 271 additions and 92 deletions

View File

@@ -166,16 +166,10 @@
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 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"
>
<button onclick={() => (showHtmlGuide = false)} class="btn btn-secondary flex-1">
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 dark:bg-blue-500 dark:hover:bg-blue-400"
>
<button onclick={confirmHtmlDownload} class="btn btn-primary flex-1">
Herunterladen
</button>
</div>
@@ -206,9 +200,7 @@
{#if loading}
<div class="py-16 text-center text-gray-400 dark:text-gray-500">Laden…</div>
{:else if !status?.released}
<div
class="rounded-xl border border-gray-200 bg-white p-6 text-center dark:border-gray-700 dark:bg-gray-800"
>
<div class="card p-6 text-center">
<svg
class="mx-auto mb-3 h-12 w-12 text-gray-300 dark:text-gray-600"
fill="none"
@@ -231,12 +223,10 @@
<p class="text-sm text-gray-500 dark:text-gray-400">Wähle dein bevorzugtes Format:</p>
<!-- ZIP card -->
<div
class="rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800"
>
<div class="card p-5">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<h2 class="font-semibold text-gray-900 dark:text-gray-100">ZIP-Archiv</h2>
<h2 class="section-title">ZIP-Archiv</h2>
<p class="mt-0.5 text-sm text-gray-500 dark:text-gray-400">
Alle Original-Fotos und Videos in strukturierten Ordnern.
</p>
@@ -253,9 +243,7 @@
<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 dark:bg-blue-500 dark:hover:bg-blue-400'
: 'bg-gray-100 text-gray-400 cursor-not-allowed dark:bg-gray-700 dark:text-gray-500'}"
class="btn btn-primary btn-sm shrink-0"
>
Download
</button>
@@ -273,12 +261,10 @@
</div>
<!-- HTML card -->
<div
class="rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800"
>
<div class="card p-5">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0">
<h2 class="font-semibold text-gray-900 dark:text-gray-100">HTML-Viewer</h2>
<h2 class="section-title">HTML-Viewer</h2>
<p class="mt-0.5 text-sm text-gray-500 dark:text-gray-400">
Schöne Offline-Galerie mit Filterung, Kommentaren und Likes — kein Internet nötig.
</p>
@@ -295,9 +281,7 @@
<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 dark:bg-blue-500 dark:hover:bg-blue-400'
: 'bg-gray-100 text-gray-400 cursor-not-allowed dark:bg-gray-700 dark:text-gray-500'}"
class="btn btn-primary btn-sm shrink-0"
>
Download
</button>

View File

@@ -12,6 +12,7 @@
import ConfirmSheet from '$lib/components/ConfirmSheet.svelte';
import Skeleton from '$lib/components/Skeleton.svelte';
import { refreshQuota } from '$lib/quota-store';
import { exportStatus } from '$lib/export-status-store';
import { toast, toastError } from '$lib/toast-store';
import { pullToRefresh } from '$lib/actions/pull-to-refresh';
import { vibrate } from '$lib/haptics';
@@ -573,7 +574,7 @@
>
<button
type="button"
class="pointer-events-auto rounded-full bg-blue-600 px-4 py-1.5 text-xs font-semibold text-white shadow-lg hover:bg-blue-700"
class="pointer-events-auto rounded-full border border-primary-400 bg-primary-100 px-4 py-1.5 text-xs font-semibold text-primary-800 shadow-lg hover:border-primary-500 hover:bg-primary-200 dark:border-primary-500/50 dark:bg-primary-950/60 dark:text-primary-200"
onclick={() => {
feedStale = false;
void loadFeed(true);
@@ -825,6 +826,54 @@
{/if}
</div>
<!-- Download banner — appears for everyone (esp. guests) once the host has
released the gallery, so the keepsake download is discoverable from the feed
and not only via the Export tab. -->
{#if $exportStatus.released}
<div class="mx-auto max-w-2xl px-4 pt-3">
<a
href="/export"
data-testid="feed-export-banner"
class="flex items-center gap-3 rounded-2xl border border-primary-300 bg-primary-50 p-4 transition hover:bg-primary-100 dark:border-primary-800/60 dark:bg-primary-950/30 dark:hover:bg-primary-900/30"
>
<span
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary-100 text-primary-700 dark:bg-primary-900/50 dark:text-primary-300"
>
<svg
class="h-5 w-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3"
/>
</svg>
</span>
<span class="min-w-0 flex-1">
<span class="block font-semibold text-primary-900 dark:text-primary-100"
>Galerie herunterladen</span
>
<span class="block text-sm text-primary-800/80 dark:text-primary-300/80"
>Alle Fotos als ZIP und als Offline-Album sichern.</span
>
</span>
<svg
class="h-5 w-5 shrink-0 text-primary-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</a>
</div>
{/if}
<!-- Content -->
{#if initialLoading && uploads.length === 0}
<div class="mx-auto max-w-2xl" data-testid="feed-skeleton">
@@ -871,9 +920,7 @@
Es sind noch nicht alle Beiträge geladen — scrolle weiter, um mehr zu durchsuchen.
</p>
{/if}
<button
onclick={clearFilters}
class="mt-2 text-sm text-blue-600 hover:underline dark:text-blue-400"
<button onclick={clearFilters} class="btn btn-ghost btn-sm mt-2"
>Filter zurücksetzen</button
>
</div>