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:
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export const env={}
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -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};
|
|
||||||
@@ -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
@@ -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
@@ -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
@@ -1 +0,0 @@
|
|||||||
import{l as o,a as r}from"../chunks/eAGLaJx1.js";export{o as load_css,r as start};
|
|
||||||
@@ -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};
|
|
||||||
@@ -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
@@ -1 +0,0 @@
|
|||||||
{"version":"1778876725548"}
|
|
||||||
File diff suppressed because one or more lines are too long
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",
|
"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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.';
|
||||||
|
|||||||
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.
|
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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user