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

@@ -899,8 +899,11 @@ async fn run_html_export_inner(
let file = tokio::fs::File::create(&tmp_path).await?; let file = tokio::fs::File::create(&tmp_path).await?;
let mut zip = ZipFileWriter::with_tokio(file); let mut zip = ZipFileWriter::with_tokio(file);
// Write embedded viewer assets (index.html, _app/*, etc.) // Write the embedded single-file viewer, injecting the export data as a
write_dir_to_zip(&VIEWER_DIR, &mut zip).await?; // `window.__EXPORT_DATA__` global into index.html. Guests double-click
// index.html (file://), where a cross-origin fetch() of a sibling file is
// blocked — so the data must be inlined rather than fetched from data.json.
write_viewer_with_data(&VIEWER_DIR, &mut zip, &data_json).await?;
let _ = update_progress(pool, event_id, "html", epoch, 75).await; let _ = update_progress(pool, event_id, "html", epoch, 75).await;
@@ -1301,22 +1304,43 @@ async fn maybe_broadcast_complete(
} }
} }
/// Recursively write all files from an embedded `include_dir::Dir` into a ZIP. /// Write the embedded viewer into the ZIP, injecting the export data as a
async fn write_dir_to_zip( /// `window.__EXPORT_DATA__` global into `index.html`. The keepsake is opened by
/// double-clicking `index.html` (file://), where browsers block a cross-origin
/// `fetch()` of a sibling `data.json` — so the data is inlined into the page.
/// (`data.json` is still written separately for the http-served case.)
async fn write_viewer_with_data(
dir: &include_dir::Dir<'_>, dir: &include_dir::Dir<'_>,
zip: &mut ZipFileWriter<tokio::fs::File>, zip: &mut ZipFileWriter<tokio::fs::File>,
data_json: &str,
) -> Result<()> { ) -> Result<()> {
for file in dir.files() { for file in dir.files() {
let path = file.path().to_string_lossy().to_string(); let path = file.path().to_string_lossy().to_string();
let contents = file.contents(); if path == "index.html" {
let html = std::str::from_utf8(file.contents())
.context("export-viewer index.html is not valid UTF-8")?;
// Escape `</` so a caption containing `</script>` can't break out of the tag.
let safe = data_json.replace("</", "<\\/");
let script = format!("<script>window.__EXPORT_DATA__={safe};</script>");
let injected = match html.find("</head>") {
Some(idx) => format!("{}{}{}", &html[..idx], script, &html[idx..]),
None => format!("{script}{html}"),
};
let builder = ZipEntryBuilder::new(path.into(), Compression::Deflate); let builder = ZipEntryBuilder::new(path.into(), Compression::Deflate);
let mut entry = zip.write_entry_stream(builder).await?; let mut entry = zip.write_entry_stream(builder).await?;
let mut cursor = AllowStdIo::new(std::io::Cursor::new(contents)); let mut cursor = AllowStdIo::new(std::io::Cursor::new(injected.as_bytes()));
fcopy(&mut cursor, &mut entry).await?;
entry.close().await?;
} else {
let builder = ZipEntryBuilder::new(path.into(), Compression::Deflate);
let mut entry = zip.write_entry_stream(builder).await?;
let mut cursor = AllowStdIo::new(std::io::Cursor::new(file.contents()));
fcopy(&mut cursor, &mut entry).await?; fcopy(&mut cursor, &mut entry).await?;
entry.close().await?; entry.close().await?;
} }
}
for sub_dir in dir.dirs() { for sub_dir in dir.dirs() {
Box::pin(write_dir_to_zip(sub_dir, zip)).await?; Box::pin(write_viewer_with_data(sub_dir, zip, data_json)).await?;
} }
Ok(()) Ok(())
} }

View File

@@ -1 +0,0 @@
export const env={}

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
import{u as o,n as t,o as c}from"./CcONa1Mr.js";function u(e){throw new Error("https://svelte.dev/e/lifecycle_outside_component")}function r(e){t===null&&u(),o(()=>{const n=c(e);if(typeof n=="function")return n})}export{r as o};

View File

@@ -1 +0,0 @@
import{f as l,g as o,p as u,i as n,j as d,k as m,h as p,e as _,m as v,l as k}from"./CcONa1Mr.js";class w{anchor;#t=new Map;#s=new Map;#e=new Map;#i=new Set;#f=!0;constructor(t,s=!0){this.anchor=t,this.#f=s}#a=t=>{if(this.#t.has(t)){var s=this.#t.get(t),e=this.#s.get(s);if(e)l(e),this.#i.delete(s);else{var f=this.#e.get(s);f&&(this.#s.set(s,f.effect),this.#e.delete(s),f.fragment.lastChild.remove(),this.anchor.before(f.fragment),e=f.effect)}for(const[i,a]of this.#t){if(this.#t.delete(i),i===t)break;const r=this.#e.get(a);r&&(o(r.effect),this.#e.delete(a))}for(const[i,a]of this.#s){if(i===s||this.#i.has(i))continue;const r=()=>{if(Array.from(this.#t.values()).includes(i)){var c=document.createDocumentFragment();v(a,c),c.append(n()),this.#e.set(i,{effect:a,fragment:c})}else o(a);this.#i.delete(i),this.#s.delete(i)};this.#f||!e?(this.#i.add(i),u(a,r,!1)):r()}}};#r=t=>{this.#t.delete(t);const s=Array.from(this.#t.values());for(const[e,f]of this.#e)s.includes(e)||(o(f.effect),this.#e.delete(e))};ensure(t,s){var e=m,f=k();if(s&&!this.#s.has(t)&&!this.#e.has(t))if(f){var i=document.createDocumentFragment(),a=n();i.append(a),this.#e.set(t,{effect:d(()=>s(a)),fragment:i})}else this.#s.set(t,d(()=>s(this.anchor)));if(this.#t.set(e,t),f){for(const[r,h]of this.#s)r===t?e.unskip_effect(h):e.skip_effect(h);for(const[r,h]of this.#e)r===t?e.unskip_effect(h.effect):e.skip_effect(h.effect);e.oncommit(this.#a),e.ondiscard(this.#r)}else p&&(this.anchor=_),this.#a(e)}}export{w as B};

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
import{b as c,h as o,a as l,E as b,r as p,s as v,c as g,d,e as m}from"./CcONa1Mr.js";import{B as y}from"./BRDva_z9.js";function k(f,h,_=!1){var n;o&&(n=m,l());var s=new y(f),u=_?b:0;function t(a,r){if(o){var e=p(n);if(a!==parseInt(e.substring(1))){var i=v();g(i),s.anchor=i,d(!1),s.ensure(a,r),d(!0);return}}s.ensure(a,r)}c(()=>{var a=!1;h((r,e=0)=>{a=!0,t(e,r)}),a||t(-1,null)},u)}export{k as i};

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
import{A as v,i as d,B as l,C as u,D as T,T as p,F as h,h as i,e as s,R as E,a as y,G as g,c as w,H as N}from"./CcONa1Mr.js";const A=globalThis?.window?.trustedTypes&&globalThis.window.trustedTypes.createPolicy("svelte-trusted-html",{createHTML:t=>t});function M(t){return A?.createHTML(t)??t}function x(t){var r=v("template");return r.innerHTML=M(t.replaceAll("<!>","<!---->")),r.content}function n(t,r){var e=l;e.nodes===null&&(e.nodes={start:t,end:r,a:null,t:null})}function b(t,r){var e=(r&p)!==0,f=(r&h)!==0,a,_=!t.startsWith("<!>");return()=>{if(i)return n(s,null),s;a===void 0&&(a=x(_?t:"<!>"+t),e||(a=u(a)));var o=f||T?document.importNode(a,!0):a.cloneNode(!0);if(e){var c=u(o),m=o.lastChild;n(c,m)}else n(o,o);return o}}function C(t=""){if(!i){var r=d(t+"");return n(r,r),r}var e=s;return e.nodeType!==g?(e.before(e=d()),w(e)):N(e),n(e,e),e}function O(){if(i)return n(s,null),s;var t=document.createDocumentFragment(),r=document.createComment(""),e=d();return t.append(r,e),n(r,e),t}function P(t,r){if(i){var e=l;((e.f&E)===0||e.nodes.end===null)&&(e.nodes.end=s),y();return}t!==null&&t.before(r)}const L="5";typeof window<"u"&&((window.__svelte??={}).v??=new Set).add(L);export{P as a,n as b,O as c,b as f,C as t};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
import{l as o,a as r}from"../chunks/eAGLaJx1.js";export{o as load_css,r as start};

View File

@@ -1 +0,0 @@
import{c as s,a as c}from"../chunks/RsTAN2PN.js";import{b as l,E as p,t as i}from"../chunks/CcONa1Mr.js";import{B as m}from"../chunks/BRDva_z9.js";function u(n,r,...e){var o=new m(n);l(()=>{const t=r()??null;o.ensure(t,t&&(a=>t(a,...e)))},p)}const f=!0,_=!1,g=Object.freeze(Object.defineProperty({__proto__:null,prerender:f,ssr:_},Symbol.toStringTag,{value:"Module"}));function h(n,r){var e=s(),o=i(e);u(o,()=>r.children),c(n,e)}export{h as component,g as universal};

View File

@@ -1 +0,0 @@
import{a as i,f as h}from"../chunks/RsTAN2PN.js";import{q as g,t as v,v as d,w as l,x as s,y as a,z as x}from"../chunks/CcONa1Mr.js";import{s as o}from"../chunks/Bb9JxzU7.js";import{s as _,p}from"../chunks/eAGLaJx1.js";const $={get error(){return p.error},get status(){return p.status}};_.updated.check;const m=$;var k=h("<h1> </h1> <p> </p>",1);function z(c,f){g(f,!0);var t=k(),r=v(t),n=s(r,!0);a(r);var e=x(r,2),u=s(e,!0);a(e),d(()=>{o(n,m.status),o(u,m.error?.message)}),i(c,t),l()}export{z as component};

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
{"version":"1778876725548"}

File diff suppressed because one or more lines are too long

View 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>

View File

@@ -15,7 +15,8 @@
"svelte": "^5.54.0", "svelte": "^5.54.0",
"tailwindcss": "^4.2.2", "tailwindcss": "^4.2.2",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.3.1" "vite": "^7.3.1",
"vite-plugin-singlefile": "^2.3.3"
} }
}, },
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
@@ -1318,6 +1319,19 @@
"node": ">= 0.4" "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": { "node_modules/clsx": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "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": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1479,6 +1506,16 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/is-reference": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
@@ -1787,6 +1824,33 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@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": { "node_modules/mrmime": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
@@ -2021,6 +2085,19 @@
"url": "https://github.com/sponsors/SuperchupuDev" "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": { "node_modules/totalist": {
"version": "3.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", "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": { "node_modules/vitefu": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz",

View File

@@ -5,7 +5,8 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"build": "vite build", "build": "vite build --config vite.standalone.config.js",
"build:sveltekit": "vite build",
"preview": "vite preview", "preview": "vite preview",
"prepare": "svelte-kit sync || echo ''" "prepare": "svelte-kit sync || echo ''"
}, },
@@ -17,6 +18,7 @@
"svelte": "^5.54.0", "svelte": "^5.54.0",
"tailwindcss": "^4.2.2", "tailwindcss": "^4.2.2",
"typescript": "^5.9.3", "typescript": "^5.9.3",
"vite": "^7.3.1" "vite": "^7.3.1",
"vite-plugin-singlefile": "^2.3.3"
} }
} }

View File

@@ -99,9 +99,18 @@
onMount(async () => { onMount(async () => {
try { try {
// 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'); const res = await fetch('./data.json');
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
data = await res.json(); data = await res.json();
}
} catch { } catch {
error = error =
'Daten konnten nicht geladen werden. Stelle sicher, dass data.json im selben Ordner liegt.'; 'Daten konnten nicht geladen werden. Stelle sicher, dass data.json im selben Ordner liegt.';

View 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')! });

View 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()]
});

View 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'
}
});

View File

@@ -166,16 +166,10 @@
Tipp: Am besten im WLAN herunterladen — die Datei kann mehrere GB groß sein. Tipp: Am besten im WLAN herunterladen — die Datei kann mehrere GB groß sein.
</p> </p>
<div class="flex gap-2"> <div class="flex gap-2">
<button <button onclick={() => (showHtmlGuide = false)} class="btn btn-secondary flex-1">
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"
>
Abbrechen Abbrechen
</button> </button>
<button <button onclick={confirmHtmlDownload} class="btn btn-primary flex-1">
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"
>
Herunterladen Herunterladen
</button> </button>
</div> </div>
@@ -206,9 +200,7 @@
{#if loading} {#if loading}
<div class="py-16 text-center text-gray-400 dark:text-gray-500">Laden…</div> <div class="py-16 text-center text-gray-400 dark:text-gray-500">Laden…</div>
{:else if !status?.released} {:else if !status?.released}
<div <div class="card p-6 text-center">
class="rounded-xl border border-gray-200 bg-white p-6 text-center dark:border-gray-700 dark:bg-gray-800"
>
<svg <svg
class="mx-auto mb-3 h-12 w-12 text-gray-300 dark:text-gray-600" class="mx-auto mb-3 h-12 w-12 text-gray-300 dark:text-gray-600"
fill="none" fill="none"
@@ -231,12 +223,10 @@
<p class="text-sm text-gray-500 dark:text-gray-400">Wähle dein bevorzugtes Format:</p> <p class="text-sm text-gray-500 dark:text-gray-400">Wähle dein bevorzugtes Format:</p>
<!-- ZIP card --> <!-- ZIP card -->
<div <div class="card p-5">
class="rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800"
>
<div class="flex items-start justify-between gap-4"> <div class="flex items-start justify-between gap-4">
<div class="min-w-0"> <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"> <p class="mt-0.5 text-sm text-gray-500 dark:text-gray-400">
Alle Original-Fotos und Videos in strukturierten Ordnern. Alle Original-Fotos und Videos in strukturierten Ordnern.
</p> </p>
@@ -253,9 +243,7 @@
<button <button
onclick={downloadZip} onclick={downloadZip}
disabled={status.zip.status !== 'done'} disabled={status.zip.status !== 'done'}
class="shrink-0 rounded-lg px-4 py-2 text-sm font-medium {status.zip.status === 'done' class="btn btn-primary btn-sm shrink-0"
? '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'}"
> >
Download Download
</button> </button>
@@ -273,12 +261,10 @@
</div> </div>
<!-- HTML card --> <!-- HTML card -->
<div <div class="card p-5">
class="rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800"
>
<div class="flex items-start justify-between gap-4"> <div class="flex items-start justify-between gap-4">
<div class="min-w-0"> <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"> <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. Schöne Offline-Galerie mit Filterung, Kommentaren und Likes — kein Internet nötig.
</p> </p>
@@ -295,9 +281,7 @@
<button <button
onclick={downloadHtml} onclick={downloadHtml}
disabled={status.html.status !== 'done'} disabled={status.html.status !== 'done'}
class="shrink-0 rounded-lg px-4 py-2 text-sm font-medium {status.html.status === 'done' class="btn btn-primary btn-sm shrink-0"
? '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'}"
> >
Download Download
</button> </button>

View File

@@ -12,6 +12,7 @@
import ConfirmSheet from '$lib/components/ConfirmSheet.svelte'; import ConfirmSheet from '$lib/components/ConfirmSheet.svelte';
import Skeleton from '$lib/components/Skeleton.svelte'; import Skeleton from '$lib/components/Skeleton.svelte';
import { refreshQuota } from '$lib/quota-store'; import { refreshQuota } from '$lib/quota-store';
import { exportStatus } from '$lib/export-status-store';
import { toast, toastError } from '$lib/toast-store'; import { toast, toastError } from '$lib/toast-store';
import { pullToRefresh } from '$lib/actions/pull-to-refresh'; import { pullToRefresh } from '$lib/actions/pull-to-refresh';
import { vibrate } from '$lib/haptics'; import { vibrate } from '$lib/haptics';
@@ -573,7 +574,7 @@
> >
<button <button
type="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={() => { onclick={() => {
feedStale = false; feedStale = false;
void loadFeed(true); void loadFeed(true);
@@ -825,6 +826,54 @@
{/if} {/if}
</div> </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 --> <!-- Content -->
{#if initialLoading && uploads.length === 0} {#if initialLoading && uploads.length === 0}
<div class="mx-auto max-w-2xl" data-testid="feed-skeleton"> <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. Es sind noch nicht alle Beiträge geladen — scrolle weiter, um mehr zu durchsuchen.
</p> </p>
{/if} {/if}
<button <button onclick={clearFilters} class="btn btn-ghost btn-sm mt-2"
onclick={clearFilters}
class="mt-2 text-sm text-blue-600 hover:underline dark:text-blue-400"
>Filter zurücksetzen</button >Filter zurücksetzen</button
> >
</div> </div>