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:
12
frontend/export-viewer/index.html
Normal file
12
frontend/export-viewer/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>EventSnap — Galerie</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/standalone.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
101
frontend/export-viewer/package-lock.json
generated
101
frontend/export-viewer/package-lock.json
generated
@@ -15,7 +15,8 @@
|
||||
"svelte": "^5.54.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1"
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-singlefile": "^2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
@@ -1318,6 +1319,19 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/braces": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fill-range": "^7.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
@@ -1457,6 +1471,19 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"to-regex-range": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -1479,6 +1506,16 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/is-number": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-reference": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
|
||||
@@ -1787,6 +1824,33 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/micromatch": {
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"braces": "^3.0.3",
|
||||
"picomatch": "^2.3.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
}
|
||||
},
|
||||
"node_modules/micromatch/node_modules/picomatch": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/mrmime": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
|
||||
@@ -2021,6 +2085,19 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-number": "^7.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/totalist": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
|
||||
@@ -2122,6 +2199,28 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vite-plugin-singlefile": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/vite-plugin-singlefile/-/vite-plugin-singlefile-2.3.3.tgz",
|
||||
"integrity": "sha512-XVnGH0QzbOa8fxRSsHdCarVN1BSBXNi7uLMQYlrGRN5apdHkk62XQWRJhVever0lnfuyBkwn+kvVChdm/OoOUg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"micromatch": "^4.0.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">18.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"rollup": "^4.59.0",
|
||||
"vite": "^5.4.21 || ^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"rollup": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vitefu": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz",
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"build": "vite build --config vite.standalone.config.js",
|
||||
"build:sveltekit": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''"
|
||||
},
|
||||
@@ -17,6 +18,7 @@
|
||||
"svelte": "^5.54.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1"
|
||||
"vite": "^7.3.1",
|
||||
"vite-plugin-singlefile": "^2.3.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,9 +99,18 @@
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const res = await fetch('./data.json');
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
data = await res.json();
|
||||
// The keepsake is opened by double-clicking index.html (file://), where a
|
||||
// cross-origin `fetch()` is blocked. The export injects the data as a global
|
||||
// so it's available without any network request; fall back to fetching
|
||||
// data.json only when the viewer is actually served over http(s).
|
||||
const injected = (globalThis as { __EXPORT_DATA__?: ViewerData }).__EXPORT_DATA__;
|
||||
if (injected) {
|
||||
data = injected;
|
||||
} else {
|
||||
const res = await fetch('./data.json');
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
data = await res.json();
|
||||
}
|
||||
} catch {
|
||||
error =
|
||||
'Daten konnten nicht geladen werden. Stelle sicher, dass data.json im selben Ordner liegt.';
|
||||
|
||||
8
frontend/export-viewer/src/standalone.ts
Normal file
8
frontend/export-viewer/src/standalone.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
// Standalone (non-SvelteKit) entry for the offline keepsake. Mounts the plain
|
||||
// Svelte viewer component into a single, self-contained index.html so it works
|
||||
// when opened by double-clicking from an extracted ZIP (file://).
|
||||
import './app.css';
|
||||
import { mount } from 'svelte';
|
||||
import App from './routes/+page.svelte';
|
||||
|
||||
mount(App, { target: document.getElementById('app')! });
|
||||
10
frontend/export-viewer/vite.config.js
Normal file
10
frontend/export-viewer/vite.config.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
// NOTE: the production keepsake is built with `vite.standalone.config.js`
|
||||
// (see `npm run build`), which inlines everything into a single, file://-safe
|
||||
// index.html. This SvelteKit config is kept only for `npm run dev`/`preview`.
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit()]
|
||||
});
|
||||
25
frontend/export-viewer/vite.standalone.config.js
Normal file
25
frontend/export-viewer/vite.standalone.config.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { svelte, vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { viteSingleFile } from 'vite-plugin-singlefile';
|
||||
import { defineConfig } from 'vite';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// Builds the keepsake viewer as ONE self-contained index.html (all JS + CSS
|
||||
// inlined) so it renders when opened via file://. Uses the plain Svelte plugin
|
||||
// (not SvelteKit) because SvelteKit emits multiple module entry points, which
|
||||
// cannot be inlined into a single file.
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
tailwindcss(),
|
||||
svelte({ configFile: false, preprocess: vitePreprocess(), compilerOptions: { runes: true } }),
|
||||
viteSingleFile()
|
||||
],
|
||||
resolve: {
|
||||
alias: { $lib: fileURLToPath(new URL('./src/lib', import.meta.url)) }
|
||||
},
|
||||
build: {
|
||||
outDir: fileURLToPath(new URL('../../backend/static/export-viewer', import.meta.url)),
|
||||
emptyOutDir: true,
|
||||
target: 'es2020'
|
||||
}
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user