- Persistent bottom tab bar (Feed · FAB · Account) on all authenticated pages - Upload FAB triggers bottom sheet (Galerie / Kamera) → navigates to composer - Upload page redesigned as full-screen composer with thumbnail strip, textarea, quick-tag chips, sticky submit button; bottom nav suppressed while composing - Slim upload progress bar above bottom nav driven by queue state - Feed: list/grid view toggle; list = chronological full-width FeedListCard; grid = 3-col with search bar, autocomplete from loaded posts, filter chips - Account page: role-gated dashboard links (Host / Admin); Konto section with leave-confirm bottom sheet; no more per-page header nav icons - Host dashboard: back arrow, collapsible sections, 2-col stats, user search - Admin dashboard: back arrow, inner tab bar (Stats/Config/Export/Nutzer), stacked config inputs with sticky save, new Nutzer tab - BottomNav hidden on unauthenticated pages via isAuthenticated store - FeedGrid: threeCol prop; OnboardingGuide upload step updated for FAB - Concept docs added to docs/ Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
9.5 KiB
HTML Viewer Export Concept
Overview
The HTML Viewer export produces a self-contained offline ZIP that is a read-only clone
of the live EventSnap feed. Opening index.html in any modern browser shows the full event
gallery — list view, grid view, search, filter, lightbox — with no internet connection or
server required.
It replaces the current HTML export job type. The old HTML export produced a raw
minijinja-rendered template; the new viewer supersedes it entirely. The existing html
job variant in the backend is repurposed to run this flow instead of being kept alongside
it.
It is distinct from the ZIP archive export (which is raw media files). The viewer is a polished, navigable web app bundled with the event's content.
User-Facing Behavior
- Download
event_name_viewer.zip, unzip, openindex.html - Full list view (chronological, newest first) and grid view with search/filter
- Likes, comments, and reaction counts shown (static snapshot from export time)
- Read-only: no uploads, no auth, no dashboards
- Works offline, no CDN or external resources
Architecture
Separate Static SvelteKit App
A new mini SvelteKit project lives at frontend/export-viewer/ within the same monorepo.
It uses adapter-static and is kept completely independent of the main app.
Why separate rather than a shared route:
- The viewer must be distributable as a standalone static bundle; the main app uses
adapter-nodeand cannot be mixed - Keeping it separate avoids auth, store, and routing dependencies leaking in
- Simpler to reason about: the viewer has exactly two concerns (list view, grid view)
Why same repo:
- Shares Tailwind config and design tokens → visual parity with the main app
- Single
pnpmworkspace, no separate CI needed - Backend can reference the pre-built output by relative path
Pre-Built Output Committed to Repo
The viewer is built once and its output committed to backend/static/export-viewer/.
The backend export job does not run a Node build at runtime — it just copies the
pre-built assets and injects event data alongside them.
When the viewer source changes, a developer rebuilds it locally (pnpm build in
frontend/export-viewer/) and commits the updated backend/static/export-viewer/ output.
ZIP Structure
event_name_viewer.zip/
├── index.html ← entry point; open this in any browser
├── _app/
│ └── immutable/
│ ├── viewer.[hash].js ← all Svelte/app logic, single bundle
│ └── viewer.[hash].css ← all styles including Tailwind output
├── data.json ← injected by backend at export time
└── media/
├── abc123_thumb.jpg ← ~400 px wide, used in grid cells
├── abc123_full.jpg ← original or capped (see Media Strategy)
├── def456_thumb.jpg
└── def456.mp4 ← videos included as-is
No external font CDN, no Google Fonts, no remote scripts. All assets are local.
data.json Schema
Generated by the backend export job. The viewer fetches this file on startup via
fetch('./data.json') (relative path, works from filesystem).
{
"event": {
"name": "Sommerfest 2025",
"exported_at": "2025-07-15T20:00:00Z"
},
"posts": [
{
"id": "abc123",
"uploader": "MaxMustermann",
"caption": "Tolle Stimmung! #party #spaß",
"tags": ["party", "spaß"],
"timestamp": "2025-07-15T18:30:00Z",
"likes": 12,
"comments": [
{
"author": "AnnaSchulz",
"text": "So schön!",
"timestamp": "2025-07-15T18:35:00Z"
}
],
"media": [
{
"type": "image",
"thumb": "media/abc123_thumb.jpg",
"full": "media/abc123_full.jpg",
"width": 1920,
"height": 1080
}
]
}
]
}
All post data (likes, comments, tags) reflects the state at export time. No live updates.
Media Strategy
Images
| Variant | Purpose | Max dimension | Format |
|---|---|---|---|
_thumb |
Grid cells, list post thumbnail | 400 px wide | JPEG q75 |
_full |
Lightbox / full-screen view | Original, or 2000 px cap if >5 MB | JPEG q85 |
The backend applies compression only when the original exceeds a threshold (e.g. >5 MB for
images). Below that threshold the original is used as _full unchanged.
The full-resolution original is always available via the separate ZIP archive export.
Videos
Included as-is (no server-side transcoding). The viewer uses a standard <video> element.
The _thumb variant for videos is a JPEG frame extracted at the 1-second mark.
SvelteKit SSG Configuration
// frontend/export-viewer/src/routes/+layout.ts
export const prerender = true;
export const ssr = false;
ssr = false produces a pure client-side SPA: SvelteKit emits a minimal index.html shell
and the JavaScript bundle hydrates it entirely in the browser. This is correct for a ZIP
distribution where no server exists to handle SSR.
// frontend/export-viewer/svelte.config.js
import adapter from '@sveltejs/adapter-static';
export default {
kit: {
adapter: adapter({
fallback: 'index.html',
pages: '../../backend/static/export-viewer',
assets: '../../backend/static/export-viewer',
})
}
};
The build output is written directly into backend/static/export-viewer/ so the backend
can reference it without a copy step.
Shared Tailwind Config
// frontend/export-viewer/tailwind.config.js
import baseConfig from '../tailwind.config.js';
export default { ...baseConfig, content: ['./src/**/*.{svelte,ts}'] };
Imports the main app's Tailwind config to guarantee visual parity. Only the content glob
is overridden.
Viewer Feature Set
| Feature | Included | Notes |
|---|---|---|
| List view (chronological, newest first) | ✓ | Full-width cards, same layout as live app |
| Grid view (3-column) | ✓ | Square cells, video duration badge |
| List/grid toggle | ✓ | Same toggle icons as live app |
| Search bar (grid view only) | ✓ | Appears only in grid view |
| Tag filter chips | ✓ | Built from tags in data.json |
| Uploader filter | ✓ | Dropdown from uploaders in data.json |
| Autocomplete suggestions | ✓ | From data.json — no network requests |
| Lightbox (tap to expand) | ✓ | Swipe left/right navigates filtered set |
| Like counts (static) | ✓ | Snapshot from export time |
| Comment list (static) | ✓ | Expandable under each post |
| Like/comment actions | ✗ | Read-only export |
| Upload button / FAB | ✗ | |
| Account / Host / Admin | ✗ | |
| Authentication | ✗ | No JWT, no PIN |
| Service Worker (offline cache) | Future | Could be added later for PWA behavior |
Backend Export Job Flow
The html job variant is repurposed. The old minijinja template rendering path is removed
and replaced entirely by the steps below.
1. Query all posts, media, reactions, and comments for the event from the DB
2. Copy pre-built viewer assets:
backend/static/export-viewer/ → tmp/{job_id}/
3. Generate data.json:
- Build the JSON structure from queried data
- Write to tmp/{job_id}/data.json
4. Process and copy media:
For each media file:
a. Copy original; if image >5 MB, also produce compressed _full variant
b. Generate _thumb (resize to 400 px wide via image library)
c. For video, extract JPEG frame for _thumb
d. Write to tmp/{job_id}/media/
5. Create ZIP:
zip -r event_name_viewer.zip tmp/{job_id}/
6. Store ZIP path, mark job as complete
7. Clean up tmp/{job_id}/
The backend needs an image processing dependency (e.g. image crate in Rust) for thumbnail
generation and compression. Video frame extraction requires ffmpeg available in the
deployment environment (already used for video handling if applicable, otherwise add to
docker-compose).
Monorepo Structure After Implementation
EventSnap/
├── backend/
│ ├── static/
│ │ └── export-viewer/ ← pre-built viewer output (committed)
│ │ ├── index.html
│ │ └── _app/...
│ └── src/
│ └── handlers/
│ └── export.rs ← export job assembles ZIP
├── frontend/
│ ├── export-viewer/ ← new mini SvelteKit project
│ │ ├── package.json
│ │ ├── svelte.config.js ← adapter-static, output → backend/static/export-viewer
│ │ ├── tailwind.config.js ← extends ../tailwind.config.js
│ │ └── src/
│ │ └── routes/
│ │ ├── +layout.ts ← prerender=true, ssr=false
│ │ └── +page.svelte ← list/grid feed, lightbox, search
│ └── src/ ← existing main app (unchanged)
└── docs/
├── CONCEPT_MOBILE_UI.md
└── CONCEPT_HTML_VIEWER.md
Open Questions for Implementation
- Image processing library: The
imagecrate handles JPEG resize/compress; is it already a backend dependency, or does it need to be added? - Video thumbnail extraction: Is
ffmpegavailable in the Docker environment? If not, a fallback (no video thumb, use a placeholder) is needed. - Viewer rebuild workflow: Add a
make build-viewerorpnpm --filter export-viewer buildstep to the developer workflow docs and CI so the committed output stays in sync. - ZIP file naming:
{event_slug}_viewer_{date}.zipor a fixed name?