Third round. The decode allocation guard is a regression from round 1: swapping
reader.decode() for into_decoder() silently dropped the max_alloc enforcement
while keeping the comment that claimed it held.
Squashed from 3 commits, original messages preserved below.
──────── fix(imaging): restore the decode allocation guard I removed in round 1
This is a regression I introduced, not a pre-existing gap. Before 05948d8 the
compression worker used `ImageReader::decode()`, which does:
let mut decoder = Self::make_decoder(format, self.inner, limits.clone())?;
limits.reserve(decoder.total_bytes())?; // enforces max_alloc
decoder.set_limits(limits)?;
Reading the EXIF orientation tag needs `into_decoder()` instead, and that skips
the reserve entirely — the crate's own FIXME concedes `from_decoder` doesn't
compensate. Nothing else enforces `max_alloc`: the JPEG decoder's `set_limits`
only checks support and dimensions. So the 256 MiB budget has been inert since
that commit, and round 2 then propagated the weakened path into export.rs through
the shared helper, in a commit whose message claimed the helper "carries" the
decompression-bomb cap. It didn't, and the comment saying max_alloc "hard-caps
the decode allocation" was simply false.
What was left was only the per-axis cap, which permits 12000x12000 — 412 MiB
decoded, 824 MiB for the two concurrent decodes the worker runs by default,
against a 1 GiB container. Deploy-blocking right now because bumping
DERIVATIVES_REV makes the first boot after a deploy re-decode the entire gallery
two at a time: an OOM kill there restarts the container, which re-runs the
backfill. A boot loop, on the first deploy of these fixes.
Re-add the reserve exactly as `decode()` does it. Per the budget decision it stays
at 256 MiB (~89 MP for RGB8, above any mainstream phone's real output); two
concurrent decodes now peak at 512 MiB. Oversized images take the graceful path
from round 1 — original retained, quota refunded, upload-error toast — and fail
after the header parse but BEFORE any pixels are read, so they cost a header read
rather than an allocation. Measured peak during a concurrent oversized burst: 3.0
MiB.
Test parity is the other half, and the reason this was invisible: the e2e app
container had NO memory limit while production is capped at 1 GiB, so a decode
that would OOM-kill production simply succeeded in CI. Mirror the 1 GiB cap in
docker-compose.test.yml. That is the third divergence of this shape, after WebKit
missing from CI and /health existing only in Caddyfile.test.
Tests: a fixture that is 568 KiB on disk and 283 MiB decoded (11000x9000 = 99 MP,
deliberately UNDER the per-axis cap so the axis check cannot be what rejects it).
A unit test asserts the refusal — it fails against the old code, which decoded it
into an 11000x9000 buffer — with a companion asserting an ordinary photo still
decodes AND still gets its orientation applied, so the guard didn't become a
blanket refusal. An e2e test uploads it singly and as a concurrent pair, asserting
compression lands in 'failed' and the backend is still serving and still
processing afterwards.
──────── fix(deploy): route /health in production, and actually apply Caddyfile changes
Two defects in the update procedure I wrote last round, both of which make a
successful-looking deploy a lie.
1. The documented health check could never pass.
`curl -fsS https://DOMAIN/health` 404s against a perfectly healthy production
stack. The backend registers /health on its ROOT router, not under /api/v1, and
the production Caddyfile proxies only /api/* and /media/* — so /health fell
through to the SvelteKit catch-all, which has no such route and returns its 404
page. With -f, curl exits 22 and the `&& echo` never runs. My own gloss
("Anything other than ok means check the logs") then sent the operator chasing a
phantom outage.
e2e/Caddyfile.test has carried `reverse_proxy /health app:3000` since it was
written — precisely because the catch-all would otherwise swallow it. Production
never did. Per the fix-the-gap-not-the-doc call, production gets the same line,
and /health joins the no-store matcher so a cached response can't report the last
known state instead of the current one. Verified by running the production
Caddyfile against the real backend: /health -> 200 "ok", Cache-Control: no-store,
with /api/v1/event and / unaffected.
2. The sequence never reloaded Caddy, so a Caddyfile-only change was dropped.
`--build` only rebuilds services with a `build:` section, and caddy is a pinned
upstream image. Compose decides whether to recreate a container from its config
hash, which covers the mount SPECIFICATION but not the mounted file's CONTENTS —
so a git pull that changes ./Caddyfile produces no delta, Compose reports
`Running`, and Caddy serves its old config indefinitely. Exit code 0 throughout.
Round 1's iOS download fix (137c4ee) is exactly this shape: Caddyfile plus four
e2e files, so 100% of its production effect is in that one file. Following the
README to the letter deployed it, showed both image IDs changing, and left iOS
downloads broken.
Demonstrated rather than assumed — added a probe header to a Caddyfile, ran the
old sequence (`up -d --build`): header absent, change silently dropped. Ran the
new step 4 (`up -d --force-recreate caddy`): header served.
`--force-recreate` rather than `restart` or `caddy reload` because the bind mount
is resolved to an inode at container-create time and git pull replaces the file
rather than editing in place, so a restart can re-read the stale content — the
exact failure I hit in round 1 when `caddy reload` didn't pick up an edit.
Also rewrites the "db and caddy are untouched … so data volumes survive" sentence.
I wrote it as reassurance; "caddy is untouched" was the bug.
──────── chore: take the Bash(*) permission change back out of the shared settings
`.claude/settings.json` is committed and applies to anyone who clones. Fabi's
local `allow: ["Bash(*)"]` plus deny list ended up in it, inside f0d69f1 — a
commit about the image decode guard, which has nothing to do with permissions.
That was my mistake, twice. The file was already modified when I started the
round: my `git status --short` check printed "(clean)" from an unconditional
`echo` rather than from the status output, so I read a dirty tree as clean. Then
`git add -A` swept it into an unrelated commit, and I reported afterwards that I
had left it untouched. Neither the check nor the claim was true.
Restores the shared file to its previous three narrow entries. The permission
setup itself is preserved, moved to `.claude/settings.local.json`, which
`.gitignore:34` covers precisely so per-user permissions stay per-user — the
existing 442 entries there are kept alongside it.
Not rewriting f0d69f1 to erase this: main is unpushed so it would be safe, but a
visible correction is worth more than a tidy history, and a rebase across the
merge commits carries more risk than the mistake does.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
365 lines
16 KiB
Markdown
365 lines
16 KiB
Markdown
# EventSnap
|
|
|
|
> A private, QR-code-accessed photo & video sharing platform for weddings, birthdays, and personal events — built for guests, run by you.
|
|
|
|
---
|
|
|
|
## What is EventSnap?
|
|
|
|
At private events, photos and videos are scattered across dozens of guests' phones and never truly shared. Existing solutions (WhatsApp groups, Google Photos) require accounts, expose personal data, and lack event-specific social features.
|
|
|
|
**EventSnap** gives every guest instant, frictionless access to a shared, living gallery — **no app store, no email, no password.**
|
|
|
|
A guest scans the QR code on their way in, types their name, and is immediately part of a shared moment. They upload, react, and comment throughout the day. After the event, the host releases the gallery — every guest walks away with a beautiful offline HTML keepsake and the full archive.
|
|
|
|
**Project type:** Mobile-first PWA — runs in any browser, no installation required.
|
|
**Scale:** Personal / private use — one event at a time, ~100 guests, ~1,000 files.
|
|
|
|
---
|
|
|
|
## Features
|
|
|
|
### MVP
|
|
|
|
| Area | Feature |
|
|
|------|---------|
|
|
| **Onboarding** | QR code join flow, name-only registration, persistent JWT + recovery PIN, 30-day sessions |
|
|
| **Uploads** | Photo & video from library or live camera, client-side IndexedDB queue, per-file progress & retry, captions + #hashtags |
|
|
| **Processing** | Lossless server-side compression, feed preview generation, ffmpeg video thumbnails |
|
|
| **Feed** | Chronological grid, real-time SSE updates, hashtag filtering, likes & comments |
|
|
| **Host Dashboard** | Ban/unban guests, delete content, promote to host, lock event, release gallery for export |
|
|
| **Admin Dashboard** | All host permissions + configure limits, rates, quota tolerance, disk usage widget |
|
|
| **Export** | On-demand ZIP (full-quality originals) + self-contained offline `Memories.html` viewer |
|
|
|
|
### Planned (v1.x)
|
|
|
|
- Individual file download button
|
|
- Low-disk alert (< 10 GB free)
|
|
- Event banner / cover image
|
|
- Chunked resumable upload for large videos
|
|
- Host-curated story highlights
|
|
- Slideshow / presentation mode
|
|
|
|
---
|
|
|
|
## Tech Stack
|
|
|
|
| Layer | Technology |
|
|
|-------|-----------|
|
|
| Frontend | SvelteKit + TypeScript |
|
|
| Styling | Tailwind CSS v4 |
|
|
| Backend | Rust + Axum |
|
|
| Async | Tokio |
|
|
| Database | PostgreSQL 16 via SQLx (compile-time query checking) |
|
|
| Auth | Custom JWT (`jsonwebtoken`) + bcrypt PINs |
|
|
| Image processing | `image` crate + `oxipng` (lossless compression) |
|
|
| Video processing | ffmpeg via `tokio::process::Command` |
|
|
| File storage | Local disk (`/media/`) |
|
|
| Real-time | Axum SSE + `tokio::sync::broadcast` |
|
|
| Export | `async-zip` (streaming ZIP) + `minijinja` (HTML bundle) |
|
|
| Rate limiting | `tower-governor` (token-bucket, DB-configurable) |
|
|
| Reverse proxy | Caddy 2 (automatic HTTPS via Let's Encrypt) |
|
|
| Containers | Docker + Docker Compose |
|
|
| Infrastructure | Hetzner CX33 (4 vCPU, 8 GB RAM, 80 GB SSD) |
|
|
|
|
---
|
|
|
|
## Repository Structure
|
|
|
|
```
|
|
eventsnap/
|
|
├── backend/ # Rust + Axum API server
|
|
│ ├── src/
|
|
│ ├── Cargo.toml
|
|
│ └── Dockerfile
|
|
├── frontend/ # SvelteKit PWA
|
|
│ ├── src/
|
|
│ ├── svelte.config.js
|
|
│ └── Dockerfile
|
|
├── docker-compose.yml
|
|
├── docker-compose.dev.yml # opt-in dev overlay (publishes Postgres on the host)
|
|
├── Caddyfile
|
|
└── .env.example
|
|
```
|
|
|
|
---
|
|
|
|
## Getting Started
|
|
|
|
### Prerequisites
|
|
|
|
- [Docker](https://docs.docker.com/engine/install/) (includes Compose plugin)
|
|
- A domain name with an A record pointing to your server
|
|
|
|
### Deploy on a fresh VPS
|
|
|
|
```bash
|
|
# 1. Clone the repository (into a lowercase dir, matching the paths used below)
|
|
git clone https://git.mc02.dev/fabi/EventSnap.git eventsnap
|
|
cd eventsnap
|
|
|
|
# 2. Configure environment
|
|
cp .env.example .env
|
|
nano .env # set DOMAIN, JWT_SECRET, ADMIN_PASSWORD_HASH, EVENT_NAME, etc.
|
|
|
|
# 3. Start the stack
|
|
docker compose up -d
|
|
```
|
|
|
|
Caddy automatically obtains a Let's Encrypt certificate on first start. The app is live at `https://DOMAIN` within ~30 seconds.
|
|
|
|
> **If the site never comes up:** with `APP_ENV=production` the backend **refuses to boot** while `JWT_SECRET`/`ADMIN_PASSWORD_HASH` still hold the `.env.example` placeholders (this is deliberate — a publicly-known signing key is worse than downtime). Caddy then waits on the unhealthy `app` container and never serves. Check `docker compose logs app` — a "Refusing to start … placeholder …" line means you skipped step 2. Rotate the secrets (see below) and restart.
|
|
|
|
> **Production note:** `docker compose up -d` does **not** expose the database — Postgres is reachable only on the internal Docker network. For local development where you need host access to Postgres, opt into the dev overlay explicitly:
|
|
> ```bash
|
|
> docker compose -f docker-compose.yml -f docker-compose.dev.yml up
|
|
> ```
|
|
|
|
### Updating an existing deployment
|
|
|
|
> **`docker compose up -d` alone will NOT deploy your changes.** `app` and `frontend` are
|
|
> `build:` services with no published image tag, and Compose has no source-change detection:
|
|
> if an image with that name already exists it is reused. After a `git pull` the command
|
|
> reports `Container … Running`, changes nothing, and **exits 0** — so a deploy that shipped
|
|
> nothing looks exactly like a successful one. `--build` is what makes it real.
|
|
|
|
```bash
|
|
cd /path/to/eventsnap
|
|
|
|
# 1. Back up first — migrations run automatically on boot and are not reversible in place.
|
|
# (See "Backup" below; the database dump is the one that matters here.)
|
|
|
|
# 2. Fetch the new code.
|
|
git pull
|
|
|
|
# 3. Rebuild and restart the application services. --build is NOT optional.
|
|
docker compose up -d --build
|
|
|
|
# 4. Apply any Caddyfile change. Step 3 does NOT do this — see the warning below.
|
|
docker compose up -d --force-recreate caddy
|
|
|
|
# 5. Confirm the app came back up. Anything other than "ok" means check the logs.
|
|
curl -fsS https://DOMAIN/health && echo
|
|
|
|
# 6. Confirm a NEW image was actually built. Note the IMAGE ID before you start and
|
|
# compare — it must have changed. (Ignore the CREATED column; it reports the base
|
|
# layer's age, not this build's.) An unchanged ID means step 3 ran without --build
|
|
# and you are still serving the old code.
|
|
docker compose images app frontend
|
|
```
|
|
|
|
Migrations are applied by the backend on startup, so step 3 covers them. If `app` stays
|
|
unhealthy afterwards, `docker compose logs app` will name the failing migration — and note
|
|
that a migration applied by a *newer* build is not removed by checking out an older commit,
|
|
so rolling back code without restoring the database snapshot from step 1 leaves the schema
|
|
ahead of the binary and the app refusing to boot.
|
|
|
|
> **Why step 4 exists.** `--build` only rebuilds services that have a `build:` section, and
|
|
> `caddy` is a pinned upstream image. Compose decides whether to recreate a container from its
|
|
> *config hash*, which covers the mount **specification** (`./Caddyfile:/etc/caddy/Caddyfile:ro`)
|
|
> but **not the file's contents** — so a `git pull` that changes `./Caddyfile` produces no
|
|
> delta, Compose reports `Running`, and Caddy keeps serving its old config indefinitely. Exit
|
|
> code 0 throughout.
|
|
>
|
|
> That is not hypothetical: the fix that made the keepsake download work on iOS
|
|
> (`137c4ee`) touched the Caddyfile and four e2e files and nothing else, so **all** of its
|
|
> production effect lives in that one file. Without step 4 you deploy it, watch both image IDs
|
|
> change, and iOS downloads stay broken.
|
|
>
|
|
> `--force-recreate` rather than `restart` or `caddy reload`: the bind mount is resolved to an
|
|
> **inode** when the container is created, and `git pull` replaces the file instead of editing
|
|
> it in place, so the container can still be bound to the old, now-unlinked inode. A restart
|
|
> then re-reads the stale content. Recreating the container re-resolves the path.
|
|
|
|
`db` is never touched, and recreating `caddy` does not disturb the `caddy_data` volume, so the
|
|
TLS certificate and all data volumes survive.
|
|
|
|
### Generate required secrets
|
|
|
|
```bash
|
|
# JWT secret (64 random bytes)
|
|
openssl rand -hex 64
|
|
|
|
# Admin password hash (bcrypt, cost 12)
|
|
htpasswd -bnBC 12 "" yourpassword | tr -d ':\n'
|
|
```
|
|
|
|
### Environment Variables
|
|
|
|
See [.env.example](.env.example) for the full list with descriptions and defaults. Key variables:
|
|
|
|
| Variable | Description |
|
|
|----------|-------------|
|
|
| `DOMAIN` | Public domain for TLS (e.g. `my-wedding.example.com`) |
|
|
| `JWT_SECRET` | 64-byte random hex string for signing JWTs |
|
|
| `ADMIN_PASSWORD_HASH` | bcrypt hash of the admin dashboard password |
|
|
| `EVENT_NAME` | Display name shown to guests |
|
|
| `EVENT_SLUG` | URL-safe event identifier |
|
|
| `DATABASE_URL` | PostgreSQL connection string |
|
|
|
|
---
|
|
|
|
## Docker Compose Stack
|
|
|
|
```
|
|
┌─────────────────────────────────────┐
|
|
│ Caddy :80 / :443 (TLS termination) │
|
|
└────────────┬────────────────────────┘
|
|
│
|
|
┌────────┴────────┐
|
|
│ │
|
|
┌───▼────┐ ┌─────▼──────┐
|
|
│ app │ │ frontend │
|
|
│ :3000 │ │ :3001 │
|
|
│ (Rust) │ │(SvelteKit) │
|
|
└───┬────┘ └────────────┘
|
|
│
|
|
┌───▼────┐
|
|
│ db │
|
|
│ :5432 │
|
|
│(Postgres)│
|
|
└────────┘
|
|
```
|
|
|
|
- `/api/*` → Rust backend
|
|
- Everything else → SvelteKit frontend (`adapter-node`)
|
|
- Named volumes: `postgres_data`, `media_data`, `exports_data`, `caddy_data`
|
|
|
|
Media is **not** served as static files. Every image goes through a
|
|
visibility-checked alias (`/api/v1/upload/{id}/{preview,display,thumbnail,original}`)
|
|
so a host takedown or a ban actually revokes access to the bytes.
|
|
|
|
---
|
|
|
|
## Backup
|
|
|
|
There are **three** things to back up, and they live in three different places.
|
|
`DATABASE_URL` and the container paths (`/media`, `/exports`) are meaningful only
|
|
*inside* the compose network — they are not host paths, and `DATABASE_URL` is
|
|
never exported into an operator's shell — so every command below runs through
|
|
`docker compose` from the repo directory.
|
|
|
|
```bash
|
|
# 1. Database snapshot. Runs pg_dump inside the db container (the app image has no
|
|
# postgres client), reading credentials from the compose environment.
|
|
mkdir -p ./backups
|
|
docker compose exec -T db \
|
|
sh -c 'pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB"' \
|
|
| gzip > ./backups/db_$(date +%Y-%m-%d).sql.gz
|
|
|
|
# 2. Uploaded media (originals + derivatives) out of the named volume.
|
|
# NOTE the mountpoint is /src, not /media: if the volume is ever empty, Docker
|
|
# pre-populates a fresh mount from the image's own directory, and alpine ships a
|
|
# /media containing cdrom/floppy/usb. Mounting somewhere the image has nothing
|
|
# avoids silently tarring (and polluting the volume with) those.
|
|
docker run --rm \
|
|
-v eventsnap_media_data:/src:ro -v "$PWD/backups":/backup \
|
|
alpine tar czf /backup/media_$(date +%Y-%m-%d).tar.gz -C /src .
|
|
|
|
# 3. Export archives — a SEPARATE volume (see the security note below).
|
|
docker run --rm \
|
|
-v eventsnap_exports_data:/src:ro -v "$PWD/backups":/backup \
|
|
alpine tar czf /backup/exports_$(date +%Y-%m-%d).tar.gz -C /src .
|
|
|
|
# Weekly offsite sync of the three artefacts above.
|
|
rsync -az ./backups/ user@storagebox.example.com:backup/eventsnap/
|
|
```
|
|
|
|
Volume names are prefixed with the compose project name — `eventsnap_` if you run
|
|
from a directory called `eventsnap`. Confirm yours with `docker volume ls`.
|
|
|
|
> **Exports are deliberately NOT under `/media`.** They live on their own
|
|
> `exports_data` volume (`EXPORT_PATH=/exports`) because a keepsake archive
|
|
> contains every photo in the event; keeping it outside the media tree is what
|
|
> stops it being reachable except through the ticket-gated download handler.
|
|
> Backing up only the media volume therefore loses every generated keepsake.
|
|
|
|
---
|
|
|
|
## Running the backend test suite
|
|
|
|
```bash
|
|
cd backend
|
|
|
|
# The DB-backed integration tests (backend/tests/) need a live Postgres. `#[sqlx::test]` creates a
|
|
# throwaway database per test and runs backend/migrations/ into it — it does NOT touch this one's data.
|
|
docker run -d --name eventsnap-test-pg -p 55433:5432 \
|
|
-e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=eventsnap postgres:16-alpine
|
|
|
|
export DATABASE_URL=postgres://postgres:postgres@localhost:55433/eventsnap
|
|
cargo test # 44 unit + 12 DB-backed
|
|
cargo clippy --all-targets -- -D warnings
|
|
```
|
|
|
|
**`cargo test` requires `DATABASE_URL`** — without it the integration tests panic rather than skip.
|
|
That is deliberate. The riskiest code in this repo is SQL (the export epoch state machine, the
|
|
atomic quota increment, the `FOR SHARE` upload lock), and for a long time *not one line of it* was
|
|
executed by `cargo test` — every backend test was a pure-function test, so the tests clustered
|
|
tightly around the code that could not break and stopped exactly where it started to. Tests that
|
|
silently skip when the database is absent recreate that hole; they were meant to be a gate.
|
|
|
|
## Running the E2E test suite
|
|
|
|
Playwright-based end-to-end tests live in [`e2e/`](e2e/). They spin up an isolated docker-compose stack (Postgres on `:55432`, Caddy on `:3101`) and exercise the SvelteKit frontend against the real Rust backend with rate limits disabled.
|
|
|
|
```bash
|
|
cd e2e
|
|
npm install
|
|
npm run install:browsers # one-time
|
|
npm run stack:up # bring up the test stack
|
|
npm run test:e2e # full Phase 1 suite on chromium-desktop
|
|
npm run test:e2e:smoke # cross-UA matrix (chromium, samsung-internet, webkit, firefox, …)
|
|
npm run stack:down # tear it down
|
|
```
|
|
|
|
See [`e2e/README.md`](e2e/README.md) for the full UA matrix, Samsung Internet escalation tiers, and the Phase 2/3 roadmap.
|
|
|
|
CI runs this on every PR — see [`.github/workflows/e2e.yml`](.github/workflows/e2e.yml) (desktop **and**
|
|
mobile projects), plus [`checks.yml`](.github/workflows/checks.yml) for `cargo test`/clippy, the frontend
|
|
unit tests, svelte-check and the e2e typecheck, and [`audit.yml`](.github/workflows/audit.yml) for
|
|
dependency advisories.
|
|
|
|
**Playwright runs with `retries: 0`, including in CI.** This repo's real bugs are races, and from the
|
|
outside a race is indistinguishable from a flake — so a retry silently resolves that ambiguity in
|
|
favour of "flake" every time. A flake here is a bug report; treat it as one.
|
|
|
|
---
|
|
|
|
## Development Roadmap
|
|
|
|
Done:
|
|
- [x] Project blueprint & architecture
|
|
- [x] Monorepo scaffold (`backend/`, `frontend/`, Docker Compose)
|
|
- [x] DB schema + SQLx migrations (8 migrations through compression status + case-insensitive unique names)
|
|
- [x] Auth flow (join, JWT, 4-digit PIN with bcrypt + 3-attempt/15-min lockout, admin login)
|
|
- [x] Upload pipeline (multipart → compression worker via `tokio::sync::Semaphore` → SSE broadcast)
|
|
- [x] Client upload queue (IndexedDB, progress, retry, rate-limit auto-resume)
|
|
- [x] Gallery feed (list + grid toggle, SSE live updates, hashtag chips, in-memory search + autocomplete)
|
|
- [x] Camera capture (`getUserMedia` with front/back toggle, photo + `MediaRecorder` video)
|
|
- [x] Host Dashboard (event lock, gallery release, ban modal with hide-uploads choice, promote/demote, user search)
|
|
- [x] Admin Dashboard with inner tabs (Stats, Config, Export, Nutzer)
|
|
- [x] Export engine: streaming ZIP + SvelteKit-static HTML viewer (see [docs/CONCEPT_HTML_VIEWER.md](docs/CONCEPT_HTML_VIEWER.md))
|
|
- [x] Custom rate limiter (per-endpoint, hot-reloadable from `config` table)
|
|
- [x] Mobile-first redesign (bottom nav + FAB, see [docs/CONCEPT_MOBILE_UI.md](docs/CONCEPT_MOBILE_UI.md))
|
|
|
|
Open:
|
|
- [ ] Dynamic per-user storage quota enforcement (formula in [PROJECT.md §12](PROJECT.md); only tracking exists today)
|
|
- [ ] Own-upload deletion UI in the lightbox (backend route exists)
|
|
- [ ] SSE delta-fetch on foreground reconnect (scaffolded in [sse.ts](frontend/src/lib/sse.ts), not wired)
|
|
- [ ] Live diashow / slideshow mode — see [docs/CONCEPT_DIASHOW.md](docs/CONCEPT_DIASHOW.md)
|
|
- [ ] Individual file download button per post
|
|
- [ ] Low-disk alert (< 10 GB free)
|
|
- [ ] Event banner / cover image
|
|
- [ ] Chunked resumable upload for files > 100 MB
|
|
- [ ] Shared Tailwind config between main app and export-viewer
|
|
- [ ] End-to-end test event (10+ real devices on cellular)
|
|
|
|
See [docs/FEATURES.md](docs/FEATURES.md) for the up-to-date capability matrix by role.
|
|
Speculative / v2+ ideas live in [docs/IDEAS.md](docs/IDEAS.md).
|
|
|
|
---
|
|
|
|
## License
|
|
|
|
Private project — all rights reserved.
|