Merge feat/platform-v0.16: platform features, dark mode, hardening
A bundle of v0.16 platform work landing on top of the SvelteKit viewer.
Brings in 7 commits across docs, backend infra + features, frontend
plumbing, the live diashow, all UI surfaces (with dark mode), and a
viewer rebuild.
Headline features:
- Live diashow with two-queue policy (live drains first, shuffle as
fallback) and pluggable transitions. Design: docs/CONCEPT_DIASHOW.md.
- Dark mode: 'system' / 'light' / 'dark' preference, picked in the
onboarding step + the Account page, applied via Tailwind v4
class-based dark variant with FOUC guard.
- Host/Admin PIN reset with one-time PIN modal; admin may also reset
host PINs. Hosts may demote other hosts.
- Per-user dynamic storage quota enforced on upload + live widget in
My Account and on the upload screen. Toggleable per-area.
- All rate-limits + quotas individually toggleable from the admin
config UI (rendered as switches + a privacy_note textarea).
- Mobile-first gestures: long-press → context sheet, double-tap to
like with heart-burst. Buttons stay as desktop equivalents.
- Data mode (Saver vs Original) per device, applied across feed,
lightbox, and diashow.
- Per-event Datenschutzhinweis admin-editable, live-refreshed on all
clients via SSE event-updated.
- /api/v1/upload/{id}/original endpoint, /me/context + /me/quota.
Hardening (latent issues from the long-term review):
- Startup recovery for stuck compression / export jobs after a crash.
- Hourly cleanup of expired sessions + cold rate-limiter HashMap keys.
- ffmpeg 120s timeout with kill_on_drop (no more permit leaks).
- Per-user IndexedDB upload queue (no more cross-user leak on shared
devices); IDB schema bumped to v2.
- SSE reconnect uses exponential backoff (no more retry storm).
- PIN lockout no longer escalates — attempts reset when the cooldown
expires.
- soft_delete is now transactional and decrements total_upload_bytes
so quotas don't drift.
- pin-reset SSE handler filters by user_id so a host resetting Anna's
PIN doesn't clear Bob's cached PIN.
- Privacy note shown preformatted; admin-editable, ≤16 KiB cap.
Docs:
- New: FEATURES.md (role matrix), USER_JOURNEYS.md, IDEAS.md,
CONCEPT_DIASHOW.md, backend/migrations/README.md,
frontend/src/lib/README.md.
- Refresh: PROJECT.md, README.md, TEST_GUIDE.md, the two existing
CONCEPT_*.md banners.
This commit is contained in:
122
PROJECT.md
122
PROJECT.md
@@ -42,7 +42,7 @@ A guest scans the QR code on their way in, types their name, and is immediately
|
|||||||
Mobile-first Progressive Web App (PWA) — accessible via browser, no app store required.
|
Mobile-first Progressive Web App (PWA) — accessible via browser, no app store required.
|
||||||
|
|
||||||
### Status
|
### Status
|
||||||
Idea / Planning phase. Greenfield personal project.
|
Implementation in progress (~v0.16). Core flows + new features all wired: auth, feed, upload, host/admin dashboards, ZIP + HTML-viewer export, SSE with delta-fetch on reconnect, toggleable rate limits + quotas with live per-user estimate, host PIN reset with one-time modal, data-mode (Saver/Original), Datenschutzhinweis, mobile gestures (long-press context sheet, double-tap to like), and the live Diashow with pluggable transitions. Open items: low-disk alert, event banner UI, chunked resumable upload for very large videos. See [FEATURES.md](docs/FEATURES.md) for the capability matrix.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -208,9 +208,9 @@ Personal / private use. One event at a time. Up to ~100 users uploading ~1,000 f
|
|||||||
│ Axum HTTP Server (Rust — Single Binary) │
|
│ Axum HTTP Server (Rust — Single Binary) │
|
||||||
│ │
|
│ │
|
||||||
│ ┌─────────────┐ ┌──────────────┐ ┌────────────────────────┐ │
|
│ ┌─────────────┐ ┌──────────────┐ ┌────────────────────────┐ │
|
||||||
│ │ REST API │ │ SSE Engine │ │ Static File Server │ │
|
│ │ REST API │ │ SSE Engine │ │ Media Static Server │ │
|
||||||
│ │ /api/v1/* │ │ /api/v1/ │ │ (SvelteKit build │ │
|
│ │ /api/v1/* │ │ /api/v1/ │ │ /media/* (originals, │ │
|
||||||
│ │ │ │ stream │ │ output, embedded) │ │
|
│ │ │ │ stream │ │ previews, thumbnails) │ │
|
||||||
│ └──────┬──────┘ └──────┬───────┘ └────────────────────────┘ │
|
│ └──────┬──────┘ └──────┬───────┘ └────────────────────────┘ │
|
||||||
│ │ │ │
|
│ │ │ │
|
||||||
│ ┌──────▼──────────────────────┐ ┌──────────────────────────┐ │
|
│ ┌──────▼──────────────────────┐ ┌──────────────────────────┐ │
|
||||||
@@ -245,36 +245,14 @@ Personal / private use. One event at a time. Up to ~100 users uploading ~1,000 f
|
|||||||
|
|
||||||
### Docker Compose Stack
|
### Docker Compose Stack
|
||||||
|
|
||||||
|
Four services: Postgres, the Rust API (`app`), the SvelteKit Node server (`frontend`), and Caddy. Caddy routes `/api/*` and `/media/*` to the Rust binary and everything else to the SvelteKit server. See [docker-compose.yml](docker-compose.yml) for the authoritative definition.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
app:
|
db: # postgres:16-alpine, persisted in postgres_data volume
|
||||||
build: ./backend # Multi-stage Rust Dockerfile
|
app: # ./backend — Rust API on :3000, mounts media_data:/media
|
||||||
env_file: .env
|
frontend: # ./frontend — SvelteKit (adapter-node) on :3001
|
||||||
depends_on: [db]
|
caddy: # caddy:2-alpine — terminates TLS on :80/:443, proxies app + frontend
|
||||||
volumes:
|
|
||||||
- media_data:/media
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
db:
|
|
||||||
image: postgres:16-alpine
|
|
||||||
env_file: .env
|
|
||||||
volumes:
|
|
||||||
- postgres_data:/var/lib/postgresql/data
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
caddy:
|
|
||||||
image: caddy:2-alpine
|
|
||||||
ports: ["80:80", "443:443"]
|
|
||||||
volumes:
|
|
||||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
|
||||||
- caddy_data:/data
|
|
||||||
depends_on: [app]
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
postgres_data:
|
|
||||||
media_data:
|
|
||||||
caddy_data:
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Caddyfile
|
### Caddyfile
|
||||||
@@ -345,11 +323,14 @@ COMPRESSION_WORKER_CONCURRENCY=2
|
|||||||
|-------|---------|---------|
|
|-------|---------|---------|
|
||||||
| `new-upload` | `{ id, preview_url, uploader, caption, created_at }` | Upload processing complete |
|
| `new-upload` | `{ id, preview_url, uploader, caption, created_at }` | Upload processing complete |
|
||||||
| `new-comment` | `{ id, upload_id, body, uploader, created_at }` | Comment posted |
|
| `new-comment` | `{ id, upload_id, body, uploader, created_at }` | Comment posted |
|
||||||
| `new-like` | `{ upload_id, like_count }` | Like toggled |
|
| `like-update` | `{ upload_id, like_count }` | Like toggled |
|
||||||
| `upload-deleted` | `{ upload_id }` | Upload deleted |
|
| `upload-deleted` | `{ upload_id }` | Upload deleted |
|
||||||
| `event-closed` | `{}` | Host locks uploads |
|
| `event-closed` | `{}` | Host locks uploads |
|
||||||
| `event-opened` | `{}` | Host unlocks uploads |
|
| `event-opened` | `{}` | Host unlocks uploads |
|
||||||
| `export-available` | `{ types: ["zip","html"] }` | Export generation complete |
|
| `export-available` | `{ types: ["zip","html"] }` | Export generation complete |
|
||||||
|
| `upload-processed` | `{ upload_id, preview_url, thumbnail_url }` | Server-side compression / preview generation finished |
|
||||||
|
| `upload-error` | `{ upload_id, message }` | Compression / preview generation failed |
|
||||||
|
| `export-progress` | `{ type, progress_pct }` | Periodic progress update from an export job |
|
||||||
|
|
||||||
**Client SSE lifecycle:** `visibilitychange: hidden` → close connection · `visible` → reconnect + delta-fetch via `GET /api/v1/feed/delta?since=`
|
**Client SSE lifecycle:** `visibilitychange: hidden` → close connection · `visible` → reconnect + delta-fetch via `GET /api/v1/feed/delta?since=`
|
||||||
|
|
||||||
@@ -372,7 +353,7 @@ COMPRESSION_WORKER_CONCURRENCY=2
|
|||||||
| Real-Time | Axum SSE + `tokio::sync::broadcast` | Native, lightweight, perfect for fan-out at this scale |
|
| Real-Time | Axum SSE + `tokio::sync::broadcast` | Native, lightweight, perfect for fan-out at this scale |
|
||||||
| ZIP Export | `async-zip` crate | Streaming ZIP generation without buffering the full archive in RAM |
|
| ZIP Export | `async-zip` crate | Streaming ZIP generation without buffering the full archive in RAM |
|
||||||
| HTML Export | `minijinja` (Rust templating) | Generates `Memories.html` as a single self-contained file |
|
| HTML Export | `minijinja` (Rust templating) | Generates `Memories.html` as a single self-contained file |
|
||||||
| Rate Limiting | `tower-governor` | Token-bucket per IP / per user; config from DB; hot-reloadable |
|
| Rate Limiting | Custom in-memory sliding-window limiter ([services/rate_limiter.rs](backend/src/services/rate_limiter.rs)) | Per IP / per user; limits read from `config` DB table on each request; hot-reloadable without restart |
|
||||||
| Reverse Proxy | Caddy 2 | Automatic HTTPS via Let's Encrypt; zero certificate management |
|
| Reverse Proxy | Caddy 2 | Automatic HTTPS via Let's Encrypt; zero certificate management |
|
||||||
| Containerisation | Docker + Docker Compose | Full stack in one file; `.env` for all config; single-command deploy |
|
| Containerisation | Docker + Docker Compose | Full stack in one file; `.env` for all config; single-command deploy |
|
||||||
| Infrastructure | Hetzner CX33 (4 vCPU, 8 GB RAM, 80 GB SSD, 20 TB traffic) | Well-sized; 20 TB/month means post-event bulk downloads are no issue |
|
| Infrastructure | Hetzner CX33 (4 vCPU, 8 GB RAM, 80 GB SSD, 20 TB traffic) | Well-sized; 20 TB/month means post-event bulk downloads are no issue |
|
||||||
@@ -417,9 +398,9 @@ No paid third-party services required.
|
|||||||
|
|
||||||
| Role | Permissions |
|
| Role | Permissions |
|
||||||
|------|------------|
|
|------|------------|
|
||||||
| Guest | Upload (within quota), caption/hashtag, like, comment, delete own content, view feed, download export (after release) |
|
| Guest | Upload (within quota), caption/hashtag, like, comment, delete own content, view feed, download export (after release), pick data mode, read privacy note |
|
||||||
| Host | All guest permissions + ban/unban users (with upload visibility prompt), delete any content, promote guests to Host, lock/unlock uploads, release gallery export |
|
| Host | All guest permissions + ban/unban users (with upload visibility prompt), delete any content, promote guests to Host, demote *other* Hosts to guest (never self), reset guest PINs (planned), lock/unlock uploads, release gallery export |
|
||||||
| Admin | All Host permissions + configure storage/file/rate limits, quota tolerance, view disk usage, manage app config, trigger export generation |
|
| Admin | All Host permissions + reset any non-admin PIN, configure storage/file/rate limits with on/off toggles, edit quota tolerance and per-area quota toggles, edit the Datenschutzhinweis, view disk usage, manage app config, trigger export generation |
|
||||||
| Banned Guest | View feed only — cannot upload, like, comment, or export |
|
| Banned Guest | View feed only — cannot upload, like, comment, or export |
|
||||||
|
|
||||||
### Compliance
|
### Compliance
|
||||||
@@ -473,37 +454,33 @@ Full-quality originals only. File naming: `{date}_{time}_{username}_{original_fi
|
|||||||
|
|
||||||
### Export Type 2: HTML Offline Viewer (`Memories.zip`)
|
### Export Type 2: HTML Offline Viewer (`Memories.zip`)
|
||||||
|
|
||||||
|
The HTML export is a **pre-built SvelteKit static app** (`adapter-static`, `ssr=false`) shipped together with the event data. It is a non-interactive, read-only clone of the live feed — same components, same Tailwind tokens, same look — minus auth, upload, comment, and any dashboards. Full design rationale in [docs/CONCEPT_HTML_VIEWER.md](docs/CONCEPT_HTML_VIEWER.md).
|
||||||
|
|
||||||
```
|
```
|
||||||
Memories/
|
Memories/
|
||||||
Memories.html ← single entry point (all CSS + JS inlined; no external deps)
|
index.html ← entry point; open this in any browser
|
||||||
README.txt ← plain-text setup guide (in German, as the UI language)
|
_app/
|
||||||
Photos/ ...
|
immutable/... ← hashed JS/CSS bundles (viewer SPA)
|
||||||
Videos/ ...
|
data.json ← event metadata, posts, comments, likes, hashtags
|
||||||
|
media/
|
||||||
|
{id}_thumb.jpg ← grid thumbnails (≈400 px wide)
|
||||||
|
{id}_full.jpg/.mp4 ← full-size media for the lightbox
|
||||||
```
|
```
|
||||||
|
|
||||||
**Fully self-contained / true offline:** `Memories.html` is a single file with all CSS and JS inlined as `<style>` and `<script>` tags — no external stylesheets, no CDN scripts, no network requests. All images and videos are referenced via **relative paths** to the sibling `Photos/` and `Videos/` folders — not base64-embedded (that would make the HTML file unworkably large). The ZIP must be unzipped first; relative paths resolve correctly from any location on disk.
|
**How it works:** open `index.html` in any modern browser. The viewer hydrates client-side, `fetch('./data.json')` loads the event snapshot, all media references are relative paths into `media/`. No network calls, no service required. The ZIP must be unzipped first; the viewer does not run from inside an archive.
|
||||||
|
|
||||||
**`Memories.html` features:** responsive photo/video grid, fullscreen lightbox, client-side hashtag filter chips, comments + like counts per upload, uploader name + timestamp, warm keepsake album aesthetic — all in self-contained vanilla JS + CSS.
|
**Viewer feature parity with the live app:**
|
||||||
|
- List view (chronological) and 3-column grid view with the same toggle as the live app
|
||||||
|
- Lightbox with swipe navigation
|
||||||
|
- Hashtag filter chips and grid-view search/autocomplete
|
||||||
|
- Like counts and comment lists shown as a static snapshot from export time
|
||||||
|
- All UI strings in German
|
||||||
|
|
||||||
**`README.txt`** (in German, as the app's UI language):
|
**Build flow:** The viewer lives at [frontend/export-viewer/](frontend/export-viewer/) and is built ahead of time into [backend/static/export-viewer/](backend/static/export-viewer/) (committed to the repo). The export job embeds those assets via `include_dir!`, generates `data.json` from the database, processes thumbnails/full-sized variants, and streams the ZIP.
|
||||||
```
|
|
||||||
Willkommen in der Event-Galerie!
|
|
||||||
|
|
||||||
So geht's:
|
**Source files (ZIP archive export, see below)** still contain the unmodified originals — the viewer is the polished read-only experience, the ZIP is the raw archive.
|
||||||
1. Entpacke diese ZIP-Datei
|
|
||||||
(Windows: Rechtsklick > "Alle extrahieren"; Mac: Doppelklick;
|
|
||||||
Handy: Dateimanager-App verwenden).
|
|
||||||
2. Öffne die Datei "Memories.html" in deinem Browser
|
|
||||||
(z. B. Chrome, Safari oder Firefox).
|
|
||||||
3. Stöbere durch alle Fotos und Videos.
|
|
||||||
Du kannst nach Hashtags filtern — klicke einfach auf einen Hashtag.
|
|
||||||
4. Eine Internetverbindung ist nicht nötig.
|
|
||||||
Alles ist lokal auf deinem Gerät gespeichert.
|
|
||||||
|
|
||||||
Viel Freude mit den Erinnerungen!
|
For video-heavy events the viewer ZIP can be several GB. The in-app download guide warns guests: *"Am besten im WLAN herunterladen."* ("Best downloaded on Wi-Fi.")
|
||||||
```
|
|
||||||
|
|
||||||
For video-heavy events the ZIP can be several GB. The in-app download guide warns guests: *"Am besten im WLAN herunterladen."* ("Best downloaded on Wi-Fi.")
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -625,7 +602,9 @@ CREATE TABLE "user" (
|
|||||||
recovery_pin_hash TEXT NOT NULL, -- bcrypt(PIN)
|
recovery_pin_hash TEXT NOT NULL, -- bcrypt(PIN)
|
||||||
total_upload_bytes BIGINT NOT NULL DEFAULT 0, -- running sum for quota checks
|
total_upload_bytes BIGINT NOT NULL DEFAULT 0, -- running sum for quota checks
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
-- No UNIQUE(event_id, display_name) — PIN disambiguates name collisions
|
-- Case-insensitive UNIQUE on (event_id, LOWER(display_name)) added by migration 007.
|
||||||
|
-- Name collisions are rejected on join; the user is prompted to recover with their PIN
|
||||||
|
-- (or to pick a different name).
|
||||||
);
|
);
|
||||||
|
|
||||||
-- ─────────────────────────────────────────
|
-- ─────────────────────────────────────────
|
||||||
@@ -735,7 +714,18 @@ INSERT INTO config (key, value) VALUES
|
|||||||
('export_rate_per_day', '3'),
|
('export_rate_per_day', '3'),
|
||||||
('quota_tolerance', '0.75'),
|
('quota_tolerance', '0.75'),
|
||||||
('estimated_guest_count', '100'),
|
('estimated_guest_count', '100'),
|
||||||
('compression_concurrency', '2')
|
('compression_concurrency', '2'),
|
||||||
|
-- Planned (see docs/FEATURES.md §2.6 and §2.7):
|
||||||
|
-- on/off switches for rate limits and quotas, and the privacy note text
|
||||||
|
('rate_limits_enabled', 'true'),
|
||||||
|
('upload_rate_enabled', 'true'),
|
||||||
|
('feed_rate_enabled', 'true'),
|
||||||
|
('export_rate_enabled', 'true'),
|
||||||
|
('join_rate_enabled', 'true'),
|
||||||
|
('quota_enabled', 'true'),
|
||||||
|
('storage_quota_enabled', 'true'),
|
||||||
|
('upload_count_quota_enabled', 'true'),
|
||||||
|
('privacy_note', '') -- free text, whitespace + newlines preserved, no HTML
|
||||||
ON CONFLICT (key) DO NOTHING;
|
ON CONFLICT (key) DO NOTHING;
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -1173,7 +1163,11 @@ The `/media` volume contains originals, previews, thumbnails, generated exports,
|
|||||||
|
|
||||||
| Decision | Chosen | Rationale |
|
| Decision | Chosen | Rationale |
|
||||||
|----------|--------|-----------|
|
|----------|--------|-----------|
|
||||||
| Recovery mechanism | 4-digit PIN, stored in `localStorage` + "My Account" page | Simple for non-technical guests; no email required |
|
| Recovery mechanism | 4-digit PIN, stored in `localStorage` + "My Account" page; Host/Admin can issue a fresh PIN via the user list when a guest loses it entirely | Simple for non-technical guests; no email required; Host-mediated reset preserves the no-email identity model |
|
||||||
|
| Host demotion authority | Hosts can demote other Hosts (never themselves); Admin can demote anyone non-admin | Avoids requiring an Admin for every staffing change at the event |
|
||||||
|
| Privacy note | Free-text, plain (no HTML), admin-edited, rendered preformatted in My Account | Many events need a per-event privacy statement; preformatted text avoids any markup-injection risk |
|
||||||
|
| Data mode | Per-device `localStorage` setting (Saver / Original), default Saver | A guest can be on cellular on one device and Wi-Fi on another; per-device is the right scope |
|
||||||
|
| Rate-limit & quota toggles | On/off switches plus numeric values in the `config` table | Lets the Admin disable enforcement for testing or trusted events without redeploying |
|
||||||
| Admin dashboard path | `/admin` (standard route) | Correct auth checks are the security; obscure paths add no meaningful protection |
|
| Admin dashboard path | `/admin` (standard route) | Correct auth checks are the security; obscure paths add no meaningful protection |
|
||||||
| ZIP contents | Full-quality originals only (Photos + Videos folders) | Clean and simple; no metadata JSON |
|
| ZIP contents | Full-quality originals only (Photos + Videos folders) | Clean and simple; no metadata JSON |
|
||||||
| HTML export assets | Fully offline (relative paths, CSS/JS inlined) | True offline experience; no external dependencies |
|
| HTML export assets | Fully offline (relative paths, CSS/JS inlined) | True offline experience; no external dependencies |
|
||||||
@@ -1215,7 +1209,7 @@ The `/media` volume contains originals, previews, thumbnails, generated exports,
|
|||||||
| `uuid` | UUID v7 (time-sortable) |
|
| `uuid` | UUID v7 (time-sortable) |
|
||||||
| `serde` / `serde_json` | Serialisation |
|
| `serde` / `serde_json` | Serialisation |
|
||||||
| `tower` / `tower-http` | Middleware stack (CORS, compression, static files, request tracing) |
|
| `tower` / `tower-http` | Middleware stack (CORS, compression, static files, request tracing) |
|
||||||
| `tower-governor` | Token-bucket rate limiting (per IP and per user) |
|
| (custom limiter, no crate) | Token-bucket / sliding window built in-tree at [services/rate_limiter.rs](backend/src/services/rate_limiter.rs) |
|
||||||
| `tokio::sync::Semaphore` | Bounded worker pool for compression tasks |
|
| `tokio::sync::Semaphore` | Bounded worker pool for compression tasks |
|
||||||
| `async-zip` | Streaming ZIP export (no in-memory buffer) |
|
| `async-zip` | Streaming ZIP export (no in-memory buffer) |
|
||||||
| `minijinja` | HTML export template rendering (`Memories.html`) |
|
| `minijinja` | HTML export template rendering (`Memories.html`) |
|
||||||
|
|||||||
44
README.md
44
README.md
@@ -144,18 +144,18 @@ See [.env.example](.env.example) for the full list with descriptions and default
|
|||||||
┌───▼────┐ ┌─────▼──────┐
|
┌───▼────┐ ┌─────▼──────┐
|
||||||
│ app │ │ frontend │
|
│ app │ │ frontend │
|
||||||
│ :3000 │ │ :3001 │
|
│ :3000 │ │ :3001 │
|
||||||
│ (Rust) │ │ (SvelteKit)│
|
│ (Rust) │ │(SvelteKit) │
|
||||||
└───┬────┘ └────────────┘
|
└───┬────┘ └────────────┘
|
||||||
│
|
│
|
||||||
┌───▼────┐
|
┌───▼────┐
|
||||||
│ db │
|
│ db │
|
||||||
│ :5432 │
|
│ :5432 │
|
||||||
│(Postgres│
|
│(Postgres)│
|
||||||
└────────┘
|
└────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
- `/api/*` and `/media/*` → Rust backend
|
- `/api/*` and `/media/*` → Rust backend
|
||||||
- Everything else → SvelteKit frontend
|
- Everything else → SvelteKit frontend (`adapter-node`)
|
||||||
- Named volumes: `postgres_data`, `media_data`, `caddy_data`
|
- Named volumes: `postgres_data`, `media_data`, `caddy_data`
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -176,19 +176,35 @@ The `/media` volume holds originals, previews, thumbnails, exports, and DB backu
|
|||||||
|
|
||||||
## Development Roadmap
|
## Development Roadmap
|
||||||
|
|
||||||
|
Done:
|
||||||
- [x] Project blueprint & architecture
|
- [x] Project blueprint & architecture
|
||||||
- [x] Monorepo scaffold (`backend/`, `frontend/`, Docker Compose)
|
- [x] Monorepo scaffold (`backend/`, `frontend/`, Docker Compose)
|
||||||
- [ ] DB schema + SQLx migrations
|
- [x] DB schema + SQLx migrations (8 migrations through compression status + case-insensitive unique names)
|
||||||
- [ ] Auth flow (join, JWT, PIN recovery)
|
- [x] Auth flow (join, JWT, 4-digit PIN with bcrypt + 3-attempt/15-min lockout, admin login)
|
||||||
- [ ] Upload pipeline (multipart → compression worker → SSE broadcast)
|
- [x] Upload pipeline (multipart → compression worker via `tokio::sync::Semaphore` → SSE broadcast)
|
||||||
- [ ] Client upload queue (IndexedDB, progress, retry)
|
- [x] Client upload queue (IndexedDB, progress, retry, rate-limit auto-resume)
|
||||||
- [ ] Gallery feed (grid, SSE, hashtag filters)
|
- [x] Gallery feed (list + grid toggle, SSE live updates, hashtag chips, in-memory search + autocomplete)
|
||||||
- [ ] Camera capture (`getUserMedia`)
|
- [x] Camera capture (`getUserMedia` with front/back toggle, photo + `MediaRecorder` video)
|
||||||
- [ ] Host Dashboard
|
- [x] Host Dashboard (event lock, gallery release, ban modal with hide-uploads choice, promote/demote, user search)
|
||||||
- [ ] Admin Dashboard
|
- [x] Admin Dashboard with inner tabs (Stats, Config, Export, Nutzer)
|
||||||
- [ ] Export engine (ZIP + offline HTML)
|
- [x] Export engine: streaming ZIP + SvelteKit-static HTML viewer (see [docs/CONCEPT_HTML_VIEWER.md](docs/CONCEPT_HTML_VIEWER.md))
|
||||||
- [ ] Rate limiting middleware
|
- [x] Custom rate limiter (per-endpoint, hot-reloadable from `config` table)
|
||||||
- [ ] End-to-end test event (10+ real devices)
|
- [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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -16,45 +16,47 @@ Please test each step in order and report any errors (console errors, wrong text
|
|||||||
9. ✅ Expected: Overlay disappears
|
9. ✅ Expected: Overlay disappears
|
||||||
|
|
||||||
### Step 3 — Feed & navigation
|
### Step 3 — Feed & navigation
|
||||||
10. ✅ Expected: Feed shows "Noch keine Fotos." empty state with an upload button
|
10. ✅ Expected: Feed shows the empty state ("Noch keine Fotos." or similar) with a hint to upload
|
||||||
11. ✅ Expected: Top-right has an **upload button** (blue) and a **person icon** link
|
11. ✅ Expected: A **persistent bottom nav** is visible with three slots — 🏠 **Feed** on the left, an elevated 📷+ **FAB** in the center, 👤 **Account** on the right
|
||||||
|
|
||||||
### Step 4 — My Account page
|
### Step 4 — My Account page
|
||||||
12. Click the **person icon** in the top-right
|
12. Tap the **👤 Account** tab in the bottom nav
|
||||||
13. ✅ Expected: `/account` page shows your name (`Max`), a blue "Gast" badge, session expiry date, and your PIN displayed large in an amber box
|
13. ✅ Expected: `/account` page shows your name (`Max`), a blue "Gast" badge, session expiry date, and your PIN displayed large in an amber box
|
||||||
14. Click **Kopieren** — check clipboard contains your PIN
|
14. Tap **Kopieren** — check the clipboard contains your PIN
|
||||||
15. ✅ Expected: Button briefly shows "Kopiert!"
|
15. ✅ Expected: Button briefly shows "Kopiert!"
|
||||||
16. Click **Zur Galerie** to go back to the feed
|
16. Tap the 🏠 **Feed** tab to go back
|
||||||
|
|
||||||
### Step 5 — Upload
|
### Step 5 — Upload
|
||||||
17. Click **Hochladen** — this takes you to `/upload`
|
17. Tap the central **📷+ FAB** in the bottom nav
|
||||||
18. Try uploading a photo from your device library
|
18. ✅ Expected: A bottom sheet slides up offering **Kamera** and **Galerie** options
|
||||||
19. ✅ Expected: Photo appears in queue with a progress bar, then completes
|
19. Tap **Galerie** → pick a photo from your device library
|
||||||
20. Go back to `/feed` — ✅ Expected: your photo appears in the feed grid
|
20. ✅ Expected: Preview screen (`/upload`) shows the staged file with an optional caption / hashtag editor
|
||||||
|
21. Tap **Hochladen**
|
||||||
|
22. ✅ Expected: You return to the feed immediately; the FAB shows a small badge while uploading; the photo appears in the feed once processing completes
|
||||||
|
|
||||||
### Step 6 — Onboarding guide not shown again
|
### Step 6 — Onboarding guide not shown again
|
||||||
21. Reload the page at `/feed`
|
23. Reload the page at `/feed`
|
||||||
22. ✅ Expected: The onboarding overlay does **not** appear (already dismissed)
|
24. ✅ Expected: The onboarding overlay does **not** appear (already dismissed)
|
||||||
|
|
||||||
### Step 7 — Recover (open a private/incognito window)
|
### Step 7 — Recover (open a private/incognito window)
|
||||||
23. Open a new **private/incognito** window at **http://localhost:5173/recover**
|
25. Open a new **private/incognito** window at **http://localhost:5173/recover**
|
||||||
24. Enter the same name (`Max`) and the PIN you copied
|
26. Enter the same name (`Max`) and the PIN you copied
|
||||||
25. ✅ Expected: You're redirected to the feed with the same account
|
27. ✅ Expected: You're redirected to the feed with the same account
|
||||||
|
|
||||||
### Step 8 — Upload rate-limit auto-retry
|
### Step 8 — Upload rate-limit auto-retry
|
||||||
26. Upload more than 20 photos in one hour to trigger the rate limit
|
28. Upload more than the per-hour limit of photos in quick succession to trigger the rate limit
|
||||||
27. ✅ Expected: When the limit is hit, remaining items stay **Wartend** (not error)
|
29. ✅ Expected: When the limit is hit, remaining items stay **Wartend** (not error)
|
||||||
28. ✅ Expected: An amber banner appears in the queue: "Upload-Limit erreicht. Wird in Xs automatisch fortgesetzt."
|
30. ✅ Expected: An amber banner appears in the queue: "Upload-Limit erreicht. Wird in Xs automatisch fortgesetzt."
|
||||||
29. ✅ Expected: The countdown ticks down and uploads resume automatically when it reaches 0
|
31. ✅ Expected: The countdown ticks down and uploads resume automatically when it reaches 0
|
||||||
|
|
||||||
### Step 9 — Name uniqueness (case-insensitive)
|
### Step 9 — Name uniqueness (case-insensitive)
|
||||||
30. In a private/incognito window go to **http://localhost:5173/join**
|
32. In a private/incognito window go to **http://localhost:5173/join**
|
||||||
31. Enter `max` or `MAX` — the same name already taken in Step 1 (different case)
|
33. Enter `max` or `MAX` — the same name already taken in Step 1 (different case)
|
||||||
32. ✅ Expected: Instead of creating a new account, an amber warning appears: „Max ist bereits vergeben." with name tips
|
34. ✅ Expected: Instead of creating a new account, an amber warning appears: „Max ist bereits vergeben." with name tips
|
||||||
33. ✅ Expected: A PIN input and **Anmelden** button appear, plus an **Anderen Namen wählen** button
|
35. ✅ Expected: A PIN input and **Anmelden** button appear, plus an **Anderen Namen wählen** button
|
||||||
34. Enter your PIN from Step 1 and click **Anmelden**
|
36. Enter your PIN from Step 1 and click **Anmelden**
|
||||||
35. ✅ Expected: You're signed in to the existing `Max` account and redirected to the feed
|
37. ✅ Expected: You're signed in to the existing `Max` account and redirected to the feed
|
||||||
36. Alternatively, click **Anderen Namen wählen** — ✅ Expected: the name input reappears with `max` pre-filled so you can edit it
|
38. Alternatively, click **Anderen Namen wählen** — ✅ Expected: the name input reappears with `max` pre-filled so you can edit it
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
27
backend/Cargo.lock
generated
27
backend/Cargo.lock
generated
@@ -513,6 +513,17 @@ dependencies = [
|
|||||||
"shlex",
|
"shlex",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfb"
|
||||||
|
version = "0.7.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
|
||||||
|
dependencies = [
|
||||||
|
"byteorder",
|
||||||
|
"fnv",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cfg-if"
|
name = "cfg-if"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
@@ -897,6 +908,7 @@ dependencies = [
|
|||||||
"futures",
|
"futures",
|
||||||
"image",
|
"image",
|
||||||
"include_dir",
|
"include_dir",
|
||||||
|
"infer",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
"oxipng",
|
"oxipng",
|
||||||
"rand 0.9.2",
|
"rand 0.9.2",
|
||||||
@@ -1004,6 +1016,12 @@ dependencies = [
|
|||||||
"spin",
|
"spin",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fnv"
|
||||||
|
version = "1.0.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foldhash"
|
name = "foldhash"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -1609,6 +1627,15 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "infer"
|
||||||
|
version = "0.15.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cb33622da908807a06f9513c19b3c1ad50fab3e4137d82a78107d502075aa199"
|
||||||
|
dependencies = [
|
||||||
|
"cfb",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "inout"
|
name = "inout"
|
||||||
version = "0.1.4"
|
version = "0.1.4"
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ image = "0.25"
|
|||||||
oxipng = "9"
|
oxipng = "9"
|
||||||
async_zip = { version = "0.0.17", features = ["tokio", "deflate"] }
|
async_zip = { version = "0.0.17", features = ["tokio", "deflate"] }
|
||||||
include_dir = "0.7"
|
include_dir = "0.7"
|
||||||
|
infer = "0.15"
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
|
|||||||
2
backend/migrations/008_compression_status.down.sql
Normal file
2
backend/migrations/008_compression_status.down.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
-- Remove compression_status field
|
||||||
|
ALTER TABLE upload DROP COLUMN compression_status;
|
||||||
6
backend/migrations/008_compression_status.up.sql
Normal file
6
backend/migrations/008_compression_status.up.sql
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
-- Add compression_status to track media processing state
|
||||||
|
ALTER TABLE upload ADD COLUMN compression_status TEXT NOT NULL DEFAULT 'pending';
|
||||||
|
|
||||||
|
-- Values: 'pending', 'processing', 'done', 'failed'
|
||||||
|
-- Add comment to document the field
|
||||||
|
COMMENT ON COLUMN upload.compression_status IS 'Tracks media compression/preview generation: pending -> processing -> (done or failed)';
|
||||||
11
backend/migrations/009_feature_toggles.down.sql
Normal file
11
backend/migrations/009_feature_toggles.down.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
DELETE FROM config WHERE key IN (
|
||||||
|
'rate_limits_enabled',
|
||||||
|
'upload_rate_enabled',
|
||||||
|
'feed_rate_enabled',
|
||||||
|
'export_rate_enabled',
|
||||||
|
'join_rate_enabled',
|
||||||
|
'quota_enabled',
|
||||||
|
'storage_quota_enabled',
|
||||||
|
'upload_count_quota_enabled',
|
||||||
|
'privacy_note'
|
||||||
|
);
|
||||||
16
backend/migrations/009_feature_toggles.up.sql
Normal file
16
backend/migrations/009_feature_toggles.up.sql
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
-- Feature toggles for rate limits and quotas, plus the admin-configurable
|
||||||
|
-- Datenschutzhinweis. Everything lives in the `config` table — no schema change.
|
||||||
|
INSERT INTO config (key, value) VALUES
|
||||||
|
-- Rate limits (master + per-endpoint)
|
||||||
|
('rate_limits_enabled', 'true'),
|
||||||
|
('upload_rate_enabled', 'true'),
|
||||||
|
('feed_rate_enabled', 'true'),
|
||||||
|
('export_rate_enabled', 'true'),
|
||||||
|
('join_rate_enabled', 'true'),
|
||||||
|
-- Quotas (master + per-area)
|
||||||
|
('quota_enabled', 'true'),
|
||||||
|
('storage_quota_enabled', 'true'),
|
||||||
|
('upload_count_quota_enabled', 'true'),
|
||||||
|
-- Free-text privacy note shown to guests in My Account. Plain text — no HTML.
|
||||||
|
('privacy_note', '')
|
||||||
|
ON CONFLICT (key) DO NOTHING;
|
||||||
28
backend/migrations/README.md
Normal file
28
backend/migrations/README.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Migrations
|
||||||
|
|
||||||
|
SQLx-managed Postgres migrations. Each `NNN_topic.up.sql` has a matching
|
||||||
|
`NNN_topic.down.sql`. Run by `sqlx::migrate!()` at app start.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
1. **Never edit a shipped migration.** If a column needs to change or a fix needs to
|
||||||
|
land, write a new migration. Production has already applied the old one and SQLx
|
||||||
|
tracks each by checksum — editing in place will fail to apply on existing databases.
|
||||||
|
2. **Always pair `.up.sql` with a `.down.sql`.** Reverts may not be perfect (data
|
||||||
|
loss is sometimes unavoidable) but the file must exist and do the best it can.
|
||||||
|
3. **Prefer additive changes.** New columns, new tables, new keys in `config`. Drop /
|
||||||
|
rename only when there is no alternative.
|
||||||
|
4. **No business logic in migrations.** Schema + seeds only. Anything that needs Rust
|
||||||
|
code goes in a one-off binary, not a migration file.
|
||||||
|
5. **One concern per migration.** Easier to revert. Easier to read in `git log`.
|
||||||
|
|
||||||
|
## Numbering
|
||||||
|
|
||||||
|
Zero-padded three digits, monotonically increasing. The next free number lives at the
|
||||||
|
bottom of the directory listing — pick that.
|
||||||
|
|
||||||
|
## Seed-only migrations
|
||||||
|
|
||||||
|
When you only need to add `config` keys (feature flags, defaults), use
|
||||||
|
`INSERT … ON CONFLICT DO NOTHING` so existing operator overrides survive. See
|
||||||
|
`009_feature_toggles.up.sql` for the canonical shape.
|
||||||
@@ -14,6 +14,7 @@ use crate::error::AppError;
|
|||||||
use crate::models::event::Event;
|
use crate::models::event::Event;
|
||||||
use crate::models::session::Session;
|
use crate::models::session::Session;
|
||||||
use crate::models::user::{User, UserRole};
|
use crate::models::user::{User, UserRole};
|
||||||
|
use crate::services::config;
|
||||||
use crate::services::rate_limiter::client_ip;
|
use crate::services::rate_limiter::client_ip;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
@@ -36,7 +37,11 @@ pub async fn join(
|
|||||||
Json(body): Json<JoinRequest>,
|
Json(body): Json<JoinRequest>,
|
||||||
) -> Result<(StatusCode, Json<JoinResponse>), AppError> {
|
) -> Result<(StatusCode, Json<JoinResponse>), AppError> {
|
||||||
let ip = client_ip(&headers, "unknown");
|
let ip = client_ip(&headers, "unknown");
|
||||||
if !state.rate_limiter.check(format!("join:{ip}"), 5, Duration::from_secs(60)) {
|
let rate_limits_on = config::get_bool(&state.pool, "rate_limits_enabled", true).await;
|
||||||
|
let join_rate_on = config::get_bool(&state.pool, "join_rate_enabled", true).await;
|
||||||
|
if rate_limits_on && join_rate_on
|
||||||
|
&& !state.rate_limiter.check(format!("join:{ip}"), 5, Duration::from_secs(60))
|
||||||
|
{
|
||||||
return Err(AppError::TooManyRequests(
|
return Err(AppError::TooManyRequests(
|
||||||
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
|
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
|
||||||
None,
|
None,
|
||||||
@@ -128,7 +133,11 @@ pub async fn recover(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for user in &users {
|
for user in &users {
|
||||||
// Check PIN lockout
|
// Check PIN lockout. If the lockout has expired, also reset the failed-attempt
|
||||||
|
// counter so the user gets a fresh 3-strike window — otherwise the counter
|
||||||
|
// stays at 3+ and every subsequent wrong PIN immediately re-locks them, even
|
||||||
|
// after waiting out the cooldown. Without this reset, a once-locked account
|
||||||
|
// is effectively permanently fragile.
|
||||||
if let Some(locked_until) = user.pin_locked_until {
|
if let Some(locked_until) = user.pin_locked_until {
|
||||||
if Utc::now() < locked_until {
|
if Utc::now() < locked_until {
|
||||||
return Err(AppError::TooManyRequests(
|
return Err(AppError::TooManyRequests(
|
||||||
@@ -136,6 +145,8 @@ pub async fn recover(
|
|||||||
None,
|
None,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
// Lockout window expired — wipe the counter and the timestamp.
|
||||||
|
User::reset_pin_attempts(&state.pool, user.id).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let pin_matches = bcrypt::verify(&body.pin, &user.recovery_pin_hash)
|
let pin_matches = bcrypt::verify(&body.pin, &user.recovery_pin_hash)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use sysinfo::System;
|
|||||||
|
|
||||||
use crate::auth::middleware::RequireAdmin;
|
use crate::auth::middleware::RequireAdmin;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
|
use crate::services::config;
|
||||||
use crate::services::rate_limiter::client_ip;
|
use crate::services::rate_limiter::client_ip;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
@@ -117,7 +118,10 @@ pub async fn patch_config(
|
|||||||
RequireAdmin(_auth): RequireAdmin,
|
RequireAdmin(_auth): RequireAdmin,
|
||||||
Json(body): Json<HashMap<String, String>>,
|
Json(body): Json<HashMap<String, String>>,
|
||||||
) -> Result<StatusCode, AppError> {
|
) -> Result<StatusCode, AppError> {
|
||||||
const ALLOWED_KEYS: &[&str] = &[
|
// Numeric keys validated as f64; boolean keys validated as truthy strings; the
|
||||||
|
// privacy note is free text. Splitting these explicitly is verbose but makes the
|
||||||
|
// failure mode for typos obvious (`Unbekannter Schlüssel: ...`).
|
||||||
|
const NUMERIC_KEYS: &[&str] = &[
|
||||||
"max_image_size_mb",
|
"max_image_size_mb",
|
||||||
"max_video_size_mb",
|
"max_video_size_mb",
|
||||||
"upload_rate_per_hour",
|
"upload_rate_per_hour",
|
||||||
@@ -127,15 +131,53 @@ pub async fn patch_config(
|
|||||||
"estimated_guest_count",
|
"estimated_guest_count",
|
||||||
"compression_concurrency",
|
"compression_concurrency",
|
||||||
];
|
];
|
||||||
|
const BOOL_KEYS: &[&str] = &[
|
||||||
|
"rate_limits_enabled",
|
||||||
|
"upload_rate_enabled",
|
||||||
|
"feed_rate_enabled",
|
||||||
|
"export_rate_enabled",
|
||||||
|
"join_rate_enabled",
|
||||||
|
"quota_enabled",
|
||||||
|
"storage_quota_enabled",
|
||||||
|
"upload_count_quota_enabled",
|
||||||
|
];
|
||||||
|
const TEXT_KEYS: &[&str] = &["privacy_note"];
|
||||||
|
const PRIVACY_NOTE_MAX_LEN: usize = 16 * 1024; // 16 KiB free text is plenty
|
||||||
|
|
||||||
|
let mut privacy_note_changed = false;
|
||||||
|
|
||||||
for (key, value) in &body {
|
for (key, value) in &body {
|
||||||
if !ALLOWED_KEYS.contains(&key.as_str()) {
|
let key_str = key.as_str();
|
||||||
return Err(AppError::BadRequest(format!("Unbekannter Konfigurationsschlüssel: {key}")));
|
if NUMERIC_KEYS.contains(&key_str) {
|
||||||
}
|
if value.parse::<f64>().is_err() {
|
||||||
// Validate numeric values
|
return Err(AppError::BadRequest(format!(
|
||||||
if value.parse::<f64>().is_err() {
|
"Ungültiger Wert für {key}: muss eine Zahl sein."
|
||||||
return Err(AppError::BadRequest(format!("Ungültiger Wert für {key}: muss eine Zahl sein.")));
|
)));
|
||||||
|
}
|
||||||
|
} else if BOOL_KEYS.contains(&key_str) {
|
||||||
|
match value.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"true" | "false" | "1" | "0" | "yes" | "no" | "on" | "off" => {}
|
||||||
|
_ => {
|
||||||
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"Ungültiger Wert für {key}: muss true oder false sein."
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if TEXT_KEYS.contains(&key_str) {
|
||||||
|
if value.len() > PRIVACY_NOTE_MAX_LEN {
|
||||||
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"Wert für {key} ist zu lang (max. {PRIVACY_NOTE_MAX_LEN} Zeichen)."
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if key_str == "privacy_note" {
|
||||||
|
privacy_note_changed = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"Unbekannter Konfigurationsschlüssel: {key}"
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
"INSERT INTO config (key, value, updated_at) VALUES ($1, $2, NOW())
|
"INSERT INTO config (key, value, updated_at) VALUES ($1, $2, NOW())
|
||||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()",
|
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()",
|
||||||
@@ -146,6 +188,15 @@ pub async fn patch_config(
|
|||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Notify all clients that a publicly-readable config value changed so their stores
|
||||||
|
// (e.g. the privacy note in My Account) refresh without a manual reload.
|
||||||
|
if privacy_note_changed {
|
||||||
|
let _ = state.sse_tx.send(crate::state::SseEvent::new(
|
||||||
|
"event-updated",
|
||||||
|
serde_json::json!({ "keys": ["privacy_note"] }).to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,11 +228,7 @@ pub async fn download_zip(
|
|||||||
_auth: crate::auth::middleware::AuthUser,
|
_auth: crate::auth::middleware::AuthUser,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
) -> Result<axum::response::Response, AppError> {
|
) -> Result<axum::response::Response, AppError> {
|
||||||
let ip = client_ip(&headers, "unknown");
|
enforce_export_rate(&state, &headers).await?;
|
||||||
let limit = get_config_usize(&state.pool, "export_rate_per_day", 3).await;
|
|
||||||
if !state.rate_limiter.check(format!("export:{ip}"), limit, Duration::from_secs(86400)) {
|
|
||||||
return Err(AppError::TooManyRequests("Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(), None));
|
|
||||||
}
|
|
||||||
|
|
||||||
let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
|
let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
|
||||||
.await?
|
.await?
|
||||||
@@ -206,11 +253,7 @@ pub async fn download_html(
|
|||||||
_auth: crate::auth::middleware::AuthUser,
|
_auth: crate::auth::middleware::AuthUser,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
) -> Result<axum::response::Response, AppError> {
|
) -> Result<axum::response::Response, AppError> {
|
||||||
let ip = client_ip(&headers, "unknown");
|
enforce_export_rate(&state, &headers).await?;
|
||||||
let limit = get_config_usize(&state.pool, "export_rate_per_day", 3).await;
|
|
||||||
if !state.rate_limiter.check(format!("export:{ip}"), limit, Duration::from_secs(86400)) {
|
|
||||||
return Err(AppError::TooManyRequests("Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(), None));
|
|
||||||
}
|
|
||||||
|
|
||||||
let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
|
let event = crate::models::event::Event::find_by_slug(&state.pool, &state.config.event_slug)
|
||||||
.await?
|
.await?
|
||||||
@@ -295,12 +338,25 @@ pub async fn export_status(
|
|||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_config_usize(pool: &sqlx::PgPool, key: &str, default: usize) -> usize {
|
/// Centralised guard for the export rate limit. Same pattern as upload/feed: master
|
||||||
let row: Option<(String,)> =
|
/// switch + per-endpoint switch + numeric value, all stored in `config` and read on
|
||||||
sqlx::query_as("SELECT value FROM config WHERE key = $1")
|
/// each request.
|
||||||
.bind(key)
|
async fn enforce_export_rate(state: &AppState, headers: &HeaderMap) -> Result<(), AppError> {
|
||||||
.fetch_optional(pool)
|
let rate_limits_on = config::get_bool(&state.pool, "rate_limits_enabled", true).await;
|
||||||
.await
|
let export_rate_on = config::get_bool(&state.pool, "export_rate_enabled", true).await;
|
||||||
.unwrap_or(None);
|
if !(rate_limits_on && export_rate_on) {
|
||||||
row.and_then(|r| r.0.parse().ok()).unwrap_or(default)
|
return Ok(());
|
||||||
|
}
|
||||||
|
let ip = client_ip(headers, "unknown");
|
||||||
|
let limit = config::get_usize(&state.pool, "export_rate_per_day", 3).await;
|
||||||
|
if !state
|
||||||
|
.rate_limiter
|
||||||
|
.check(format!("export:{ip}"), limit, Duration::from_secs(86400))
|
||||||
|
{
|
||||||
|
return Err(AppError::TooManyRequests(
|
||||||
|
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
|
||||||
|
None,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::auth::middleware::AuthUser;
|
use crate::auth::middleware::AuthUser;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
|
use crate::services::config;
|
||||||
use crate::services::rate_limiter::client_ip;
|
use crate::services::rate_limiter::client_ip;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
@@ -61,9 +62,19 @@ pub async fn feed(
|
|||||||
Query(q): Query<FeedQuery>,
|
Query(q): Query<FeedQuery>,
|
||||||
) -> Result<Json<FeedResponse>, AppError> {
|
) -> Result<Json<FeedResponse>, AppError> {
|
||||||
let ip = client_ip(&headers, "unknown");
|
let ip = client_ip(&headers, "unknown");
|
||||||
let rate_limit = get_config_usize(&state.pool, "feed_rate_per_min", 60).await;
|
let rate_limits_on = config::get_bool(&state.pool, "rate_limits_enabled", true).await;
|
||||||
if !state.rate_limiter.check(format!("feed:{ip}"), rate_limit, Duration::from_secs(60)) {
|
let feed_rate_on = config::get_bool(&state.pool, "feed_rate_enabled", true).await;
|
||||||
return Err(AppError::TooManyRequests("Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(), None));
|
if rate_limits_on && feed_rate_on {
|
||||||
|
let rate_limit = config::get_usize(&state.pool, "feed_rate_per_min", 60).await;
|
||||||
|
if !state
|
||||||
|
.rate_limiter
|
||||||
|
.check(format!("feed:{ip}"), rate_limit, Duration::from_secs(60))
|
||||||
|
{
|
||||||
|
return Err(AppError::TooManyRequests(
|
||||||
|
"Zu viele Anfragen. Bitte warte kurz und versuche es erneut.".into(),
|
||||||
|
None,
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let limit = q.limit.unwrap_or(20).min(100);
|
let limit = q.limit.unwrap_or(20).min(100);
|
||||||
@@ -238,16 +249,6 @@ pub async fn hashtags(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_config_usize(pool: &sqlx::PgPool, key: &str, default: usize) -> usize {
|
|
||||||
let row: Option<(String,)> =
|
|
||||||
sqlx::query_as("SELECT value FROM config WHERE key = $1")
|
|
||||||
.bind(key)
|
|
||||||
.fetch_optional(pool)
|
|
||||||
.await
|
|
||||||
.unwrap_or(None);
|
|
||||||
row.and_then(|r| r.0.parse().ok()).unwrap_or(default)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_cursor_time(pool: &sqlx::PgPool, cursor_id: Uuid) -> Option<DateTime<Utc>> {
|
async fn get_cursor_time(pool: &sqlx::PgPool, cursor_id: Uuid) -> Option<DateTime<Utc>> {
|
||||||
let row: Option<(DateTime<Utc>,)> =
|
let row: Option<(DateTime<Utc>,)> =
|
||||||
sqlx::query_as("SELECT created_at FROM upload WHERE id = $1")
|
sqlx::query_as("SELECT created_at FROM upload WHERE id = $1")
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ use crate::error::AppError;
|
|||||||
use crate::models::comment::Comment;
|
use crate::models::comment::Comment;
|
||||||
use crate::models::event::Event;
|
use crate::models::event::Event;
|
||||||
use crate::models::upload::Upload;
|
use crate::models::upload::Upload;
|
||||||
use crate::state::AppState;
|
use crate::models::user::UserRole;
|
||||||
|
use crate::state::{AppState, SseEvent};
|
||||||
|
|
||||||
// ── DTOs ─────────────────────────────────────────────────────────────────────
|
// ── DTOs ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -141,13 +142,39 @@ pub async fn set_role(
|
|||||||
Json(body): Json<SetRoleRequest>,
|
Json(body): Json<SetRoleRequest>,
|
||||||
) -> Result<StatusCode, AppError> {
|
) -> Result<StatusCode, AppError> {
|
||||||
if user_id == auth.user_id {
|
if user_id == auth.user_id {
|
||||||
return Err(AppError::BadRequest("Du kannst deine eigene Rolle nicht ändern.".into()));
|
return Err(AppError::BadRequest(
|
||||||
|
"Du kannst deine eigene Rolle nicht ändern.".into(),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
let new_role = match body.role.as_str() {
|
let new_role = match body.role.as_str() {
|
||||||
"guest" => "guest",
|
"guest" => "guest",
|
||||||
"host" => "host",
|
"host" => "host",
|
||||||
_ => return Err(AppError::BadRequest("Ungültige Rolle. Erlaubt: guest, host.".into())),
|
_ => {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"Ungültige Rolle. Erlaubt: guest, host.".into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Look up the current role so we can apply the host-vs-admin guard. Hosts may
|
||||||
|
// promote guests and demote *other* hosts (the user explicitly requested this
|
||||||
|
// expansion). Hosts may not touch admins. Admins may do anything (except change
|
||||||
|
// themselves, blocked above).
|
||||||
|
let target = sqlx::query_as::<_, (String,)>(
|
||||||
|
"SELECT role::text FROM \"user\" WHERE id = $1 AND event_id = $2",
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(auth.event_id)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
|
||||||
|
|
||||||
|
if target.0 == "admin" {
|
||||||
|
return Err(AppError::Forbidden(
|
||||||
|
"Admins können nicht geändert werden.".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
sqlx::query("UPDATE \"user\" SET role = $2::user_role WHERE id = $1 AND event_id = $3")
|
sqlx::query("UPDATE \"user\" SET role = $2::user_role WHERE id = $1 AND event_id = $3")
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.bind(new_role)
|
.bind(new_role)
|
||||||
@@ -157,6 +184,76 @@ pub async fn set_role(
|
|||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct PinResetResponse {
|
||||||
|
/// Plaintext PIN — shown to the operator **once**. Never persisted client-side.
|
||||||
|
pub pin: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a fresh PIN for another user, returning the plaintext exactly once.
|
||||||
|
///
|
||||||
|
/// Authorisation:
|
||||||
|
/// - Host caller → may reset **guest** PINs only.
|
||||||
|
/// - Admin caller → may reset **guest** and **host** PINs (never another admin).
|
||||||
|
/// - Target ≠ caller.
|
||||||
|
pub async fn reset_user_pin(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
RequireHost(auth): RequireHost,
|
||||||
|
Path(user_id): Path<Uuid>,
|
||||||
|
) -> Result<Json<PinResetResponse>, AppError> {
|
||||||
|
use rand::Rng;
|
||||||
|
|
||||||
|
if user_id == auth.user_id {
|
||||||
|
return Err(AppError::BadRequest(
|
||||||
|
"Du kannst deine eigene PIN nicht über diese Funktion zurücksetzen.".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let target = sqlx::query_as::<_, (String,)>(
|
||||||
|
"SELECT role::text FROM \"user\" WHERE id = $1 AND event_id = $2",
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(auth.event_id)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
|
||||||
|
|
||||||
|
match (auth.role.clone(), target.0.as_str()) {
|
||||||
|
(UserRole::Admin, "guest" | "host") => {}
|
||||||
|
(UserRole::Host, "guest") => {}
|
||||||
|
_ => {
|
||||||
|
return Err(AppError::Forbidden(
|
||||||
|
"Du darfst die PIN dieses Benutzers nicht zurücksetzen.".into(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let pin: String = format!("{:04}", rand::rng().random_range(0..10000u32));
|
||||||
|
let pin_hash =
|
||||||
|
bcrypt::hash(&pin, 12).map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?;
|
||||||
|
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE \"user\"
|
||||||
|
SET recovery_pin_hash = $1,
|
||||||
|
pin_failed_attempts = 0,
|
||||||
|
pin_locked_until = NULL
|
||||||
|
WHERE id = $2",
|
||||||
|
)
|
||||||
|
.bind(&pin_hash)
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Notify the *recipient* device(s) if they happen to be online so they can clear
|
||||||
|
// their cached local PIN. They'll save the new one on the next /recover.
|
||||||
|
let _ = state.sse_tx.send(SseEvent::new(
|
||||||
|
"pin-reset",
|
||||||
|
serde_json::json!({ "user_id": user_id }).to_string(),
|
||||||
|
));
|
||||||
|
|
||||||
|
Ok(Json(PinResetResponse { pin }))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn host_delete_upload(
|
pub async fn host_delete_upload(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
RequireHost(_auth): RequireHost,
|
RequireHost(_auth): RequireHost,
|
||||||
@@ -168,10 +265,10 @@ pub async fn host_delete_upload(
|
|||||||
|
|
||||||
Upload::soft_delete(&state.pool, upload_id).await?;
|
Upload::soft_delete(&state.pool, upload_id).await?;
|
||||||
|
|
||||||
let _ = state.sse_tx.send(crate::state::SseEvent {
|
let _ = state.sse_tx.send(SseEvent::new(
|
||||||
event_type: "upload-deleted".to_string(),
|
"upload-deleted",
|
||||||
data: serde_json::json!({ "upload_id": upload.id }).to_string(),
|
serde_json::json!({ "upload_id": upload.id }).to_string(),
|
||||||
});
|
));
|
||||||
|
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
@@ -200,10 +297,7 @@ pub async fn close_event(
|
|||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let _ = state.sse_tx.send(crate::state::SseEvent {
|
let _ = state.sse_tx.send(SseEvent::new("event-closed", "{}"));
|
||||||
event_type: "event-closed".to_string(),
|
|
||||||
data: "{}".to_string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
@@ -219,10 +313,7 @@ pub async fn open_event(
|
|||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let _ = state.sse_tx.send(crate::state::SseEvent {
|
let _ = state.sse_tx.send(SseEvent::new("event-opened", "{}"));
|
||||||
event_type: "event-opened".to_string(),
|
|
||||||
data: "{}".to_string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|||||||
80
backend/src/handlers/me.rs
Normal file
80
backend/src/handlers/me.rs
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
//! Endpoints scoped to the *current user*. Kept separate from `auth::handlers` because
|
||||||
|
//! these aren't about acquiring / refreshing a session — they're about reading my own
|
||||||
|
//! state once I'm already signed in.
|
||||||
|
//!
|
||||||
|
//! Current routes:
|
||||||
|
//! - `GET /api/v1/me/context` — bundled profile + feature flags + privacy note. The
|
||||||
|
//! account page loads this once on mount instead of issuing several round trips.
|
||||||
|
//! - `GET /api/v1/me/quota` — live per-user storage quota estimate.
|
||||||
|
|
||||||
|
use axum::extract::State;
|
||||||
|
use axum::Json;
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
use crate::auth::middleware::AuthUser;
|
||||||
|
use crate::error::AppError;
|
||||||
|
use crate::handlers::upload::compute_storage_quota;
|
||||||
|
use crate::models::user::User;
|
||||||
|
use crate::services::config;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct QuotaDto {
|
||||||
|
pub enabled: bool,
|
||||||
|
pub used_bytes: i64,
|
||||||
|
pub limit_bytes: Option<i64>,
|
||||||
|
pub active_uploaders: i64,
|
||||||
|
pub free_disk_bytes: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_quota(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
) -> Result<Json<QuotaDto>, AppError> {
|
||||||
|
let user = User::find_by_id(&state.pool, auth.user_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
|
||||||
|
|
||||||
|
let estimate = compute_storage_quota(&state).await;
|
||||||
|
|
||||||
|
Ok(Json(QuotaDto {
|
||||||
|
enabled: estimate.limit_bytes.is_some(),
|
||||||
|
used_bytes: user.total_upload_bytes,
|
||||||
|
limit_bytes: estimate.limit_bytes,
|
||||||
|
active_uploaders: estimate.active_uploaders,
|
||||||
|
free_disk_bytes: estimate.free_disk_bytes,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct MeContextDto {
|
||||||
|
pub user_id: uuid::Uuid,
|
||||||
|
pub display_name: String,
|
||||||
|
pub role: String,
|
||||||
|
/// Plain-text Datenschutzhinweis set by the admin. Empty string when not configured.
|
||||||
|
pub privacy_note: String,
|
||||||
|
pub quota_enabled: bool,
|
||||||
|
pub storage_quota_enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_context(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: AuthUser,
|
||||||
|
) -> Result<Json<MeContextDto>, AppError> {
|
||||||
|
let user = User::find_by_id(&state.pool, auth.user_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
|
||||||
|
|
||||||
|
let privacy_note = config::get_str(&state.pool, "privacy_note", "").await;
|
||||||
|
let quota_enabled = config::get_bool(&state.pool, "quota_enabled", true).await;
|
||||||
|
let storage_quota_enabled = config::get_bool(&state.pool, "storage_quota_enabled", true).await;
|
||||||
|
|
||||||
|
Ok(Json(MeContextDto {
|
||||||
|
user_id: user.id,
|
||||||
|
display_name: user.display_name,
|
||||||
|
role: user.role.as_str().to_string(),
|
||||||
|
privacy_note,
|
||||||
|
quota_enabled,
|
||||||
|
storage_quota_enabled,
|
||||||
|
}))
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
pub mod admin;
|
pub mod admin;
|
||||||
pub mod feed;
|
pub mod feed;
|
||||||
pub mod host;
|
pub mod host;
|
||||||
|
pub mod me;
|
||||||
pub mod social;
|
pub mod social;
|
||||||
pub mod sse;
|
pub mod sse;
|
||||||
pub mod upload;
|
pub mod upload;
|
||||||
|
|||||||
@@ -11,24 +11,32 @@ use crate::error::AppError;
|
|||||||
use crate::models::hashtag::{self, Hashtag};
|
use crate::models::hashtag::{self, Hashtag};
|
||||||
use crate::models::upload::{Upload, UploadDto};
|
use crate::models::upload::{Upload, UploadDto};
|
||||||
use crate::models::user::User;
|
use crate::models::user::User;
|
||||||
|
use crate::services::config;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
const MAX_CAPTION_LENGTH: usize = 2000;
|
||||||
|
|
||||||
pub async fn upload(
|
pub async fn upload(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
auth: AuthUser,
|
auth: AuthUser,
|
||||||
mut multipart: Multipart,
|
mut multipart: Multipart,
|
||||||
) -> Result<(StatusCode, Json<UploadDto>), AppError> {
|
) -> Result<(StatusCode, Json<UploadDto>), AppError> {
|
||||||
// Rate limit: N uploads per hour per user
|
// Rate limit: N uploads per hour per user. Gated by master + per-endpoint toggles.
|
||||||
let upload_rate = get_config_i64(&state.pool, "upload_rate_per_hour", 10).await as usize;
|
let rate_limits_on = config::get_bool(&state.pool, "rate_limits_enabled", true).await;
|
||||||
if let Err(retry_after_secs) = state
|
let upload_rate_on = config::get_bool(&state.pool, "upload_rate_enabled", true).await;
|
||||||
.rate_limiter
|
if rate_limits_on && upload_rate_on {
|
||||||
.check_with_retry(format!("upload:{}", auth.user_id), upload_rate, Duration::from_secs(3600))
|
let upload_rate = config::get_i64(&state.pool, "upload_rate_per_hour", 10).await as usize;
|
||||||
{
|
if let Err(retry_after_secs) = state.rate_limiter.check_with_retry(
|
||||||
drain_multipart(multipart).await;
|
format!("upload:{}", auth.user_id),
|
||||||
return Err(AppError::TooManyRequests(
|
upload_rate,
|
||||||
"Du hast dein Upload-Limit für diese Stunde erreicht.".into(),
|
Duration::from_secs(3600),
|
||||||
Some(retry_after_secs),
|
) {
|
||||||
));
|
drain_multipart(multipart).await;
|
||||||
|
return Err(AppError::TooManyRequests(
|
||||||
|
"Du hast dein Upload-Limit für diese Stunde erreicht.".into(),
|
||||||
|
Some(retry_after_secs),
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user is banned
|
// Check if user is banned
|
||||||
@@ -50,8 +58,8 @@ pub async fn upload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Read config limits from DB
|
// Read config limits from DB
|
||||||
let max_image_mb: i64 = get_config_i64(&state.pool, "max_image_size_mb", 20).await;
|
let max_image_mb: i64 = config::get_i64(&state.pool, "max_image_size_mb", 20).await;
|
||||||
let max_video_mb: i64 = get_config_i64(&state.pool, "max_video_size_mb", 500).await;
|
let max_video_mb: i64 = config::get_i64(&state.pool, "max_video_size_mb", 500).await;
|
||||||
|
|
||||||
let mut file_data: Option<Vec<u8>> = None;
|
let mut file_data: Option<Vec<u8>> = None;
|
||||||
let mut file_name: Option<String> = None;
|
let mut file_name: Option<String> = None;
|
||||||
@@ -91,6 +99,33 @@ pub async fn upload(
|
|||||||
let mime = content_type.unwrap_or_else(|| "application/octet-stream".to_string());
|
let mime = content_type.unwrap_or_else(|| "application/octet-stream".to_string());
|
||||||
let size = data.len() as i64;
|
let size = data.len() as i64;
|
||||||
|
|
||||||
|
// Validate caption length
|
||||||
|
if let Some(ref cap) = caption {
|
||||||
|
if cap.len() > MAX_CAPTION_LENGTH {
|
||||||
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"Beschreibung ist zu lang. Maximum: {} Zeichen.",
|
||||||
|
MAX_CAPTION_LENGTH
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file MIME type using magic bytes
|
||||||
|
let detected_mime = infer::get(&data);
|
||||||
|
if let Some(detected) = detected_mime {
|
||||||
|
let detected_type = detected.mime_type();
|
||||||
|
// Ensure detected type is compatible with declared MIME type
|
||||||
|
let declared_category = mime.split('/').next().unwrap_or("");
|
||||||
|
let detected_category = detected_type.split('/').next().unwrap_or("");
|
||||||
|
|
||||||
|
// Only reject if categories don't match (e.g., image vs video)
|
||||||
|
if declared_category != "application" && declared_category != detected_category {
|
||||||
|
return Err(AppError::BadRequest(format!(
|
||||||
|
"Dateiinhalt entspricht nicht dem deklarierten Typ. Erwartet: {}, erkannt: {}",
|
||||||
|
mime, detected_type
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Validate file size
|
// Validate file size
|
||||||
let max_bytes = if mime.starts_with("video/") {
|
let max_bytes = if mime.starts_with("video/") {
|
||||||
max_video_mb * 1024 * 1024
|
max_video_mb * 1024 * 1024
|
||||||
@@ -104,6 +139,24 @@ pub async fn upload(
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-user storage quota — dynamic formula based on available disk space and the
|
||||||
|
// number of active uploaders. Gated by master + per-area toggles so the admin can
|
||||||
|
// disable it on trusted instances.
|
||||||
|
let quota_on = config::get_bool(&state.pool, "quota_enabled", true).await;
|
||||||
|
let storage_quota_on = config::get_bool(&state.pool, "storage_quota_enabled", true).await;
|
||||||
|
if quota_on && storage_quota_on {
|
||||||
|
let estimate = compute_storage_quota(&state).await;
|
||||||
|
if let Some(limit) = estimate.limit_bytes {
|
||||||
|
let prospective_total = user.total_upload_bytes.saturating_add(size);
|
||||||
|
if prospective_total > limit {
|
||||||
|
return Err(AppError::TooManyRequests(
|
||||||
|
"Du hast dein Upload-Limit für dieses Event erreicht.".into(),
|
||||||
|
None,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Determine file extension
|
// Determine file extension
|
||||||
let ext = file_name
|
let ext = file_name
|
||||||
.as_deref()
|
.as_deref()
|
||||||
@@ -182,10 +235,10 @@ pub async fn upload(
|
|||||||
created_at: upload.created_at,
|
created_at: upload.created_at,
|
||||||
};
|
};
|
||||||
|
|
||||||
let _ = state.sse_tx.send(crate::state::SseEvent {
|
let _ = state.sse_tx.send(crate::state::SseEvent::new(
|
||||||
event_type: "new-upload".to_string(),
|
"new-upload",
|
||||||
data: serde_json::to_string(&dto).unwrap_or_default(),
|
serde_json::to_string(&dto).unwrap_or_default(),
|
||||||
});
|
));
|
||||||
|
|
||||||
Ok((StatusCode::CREATED, Json(dto)))
|
Ok((StatusCode::CREATED, Json(dto)))
|
||||||
}
|
}
|
||||||
@@ -252,12 +305,107 @@ async fn drain_multipart(mut mp: Multipart) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_config_i64(pool: &sqlx::PgPool, key: &str, default: i64) -> i64 {
|
/// Snapshot of the dynamic per-user quota used both by the upload pre-check and the
|
||||||
let row: Option<(String,)> =
|
/// `GET /me/quota` endpoint. `limit_bytes = None` means quota enforcement is currently
|
||||||
sqlx::query_as("SELECT value FROM config WHERE key = $1")
|
/// off (the frontend hides the widget in that case).
|
||||||
.bind(key)
|
pub struct QuotaEstimate {
|
||||||
.fetch_optional(pool)
|
pub limit_bytes: Option<i64>,
|
||||||
.await
|
pub active_uploaders: i64,
|
||||||
.unwrap_or(None);
|
pub free_disk_bytes: i64,
|
||||||
row.and_then(|r| r.0.parse().ok()).unwrap_or(default)
|
pub tolerance: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Computes the per-user storage quota using
|
||||||
|
/// `floor((free_disk * tolerance) / max(active_uploaders, 1))`. Returns `limit_bytes =
|
||||||
|
/// None` whenever the storage quota is currently disabled — callers should skip the
|
||||||
|
/// check (upload handler) or hide the UI (quota endpoint).
|
||||||
|
pub async fn compute_storage_quota(state: &AppState) -> QuotaEstimate {
|
||||||
|
let quota_on = config::get_bool(&state.pool, "quota_enabled", true).await;
|
||||||
|
let storage_quota_on = config::get_bool(&state.pool, "storage_quota_enabled", true).await;
|
||||||
|
let tolerance = config::get_f64(&state.pool, "quota_tolerance", 0.75).await;
|
||||||
|
|
||||||
|
let (active_count,): (i64,) = sqlx::query_as(
|
||||||
|
"SELECT COUNT(DISTINCT user_id) FROM upload WHERE deleted_at IS NULL",
|
||||||
|
)
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or((0,));
|
||||||
|
let active = active_count.max(1);
|
||||||
|
|
||||||
|
let media_path = state.config.media_path.to_string_lossy().to_string();
|
||||||
|
let free_disk = sysinfo::Disks::new_with_refreshed_list()
|
||||||
|
.iter()
|
||||||
|
.find(|d| media_path.starts_with(d.mount_point().to_string_lossy().as_ref()))
|
||||||
|
.map(|d| d.available_space())
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
sysinfo::Disks::new_with_refreshed_list()
|
||||||
|
.iter()
|
||||||
|
.find(|d| d.mount_point().to_string_lossy() == "/")
|
||||||
|
.map(|d| d.available_space())
|
||||||
|
.unwrap_or(0)
|
||||||
|
}) as i64;
|
||||||
|
|
||||||
|
let limit_bytes = if quota_on && storage_quota_on {
|
||||||
|
Some(((free_disk as f64 * tolerance) / active as f64).floor() as i64)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
QuotaEstimate {
|
||||||
|
limit_bytes,
|
||||||
|
active_uploaders: active,
|
||||||
|
free_disk_bytes: free_disk,
|
||||||
|
tolerance,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Streaming download of the original file behind an upload. Used by:
|
||||||
|
/// - the per-post "Original anzeigen" context action (`window.open`)
|
||||||
|
/// - `<img src>` / `<video src>` in the feed, lightbox, and diashow when the user is in
|
||||||
|
/// Data Mode = Original
|
||||||
|
///
|
||||||
|
/// **Auth model:** the route is intentionally unauthenticated, matching how the rest of
|
||||||
|
/// `/media/*` is served (preview + thumbnail variants). The URL contains the upload's
|
||||||
|
/// UUID, which is unguessable — same security posture as `/media/originals/{slug}/{id}`.
|
||||||
|
/// Adding `Authorization: Bearer` here would make the endpoint unusable from `<img src>`
|
||||||
|
/// and `window.open`, defeating the purpose of having the alias.
|
||||||
|
pub async fn get_original(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(upload_id): Path<Uuid>,
|
||||||
|
) -> Result<axum::response::Response, AppError> {
|
||||||
|
let upload = Upload::find_by_id(&state.pool, upload_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| AppError::NotFound("Upload nicht gefunden.".into()))?;
|
||||||
|
|
||||||
|
let absolute = state.config.media_path.join(&upload.original_path);
|
||||||
|
if !absolute.exists() {
|
||||||
|
return Err(AppError::NotFound("Datei nicht gefunden.".into()));
|
||||||
|
}
|
||||||
|
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::http::{header, Response, StatusCode};
|
||||||
|
use tokio_util::io::ReaderStream;
|
||||||
|
|
||||||
|
let file = tokio::fs::File::open(&absolute)
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(e.into()))?;
|
||||||
|
let metadata = file
|
||||||
|
.metadata()
|
||||||
|
.await
|
||||||
|
.map_err(|e| AppError::Internal(e.into()))?;
|
||||||
|
let stream = ReaderStream::new(file);
|
||||||
|
|
||||||
|
let filename = absolute
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("original");
|
||||||
|
let disposition = format!("attachment; filename=\"{filename}\"");
|
||||||
|
|
||||||
|
Response::builder()
|
||||||
|
.status(StatusCode::OK)
|
||||||
|
.header(header::CONTENT_TYPE, upload.mime_type)
|
||||||
|
.header(header::CONTENT_DISPOSITION, disposition)
|
||||||
|
.header(header::CONTENT_LENGTH, metadata.len())
|
||||||
|
.body(Body::from_stream(stream))
|
||||||
|
.map_err(|e| AppError::Internal(e.into()))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,18 @@ async fn main() -> Result<()> {
|
|||||||
|
|
||||||
let config = AppConfig::from_env()?;
|
let config = AppConfig::from_env()?;
|
||||||
let pool = db::create_pool(&config.database_url).await?;
|
let pool = db::create_pool(&config.database_url).await?;
|
||||||
let state = AppState::new(pool, config.clone());
|
|
||||||
|
// Reset any rows left mid-flight by a previous (possibly crashed) instance —
|
||||||
|
// stuck `compression_status='processing'` uploads and `status='running'` export
|
||||||
|
// jobs. Must run before the server starts taking requests so clients never see
|
||||||
|
// the half-state.
|
||||||
|
services::maintenance::startup_recovery(&pool).await;
|
||||||
|
|
||||||
|
let state = AppState::new(pool.clone(), config.clone());
|
||||||
|
|
||||||
|
// Hourly background hygiene: prune expired sessions, evict cold rate-limiter
|
||||||
|
// keys. Keeps the DB and process from growing unboundedly over multi-day events.
|
||||||
|
services::maintenance::spawn_periodic_tasks(pool, state.rate_limiter.clone());
|
||||||
|
|
||||||
// Ensure media directories exist
|
// Ensure media directories exist
|
||||||
tokio::fs::create_dir_all(&config.media_path).await.ok();
|
tokio::fs::create_dir_all(&config.media_path).await.ok();
|
||||||
@@ -49,6 +60,13 @@ async fn main() -> Result<()> {
|
|||||||
"/api/v1/upload/{id}",
|
"/api/v1/upload/{id}",
|
||||||
patch(handlers::upload::edit_upload).delete(handlers::upload::delete_upload),
|
patch(handlers::upload::edit_upload).delete(handlers::upload::delete_upload),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/v1/upload/{id}/original",
|
||||||
|
get(handlers::upload::get_original),
|
||||||
|
)
|
||||||
|
// Current-user endpoints (live quota estimate, profile + privacy note bundle)
|
||||||
|
.route("/api/v1/me/context", get(handlers::me::get_context))
|
||||||
|
.route("/api/v1/me/quota", get(handlers::me::get_quota))
|
||||||
// Feed
|
// Feed
|
||||||
.route("/api/v1/feed", get(handlers::feed::feed))
|
.route("/api/v1/feed", get(handlers::feed::feed))
|
||||||
.route("/api/v1/feed/delta", get(handlers::feed::feed_delta))
|
.route("/api/v1/feed/delta", get(handlers::feed::feed_delta))
|
||||||
@@ -71,6 +89,10 @@ async fn main() -> Result<()> {
|
|||||||
.route("/api/v1/host/users/{id}/ban", post(handlers::host::ban_user))
|
.route("/api/v1/host/users/{id}/ban", post(handlers::host::ban_user))
|
||||||
.route("/api/v1/host/users/{id}/unban", post(handlers::host::unban_user))
|
.route("/api/v1/host/users/{id}/unban", post(handlers::host::unban_user))
|
||||||
.route("/api/v1/host/users/{id}/role", patch(handlers::host::set_role))
|
.route("/api/v1/host/users/{id}/role", patch(handlers::host::set_role))
|
||||||
|
.route(
|
||||||
|
"/api/v1/host/users/{id}/pin-reset",
|
||||||
|
post(handlers::host::reset_user_pin),
|
||||||
|
)
|
||||||
.route("/api/v1/host/upload/{id}", delete(handlers::host::host_delete_upload))
|
.route("/api/v1/host/upload/{id}", delete(handlers::host::host_delete_upload))
|
||||||
.route("/api/v1/host/comment/{id}", delete(handlers::host::host_delete_comment))
|
.route("/api/v1/host/comment/{id}", delete(handlers::host::host_delete_comment))
|
||||||
// Export (all authenticated users)
|
// Export (all authenticated users)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ pub struct Upload {
|
|||||||
pub mime_type: String,
|
pub mime_type: String,
|
||||||
pub original_size_bytes: i64,
|
pub original_size_bytes: i64,
|
||||||
pub caption: Option<String>,
|
pub caption: Option<String>,
|
||||||
|
pub compression_status: String,
|
||||||
pub created_at: DateTime<Utc>,
|
pub created_at: DateTime<Utc>,
|
||||||
pub deleted_at: Option<DateTime<Utc>>,
|
pub deleted_at: Option<DateTime<Utc>>,
|
||||||
}
|
}
|
||||||
@@ -94,11 +95,36 @@ impl Upload {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Soft-deletes the upload and decrements the uploader's `total_upload_bytes`.
|
||||||
|
/// Done in a single transaction so a crash between the two writes can't leave
|
||||||
|
/// the quota counter pointing at bytes the user has already deleted (which would
|
||||||
|
/// silently lock them out of future uploads).
|
||||||
|
///
|
||||||
|
/// No-op if the row is already deleted — protects against a double-tap on the
|
||||||
|
/// delete action double-decrementing the counter.
|
||||||
pub async fn soft_delete(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error> {
|
pub async fn soft_delete(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error> {
|
||||||
sqlx::query("UPDATE upload SET deleted_at = NOW() WHERE id = $1")
|
let mut tx = pool.begin().await?;
|
||||||
.bind(id)
|
let row: Option<(Uuid, i64)> = sqlx::query_as(
|
||||||
.execute(pool)
|
"UPDATE upload
|
||||||
|
SET deleted_at = NOW()
|
||||||
|
WHERE id = $1 AND deleted_at IS NULL
|
||||||
|
RETURNING user_id, original_size_bytes",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.await?;
|
||||||
|
if let Some((user_id, bytes)) = row {
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE \"user\"
|
||||||
|
SET total_upload_bytes = GREATEST(0, total_upload_bytes - $2)
|
||||||
|
WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(bytes)
|
||||||
|
.execute(&mut *tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
}
|
||||||
|
tx.commit().await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,4 +140,17 @@ impl Upload {
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn set_compression_status(
|
||||||
|
pool: &PgPool,
|
||||||
|
id: Uuid,
|
||||||
|
status: &str,
|
||||||
|
) -> Result<(), sqlx::Error> {
|
||||||
|
sqlx::query("UPDATE upload SET compression_status = $2 WHERE id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.bind(status)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,16 @@ pub enum UserRole {
|
|||||||
Admin,
|
Admin,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl UserRole {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
UserRole::Guest => "guest",
|
||||||
|
UserRole::Host => "host",
|
||||||
|
UserRole::Admin => "admin",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, sqlx::FromRow)]
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
pub struct User {
|
pub struct User {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
|
|||||||
@@ -3,24 +3,27 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use tokio::sync::Semaphore;
|
use tokio::sync::{broadcast, Semaphore};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::models::upload::Upload;
|
use crate::models::upload::Upload;
|
||||||
|
use crate::state::SseEvent;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct CompressionWorker {
|
pub struct CompressionWorker {
|
||||||
semaphore: Arc<Semaphore>,
|
semaphore: Arc<Semaphore>,
|
||||||
pool: PgPool,
|
pool: PgPool,
|
||||||
media_path: PathBuf,
|
media_path: PathBuf,
|
||||||
|
sse_tx: broadcast::Sender<SseEvent>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CompressionWorker {
|
impl CompressionWorker {
|
||||||
pub fn new(pool: PgPool, media_path: PathBuf, concurrency: usize) -> Self {
|
pub fn new(pool: PgPool, media_path: PathBuf, concurrency: usize, sse_tx: broadcast::Sender<SseEvent>) -> Self {
|
||||||
Self {
|
Self {
|
||||||
semaphore: Arc::new(Semaphore::new(concurrency)),
|
semaphore: Arc::new(Semaphore::new(concurrency)),
|
||||||
pool,
|
pool,
|
||||||
media_path,
|
media_path,
|
||||||
|
sse_tx,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,8 +32,22 @@ impl CompressionWorker {
|
|||||||
let worker = self.clone();
|
let worker = self.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let _permit = worker.semaphore.acquire().await;
|
let _permit = worker.semaphore.acquire().await;
|
||||||
if let Err(e) = worker.do_process(upload_id, &original_path, &mime_type).await {
|
match worker.do_process(upload_id, &original_path, &mime_type).await {
|
||||||
tracing::error!("compression failed for upload {upload_id}: {e:#}");
|
Ok(_) => {
|
||||||
|
tracing::info!("compression completed for upload {upload_id}");
|
||||||
|
let _ = worker.sse_tx.send(SseEvent {
|
||||||
|
event_type: "upload-processed".to_string(),
|
||||||
|
data: serde_json::json!({ "upload_id": upload_id }).to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("compression failed for upload {upload_id}: {e:#}");
|
||||||
|
let _ = worker.sse_tx.send(SseEvent {
|
||||||
|
event_type: "upload-error".to_string(),
|
||||||
|
data: serde_json::json!({ "upload_id": upload_id, "error": e.to_string() }).to_string(),
|
||||||
|
});
|
||||||
|
let _ = Upload::set_compression_status(&worker.pool, upload_id, "failed").await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -41,6 +58,8 @@ impl CompressionWorker {
|
|||||||
original_path: &str,
|
original_path: &str,
|
||||||
mime_type: &str,
|
mime_type: &str,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
|
Upload::set_compression_status(&self.pool, upload_id, "processing").await?;
|
||||||
|
|
||||||
let original = self.media_path.join(original_path);
|
let original = self.media_path.join(original_path);
|
||||||
|
|
||||||
if mime_type.starts_with("image/") {
|
if mime_type.starts_with("image/") {
|
||||||
@@ -53,6 +72,7 @@ impl CompressionWorker {
|
|||||||
tracing::info!("thumbnail generated for upload {upload_id}");
|
tracing::info!("thumbnail generated for upload {upload_id}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Upload::set_compression_status(&self.pool, upload_id, "done").await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +132,11 @@ impl CompressionWorker {
|
|||||||
let thumb_filename = format!("{upload_id}.jpg");
|
let thumb_filename = format!("{upload_id}.jpg");
|
||||||
let thumb_path = thumbs_dir.join(&thumb_filename);
|
let thumb_path = thumbs_dir.join(&thumb_filename);
|
||||||
|
|
||||||
let output = tokio::process::Command::new("ffmpeg")
|
// Hard timeout — a malformed video can hang `ffmpeg` indefinitely. Without a
|
||||||
|
// cap, the held compression-worker semaphore permit is never released and the
|
||||||
|
// pool eventually deadlocks (no further uploads ever processed). 120s is well
|
||||||
|
// above the time to extract one frame from any sane input.
|
||||||
|
let mut child = tokio::process::Command::new("ffmpeg")
|
||||||
.args([
|
.args([
|
||||||
"-i",
|
"-i",
|
||||||
original.to_str().unwrap_or_default(),
|
original.to_str().unwrap_or_default(),
|
||||||
@@ -125,13 +149,36 @@ impl CompressionWorker {
|
|||||||
"-y",
|
"-y",
|
||||||
thumb_path.to_str().unwrap_or_default(),
|
thumb_path.to_str().unwrap_or_default(),
|
||||||
])
|
])
|
||||||
.output()
|
.stdout(std::process::Stdio::piped())
|
||||||
.await
|
.stderr(std::process::Stdio::piped())
|
||||||
.context("failed to run ffmpeg")?;
|
.kill_on_drop(true)
|
||||||
|
.spawn()
|
||||||
|
.context("failed to spawn ffmpeg")?;
|
||||||
|
|
||||||
if !output.status.success() {
|
let status = match tokio::time::timeout(
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
std::time::Duration::from_secs(120),
|
||||||
anyhow::bail!("ffmpeg failed: {stderr}");
|
child.wait(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(res) => res.context("ffmpeg wait failed")?,
|
||||||
|
Err(_) => {
|
||||||
|
let _ = child.kill().await;
|
||||||
|
anyhow::bail!("ffmpeg timeout after 120s");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !status.success() {
|
||||||
|
// Best-effort: drain stderr for the log.
|
||||||
|
let mut stderr = Vec::new();
|
||||||
|
if let Some(mut handle) = child.stderr.take() {
|
||||||
|
use tokio::io::AsyncReadExt;
|
||||||
|
let _ = handle.read_to_end(&mut stderr).await;
|
||||||
|
}
|
||||||
|
anyhow::bail!(
|
||||||
|
"ffmpeg failed: {}",
|
||||||
|
String::from_utf8_lossy(&stderr)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(format!("thumbnails/{thumb_filename}"))
|
Ok(format!("thumbnails/{thumb_filename}"))
|
||||||
|
|||||||
49
backend/src/services/config.rs
Normal file
49
backend/src/services/config.rs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
//! Reads of the runtime-tunable `config` table.
|
||||||
|
//!
|
||||||
|
//! Each handler used to keep a small local copy of these helpers; consolidating them
|
||||||
|
//! here means one place to add a parser, one place to mock for tests, and one place to
|
||||||
|
//! find when a key changes. New keys do not require code changes — they're picked up
|
||||||
|
//! the next time someone calls `get_*`.
|
||||||
|
//!
|
||||||
|
//! Values are read with a default fallback so the app still starts if a key is missing
|
||||||
|
//! (e.g. during a migration window). Production seeds keys via migrations 005 and 009.
|
||||||
|
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
async fn fetch_raw(pool: &PgPool, key: &str) -> Option<String> {
|
||||||
|
sqlx::query_as::<_, (String,)>("SELECT value FROM config WHERE key = $1")
|
||||||
|
.bind(key)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.map(|(v,)| v)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_str(pool: &PgPool, key: &str, default: &str) -> String {
|
||||||
|
fetch_raw(pool, key).await.unwrap_or_else(|| default.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_i64(pool: &PgPool, key: &str, default: i64) -> i64 {
|
||||||
|
fetch_raw(pool, key).await.and_then(|v| v.parse().ok()).unwrap_or(default)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_usize(pool: &PgPool, key: &str, default: usize) -> usize {
|
||||||
|
fetch_raw(pool, key).await.and_then(|v| v.parse().ok()).unwrap_or(default)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_f64(pool: &PgPool, key: &str, default: f64) -> f64 {
|
||||||
|
fetch_raw(pool, key).await.and_then(|v| v.parse().ok()).unwrap_or(default)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses common truthy spellings used by both the migration seeds and the admin form.
|
||||||
|
/// Accepts `true/false`, `1/0`, `yes/no`, `on/off` — case-insensitive. Anything else
|
||||||
|
/// returns `default`.
|
||||||
|
pub async fn get_bool(pool: &PgPool, key: &str, default: bool) -> bool {
|
||||||
|
let Some(raw) = fetch_raw(pool, key).await else { return default };
|
||||||
|
match raw.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"true" | "1" | "yes" | "on" => true,
|
||||||
|
"false" | "0" | "no" | "off" => false,
|
||||||
|
_ => default,
|
||||||
|
}
|
||||||
|
}
|
||||||
73
backend/src/services/jobs.rs
Normal file
73
backend/src/services/jobs.rs
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
//! Shared shape for long-running background work.
|
||||||
|
//!
|
||||||
|
//! Today's [`compression`](crate::services::compression) and [`export`](crate::services::export)
|
||||||
|
//! pipelines each implement their own progress + SSE plumbing. They could converge on the
|
||||||
|
//! trait sketched here so future jobs (analytics, archival, ...) plug into one progress
|
||||||
|
//! pipeline.
|
||||||
|
//!
|
||||||
|
//! This module is intentionally a *sketch*: the existing services are not yet wired to
|
||||||
|
//! it. The aim is to (a) document the convention so new jobs follow it, (b) make the
|
||||||
|
//! refactor mechanical when someone is ready to do it. See `docs/IDEAS.md` —
|
||||||
|
//! "Maintainability principles" — for the rationale.
|
||||||
|
//!
|
||||||
|
//! Example of an eventual implementor:
|
||||||
|
//!
|
||||||
|
//! ```ignore
|
||||||
|
//! struct ZipExport { event_id: Uuid, /* … */ }
|
||||||
|
//!
|
||||||
|
//! impl BackgroundJob for ZipExport {
|
||||||
|
//! fn name(&self) -> &'static str { "zip-export" }
|
||||||
|
//! async fn run(self, ctx: JobContext) -> Result<()> {
|
||||||
|
//! for (i, item) in items.iter().enumerate() {
|
||||||
|
//! ctx.report(percent(i, items.len())).await?;
|
||||||
|
//! // … write to zip …
|
||||||
|
//! }
|
||||||
|
//! Ok(())
|
||||||
|
//! }
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
/// Handle handed to a running job: reports progress and emits SSE events.
|
||||||
|
///
|
||||||
|
/// Wraps the existing SSE broadcaster and an optional `export_job` row. Implementors
|
||||||
|
/// don't need to know about `state.sse_tx` directly — they call [`JobContext::report`]
|
||||||
|
/// and get the same effect.
|
||||||
|
pub struct JobContext {
|
||||||
|
pub job_id: Option<uuid::Uuid>,
|
||||||
|
pub event_kind: &'static str,
|
||||||
|
pub sse_tx: tokio::sync::broadcast::Sender<crate::state::SseEvent>,
|
||||||
|
pub pool: sqlx::PgPool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JobContext {
|
||||||
|
/// Update progress (0..=100) and broadcast an SSE tick. Cheap to call often —
|
||||||
|
/// rate-limit at the call site if a job emits at > 10 Hz.
|
||||||
|
pub async fn report(&self, percent: u8) -> Result<()> {
|
||||||
|
if let Some(job_id) = self.job_id {
|
||||||
|
sqlx::query("UPDATE export_job SET progress_pct = $1 WHERE id = $2")
|
||||||
|
.bind(percent as i16)
|
||||||
|
.bind(job_id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
let _ = self.sse_tx.send(crate::state::SseEvent::new(
|
||||||
|
self.event_kind,
|
||||||
|
serde_json::json!({ "progress_pct": percent }).to_string(),
|
||||||
|
));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One unit of work that publishes progress through a [`JobContext`].
|
||||||
|
///
|
||||||
|
/// `run` consumes `self`; spawn with `tokio::spawn` at the caller. Errors propagate;
|
||||||
|
/// the caller is responsible for mapping them to `export_job.error_message` or
|
||||||
|
/// equivalent. Implementors stay small — the trait deliberately has no `cancel`
|
||||||
|
/// or `pause`; we have not needed those yet.
|
||||||
|
#[allow(async_fn_in_trait)]
|
||||||
|
pub trait BackgroundJob: Send + 'static {
|
||||||
|
fn name(&self) -> &'static str;
|
||||||
|
async fn run(self, ctx: JobContext) -> Result<()>;
|
||||||
|
}
|
||||||
97
backend/src/services/maintenance.rs
Normal file
97
backend/src/services/maintenance.rs
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
//! Startup recovery + periodic background hygiene.
|
||||||
|
//!
|
||||||
|
//! Two responsibilities:
|
||||||
|
//!
|
||||||
|
//! 1. **Startup sweep** — when the server boots, fix rows left in an "in-progress"
|
||||||
|
//! state by the previous (possibly crashed) instance. Compression and export jobs
|
||||||
|
//! each leave a status row when they begin; if the process is killed mid-run, that
|
||||||
|
//! row stays `'processing'` / `'running'` forever, blocking re-tries and leaving
|
||||||
|
//! users staring at a spinner. Resetting them on startup recovers gracefully.
|
||||||
|
//!
|
||||||
|
//! 2. **Periodic tasks** — pruning that should happen "every hour" rather than per
|
||||||
|
//! request: expired sessions (otherwise the table grows unboundedly), and the
|
||||||
|
//! rate-limiter's in-memory windows (so keys for IPs that left long ago don't
|
||||||
|
//! accumulate).
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use sqlx::PgPool;
|
||||||
|
|
||||||
|
use crate::services::rate_limiter::RateLimiter;
|
||||||
|
|
||||||
|
/// Reset rows left in flight by a previous crashed instance. Run once on startup,
|
||||||
|
/// before the HTTP server starts taking requests, so users never observe the
|
||||||
|
/// half-state.
|
||||||
|
pub async fn startup_recovery(pool: &PgPool) {
|
||||||
|
// Uploads whose preview generation was interrupted. Marking them 'failed' is
|
||||||
|
// safer than re-queueing — the original file is still on disk, the user can
|
||||||
|
// delete + re-upload if they care, and we avoid double-processing risk.
|
||||||
|
match sqlx::query(
|
||||||
|
"UPDATE upload SET compression_status = 'failed'
|
||||||
|
WHERE compression_status = 'processing'",
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(r) if r.rows_affected() > 0 => {
|
||||||
|
tracing::warn!(
|
||||||
|
"startup recovery: reset {} stuck upload(s) from 'processing' to 'failed'",
|
||||||
|
r.rows_affected()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => tracing::error!("startup recovery: failed to sweep uploads: {e:#}"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export jobs interrupted mid-run. Mark 'failed' so the host can re-trigger.
|
||||||
|
// The `UNIQUE(event_id, type)` constraint would otherwise block re-release.
|
||||||
|
match sqlx::query(
|
||||||
|
"UPDATE export_job
|
||||||
|
SET status = 'failed',
|
||||||
|
error_message = COALESCE(error_message, 'Server-Neustart während des Exports')
|
||||||
|
WHERE status = 'running'",
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(r) if r.rows_affected() > 0 => {
|
||||||
|
tracing::warn!(
|
||||||
|
"startup recovery: reset {} stuck export job(s) from 'running' to 'failed'",
|
||||||
|
r.rows_affected()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => tracing::error!("startup recovery: failed to sweep export_job: {e:#}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns a background task that periodically:
|
||||||
|
/// - deletes session rows whose `expires_at` is more than a day in the past
|
||||||
|
/// - prunes the in-memory rate-limiter HashMap of empty windows
|
||||||
|
///
|
||||||
|
/// Cadence is 1h — fine for both jobs at our scale.
|
||||||
|
pub fn spawn_periodic_tasks(pool: PgPool, rate_limiter: RateLimiter) {
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut tick = tokio::time::interval(Duration::from_secs(3600));
|
||||||
|
// Fire the first tick immediately, then hourly.
|
||||||
|
tick.tick().await;
|
||||||
|
loop {
|
||||||
|
tick.tick().await;
|
||||||
|
cleanup_sessions(&pool).await;
|
||||||
|
rate_limiter.prune();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn cleanup_sessions(pool: &PgPool) {
|
||||||
|
match sqlx::query("DELETE FROM session WHERE expires_at < NOW() - INTERVAL '1 day'")
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(r) if r.rows_affected() > 0 => {
|
||||||
|
tracing::info!("cleaned up {} expired session(s)", r.rows_affected());
|
||||||
|
}
|
||||||
|
Ok(_) => {}
|
||||||
|
Err(e) => tracing::warn!("session cleanup failed: {e:#}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
pub mod compression;
|
pub mod compression;
|
||||||
|
pub mod config;
|
||||||
pub mod export;
|
pub mod export;
|
||||||
|
pub mod jobs;
|
||||||
|
pub mod maintenance;
|
||||||
pub mod rate_limiter;
|
pub mod rate_limiter;
|
||||||
|
|||||||
@@ -41,6 +41,28 @@ impl RateLimiter {
|
|||||||
Err(remaining.as_secs().max(1))
|
Err(remaining.as_secs().max(1))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Drop keys whose windows are empty after expiring old timestamps. Called from a
|
||||||
|
/// background task (see [`crate::services::maintenance`]) so that long-lived
|
||||||
|
/// processes don't accumulate one HashMap entry per IP that ever connected.
|
||||||
|
///
|
||||||
|
/// Uses a conservative 24h ceiling — anything older than that is gone regardless
|
||||||
|
/// of which endpoint's window it was tracked under (the longest window today is
|
||||||
|
/// 24h for export downloads). If we ever add longer windows, raise this constant.
|
||||||
|
pub fn prune(&self) {
|
||||||
|
let now = Instant::now();
|
||||||
|
let ceiling = Duration::from_secs(24 * 60 * 60);
|
||||||
|
let mut map = self.windows.lock().unwrap();
|
||||||
|
let before = map.len();
|
||||||
|
map.retain(|_, ts| {
|
||||||
|
ts.retain(|&t| now.duration_since(t) < ceiling);
|
||||||
|
!ts.is_empty()
|
||||||
|
});
|
||||||
|
let dropped = before.saturating_sub(map.len());
|
||||||
|
if dropped > 0 {
|
||||||
|
tracing::debug!("rate limiter pruned {dropped} idle keys");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract the client IP from X-Forwarded-For (Caddy sets this) or fall back
|
/// Extract the client IP from X-Forwarded-For (Caddy sets this) or fall back
|
||||||
|
|||||||
@@ -11,6 +11,17 @@ pub struct SseEvent {
|
|||||||
pub data: String,
|
pub data: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl SseEvent {
|
||||||
|
/// Standardised constructor. Prefer this over building the struct inline so the
|
||||||
|
/// event-type strings stay consistent across handlers.
|
||||||
|
pub fn new(event_type: impl Into<String>, data: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
event_type: event_type.into(),
|
||||||
|
data: data.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub pool: PgPool,
|
pub pool: PgPool,
|
||||||
@@ -24,7 +35,7 @@ impl AppState {
|
|||||||
pub fn new(pool: PgPool, config: AppConfig) -> Self {
|
pub fn new(pool: PgPool, config: AppConfig) -> Self {
|
||||||
let (sse_tx, _) = broadcast::channel(256);
|
let (sse_tx, _) = broadcast::channel(256);
|
||||||
let compression =
|
let compression =
|
||||||
CompressionWorker::new(pool.clone(), config.media_path.clone(), 2);
|
CompressionWorker::new(pool.clone(), config.media_path.clone(), 2, sse_tx.clone());
|
||||||
Self {
|
Self {
|
||||||
pool,
|
pool,
|
||||||
config,
|
config,
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
import{l as o,a as r}from"../chunks/eAGLaJx1.js";export{o as load_css,r as start};
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{l as o,a as r}from"../chunks/Dy1jDy4J.js";export{o as load_css,r as start};
|
|
||||||
@@ -1 +1 @@
|
|||||||
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/Dy1jDy4J.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};
|
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};
|
||||||
@@ -1 +1 @@
|
|||||||
{"version":"1775501323159"}
|
{"version":"1778876725548"}
|
||||||
@@ -3,11 +3,11 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<link href="/_app/immutable/entry/start.ctwmcI8C.js" rel="modulepreload">
|
<link href="/_app/immutable/entry/start.YjNZv4co.js" rel="modulepreload">
|
||||||
<link href="/_app/immutable/chunks/Dy1jDy4J.js" rel="modulepreload">
|
<link href="/_app/immutable/chunks/eAGLaJx1.js" rel="modulepreload">
|
||||||
<link href="/_app/immutable/chunks/CcONa1Mr.js" rel="modulepreload">
|
<link href="/_app/immutable/chunks/CcONa1Mr.js" rel="modulepreload">
|
||||||
<link href="/_app/immutable/chunks/BJ__EZ0W.js" rel="modulepreload">
|
<link href="/_app/immutable/chunks/BJ__EZ0W.js" rel="modulepreload">
|
||||||
<link href="/_app/immutable/entry/app.jfkZT8Zg.js" rel="modulepreload">
|
<link href="/_app/immutable/entry/app.BTH3knpg.js" rel="modulepreload">
|
||||||
<link href="/_app/immutable/chunks/Bb9JxzU7.js" rel="modulepreload">
|
<link href="/_app/immutable/chunks/Bb9JxzU7.js" rel="modulepreload">
|
||||||
<link href="/_app/immutable/chunks/RsTAN2PN.js" rel="modulepreload">
|
<link href="/_app/immutable/chunks/RsTAN2PN.js" rel="modulepreload">
|
||||||
<link href="/_app/immutable/chunks/Bxn6SmGf.js" rel="modulepreload">
|
<link href="/_app/immutable/chunks/Bxn6SmGf.js" rel="modulepreload">
|
||||||
@@ -18,15 +18,15 @@
|
|||||||
<div style="display: contents">
|
<div style="display: contents">
|
||||||
<script>
|
<script>
|
||||||
{
|
{
|
||||||
__sveltekit_ftrcoq = {
|
__sveltekit_19z1hjw = {
|
||||||
base: ""
|
base: ""
|
||||||
};
|
};
|
||||||
|
|
||||||
const element = document.currentScript.parentElement;
|
const element = document.currentScript.parentElement;
|
||||||
|
|
||||||
Promise.all([
|
Promise.all([
|
||||||
import("/_app/immutable/entry/start.ctwmcI8C.js"),
|
import("/_app/immutable/entry/start.YjNZv4co.js"),
|
||||||
import("/_app/immutable/entry/app.jfkZT8Zg.js")
|
import("/_app/immutable/entry/app.BTH3knpg.js")
|
||||||
]).then(([kit, app]) => {
|
]).then(([kit, app]) => {
|
||||||
kit.start(app, element);
|
kit.start(app, element);
|
||||||
});
|
});
|
||||||
|
|||||||
196
docs/CONCEPT_DIASHOW.md
Normal file
196
docs/CONCEPT_DIASHOW.md
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
# Live Diashow Concept
|
||||||
|
|
||||||
|
> **Status: SHIPPED.** Implementation lives at
|
||||||
|
> [frontend/src/lib/diashow/](../frontend/src/lib/diashow/) and
|
||||||
|
> [frontend/src/routes/diashow/+page.svelte](../frontend/src/routes/diashow/+page.svelte).
|
||||||
|
> Treat this doc as the design reference; code is the source of truth.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
A fullscreen, auto-advancing slideshow that any user can start from their device. Suitable
|
||||||
|
for a venue projector or TV running off a single phone/laptop. Two behaviours combine in one
|
||||||
|
view:
|
||||||
|
|
||||||
|
1. **Live drain** — when a new post is uploaded mid-event, it appears on the next slide
|
||||||
|
transition.
|
||||||
|
2. **Shuffle fallback** — between new uploads (and they will be rare in quiet stretches), the
|
||||||
|
diashow rotates through everything posted so far, in shuffled order.
|
||||||
|
|
||||||
|
The user does **not** need to be Host or Admin. Any guest can start the diashow on their own
|
||||||
|
device. There is no global "the room's diashow" — each device runs its own (though they will
|
||||||
|
look very similar if started at the same time).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Behavioural model
|
||||||
|
|
||||||
|
Two FIFO queues live in the client:
|
||||||
|
|
||||||
|
| Queue | Source | Drain priority |
|
||||||
|
|----------------|-------------------------------------------|----------------|
|
||||||
|
| `liveQueue` | SSE events for new processed uploads | First |
|
||||||
|
| `shuffleQueue` | Snapshot of all known uploads, shuffled | When live empty |
|
||||||
|
|
||||||
|
### Slide-advance algorithm
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function nextSlide(): Slide | null {
|
||||||
|
// 1. Drain live posts first (FIFO).
|
||||||
|
if (liveQueue.length) return liveQueue.shift()!;
|
||||||
|
|
||||||
|
// 2. Refill shuffle queue from `allKnown` if drained.
|
||||||
|
if (!shuffleQueue.length) {
|
||||||
|
shuffleQueue = shuffle(
|
||||||
|
[...allKnown.values()].filter(s => !recentlyShown.has(s.id))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return shuffleQueue.shift() ?? null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A small ring buffer `recentlyShown` (last ~5 IDs) prevents the same picture coming back
|
||||||
|
within seconds when the shuffle queue is rebuilt.
|
||||||
|
|
||||||
|
### Live insertion
|
||||||
|
|
||||||
|
```ts
|
||||||
|
sseClient.on('upload-processed', (msg) => {
|
||||||
|
if (allKnown.has(msg.upload_id)) return;
|
||||||
|
const slide = await fetchUpload(msg.upload_id); // or use payload directly
|
||||||
|
allKnown.set(slide.id, slide);
|
||||||
|
liveQueue.push(slide);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Listen on **`upload-processed`**, not `new-upload` — the preview/thumbnail must exist before
|
||||||
|
we try to display the slide.
|
||||||
|
|
||||||
|
### Deletion / hiding
|
||||||
|
|
||||||
|
```ts
|
||||||
|
sseClient.on('upload-deleted', ({ upload_id }) => {
|
||||||
|
allKnown.delete(upload_id);
|
||||||
|
liveQueue = liveQueue.filter(s => s.id !== upload_id);
|
||||||
|
shuffleQueue = shuffleQueue.filter(s => s.id !== upload_id);
|
||||||
|
if (currentSlide?.id === upload_id) advanceImmediately();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Hidden uploads (banned user with `uploads_hidden=true`) need either a new SSE event or to
|
||||||
|
piggyback `upload-deleted` for diashow purposes. Simplest path: emit `upload-deleted` for
|
||||||
|
hidden posts to all subscribers (the live feed already filters them via `v_feed`, so this is
|
||||||
|
a backwards-compatible signal).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Initial pool
|
||||||
|
|
||||||
|
On start:
|
||||||
|
|
||||||
|
1. Call `GET /api/v1/feed?limit=200` (or paginate-and-drain in the background while the
|
||||||
|
diashow runs).
|
||||||
|
2. Push every returned upload into `allKnown`.
|
||||||
|
3. Build the first `shuffleQueue` from it.
|
||||||
|
4. Open the SSE stream and route `upload-processed` / `upload-deleted` into the queues.
|
||||||
|
|
||||||
|
If the event is empty, show a friendly placeholder:
|
||||||
|
*"Noch keine Beiträge — neue erscheinen automatisch."*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontend surface
|
||||||
|
|
||||||
|
### Entry point
|
||||||
|
|
||||||
|
A small **Diashow / "Präsentation starten"** action visible:
|
||||||
|
|
||||||
|
- In the feed header (icon next to the list/grid toggle) on tablet/desktop layouts.
|
||||||
|
- In the Account page on mobile (less prominent — diashow is primarily a venue-screen
|
||||||
|
feature).
|
||||||
|
|
||||||
|
Tapping it navigates to the `/diashow` route (full-screen, bottom nav hidden).
|
||||||
|
|
||||||
|
### Route: `/diashow`
|
||||||
|
|
||||||
|
- Fullscreen request via `element.requestFullscreen()` after first user gesture.
|
||||||
|
- **Screen Wake Lock**: `navigator.wakeLock.request('screen')` to keep the screen on during
|
||||||
|
long shows. Renew on `visibilitychange` if needed.
|
||||||
|
- Default dwell: **6 seconds** per slide. Configurable via overlay control: 3 / 6 / 10 s.
|
||||||
|
- Tap or `Escape` reveals an overlay with: pause/resume, dwell selector, **transition
|
||||||
|
picker**, exit.
|
||||||
|
- Transitions: crossfade (≈400 ms) by default; Ken Burns, zoom, slide, etc. available as
|
||||||
|
pluggable components — see "Pluggable transitions" below.
|
||||||
|
- Videos: autoplay muted, fit-to-screen, advance on `ended` or after `max(dwell, 12 s)`,
|
||||||
|
whichever first.
|
||||||
|
- Preload the next slide's media into a hidden `<img>`/`<video>` to avoid flashing.
|
||||||
|
- **Media source respects the user's data mode**
|
||||||
|
(see [FEATURES.md §2.5](FEATURES.md)). In Saver mode the diashow loads `preview_url`;
|
||||||
|
in Original mode it loads the original. The data-usage warning is shown once when the
|
||||||
|
mode is toggled in My Account — the diashow itself stays silent.
|
||||||
|
|
||||||
|
### Pluggable transitions
|
||||||
|
|
||||||
|
Each transition is a **drop-in Svelte component** under
|
||||||
|
`frontend/src/lib/diashow/transitions/` (path finalised at implementation time). The
|
||||||
|
interface is intentionally tiny:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// pseudocode — the real shape lands with the feature
|
||||||
|
export interface SlideTransition {
|
||||||
|
id: string; // 'crossfade', 'kenburns', ...
|
||||||
|
label: string; // shown in the dwell/transition picker
|
||||||
|
durationMs: number; // default; can be overridden per-event
|
||||||
|
// The actual motion is implemented by mounting the component with `from` / `to` slides.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
A small registry maps `id → component`; the settings popover renders that registry as a
|
||||||
|
dropdown. **Adding a new animation is one new file plus one line in the registry — no
|
||||||
|
other changes required.** This is the maintainability target called out in
|
||||||
|
[FEATURES.md §2.9](FEATURES.md) and [IDEAS.md](IDEAS.md) ("Animation pack").
|
||||||
|
|
||||||
|
The same pattern is a candidate for whole-event "themes" later — a bundle of (transition
|
||||||
|
+ dwell + optional background-music defaults).
|
||||||
|
|
||||||
|
### Edge cases
|
||||||
|
|
||||||
|
| Case | Behaviour |
|
||||||
|
|--------------------------------------------|--------------------------------------------------------|
|
||||||
|
| Empty event | Placeholder card; live SSE will trigger the first show |
|
||||||
|
| All known uploads are still compressing | Same placeholder — wait for `upload-processed` |
|
||||||
|
| Network drop / SSE reconnect | EventSource auto-reconnects; queues survive |
|
||||||
|
| Current slide gets deleted | Advance immediately |
|
||||||
|
| Event is closed (no new uploads possible) | Diashow keeps running on shuffle queue indefinitely |
|
||||||
|
| Banned user's content (`uploads_hidden`) | Removed via `upload-deleted` signal (see Deletion) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backend changes
|
||||||
|
|
||||||
|
**Essentially none.** The diashow reuses:
|
||||||
|
|
||||||
|
- `GET /api/v1/feed` (initial pool)
|
||||||
|
- `GET /api/v1/stream` SSE (`upload-processed`, `upload-deleted`)
|
||||||
|
|
||||||
|
Optional small additions:
|
||||||
|
|
||||||
|
1. Emit `upload-deleted` (or a new `upload-hidden`) when a host bans a user with
|
||||||
|
`hide_uploads=true`, so that diashow clients can scrub the relevant slides without
|
||||||
|
reloading.
|
||||||
|
2. Consider raising the cap on `GET /api/v1/feed?limit=` for diashow clients (or paginate
|
||||||
|
the initial pool in the background — preferred, no API change needed).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future extensions (not in scope for v1)
|
||||||
|
|
||||||
|
The big ones live in [IDEAS.md](IDEAS.md) under "Diashow extensions" — most notably the
|
||||||
|
**global / synchronised diashow** where multiple screens share one server-side cursor.
|
||||||
|
Short list of others kept here for context:
|
||||||
|
|
||||||
|
- **Curated highlights mode** — only show uploads tagged with a specific hashtag, or
|
||||||
|
Host-pinned "Story" uploads (depends on the story-highlights feature).
|
||||||
|
- **Audio bed** — host can pick a background track; mute videos so they don't fight the
|
||||||
|
music.
|
||||||
|
- **Slide caption / uploader chyron** — small lower-third with the uploader's name and
|
||||||
|
caption. Out by default to keep the visual clean.
|
||||||
@@ -1,5 +1,15 @@
|
|||||||
# HTML Viewer Export Concept
|
# HTML Viewer Export Concept
|
||||||
|
|
||||||
|
> **Status: IMPLEMENTED.** Viewer source: [frontend/export-viewer/](../frontend/export-viewer/).
|
||||||
|
> Pre-built output committed to [backend/static/export-viewer/](../backend/static/export-viewer/).
|
||||||
|
> Backend export pipeline: [backend/src/services/export.rs](../backend/src/services/export.rs).
|
||||||
|
>
|
||||||
|
> Outstanding follow-ups:
|
||||||
|
> - The export-viewer's `tailwind.config.js` does not yet extend the main app's config. Visual
|
||||||
|
> drift risk — see "Shared Tailwind Config" section below.
|
||||||
|
> - Service-worker (offline PWA caching) is still "Future" — fine for v1 since the ZIP is
|
||||||
|
> already fully offline by virtue of relative paths.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
The HTML Viewer export produces a **self-contained offline ZIP** that is a read-only clone
|
The HTML Viewer export produces a **self-contained offline ZIP** that is a read-only clone
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
# Mobile-First UI/UX Redesign Concept
|
# Mobile-First UI/UX Redesign Concept
|
||||||
|
|
||||||
|
> **Status: IMPLEMENTED (v0.15).** This document captures the design intent. The redesign
|
||||||
|
> has shipped — see [BottomNav.svelte](../frontend/src/lib/components/BottomNav.svelte),
|
||||||
|
> [UploadSheet.svelte](../frontend/src/lib/components/UploadSheet.svelte),
|
||||||
|
> [CameraCapture.svelte](../frontend/src/lib/components/CameraCapture.svelte),
|
||||||
|
> [feed/+page.svelte](../frontend/src/routes/feed/+page.svelte),
|
||||||
|
> [account/+page.svelte](../frontend/src/routes/account/+page.svelte),
|
||||||
|
> [host/+page.svelte](../frontend/src/routes/host/+page.svelte),
|
||||||
|
> [admin/+page.svelte](../frontend/src/routes/admin/+page.svelte). Use this doc as the design
|
||||||
|
> reference; treat code as the source of truth for current behaviour.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
EventSnap is intended for mobile use at live events, but the current UI is desktop-oriented.
|
EventSnap is intended for mobile use at live events. This document describes the full
|
||||||
This document describes a full mobile-first redesign covering navigation, the feed/gallery,
|
mobile-first design covering navigation, the feed/gallery, account page, host dashboard,
|
||||||
account page, host dashboard, and admin dashboard.
|
and admin dashboard.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -406,6 +416,46 @@ Same structure as Host Nutzerverwaltung, with any additional admin-only actions
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Touch gestures vs. desktop buttons (planned extension)
|
||||||
|
|
||||||
|
Where a gesture is more ergonomic on mobile than a button, EventSnap prefers the gesture
|
||||||
|
on touch and mirrors it as an explicit button on desktop. Inspired by Instagram, WhatsApp
|
||||||
|
and Telegram — long-press for context, swipe to dismiss, double-tap to react.
|
||||||
|
|
||||||
|
| Surface | Touch gesture | Desktop equivalent |
|
||||||
|
|-----------------------------------------|-------------------------------------|------------------------------------------|
|
||||||
|
| Post card | Long-press → context bottom sheet | ⋯ kebab in the card corner |
|
||||||
|
| Comment row | Long-press → bottom sheet | ⋯ next to the comment timestamp |
|
||||||
|
| User row (Host / Admin dashboards) | Long-press → bottom sheet | Inline buttons (ban, promote, reset PIN) |
|
||||||
|
| Lightbox | Swipe left / right | ←/→ arrow keys + on-screen chevrons |
|
||||||
|
| Lightbox | Swipe down to close | Esc + ✕ button |
|
||||||
|
| Bottom sheet | Swipe down to dismiss | Click backdrop or × in the sheet header |
|
||||||
|
| Feed | Pull to refresh | Refresh icon next to the view toggle |
|
||||||
|
| Post (any) | Double-tap → like | Click the heart icon |
|
||||||
|
|
||||||
|
**Discoverability rule:** every gesture must have a visible button equivalent on the same
|
||||||
|
page. Gestures are never the *only* path to an action. Helps with stylus users,
|
||||||
|
accessibility, and people who don't know the gesture vocabulary.
|
||||||
|
|
||||||
|
**Context bottom-sheet pattern** (used by every long-press above):
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────┐
|
||||||
|
│ ▬ (drag handle) │
|
||||||
|
│ │
|
||||||
|
│ 🗑 Löschen │ ← destructive action red
|
||||||
|
│ 📥 Original anzeigen │
|
||||||
|
│ 🔗 Teilen │
|
||||||
|
│ 🚩 Melden │ (only on others' content)
|
||||||
|
│ │
|
||||||
|
│ [ Abbrechen ] │
|
||||||
|
└──────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Each sheet is composed from a shared `<ContextSheet>` component (planned) with a single
|
||||||
|
`actions: ContextAction[]` prop. Adding a new gesture context = define the actions array
|
||||||
|
where needed. Drop-in, one file.
|
||||||
|
|
||||||
## Design Principles Summary
|
## Design Principles Summary
|
||||||
|
|
||||||
| Principle | Application |
|
| Principle | Application |
|
||||||
@@ -418,3 +468,4 @@ Same structure as Host Nutzerverwaltung, with any additional admin-only actions
|
|||||||
| No role clutter in nav | Role links only in Account, bar stays clean |
|
| No role clutter in nav | Role links only in Account, bar stays clean |
|
||||||
| Collapsible sections | Long management pages stay usable on small phones |
|
| Collapsible sections | Long management pages stay usable on small phones |
|
||||||
| Inner tabs for complex pages | Admin dashboard split across 4 focused tabs |
|
| Inner tabs for complex pages | Admin dashboard split across 4 focused tabs |
|
||||||
|
| Gestures over chrome | Long-press for context menus, swipe to dismiss, double-tap to react — always with a button fallback for desktop and accessibility |
|
||||||
|
|||||||
313
docs/FEATURES.md
Normal file
313
docs/FEATURES.md
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
# EventSnap — Feature Set & Capability Matrix
|
||||||
|
|
||||||
|
This document is the authoritative, code-cross-checked summary of what EventSnap can do today
|
||||||
|
and what is planned. For the design rationale of each area see [PROJECT.md](../PROJECT.md);
|
||||||
|
for journeys / step-by-step flows see [USER_JOURNEYS.md](USER_JOURNEYS.md).
|
||||||
|
|
||||||
|
Status legend: **✓ shipped** · **◐ partial** · **◯ planned** · **✗ out of scope**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Capability matrix by role
|
||||||
|
|
||||||
|
| Capability | Guest | Host | Admin | Notes |
|
||||||
|
|---------------------------------------------------------|:-----:|:-----:|:-----:|-----------------------------------------------------------------------|
|
||||||
|
| **Onboarding & sessions** | | | | |
|
||||||
|
| Join via shared event link / QR code | ✓ | ✓ | ✓ | Name-only registration; server issues JWT + 4-digit PIN |
|
||||||
|
| First-visit guided tour (4 steps) | ✓ | ✓ | ✓ | Dismissed once, flag in `localStorage` |
|
||||||
|
| Persistent 30-day session | ✓ | ✓ | ✓ | JWT in `localStorage`; refreshed on activity |
|
||||||
|
| Sign in on another device using name + PIN | ✓ | ✓ | ✓ | 3 wrong PINs → 15-min lockout |
|
||||||
|
| "Ich habe bereits einen Account" link on the join page | ✓ | ✓ | ✓ | Small inline link → `/recover` (name + PIN) |
|
||||||
|
| View / copy own PIN any time ("My Account") | ✓ | ✓ | ✓ | Read from `localStorage`; never sent back from the server |
|
||||||
|
| Log out / "Leave event" | ✓ | ✓ | ✓ | Confirmation bottom-sheet; invalidates the session row |
|
||||||
|
| Rename own display name | ◯ | ◯ | ◯ | Not yet wired; PIN-protected change |
|
||||||
|
| Pick **data mode** (Saver / Original) in My Account | ✓ | ✓ | ✓ | Saver = compressed (default). Original = full files + data-usage warning. Applies to feed and diashow. Per-device, in `localStorage` |
|
||||||
|
| Read the **Datenschutzhinweis** (privacy note) | ✓ | ✓ | ✓ | Free text set by Admin during setup; rendered preformatted in My Account; first-visit guide briefly points to it |
|
||||||
|
| Admin password login (separate route) | | | ✓ | 1-day token; lives in `sessionStorage` |
|
||||||
|
| Reset another user's PIN (one-time display modal) | | ✓* | ✓ | Host: guests only. Admin: hosts + guests. New PIN shown once to the requester; user signs in with it; PIN is stored on their device on next login. \* Host cannot reset another Host's PIN |
|
||||||
|
| | | | | |
|
||||||
|
| **Posting** | | | | |
|
||||||
|
| Pick photos/videos from device library (multi-select) | ✓ | ✓ | ✓ | Bottom-sheet source picker |
|
||||||
|
| In-app camera capture (`getUserMedia`) | ✓ | ✓ | ✓ | Front/back toggle, photo, `MediaRecorder` video |
|
||||||
|
| Caption + `#hashtag` extraction | ✓ | ✓ | ✓ | Optional; hashtags parsed server-side |
|
||||||
|
| Edit own caption / hashtags after upload | ✓ | ✓ | ✓ | `PATCH /api/v1/upload/{id}` |
|
||||||
|
| Delete own upload | ✓ | ✓ | ✓ | Long-press on the card (or the kebab menu on desktop) → **Löschen** in the context sheet. Comment-style trash icon also available on each post elsewhere as it's added. |
|
||||||
|
| Delete own comment | ✓ | ✓ | ✓ | Trash icon in lightbox |
|
||||||
|
| Background upload queue (survives reload) | ✓ | ✓ | ✓ | IndexedDB-persisted, sequential, retry |
|
||||||
|
| Rate-limit auto-resume banner | ✓ | ✓ | ✓ | Countdown above bottom nav; resumes when window opens |
|
||||||
|
| Chunked / resumable upload for > 100 MB | ◯ | ◯ | ◯ | Planned (v1.x) |
|
||||||
|
| | | | | |
|
||||||
|
| **Feed & social** | | | | |
|
||||||
|
| Chronological list feed (full-width cards) | ✓ | ✓ | ✓ | Default view, infinite scroll |
|
||||||
|
| 3-column grid feed with toggle | ✓ | ✓ | ✓ | Video play badges, duration |
|
||||||
|
| Search & autocomplete (uploader + hashtag) | ✓ | ✓ | ✓ | Grid view; derived in-memory, no extra API calls |
|
||||||
|
| Active filter chips (OR within type, AND across types) | ✓ | ✓ | ✓ | Multiple hashtags = OR; uploader + hashtag = AND |
|
||||||
|
| Fullscreen lightbox with swipe | ✓ | ✓ | ✓ | Swipe navigates the filtered set |
|
||||||
|
| Like / unlike any post | ✓ | ✓ | ✓ | Single toggle; SSE `like-update` |
|
||||||
|
| Read comments on any post | ✓ | ✓ | ✓ | |
|
||||||
|
| Add a comment | ✓ | ✓ | ✓ | Hashtags in comments also parsed |
|
||||||
|
| Real-time feed via SSE | ✓ | ✓ | ✓ | `new-upload`, `new-comment`, `like-update`, `upload-processed`, `pin-reset`, `event-updated`, etc. |
|
||||||
|
| Pause SSE when app is backgrounded | ✓ | ✓ | ✓ | Page Visibility API; reconnect on foreground |
|
||||||
|
| Delta-fetch (`/feed/delta?since=`) on reconnect | ✓ | ✓ | ✓ | Runs on every visibility-restore; merges new + deleted uploads |
|
||||||
|
| Individual file download button per post | ✓ | ✓ | ✓ | "Original anzeigen" in the post context sheet — streams via `/api/v1/upload/{id}/original` |
|
||||||
|
| | | | | |
|
||||||
|
| **Live diashow** (see [CONCEPT_DIASHOW.md](CONCEPT_DIASHOW.md)) | | | | |
|
||||||
|
| Start fullscreen auto-advancing slideshow | ✓ | ✓ | ✓ | Two queues: live (SSE) drains first, shuffle as fallback. Crossfade + Ken Burns transitions; pluggable. Respects data mode. |
|
||||||
|
| | | | | |
|
||||||
|
| **Moderation (Host)** | | | | |
|
||||||
|
| List all event users | | ✓ | ✓ | Includes upload count, total bytes |
|
||||||
|
| Ban / unban a user | | ✓ | ✓ | Modal asks: hide their existing uploads, or keep visible? |
|
||||||
|
| Delete any upload | | ✓ | ✓ | |
|
||||||
|
| Delete any comment | | ✓ | ✓ | |
|
||||||
|
| Promote guest to Host | | ✓ | ✓ | |
|
||||||
|
| Demote Host to guest | | ✓ | ✓ | Hosts may demote other Hosts. Cannot demote self. Admins cannot be demoted by hosts. |
|
||||||
|
| Reset a guest's PIN (Host) / any non-admin PIN (Admin) | | ✓ | ✓ | New PIN shown once in modal; Host shows/shares it with the guest |
|
||||||
|
| Lock new uploads ("Event schließen") | | ✓ | ✓ | Likes + comments + browsing remain open |
|
||||||
|
| Unlock new uploads | | ✓ | ✓ | |
|
||||||
|
| Release gallery → trigger export generation | | ✓ | ✓ | Enqueues both ZIP and HTML-viewer jobs |
|
||||||
|
| | | | | |
|
||||||
|
| **Instance configuration (Admin)** | | | | |
|
||||||
|
| Live disk-usage / user / upload / banned stats | | | ✓ | Stats tab; queries `sysinfo` |
|
||||||
|
| Edit per-file limits (image MB / video MB) | | | ✓ | Config tab; hot-reloadable from DB |
|
||||||
|
| Edit per-endpoint rate limits | | | ✓ | Upload/hour, feed/min, export/day |
|
||||||
|
| Toggle **all** rate limits on/off | | | ✓ | Master switch — when off, every limiter passes through |
|
||||||
|
| Toggle **individual** rate limits on/off | | | ✓ | Per-endpoint switch (upload / feed / export / join) |
|
||||||
|
| Toggle quota enforcement on/off (master + per-area) | | | ✓ | Master switch + per-area (storage / upload count). When off, nothing is enforced |
|
||||||
|
| Edit quota tolerance | | | ✓ | Live `(free_disk × tolerance) / active_uploaders` formula enforced on upload |
|
||||||
|
| Edit estimated guest count | | | ✓ | |
|
||||||
|
| Edit compression-worker concurrency | | | ✓ | |
|
||||||
|
| Edit **Datenschutzhinweis** (privacy note, free text) | | | ✓ | Plain text, whitespace + newlines preserved, no HTML. SSE `event-updated` broadcasts edits live. |
|
||||||
|
| Inspect export job list & progress | | | ✓ | |
|
||||||
|
| Low-disk alert (< 10 GB free) | | | ◯ | Planned |
|
||||||
|
| Event banner / cover image | | | ◯ | DB column exists, no UI |
|
||||||
|
| | | | | |
|
||||||
|
| **Quota visibility (Guest-facing)** | | | | |
|
||||||
|
| Show current per-user quota estimate | ✓ | ✓ | ✓ | "Du hast X MB von Y MB genutzt." in My Account and on the upload screen. Computed from the live formula. Hidden when quota enforcement is toggled off |
|
||||||
|
| | | | | |
|
||||||
|
| **Export** | | | | |
|
||||||
|
| Wait at locked export page until released | ✓ | ✓ | ✓ | Friendly "not yet available" copy |
|
||||||
|
| Download `Gallery.zip` (full-quality originals) | ✓ | ✓ | ✓ | Streamed via `async-zip`; `Photos/` + `Videos/` folders |
|
||||||
|
| Download `Memories.zip` (offline HTML viewer) | ✓ | ✓ | ✓ | Self-contained SvelteKit-static app + `data.json` + `media/` |
|
||||||
|
| HTML-export in-app guide modal before download | ✓ | ✓ | ✓ | Explains: unzip first, open `index.html` |
|
||||||
|
| Per-IP export download rate limit (3 / day) | ✓ | ✓ | ✓ | |
|
||||||
|
| | | | | |
|
||||||
|
| **Banned guest** (subset) | | | | |
|
||||||
|
| Cannot upload, like, or comment | ✗ | | | Returns HTTP 403 |
|
||||||
|
| Can browse the feed | ✓ | | | |
|
||||||
|
| Can still download the export once released | ✓ | | | Spec design choice |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Feature areas in detail
|
||||||
|
|
||||||
|
### 2.0 Touch-first interactions (mobile) vs. buttons (desktop)
|
||||||
|
|
||||||
|
EventSnap is mobile-first. Where it makes the UI cleaner, primary actions are reached via
|
||||||
|
**gestures** on touch devices, with conventional **buttons** mirrored on tablet/desktop:
|
||||||
|
|
||||||
|
- **Long-press on a post** → context bottom sheet ("Löschen", "Original anzeigen", report,
|
||||||
|
share). On desktop the same actions are a kebab/⋯ menu in the card's corner.
|
||||||
|
- **Long-press on a comment** → context sheet with "Löschen" (own comments only) and
|
||||||
|
"Kopieren".
|
||||||
|
- **Swipe left/right in the lightbox** → navigate the filtered set.
|
||||||
|
- **Swipe down on a bottom sheet** → dismiss.
|
||||||
|
- **Pull-to-refresh on the feed** → force a delta-fetch even when SSE is up.
|
||||||
|
- **Double-tap on a post** → like (Instagram-style), with a heart-burst animation. Tap the
|
||||||
|
heart icon as the explicit alternative.
|
||||||
|
|
||||||
|
Design rule: **gestures should always have a discoverable button equivalent** somewhere on
|
||||||
|
the page, so the app stays usable on a stylus, mouse, or for users who don't know the
|
||||||
|
gesture vocabulary. Take inspiration from Instagram, WhatsApp, and Telegram for the
|
||||||
|
"feels right" baseline — long-press for context, swipe to dismiss, double-tap to react.
|
||||||
|
|
||||||
|
### 2.1 Authentication and identity
|
||||||
|
|
||||||
|
EventSnap's identity model is "**a name + a 4-digit PIN, scoped to one event**". There is no
|
||||||
|
email, no password, no account portal.
|
||||||
|
|
||||||
|
- **Joining.** On the join page the user types a display name. The server creates a `user`
|
||||||
|
row, generates a 4-digit PIN, stores `bcrypt(pin)`, signs a 30-day JWT, and returns the
|
||||||
|
PIN in clear text **once** in the response. The client persists the JWT and the PIN to
|
||||||
|
`localStorage`.
|
||||||
|
- **PIN visibility.** The PIN is shown to the user *prominently* once at registration, and
|
||||||
|
remains visible in the My-Account page (read directly from `localStorage` — never sent
|
||||||
|
back from the server).
|
||||||
|
- **Returning on the same device.** A valid JWT in `localStorage` → straight to the feed.
|
||||||
|
- **Returning on a new device.** Type the name on the join page → server detects the
|
||||||
|
existing user → user is prompted for their PIN. `bcrypt.verify` → new JWT, fresh device
|
||||||
|
is now bound to the same account.
|
||||||
|
- **Lockout.** 3 wrong PIN attempts → 15-minute lockout per user (`pin_locked_until` column,
|
||||||
|
migration 006).
|
||||||
|
- **Name collisions.** Names are unique per event (case-insensitive, migration 007). If
|
||||||
|
someone tries to join with a name already taken, the join page automatically presents the
|
||||||
|
PIN-recovery form for that account ("Already taken — sign in instead, or pick another
|
||||||
|
name"). The join page also surfaces an explicit **"Ich habe bereits einen Account"**
|
||||||
|
link routing to `/recover` for users who already know they want to sign in.
|
||||||
|
- **PIN reset by Host / Admin.** Planned. If a guest loses their PIN and `localStorage` is
|
||||||
|
gone everywhere, a Host (for guests) or Admin (for hosts and guests) can hit a
|
||||||
|
**PIN zurücksetzen** action in the user list. A fresh PIN is generated server-side, its
|
||||||
|
bcrypt stored, and the plaintext is shown **once** in a modal to the requesting
|
||||||
|
operator. The operator shows / sends the new PIN to the user, who then signs in via
|
||||||
|
`/recover` — the PIN is persisted to `localStorage` on that device on a successful
|
||||||
|
recovery, exactly like a brand-new join. Host cannot reset another Host's PIN; only
|
||||||
|
Admins can.
|
||||||
|
- **Roles.** `guest` (default), `host`, `admin`. The Admin role is seeded from the
|
||||||
|
`ADMIN_PASSWORD_HASH` env var; admins log in at `/admin/login` with a password (separate
|
||||||
|
JWT, 1-day expiry, in `sessionStorage`). Hosts are guests promoted by an admin. **Hosts
|
||||||
|
may also demote other Hosts to guests** (planned) — but never themselves, to avoid
|
||||||
|
locking the event out of moderation. Admins can demote anyone except admins.
|
||||||
|
|
||||||
|
### 2.2 Posting pipeline
|
||||||
|
|
||||||
|
The upload pipeline is built for flaky mobile networks:
|
||||||
|
|
||||||
|
1. **Source picker** (bottom sheet from the FAB): camera or gallery.
|
||||||
|
2. **Preview screen** — staged files appear as thumbnails; user can remove individuals, add
|
||||||
|
a caption (with `#hashtags`), and tap quick-tag chips derived from the caption.
|
||||||
|
3. **Submit** — the client immediately returns to the feed (optimistic UX). Files enter an
|
||||||
|
IndexedDB-persisted queue.
|
||||||
|
4. **Queue worker** — runs sequentially (one upload at a time), per-file progress via XHR.
|
||||||
|
Survives reloads and app backgrounding. A red badge on the FAB indicates active uploads.
|
||||||
|
5. **Server-side processing** — multipart received → MIME-sniffed via `infer` → size
|
||||||
|
validated → original stored → compression worker (bounded by a `tokio::sync::Semaphore`)
|
||||||
|
resizes to an 800-px preview (images via the `image` crate + `oxipng` for PNG) or
|
||||||
|
extracts a frame at the 1-second mark (videos via `ffmpeg`). Status is tracked in the new
|
||||||
|
`compression_status` column (migration 008).
|
||||||
|
6. **Real-time fan-out** — `new-upload` SSE first (no preview yet), then `upload-processed`
|
||||||
|
when the preview/thumbnail is ready, so clients can swap a placeholder for the real image
|
||||||
|
without re-fetching the feed.
|
||||||
|
7. **Rate-limit-aware client** — when the server returns HTTP 429 with `Retry-After`, the
|
||||||
|
queue parks remaining items and shows an inline countdown banner; uploads resume
|
||||||
|
automatically.
|
||||||
|
|
||||||
|
### 2.3 Feed
|
||||||
|
|
||||||
|
- **Two layouts** — chronological list (default) and 3-column grid. Toggle in the header.
|
||||||
|
- **List view** has no search; it's the consumption-focused mode (like an Instagram feed).
|
||||||
|
- **Grid view** has the search bar — autocomplete suggestions are computed in-memory from
|
||||||
|
the loaded uploads, so typing never hits the server.
|
||||||
|
- **Filter chips** — multiple hashtags combine with OR; multiple uploaders combine with OR;
|
||||||
|
hashtag + uploader combine with AND. Matches the redesign concept exactly.
|
||||||
|
- **Lightbox** — fullscreen view, swipe navigates the *filtered* set, with embedded
|
||||||
|
like/comment UI.
|
||||||
|
- **Real-time** — SSE delivers `new-upload`, `upload-processed`, `like-update`,
|
||||||
|
`new-comment`, `upload-deleted`, `event-closed`/`event-opened`, `export-progress`,
|
||||||
|
`export-available`. Client pauses SSE on `visibilitychange: hidden` and reopens on visible.
|
||||||
|
|
||||||
|
### 2.4 Host / Admin tooling
|
||||||
|
|
||||||
|
- **Host dashboard** — three collapsible sections: Stats, Event-Einstellungen,
|
||||||
|
Nutzerverwaltung. Ban modal asks explicitly whether to hide the user's existing uploads
|
||||||
|
from the public feed. Promote/demote, lock/unlock, release-gallery are one-tap.
|
||||||
|
- **Admin dashboard** — same dashboard plus three more inner tabs (Stats, Config, Export,
|
||||||
|
Nutzer). Config form covers per-file limits, rate limits, quota tolerance, estimated
|
||||||
|
guest count, and compression concurrency — all stored in the `config` table and read on
|
||||||
|
each request, so changes take effect without a restart. Disk widget pulls from the
|
||||||
|
`sysinfo` crate live.
|
||||||
|
|
||||||
|
### 2.5 Data mode (planned)
|
||||||
|
|
||||||
|
Each device picks a **data mode** in My Account; the setting lives in `localStorage` so a
|
||||||
|
guest can be on Saver on their phone and Original on their laptop.
|
||||||
|
|
||||||
|
| Mode | Default? | Feed loads... | Lightbox / diashow loads... | Warning shown? |
|
||||||
|
|------------|:--------:|-----------------------------|------------------------------|:-------------:|
|
||||||
|
| Datensparer (Saver) | ✓ | preview (compressed) | preview | no |
|
||||||
|
| Original | | original | original | yes — "kann mobile Datennutzung erhöhen" once on enable |
|
||||||
|
|
||||||
|
Applies uniformly to the live app's feed/lightbox **and** the diashow. The viewer (offline
|
||||||
|
HTML export) is unaffected — it's already a snapshot of pre-bundled media variants.
|
||||||
|
|
||||||
|
### 2.6 Rate limits and quotas — toggleable (planned)
|
||||||
|
|
||||||
|
The Admin Config tab gains explicit on/off toggles in addition to the numeric inputs:
|
||||||
|
|
||||||
|
- **Master switch — all rate limits.** When off, every limiter middleware short-circuits to
|
||||||
|
pass-through. Useful for testing or trusted internal events.
|
||||||
|
- **Per-endpoint switches.** Upload / feed / export / join each have their own toggle. The
|
||||||
|
numeric input becomes informational while the toggle is off.
|
||||||
|
- **Master switch — quotas.** When off, no quota check ever runs.
|
||||||
|
- **Per-area quota switch.** Storage-bytes quota and upload-count quota can be disabled
|
||||||
|
independently.
|
||||||
|
|
||||||
|
When a feature is toggled off, the relevant UI in the guest-facing app should adapt: e.g.
|
||||||
|
the "Du hast X von Y MB genutzt" widget hides itself when storage quota is disabled. The
|
||||||
|
quota estimate is computed from the same formula the server uses
|
||||||
|
(`(free_disk × tolerance) / max(active_uploaders, 1)`) — surfaced in My Account *and* on
|
||||||
|
the upload preview screen so guests know before they pick files.
|
||||||
|
|
||||||
|
### 2.7 Privacy note (Datenschutzhinweis, planned)
|
||||||
|
|
||||||
|
Admin sets a free-text **Datenschutzhinweis** during instance setup (Admin Dashboard →
|
||||||
|
Config). It's stored as a single config key (plain text, whitespace and newlines
|
||||||
|
preserved, no HTML). Guests see it in their **My Account** page, rendered inside a
|
||||||
|
preformatted block — no parsing, no markdown, just exactly what the admin typed. The
|
||||||
|
first-visit onboarding guide gains a one-line nudge: *"Datenschutzhinweis findest du in
|
||||||
|
deinem Account."*
|
||||||
|
|
||||||
|
Rationale: many real events (in Germany especially) need a per-event privacy statement
|
||||||
|
without the operator wanting to ship a separate static page or rebuild the app.
|
||||||
|
|
||||||
|
### 2.8 Export
|
||||||
|
|
||||||
|
Two artifacts, both generated on demand after the host taps "Release gallery":
|
||||||
|
|
||||||
|
- **Gallery.zip** — full-quality originals only, structured into `Photos/` and `Videos/`,
|
||||||
|
filenames `{date}_{time}_{username}_{id}.{ext}`, streamed via `async-zip` with no full
|
||||||
|
archive in memory.
|
||||||
|
- **Memories.zip** — the offline HTML viewer. Pre-built SvelteKit-static app from
|
||||||
|
[frontend/export-viewer/](../frontend/export-viewer/), bundled with a generated
|
||||||
|
`data.json` snapshot and a `media/` folder of thumbnails + full-size variants. Open
|
||||||
|
`index.html` in any browser — no server required, no internet required. List/grid views,
|
||||||
|
lightbox, hashtag chips, like counts, comments — all visually matched to the live app.
|
||||||
|
|
||||||
|
The export page shows live progress (SSE) while jobs run, then becomes a download button
|
||||||
|
when complete.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2.9 Maintainability and extensibility
|
||||||
|
|
||||||
|
EventSnap is small enough to be a single-developer project; it should stay easy to extend.
|
||||||
|
A few principles to keep adding features cheap:
|
||||||
|
|
||||||
|
- **Diashow transitions are drop-in components.** Each animation implements a small
|
||||||
|
interface and lives under `frontend/src/lib/diashow/transitions/`. Adding a transition is
|
||||||
|
one file + one entry in a registry.
|
||||||
|
- **Feature toggles live in the `config` table.** Today's rate-limit and quota switches
|
||||||
|
follow the same pattern any new opt-in feature would use — no redeploy to flip
|
||||||
|
behaviour.
|
||||||
|
- **One Svelte store per cross-cutting concern.** Auth, upload queue, SSE, data mode,
|
||||||
|
diashow state — composable rather than copy-pasted into each route.
|
||||||
|
- **Migrations are append-only.** Never edit a shipped migration; always add a new pair.
|
||||||
|
- **Background jobs share one pipeline.** Export and compression already publish progress
|
||||||
|
via the `export_job` row + SSE; future long-running work (analytics, archival) should
|
||||||
|
plug into the same shape.
|
||||||
|
|
||||||
|
See [IDEAS.md](IDEAS.md) for a longer riff on these patterns.
|
||||||
|
|
||||||
|
## 3. Out of scope (intentionally not built)
|
||||||
|
|
||||||
|
These are explicit non-goals from [PROJECT.md §4](../PROJECT.md):
|
||||||
|
|
||||||
|
- Native iOS / Android apps
|
||||||
|
- Multiple simultaneous events (multi-tenancy)
|
||||||
|
- Email-based auth / password reset
|
||||||
|
- Push notifications
|
||||||
|
- User-to-user direct messaging
|
||||||
|
- Payment / monetisation
|
||||||
|
- CI/CD pipeline
|
||||||
|
- "Save to camera roll" automation on iOS/Android — guests download the ZIP and use their
|
||||||
|
platform file manager
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. See also
|
||||||
|
|
||||||
|
- [USER_JOURNEYS.md](USER_JOURNEYS.md) — step-by-step flows for every supported scenario.
|
||||||
|
- [CONCEPT_MOBILE_UI.md](CONCEPT_MOBILE_UI.md) — design reference for the mobile layout.
|
||||||
|
- [CONCEPT_HTML_VIEWER.md](CONCEPT_HTML_VIEWER.md) — export-viewer design.
|
||||||
|
- [CONCEPT_DIASHOW.md](CONCEPT_DIASHOW.md) — planned diashow design.
|
||||||
|
- [IDEAS.md](IDEAS.md) — speculative extensions (global diashow, reactions, multi-tenancy, ...).
|
||||||
|
- [PROJECT.md](../PROJECT.md) — full architectural blueprint and rationale.
|
||||||
|
- [TEST_GUIDE.md](../TEST_GUIDE.md) — manual smoke-test script for the main flows.
|
||||||
199
docs/IDEAS.md
Normal file
199
docs/IDEAS.md
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
# EventSnap — Ideas & Future Extensions
|
||||||
|
|
||||||
|
A dumping ground for design ideas that are **not yet on the roadmap**. Everything here is a
|
||||||
|
v2+ candidate, brainstormed once the core experience is stable. For shipped or actively
|
||||||
|
planned scope see [FEATURES.md](FEATURES.md) and the `CONCEPT_*.md` design docs.
|
||||||
|
|
||||||
|
The bar to land here is low: "would be cool one day" qualifies. The bar to graduate to a
|
||||||
|
`CONCEPT_*.md` is much higher (design committed, ready to build).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Diashow extensions
|
||||||
|
|
||||||
|
### Global / synchronised diashow
|
||||||
|
|
||||||
|
Multiple devices show **the same slide at the same time** (e.g. a projector in the main
|
||||||
|
hall plus tablets behind the bar plus a screen by the photo booth).
|
||||||
|
|
||||||
|
Sketch:
|
||||||
|
- Server holds a single authoritative "current slide" cursor for the event.
|
||||||
|
- New SSE event `diashow-tick` broadcasts `{ slide_id, started_at, next_at }`.
|
||||||
|
- Each subscribed client renders locally — server only chooses ordering and pace.
|
||||||
|
- Live-queue / shuffle-queue logic (see [CONCEPT_DIASHOW.md](CONCEPT_DIASHOW.md)) lives
|
||||||
|
server-side instead of client-side.
|
||||||
|
- A "leader device" can claim the diashow, or the server runs it headlessly. Host UI lets
|
||||||
|
Host start / stop the global diashow.
|
||||||
|
- Plays well with venues that already have multiple displays — no need for HDMI splitters
|
||||||
|
or chromecast hacks.
|
||||||
|
|
||||||
|
### Audio bed
|
||||||
|
|
||||||
|
- Host uploads or selects a background track (per-event).
|
||||||
|
- Videos in the diashow auto-mute so they don't fight the music.
|
||||||
|
- Optional ducking when a video has speech.
|
||||||
|
|
||||||
|
### Curated diashow mode
|
||||||
|
|
||||||
|
- Diashow filtered by a hashtag (`#highlights`) or to a Host-pinned set ("Story" feature).
|
||||||
|
- Useful for the end-of-evening recap reel.
|
||||||
|
|
||||||
|
### Animation pack
|
||||||
|
|
||||||
|
- More transitions out of the box: zoom, slide, mosaic, dip-to-black, push.
|
||||||
|
- Per-event "theme" preset — wedding-elegant, party-energetic, minimal, gallery-classic.
|
||||||
|
- Builds on the maintainability principle below: each transition is a drop-in Svelte
|
||||||
|
component, so growing the pack is trivial.
|
||||||
|
|
||||||
|
### Lower-third metadata
|
||||||
|
|
||||||
|
- Subtle chyron at the bottom of each slide: uploader name + timestamp + caption.
|
||||||
|
- Off by default; toggle in the diashow settings popover.
|
||||||
|
|
||||||
|
### Smart pacing
|
||||||
|
|
||||||
|
- Detect video duration and let videos play their full length (with a cap), pause stills
|
||||||
|
for the remainder. Avoids choppy 6-second cuts on a clip with key content at 0:08.
|
||||||
|
- "Action density" heuristic — slow down for portraits, speed up for landscapes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Social
|
||||||
|
|
||||||
|
### Per-guest gallery
|
||||||
|
|
||||||
|
A first-class "All posts by Anna" view, navigable from a guest's avatar — not just a
|
||||||
|
filter chip. Doubles as a personal "what did I post?" page.
|
||||||
|
|
||||||
|
### Story-style highlights
|
||||||
|
|
||||||
|
Host curates a best-of timeline pinned at the top of the feed (already in PROJECT.md's
|
||||||
|
"Should Have"). Tap-through, fullscreen, ~5 s per story, like Instagram. Could double as
|
||||||
|
the source for the curated-diashow mode above.
|
||||||
|
|
||||||
|
### Reactions beyond like
|
||||||
|
|
||||||
|
Multiple emoji reactions (❤️ 😂 😍 🎉 🥲) instead of just like. The DB design already keys
|
||||||
|
the `like` table on `(upload_id, user_id)` — generalising to `(upload_id, user_id, kind)`
|
||||||
|
is a small migration.
|
||||||
|
|
||||||
|
### Mentions and reply-threads in comments
|
||||||
|
|
||||||
|
- `@anna` in a comment becomes a tap-through to her profile / posts.
|
||||||
|
- Threaded replies under each top-level comment.
|
||||||
|
- Combined with PWA push, drives engagement.
|
||||||
|
|
||||||
|
### Collaborative captions
|
||||||
|
|
||||||
|
Co-authored captions when multiple uploaders are in the same photo — second tagger
|
||||||
|
contributes their `#hashtags` to the same post.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notifications
|
||||||
|
|
||||||
|
- PWA push for new comments on a guest's own posts.
|
||||||
|
- Per-user opt-in; granular per-event preference (mute event, mute uploader, etc.).
|
||||||
|
- Email digest after the event (1 message per guest) — optional, controversial vs. the
|
||||||
|
"no email" identity model. Could be opt-in only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Capture & posting
|
||||||
|
|
||||||
|
- **Live-photo mode** — capture a 1–2 s clip alongside each still (Apple-style). Diashow
|
||||||
|
could animate stills using the live clip as the Ken Burns source.
|
||||||
|
- **Boomerang / GIF capture** — short looping clips.
|
||||||
|
- **Client-side filters and stickers** — Instagram-style.
|
||||||
|
- **Voice notes** attached to a photo — "first dance" voice memo + the photo.
|
||||||
|
- **Bulk-upload presets** — pre-fill a caption for a batch ("Photos from the ceremony").
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Privacy & moderation
|
||||||
|
|
||||||
|
- **Per-post visibility** — "only visible to people with this hashtag" or
|
||||||
|
"private to my friend group".
|
||||||
|
- **Pre-moderation queue** — Host approves posts before they hit the public feed (default
|
||||||
|
off; for sensitive events).
|
||||||
|
- **Auto-blur** of detected faces of non-guests, or NSFW detection.
|
||||||
|
- **Per-uploader watermark** on full-quality downloads.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Multi-tenancy
|
||||||
|
|
||||||
|
- **Multiple events per instance** — picked by URL slug. Today the binary is single-event.
|
||||||
|
- **Org accounts** — a wedding photographer running 4 weddings a month against the same
|
||||||
|
deployment.
|
||||||
|
- **Per-event admin** vs. **instance admin** roles.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Internationalisation
|
||||||
|
|
||||||
|
- Localisation beyond German — English, French, Spanish, ...
|
||||||
|
- Admin picks UI language during setup; per-user override.
|
||||||
|
- Strings extracted into a small JSON catalogue — works well with `svelte-i18n` or similar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Export
|
||||||
|
|
||||||
|
- **Year-in-pictures PDF** — host-curated layout, printable.
|
||||||
|
- **ICS calendar attachment** of the event, included in the export ZIP.
|
||||||
|
- **Direct upload to a guest's chosen cloud** (iCloud, Google Photos) — needs OAuth, adds
|
||||||
|
a third-party integration where today there are none.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Resilience / infrastructure
|
||||||
|
|
||||||
|
- **Distributed rate limiting** (Redis) for multi-instance / multi-event deploys.
|
||||||
|
- **Object-storage backend** (S3 / MinIO) behind a feature flag — out of scope for the
|
||||||
|
single-VPS use case but easy to add if multi-tenancy is ever pursued.
|
||||||
|
- **Read replicas** for very large events.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Maintainability principles to keep adding features cheap
|
||||||
|
|
||||||
|
The codebase is small today and should stay friendly to extension. A few patterns to lean
|
||||||
|
into as the surface grows:
|
||||||
|
|
||||||
|
- **Diashow transitions as drop-in components.** Each transition implements a tiny
|
||||||
|
interface (`enter`, `leave`, optional `duration`). Adding a new animation is one file in
|
||||||
|
`frontend/src/lib/diashow/transitions/` and one line in a registry. Same idea for
|
||||||
|
hashtag-filter operators.
|
||||||
|
- **Per-feature toggle flags in the `config` table.** Today rate limits and quotas are
|
||||||
|
individually toggleable (see [FEATURES.md](FEATURES.md)). The same pattern fits for any
|
||||||
|
future opt-in feature — no need to redeploy to flip behaviour.
|
||||||
|
- **Background-task trait on the server.** Export, compression, and (future) analytics
|
||||||
|
jobs would all share a `BackgroundJob` interface that wires into the existing
|
||||||
|
`export_job` progress + SSE pipeline. New long-running work plugs in by implementing the
|
||||||
|
trait — no bespoke worker code per feature.
|
||||||
|
- **One Svelte store per cross-cutting concern.** Auth, upload queue, SSE, data mode,
|
||||||
|
diashow state — each lives in its own store under `frontend/src/lib/`. New UI features
|
||||||
|
consume the stores; cross-feature behaviour is composed, not copy-pasted.
|
||||||
|
- **DTOs in one file** ([frontend/src/lib/types.ts](../frontend/src/lib/types.ts)),
|
||||||
|
mirrored to the Rust DTOs. Changing a contract is exactly two edits.
|
||||||
|
- **Migration-first schema evolution** — never edit an old migration; always add a new
|
||||||
|
`0NN_*.up.sql` / `.down.sql` pair. Already the discipline; just keep it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Speculative / "would be cool"
|
||||||
|
|
||||||
|
Lower bar of plausibility — keep these around as conversation seeds:
|
||||||
|
|
||||||
|
- **AI-generated event summary** at release time (3-paragraph recap, key moments,
|
||||||
|
funniest comment).
|
||||||
|
- **AI auto-tagging** — suggested hashtags based on image content, opt-in per upload.
|
||||||
|
- **Guest-of-honour mode** — special UI for the couple / birthday person showing
|
||||||
|
*everything they're in*, prioritised by face detection.
|
||||||
|
- **Live caption translation** for international weddings — auto-translate comments
|
||||||
|
inline.
|
||||||
|
- **Sound-reactive diashow** — slides advance in sync with music BPM picked up via the
|
||||||
|
device mic.
|
||||||
|
- **Photo-booth integration** — a fixed iPad at the venue posts to the feed with a single
|
||||||
|
tap, no PIN.
|
||||||
292
docs/USER_JOURNEYS.md
Normal file
292
docs/USER_JOURNEYS.md
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
# EventSnap — User Journeys
|
||||||
|
|
||||||
|
This document walks through every supported user scenario step-by-step. For a quick "who
|
||||||
|
can do what" overview, see [FEATURES.md](FEATURES.md). For manual QA, see
|
||||||
|
[TEST_GUIDE.md](../TEST_GUIDE.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. First-time guest (the happy path)
|
||||||
|
|
||||||
|
1. Guest scans the QR code / opens the event link.
|
||||||
|
2. Lands on the **join page** (`/join`), sees the event name. A small
|
||||||
|
*"Ich habe bereits einen Account"* link is visible below the form for returning users
|
||||||
|
— it routes to `/recover`.
|
||||||
|
3. Types display name → taps **Beitreten**.
|
||||||
|
4. Server creates the account, generates a 4-digit PIN, stores `bcrypt(PIN)`, signs a
|
||||||
|
30-day JWT.
|
||||||
|
5. A **PIN modal** appears: large monospace digits, a **Kopieren** button, a warning that
|
||||||
|
this PIN is the only way to sign in on another device. PIN is also written to
|
||||||
|
`localStorage`.
|
||||||
|
6. Guest taps **Weiter zur Galerie** → lands in the feed (`/feed`).
|
||||||
|
7. The **first-visit onboarding overlay** appears: dismissible steps (welcome, upload,
|
||||||
|
hashtags, PIN, and a brief pointer to the **Datenschutzhinweis** in My Account).
|
||||||
|
`localStorage('eventsnap_guide_seen') = 'true'` after dismiss.
|
||||||
|
8. Guest sees the bottom nav: **🏠 Feed · [📷+ FAB] · 👤 Account**.
|
||||||
|
|
||||||
|
## 2. Returning guest, same device
|
||||||
|
|
||||||
|
1. App finds a valid JWT in `localStorage`.
|
||||||
|
2. Redirected straight to `/feed`, no input required.
|
||||||
|
|
||||||
|
## 3. Returning guest, new device or cleared storage
|
||||||
|
|
||||||
|
1. Guest opens the event link on the new device → join page.
|
||||||
|
2. Types the **same name** they used before.
|
||||||
|
3. Server detects the existing account → the join page transforms into a recovery prompt:
|
||||||
|
*"„Name" ist bereits vergeben"* with a **PIN input** and an **Anmelden** button, plus
|
||||||
|
an **Anderen Namen wählen** escape hatch.
|
||||||
|
4. Guest types their PIN → `bcrypt.verify` succeeds → new JWT issued for the existing
|
||||||
|
`user_id`. PIN is written to `localStorage` on this device too.
|
||||||
|
5. Wrong PIN: up to 3 attempts. After the third, the account is locked for 15 minutes
|
||||||
|
(`pin_locked_until` is set; further attempts return HTTP 429 with a localized message).
|
||||||
|
|
||||||
|
## 4. PIN forgotten — Host or Admin resets it (planned)
|
||||||
|
|
||||||
|
The PIN is visible in **My Account** as long as `localStorage` is intact on at least one
|
||||||
|
of the user's devices. If lost everywhere, the user asks a Host (or Admin) for a reset.
|
||||||
|
|
||||||
|
1. Guest approaches the Host: *"I can't sign in on my new phone."*
|
||||||
|
2. Host opens the **Host Dashboard → Nutzerverwaltung** and finds the user.
|
||||||
|
3. Host taps **PIN zurücksetzen** on that row.
|
||||||
|
4. A confirmation prompt explains what happens; on confirm the server generates a fresh
|
||||||
|
4-digit PIN, replaces `recovery_pin_hash` with the new bcrypt, clears any active
|
||||||
|
`pin_locked_until`, and returns the new plaintext PIN in the response.
|
||||||
|
5. A **modal shows the new PIN ONCE** — large, with a copy button. The Host shows the
|
||||||
|
screen to the guest or sends it via another channel (SMS, slip of paper, …). Closing
|
||||||
|
the modal forgets the plaintext on the operator's device too.
|
||||||
|
6. Guest goes to `/recover` (or taps "Ich habe bereits einen Account" on `/join`), enters
|
||||||
|
their name + the new PIN, signs in, and the PIN is persisted to `localStorage` on
|
||||||
|
their device — exactly like a fresh join.
|
||||||
|
|
||||||
|
**Permission rules:**
|
||||||
|
- Host can reset PINs for **guests** only.
|
||||||
|
- Admin can reset PINs for **hosts and guests** (not other admins; admins use the
|
||||||
|
password login).
|
||||||
|
- Anyone whose PIN was reset retains all their uploads, comments, and likes — only the
|
||||||
|
PIN changes.
|
||||||
|
|
||||||
|
**If no Host or Admin is reachable**, the guest can still re-join under a new name (a
|
||||||
|
clean account; their previous uploads remain attributed to the abandoned account, which
|
||||||
|
the Host can clean up later).
|
||||||
|
|
||||||
|
## 5. Posting a photo / video
|
||||||
|
|
||||||
|
1. Guest taps the central **📷+ FAB** in the bottom nav.
|
||||||
|
2. A **bottom sheet** slides up offering **Kamera** (in-app capture) or **Galerie** (file
|
||||||
|
picker, multi-select).
|
||||||
|
3a. **Camera path** — [CameraCapture](../frontend/src/lib/components/CameraCapture.svelte)
|
||||||
|
opens the back camera (`facingMode: 'environment'`), with toggle for front camera,
|
||||||
|
photo button, and a video-record button using `MediaRecorder`.
|
||||||
|
3b. **Gallery path** — native picker, multiple selection.
|
||||||
|
4. **Preview screen** (`/upload`) shows staged files as horizontal thumbnails. The user can:
|
||||||
|
- Remove individual files.
|
||||||
|
- Type a caption with `#hashtags`.
|
||||||
|
- Tap quick-tag chips (derived from the caption) to copy a hashtag into the caption.
|
||||||
|
5. Taps **Hochladen** → returns immediately to the feed (optimistic UX). The slim progress
|
||||||
|
bar above the bottom nav and the red badge on the FAB indicate active uploads.
|
||||||
|
6. The client uploads files **one at a time** (XHR with progress) from an IndexedDB queue.
|
||||||
|
7. Each upload triggers a server-side compression job; once the preview is ready the feed
|
||||||
|
updates via `upload-processed` SSE — placeholders swap for actual previews.
|
||||||
|
|
||||||
|
## 6. Posting under rate limits
|
||||||
|
|
||||||
|
1. Hit the per-hour upload limit (default 10 / hour, configurable).
|
||||||
|
2. Server returns **HTTP 429** with a `Retry-After` header on the next upload attempt.
|
||||||
|
3. Client parks pending items in **Wartend** state and shows an amber banner:
|
||||||
|
*"Upload-Limit erreicht. Wird in Xs automatisch fortgesetzt."*
|
||||||
|
4. Countdown ticks down. When it reaches 0, the queue resumes automatically.
|
||||||
|
|
||||||
|
## 7. Liking and commenting
|
||||||
|
|
||||||
|
1. Tap the heart icon on a card or in the lightbox → like is recorded; count increments
|
||||||
|
optimistically; server returns the canonical count via `like-update` SSE.
|
||||||
|
2. Tap the comment icon → opens the lightbox with the comments list.
|
||||||
|
3. Type a comment → `POST /api/v1/upload/{id}/comment`. Hashtags inside the comment are
|
||||||
|
parsed and attached.
|
||||||
|
4. The user can delete their own comments (trash icon next to them).
|
||||||
|
|
||||||
|
## 8. Filtering the gallery
|
||||||
|
|
||||||
|
1. Toggle to **grid view** (icon top-right of the feed header).
|
||||||
|
2. A search bar appears below the header (auto-focused).
|
||||||
|
3. Type a name or `#hashtag` — autocomplete suggestions are derived **in memory** from the
|
||||||
|
loaded uploads.
|
||||||
|
4. Tap a suggestion → it becomes an **active filter chip** and the search bar clears.
|
||||||
|
5. Filter logic:
|
||||||
|
- Multiple hashtag chips: OR
|
||||||
|
- Multiple uploader chips: OR
|
||||||
|
- One uploader + one hashtag: AND
|
||||||
|
6. Open a post → swipe in the lightbox navigates the **filtered set**, not the full feed.
|
||||||
|
|
||||||
|
## 9. Hosting the event — moderation
|
||||||
|
|
||||||
|
1. Host opens **My Account** → taps **⭐ Host-Dashboard**.
|
||||||
|
2. **Stats section** — guest count, upload count, lock status, release status.
|
||||||
|
3. **Event settings** — toggle to lock new uploads (likes / comments / browsing stay open;
|
||||||
|
broadcasts `event-closed` SSE so all clients show a "uploads are locked" banner).
|
||||||
|
4. **Galerie freigeben** — releases the export. Enqueues two export jobs (ZIP + HTML
|
||||||
|
viewer). Progress is visible in the Admin dashboard's Export tab; SSE
|
||||||
|
`export-progress` keeps it live; `export-available` notifies all guests when ready.
|
||||||
|
5. **Nutzerverwaltung** — search users; per-user controls:
|
||||||
|
- **Sperren** opens a confirmation modal with a checkbox "Uploads aus der Galerie
|
||||||
|
ausblenden" — Host chooses whether to hide the user's existing uploads or leave them
|
||||||
|
visible. Submitting calls `POST /host/users/{id}/ban` with `hide_uploads`.
|
||||||
|
- **Entsperren** lifts the ban.
|
||||||
|
- **Host** promotes a guest to host.
|
||||||
|
- **Degradieren** — visible on Host rows. A Host can demote *other* Hosts back to
|
||||||
|
guest (planned). The button is hidden on the Host's own row to prevent self-lockout;
|
||||||
|
only an Admin can demote themselves out of moderation. Admins see Degradieren on
|
||||||
|
every Host row.
|
||||||
|
- **PIN zurücksetzen** (planned) — generates a new PIN and shows it once in a modal.
|
||||||
|
See journey §4. Hosts see this on Guest rows only; Admins see it on Guest + Host
|
||||||
|
rows.
|
||||||
|
6. **Deleting content** — Host can delete any upload or comment via the moderation routes
|
||||||
|
(`DELETE /host/upload/{id}`, `DELETE /host/comment/{id}`). On mobile this is also
|
||||||
|
reachable by long-pressing the content (planned, see §15).
|
||||||
|
|
||||||
|
## 10. Banned-guest experience
|
||||||
|
|
||||||
|
1. The banned user's next authenticated request returns HTTP 403 with a clear message
|
||||||
|
("Du bist gesperrt.").
|
||||||
|
2. They can still browse the read-only feed (and download the export once it's released).
|
||||||
|
3. They cannot upload, like, or comment.
|
||||||
|
4. If `hide_uploads` was set on the ban, their existing uploads are filtered out of the
|
||||||
|
feed for everyone (the `v_feed` view already enforces this).
|
||||||
|
|
||||||
|
## 11. Admin — instance configuration
|
||||||
|
|
||||||
|
1. Admin opens `/admin/login`, types the admin password (compared against
|
||||||
|
`ADMIN_PASSWORD_HASH`). Receives a separate 1-day admin JWT (in `sessionStorage`).
|
||||||
|
2. Admin dashboard has four inner tabs:
|
||||||
|
- **Stats**: live counts and disk-usage widget (via `sysinfo`).
|
||||||
|
- **Config**: per-file limits (image MB / video MB), rate limits (upload / feed /
|
||||||
|
export), quota tolerance, estimated guest count, compression-worker concurrency,
|
||||||
|
plus the **Datenschutzhinweis** free-text editor and **on/off toggles** for the rate
|
||||||
|
limiters and quotas (planned — see §16). Whitelist on the server side rejects
|
||||||
|
unknown keys. Values are read from the `config` table on each request — no restart
|
||||||
|
needed.
|
||||||
|
- **Export**: list of past export jobs with status badges (pending / running / done /
|
||||||
|
failed) and progress bars; refresh button re-polls.
|
||||||
|
- **Nutzer**: same user list as Host, with the additional Demote action and (planned)
|
||||||
|
PIN-reset on host rows.
|
||||||
|
|
||||||
|
## 12. Releasing the export and downloading
|
||||||
|
|
||||||
|
1. Host (or Admin) taps **Galerie freigeben** in the dashboard.
|
||||||
|
2. Server sets `event.export_released_at` and enqueues two background jobs.
|
||||||
|
3. ZIP job: streams `Gallery.zip` (`Photos/` + `Videos/`, full-quality originals) directly
|
||||||
|
to disk via `async-zip`. Progress updates via `export-progress` SSE.
|
||||||
|
4. HTML-viewer job: copies the pre-built viewer assets from
|
||||||
|
[backend/static/export-viewer/](../backend/static/export-viewer/) (embedded via
|
||||||
|
`include_dir!`), generates `data.json` from the database, processes `_thumb`/`_full`
|
||||||
|
variants for each upload, and assembles `Memories.zip`.
|
||||||
|
5. Both jobs complete → server broadcasts `export-available` SSE.
|
||||||
|
6. Any user opens `/export`:
|
||||||
|
- Before release: friendly "Export not yet available" banner.
|
||||||
|
- During generation: progress bars per artifact.
|
||||||
|
- After completion: two cards (**ZIP-Archiv** and **HTML-Viewer**) with download
|
||||||
|
buttons. Tapping the HTML download first shows an in-app guide modal explaining:
|
||||||
|
"Entpacke die ZIP, öffne `index.html`". Tapping **Herunterladen** triggers the
|
||||||
|
browser download.
|
||||||
|
7. Downloads are rate-limited per IP (default 3 / day).
|
||||||
|
|
||||||
|
## 13. Diashow (planned)
|
||||||
|
|
||||||
|
See [CONCEPT_DIASHOW.md](CONCEPT_DIASHOW.md). Summary of the planned flow:
|
||||||
|
|
||||||
|
1. User taps a **Diashow / Präsentation** action (feed header on tablet/desktop, Account
|
||||||
|
on mobile).
|
||||||
|
2. Navigates to `/diashow` — fullscreen, bottom nav hidden, screen wake-lock acquired.
|
||||||
|
3. Initial pool fetched from `GET /api/v1/feed`. Slides crossfade every ~6 s.
|
||||||
|
4. New uploads (`upload-processed` SSE) push to a live queue; the next slide transition
|
||||||
|
pops from the live queue first, otherwise from a shuffled queue.
|
||||||
|
5. `upload-deleted` removes that ID from both queues; if it's the current slide, advance
|
||||||
|
immediately.
|
||||||
|
6. Tap or Escape reveals an overlay (pause, dwell selector, exit).
|
||||||
|
|
||||||
|
## 14. Picking a data mode (planned)
|
||||||
|
|
||||||
|
1. Guest opens **My Account** → scrolls to **Datennutzung**.
|
||||||
|
2. Two options: **Datensparer (empfohlen)** and **Original**. Saver is the default.
|
||||||
|
3. Selecting **Original** shows a one-time warning bottom-sheet:
|
||||||
|
*"Original-Dateien werden geladen — das kann deine mobile Datennutzung deutlich
|
||||||
|
erhöhen. Trotzdem aktivieren?"* with **Abbrechen** / **Aktivieren** buttons.
|
||||||
|
4. Choice persists in `localStorage` (per-device). The feed, lightbox, and diashow all
|
||||||
|
read this flag and load originals instead of compressed previews when Original is on.
|
||||||
|
5. The viewer (offline HTML export) is unaffected — it already ships with its own pre-
|
||||||
|
bundled `_thumb` / `_full` variants.
|
||||||
|
|
||||||
|
## 15. Leaving an event
|
||||||
|
|
||||||
|
1. User opens **My Account** → taps **🚪 Event verlassen**.
|
||||||
|
2. Bottom-sheet confirmation: "Event verlassen?" with **Abmelden** and **Bleiben**.
|
||||||
|
3. Confirming calls `DELETE /api/v1/session` (invalidates the session row), clears the JWT
|
||||||
|
and PIN from `localStorage`, and redirects to the join page.
|
||||||
|
|
||||||
|
## 16. Reading the Datenschutzhinweis (planned)
|
||||||
|
|
||||||
|
1. User opens **My Account** → scrolls to **Datenschutzhinweis**.
|
||||||
|
2. The note is rendered inside a preformatted block (`<pre>`-style: monospace, whitespace
|
||||||
|
and newlines preserved exactly as the Admin typed them). No HTML, no markdown — the
|
||||||
|
admin's plain text is shown verbatim.
|
||||||
|
3. The first-visit onboarding overlay carries a one-line reminder of where to find this:
|
||||||
|
*"Datenschutzhinweis findest du in deinem Account."*
|
||||||
|
4. Admin sets / edits the note in **Admin Dashboard → Config → Datenschutzhinweis**: a
|
||||||
|
tall textarea with a save button. Saved to a single `config` key.
|
||||||
|
|
||||||
|
## 17. Mobile-first gestures (planned)
|
||||||
|
|
||||||
|
EventSnap's UI is mobile-first; gestures replace explicit buttons where they're more
|
||||||
|
ergonomic. Buttons are always present as fallback for desktop and accessibility.
|
||||||
|
|
||||||
|
| Gesture | Action |
|
||||||
|
|-------------------------------------------|-------------------------------------------------------|
|
||||||
|
| Long-press on a post (own) | Bottom sheet → Löschen, Original anzeigen, Teilen |
|
||||||
|
| Long-press on a post (other) | Bottom sheet → Original anzeigen, Teilen, Melden (planned) |
|
||||||
|
| Long-press on a comment (own) | Bottom sheet → Löschen |
|
||||||
|
| Long-press on a comment (other) | Bottom sheet → Kopieren |
|
||||||
|
| Long-press on a user row (Host) | Bottom sheet → Sperren, Promote/Demote, PIN zurücksetzen |
|
||||||
|
| Swipe left/right in the lightbox | Navigate the filtered set |
|
||||||
|
| Swipe down on any bottom sheet | Dismiss |
|
||||||
|
| Pull-to-refresh on the feed | Force a delta-fetch |
|
||||||
|
| Double-tap on a post | Like (heart-burst animation) |
|
||||||
|
|
||||||
|
On desktop the same actions surface as kebab/⋯ menus, click-able icons in card corners,
|
||||||
|
and keyboard shortcuts in the lightbox (← → for navigate, Esc to close).
|
||||||
|
|
||||||
|
Inspiration: Instagram (double-tap heart, swipe stories), WhatsApp (long-press for
|
||||||
|
context), Telegram (swipe-to-reply on messages — could inform comment threads if those
|
||||||
|
land).
|
||||||
|
|
||||||
|
## 18. Admin toggles a rate limit or quota off (planned)
|
||||||
|
|
||||||
|
1. Admin opens **Admin Dashboard → Config**.
|
||||||
|
2. **Rate-Limits** section: a master switch and per-endpoint switches (upload / feed /
|
||||||
|
export / join).
|
||||||
|
3. Admin flips, e.g., **Upload-Limit aktiv** off. The numeric input for "uploads per hour"
|
||||||
|
stays visible but greyed out (still editable for when the toggle goes back on).
|
||||||
|
4. **Speichern** persists to the `config` table. The next upload request bypasses the
|
||||||
|
limiter entirely.
|
||||||
|
5. **Quoten** section mirrors the pattern: master toggle plus per-area toggles (storage
|
||||||
|
bytes / upload count).
|
||||||
|
6. When the storage-quota toggle is off, the **"X von Y MB genutzt"** widget in the
|
||||||
|
guest's My Account and upload screen hides itself (no quota → no number to show).
|
||||||
|
|
||||||
|
Suggested defaults at deploy time: all toggles **on**, sensible numeric limits.
|
||||||
|
Toggling off is the explicit escape hatch for testing or trusted internal events.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Edge cases worth knowing
|
||||||
|
|
||||||
|
| Case | Behaviour |
|
||||||
|
|-------------------------------------------------------|---------------------------------------------------------------------------------|
|
||||||
|
| Browser tab backgrounded for > 5 min | SSE closes on `visibilitychange: hidden`; reopens on visible |
|
||||||
|
| Upload finishes while user is on `/account` | Feed updates anyway — the queue + SSE are global stores |
|
||||||
|
| Event "closed" while files are still in the queue | Server rejects with a friendly error; client surfaces it in the queue UI |
|
||||||
|
| Network drops mid-upload | Queue retries the file; retry button available on permanent failure |
|
||||||
|
| New device but the PIN was lost | Either re-join under a new name, or Host manually re-links (no self-service) |
|
||||||
|
| Two guests pick the same name | Second one is offered the PIN-recovery form (case-insensitive UNIQUE, mig. 007) |
|
||||||
|
| Compression fails for a file | Server emits `upload-error` SSE; the upload is still listed but marked degraded |
|
||||||
|
| User deletes their own post (once UI is shipped) | Soft delete (`deleted_at`); SSE `upload-deleted`; vanishes from feed everywhere |
|
||||||
@@ -1 +1,3 @@
|
|||||||
@import "tailwindcss";
|
/* Pulls the live app's design tokens so the offline keepsake matches visually.
|
||||||
|
* See ../../src/tailwind-theme.css for the source of truth. */
|
||||||
|
@import "../../src/tailwind-theme.css";
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
@import "tailwindcss";
|
@import "./tailwind-theme.css";
|
||||||
|
|||||||
@@ -4,6 +4,22 @@
|
|||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="text-scale" content="scale" />
|
<meta name="text-scale" content="scale" />
|
||||||
|
<meta name="theme-color" content="#ffffff" />
|
||||||
|
<!--
|
||||||
|
FOUC guard: apply the dark class *before* paint, so reloads of pages with
|
||||||
|
theme=dark don't flash a white screen. Mirrors the logic in
|
||||||
|
`src/lib/theme-store.ts`; kept in sync by hand (it's 6 lines).
|
||||||
|
-->
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
try {
|
||||||
|
var pref = localStorage.getItem('eventsnap_theme') || 'system';
|
||||||
|
var dark = pref === 'dark' ||
|
||||||
|
(pref === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||||
|
if (dark) document.documentElement.classList.add('dark');
|
||||||
|
} catch (_) {}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
<body data-sveltekit-preload-data="hover">
|
<body data-sveltekit-preload-data="hover">
|
||||||
|
|||||||
34
frontend/src/lib/README.md
Normal file
34
frontend/src/lib/README.md
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# `lib/` conventions
|
||||||
|
|
||||||
|
Short rules. The patterns we already follow as of v0.16 — write new code that fits.
|
||||||
|
|
||||||
|
**One store per cross-cutting concern.** A single `*-store.ts` file owns each one:
|
||||||
|
- `auth.ts` — JWT / PIN in `localStorage`, `isAuthenticated` writable
|
||||||
|
- `ui-store.ts` — bottom-nav visibility, upload-sheet open state, FAB badge count
|
||||||
|
- `data-mode-store.ts` — Saver vs Original media-loading preference
|
||||||
|
- `privacy-note-store.ts` — admin-configured Datenschutzhinweis text
|
||||||
|
- `quota-store.ts` — live per-user storage snapshot
|
||||||
|
- `upload-queue.ts` — IndexedDB-persisted upload queue + processing state
|
||||||
|
|
||||||
|
Don't import these into other stores unless strictly necessary; let pages compose them.
|
||||||
|
|
||||||
|
**DTOs mirror Rust types.** All TS interfaces live in `types.ts`. Each one carries a
|
||||||
|
`// mirrors backend/src/path::TypeName` comment so the two stay searchable. If you add
|
||||||
|
a Rust DTO, add the TS twin in the same PR.
|
||||||
|
|
||||||
|
**Gestures via Svelte actions in `actions/`.** Long-press, double-tap, future swipe —
|
||||||
|
each is a `use:` action that fires a CustomEvent. Components stay free of gesture
|
||||||
|
plumbing.
|
||||||
|
|
||||||
|
**Reusable bottom sheets via `ContextSheet.svelte`.** Pass an `actions: ContextAction[]`
|
||||||
|
array. Any page that needs a long-press / kebab context menu uses the same primitive.
|
||||||
|
|
||||||
|
**SSE relays are listed in `sse.ts::KNOWN_EVENTS`.** New server event → add one entry
|
||||||
|
to that array, that's it.
|
||||||
|
|
||||||
|
**Diashow transitions live in `diashow/transitions/`.** Each is a Svelte component
|
||||||
|
plus one entry in `transitions/index.ts`. Adding a new animation is two-line work; no
|
||||||
|
diashow code needs to change.
|
||||||
|
|
||||||
|
**No new global stores** beyond the list above unless the new concept is genuinely
|
||||||
|
app-wide. Page state belongs in the page.
|
||||||
55
frontend/src/lib/actions/doubletap.ts
Normal file
55
frontend/src/lib/actions/doubletap.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
// Svelte action — fires a `doubletap` CustomEvent when two pointerup events occur
|
||||||
|
// within `interval` ms on roughly the same spot. Used in the lightbox for the
|
||||||
|
// Instagram-style "double-tap to like" gesture.
|
||||||
|
//
|
||||||
|
// Native `dblclick` exists, but on iOS Safari it also zooms the page; gating on
|
||||||
|
// pointer events lets us preventDefault selectively and avoid the zoom.
|
||||||
|
|
||||||
|
import type { ActionReturn } from 'svelte/action';
|
||||||
|
|
||||||
|
const INTERVAL_MS = 300;
|
||||||
|
const MOVE_THRESHOLD = 12;
|
||||||
|
|
||||||
|
export interface DoubletapOptions {
|
||||||
|
interval?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DoubletapAttributes {
|
||||||
|
'ondoubletap'?: (event: CustomEvent<void>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function doubletap(
|
||||||
|
node: HTMLElement,
|
||||||
|
options: DoubletapOptions = {}
|
||||||
|
): ActionReturn<DoubletapOptions, DoubletapAttributes> {
|
||||||
|
let interval = options.interval ?? INTERVAL_MS;
|
||||||
|
let lastTime = 0;
|
||||||
|
let lastX = 0;
|
||||||
|
let lastY = 0;
|
||||||
|
|
||||||
|
const onPointerUp = (e: PointerEvent) => {
|
||||||
|
const now = performance.now();
|
||||||
|
const dx = Math.abs(e.clientX - lastX);
|
||||||
|
const dy = Math.abs(e.clientY - lastY);
|
||||||
|
if (now - lastTime < interval && dx < MOVE_THRESHOLD && dy < MOVE_THRESHOLD) {
|
||||||
|
e.preventDefault();
|
||||||
|
node.dispatchEvent(new CustomEvent('doubletap'));
|
||||||
|
lastTime = 0; // reset so a triple-tap doesn't re-fire
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastTime = now;
|
||||||
|
lastX = e.clientX;
|
||||||
|
lastY = e.clientY;
|
||||||
|
};
|
||||||
|
|
||||||
|
node.addEventListener('pointerup', onPointerUp);
|
||||||
|
|
||||||
|
return {
|
||||||
|
update(newOptions) {
|
||||||
|
interval = newOptions.interval ?? INTERVAL_MS;
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
node.removeEventListener('pointerup', onPointerUp);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
111
frontend/src/lib/actions/longpress.ts
Normal file
111
frontend/src/lib/actions/longpress.ts
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
// Svelte action — fires a `longpress` CustomEvent when the user holds the pointer
|
||||||
|
// down for `duration` ms without moving too far.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// <div use:longpress={{ duration: 500 }} onlongpress={() => openMenu()} />
|
||||||
|
//
|
||||||
|
// Cancels on:
|
||||||
|
// - pointerup before duration elapses
|
||||||
|
// - pointermove > MOVE_THRESHOLD pixels (so a scroll attempt doesn't accidentally
|
||||||
|
// trigger the menu)
|
||||||
|
// - pointercancel / pointerleave / contextmenu
|
||||||
|
//
|
||||||
|
// When the long-press fires we also swallow the *next* click that comes from the same
|
||||||
|
// pointer-release. Without this, holding on a post card would (a) open the context
|
||||||
|
// sheet and then (b) trigger the post's onclick → lightbox at pointer-up. Same goes
|
||||||
|
// for the native contextmenu event on long-press desktop right-click.
|
||||||
|
|
||||||
|
import type { ActionReturn } from 'svelte/action';
|
||||||
|
|
||||||
|
const MOVE_THRESHOLD = 10; // px
|
||||||
|
|
||||||
|
export interface LongpressOptions {
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LongpressAttributes {
|
||||||
|
'onlongpress'?: (event: CustomEvent<void>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function longpress(
|
||||||
|
node: HTMLElement,
|
||||||
|
options: LongpressOptions = {}
|
||||||
|
): ActionReturn<LongpressOptions, LongpressAttributes> {
|
||||||
|
let duration = options.duration ?? 500;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let startX = 0;
|
||||||
|
let startY = 0;
|
||||||
|
/** Set true when the long-press fires; the very next click is then swallowed. */
|
||||||
|
let suppressNextClick = false;
|
||||||
|
|
||||||
|
const cancel = () => {
|
||||||
|
if (timer !== null) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fireLongpress = () => {
|
||||||
|
suppressNextClick = true;
|
||||||
|
// Reset the flag on the next event loop if no click followed — protects against
|
||||||
|
// the case where the user lifts their finger by lifting the device, no click.
|
||||||
|
setTimeout(() => {
|
||||||
|
suppressNextClick = false;
|
||||||
|
}, 400);
|
||||||
|
node.dispatchEvent(new CustomEvent('longpress'));
|
||||||
|
timer = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerDown = (e: PointerEvent) => {
|
||||||
|
startX = e.clientX;
|
||||||
|
startY = e.clientY;
|
||||||
|
suppressNextClick = false;
|
||||||
|
cancel();
|
||||||
|
timer = setTimeout(fireLongpress, duration);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPointerMove = (e: PointerEvent) => {
|
||||||
|
if (timer === null) return;
|
||||||
|
const dx = Math.abs(e.clientX - startX);
|
||||||
|
const dy = Math.abs(e.clientY - startY);
|
||||||
|
if (dx > MOVE_THRESHOLD || dy > MOVE_THRESHOLD) cancel();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onClickCapture = (e: Event) => {
|
||||||
|
if (suppressNextClick) {
|
||||||
|
suppressNextClick = false;
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onContextMenu = (e: Event) => {
|
||||||
|
// Block the desktop right-click menu when we've already fired our own.
|
||||||
|
if (suppressNextClick) e.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
node.addEventListener('pointerdown', onPointerDown);
|
||||||
|
node.addEventListener('pointermove', onPointerMove);
|
||||||
|
node.addEventListener('pointerup', cancel);
|
||||||
|
node.addEventListener('pointerleave', cancel);
|
||||||
|
node.addEventListener('pointercancel', cancel);
|
||||||
|
node.addEventListener('contextmenu', onContextMenu);
|
||||||
|
// Capture phase so we beat the inner button's bubbling click handler.
|
||||||
|
node.addEventListener('click', onClickCapture, true);
|
||||||
|
|
||||||
|
return {
|
||||||
|
update(newOptions) {
|
||||||
|
duration = newOptions.duration ?? 500;
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
cancel();
|
||||||
|
node.removeEventListener('pointerdown', onPointerDown);
|
||||||
|
node.removeEventListener('pointermove', onPointerMove);
|
||||||
|
node.removeEventListener('pointerup', cancel);
|
||||||
|
node.removeEventListener('pointerleave', cancel);
|
||||||
|
node.removeEventListener('pointercancel', cancel);
|
||||||
|
node.removeEventListener('contextmenu', onContextMenu);
|
||||||
|
node.removeEventListener('click', onClickCapture, true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -8,6 +8,13 @@ const DISPLAY_NAME_KEY = 'eventsnap_display_name';
|
|||||||
|
|
||||||
export const isAuthenticated = writable(false);
|
export const isAuthenticated = writable(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reactive mirror of `localStorage[PIN_KEY]`. Subscribers (the My Account page) see
|
||||||
|
* the PIN change immediately when an SSE `pin-reset` event invalidates it from any
|
||||||
|
* route — keeps the displayed PIN consistent with the server hash.
|
||||||
|
*/
|
||||||
|
export const currentPin = writable<string | null>(null);
|
||||||
|
|
||||||
export function getToken(): string | null {
|
export function getToken(): string | null {
|
||||||
if (!browser) return null;
|
if (!browser) return null;
|
||||||
return localStorage.getItem(TOKEN_KEY);
|
return localStorage.getItem(TOKEN_KEY);
|
||||||
@@ -18,6 +25,17 @@ export function getPin(): string | null {
|
|||||||
return localStorage.getItem(PIN_KEY);
|
return localStorage.getItem(PIN_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the locally-cached recovery PIN. Called when the server resets it (host
|
||||||
|
* action) — the cached plaintext no longer matches the bcrypt hash, so showing it
|
||||||
|
* would mislead the user.
|
||||||
|
*/
|
||||||
|
export function clearPin(): void {
|
||||||
|
if (!browser) return;
|
||||||
|
localStorage.removeItem(PIN_KEY);
|
||||||
|
currentPin.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
export function getUserId(): string | null {
|
export function getUserId(): string | null {
|
||||||
if (!browser) return null;
|
if (!browser) return null;
|
||||||
return localStorage.getItem(USER_ID_KEY);
|
return localStorage.getItem(USER_ID_KEY);
|
||||||
@@ -42,7 +60,10 @@ export function getExpiry(): Date | null {
|
|||||||
export function setAuth(jwt: string, pin: string | null, userId: string, displayName?: string): void {
|
export function setAuth(jwt: string, pin: string | null, userId: string, displayName?: string): void {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
localStorage.setItem(TOKEN_KEY, jwt);
|
localStorage.setItem(TOKEN_KEY, jwt);
|
||||||
if (pin) localStorage.setItem(PIN_KEY, pin);
|
if (pin) {
|
||||||
|
localStorage.setItem(PIN_KEY, pin);
|
||||||
|
currentPin.set(pin);
|
||||||
|
}
|
||||||
localStorage.setItem(USER_ID_KEY, userId);
|
localStorage.setItem(USER_ID_KEY, userId);
|
||||||
if (displayName) localStorage.setItem(DISPLAY_NAME_KEY, displayName);
|
if (displayName) localStorage.setItem(DISPLAY_NAME_KEY, displayName);
|
||||||
isAuthenticated.set(true);
|
isAuthenticated.set(true);
|
||||||
@@ -70,4 +91,5 @@ export function getRole(): 'guest' | 'host' | 'admin' | null {
|
|||||||
export function initAuth(): void {
|
export function initAuth(): void {
|
||||||
if (!browser) return;
|
if (!browser) return;
|
||||||
isAuthenticated.set(!!getToken());
|
isAuthenticated.set(!!getToken());
|
||||||
|
currentPin.set(getPin());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
<!-- Bottom navigation bar — fixed, full-width, safe-area aware -->
|
<!-- Bottom navigation bar — fixed, full-width, safe-area aware -->
|
||||||
<nav
|
<nav
|
||||||
class="fixed bottom-0 left-0 right-0 z-40 border-t border-gray-200 bg-white/90 backdrop-blur-md"
|
class="fixed bottom-0 left-0 right-0 z-40 border-t border-gray-200 bg-white/90 backdrop-blur-md dark:border-gray-800 dark:bg-gray-900/90"
|
||||||
style="padding-bottom: env(safe-area-inset-bottom)"
|
style="padding-bottom: env(safe-area-inset-bottom)"
|
||||||
>
|
>
|
||||||
<div class="mx-auto flex h-14 max-w-2xl items-end justify-around px-4 pb-1">
|
<div class="mx-auto flex h-14 max-w-2xl items-end justify-around px-4 pb-1">
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
<a
|
<a
|
||||||
href="/feed"
|
href="/feed"
|
||||||
class="flex flex-col items-center gap-0.5 px-4 py-1 text-xs font-medium transition-colors
|
class="flex flex-col items-center gap-0.5 px-4 py-1 text-xs font-medium transition-colors
|
||||||
{isActive('/feed') ? 'text-blue-600' : 'text-gray-400 hover:text-gray-600'}"
|
{isActive('/feed') ? 'text-blue-600 dark:text-blue-400' : 'text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300'}"
|
||||||
aria-label="Galerie"
|
aria-label="Galerie"
|
||||||
>
|
>
|
||||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||||
@@ -53,7 +53,7 @@
|
|||||||
<a
|
<a
|
||||||
href="/account"
|
href="/account"
|
||||||
class="flex flex-col items-center gap-0.5 px-4 py-1 text-xs font-medium transition-colors
|
class="flex flex-col items-center gap-0.5 px-4 py-1 text-xs font-medium transition-colors
|
||||||
{isActive('/account') ? 'text-blue-600' : 'text-gray-400 hover:text-gray-600'}"
|
{isActive('/account') ? 'text-blue-600 dark:text-blue-400' : 'text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300'}"
|
||||||
aria-label="Konto"
|
aria-label="Konto"
|
||||||
>
|
>
|
||||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||||
|
|||||||
95
frontend/src/lib/components/ContextSheet.svelte
Normal file
95
frontend/src/lib/components/ContextSheet.svelte
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
// Action shape used by every long-press / kebab menu in the app. Defined in `module`
|
||||||
|
// so importers don't pay for instance state.
|
||||||
|
export interface ContextAction {
|
||||||
|
label: string;
|
||||||
|
icon?: string; // optional emoji or unicode glyph
|
||||||
|
tone?: 'default' | 'danger';
|
||||||
|
disabled?: boolean;
|
||||||
|
onClick: () => void | Promise<void>;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
// Reusable bottom-sheet for context menus. Consumers pass `open`, an array of
|
||||||
|
// `actions`, and a close callback. Mobile: long-press → open. Desktop: kebab
|
||||||
|
// icon → open. The sheet itself is platform-agnostic.
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
actions: ContextAction[];
|
||||||
|
onClose: () => void;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { open, actions, onClose, title }: Props = $props();
|
||||||
|
|
||||||
|
async function handle(action: ContextAction) {
|
||||||
|
if (action.disabled) return;
|
||||||
|
try {
|
||||||
|
await action.onClick();
|
||||||
|
} finally {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Backdrop -->
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-40 bg-black/50 transition-opacity duration-200"
|
||||||
|
class:opacity-0={!open}
|
||||||
|
class:pointer-events-none={!open}
|
||||||
|
class:opacity-100={open}
|
||||||
|
onclick={onClose}
|
||||||
|
aria-hidden="true"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<!-- Sheet -->
|
||||||
|
<div
|
||||||
|
class="fixed inset-x-0 bottom-0 z-50 rounded-t-2xl bg-white transition-transform duration-200 dark:bg-gray-900"
|
||||||
|
class:translate-y-full={!open}
|
||||||
|
class:translate-y-0={open}
|
||||||
|
style="padding-bottom: env(safe-area-inset-bottom)"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
>
|
||||||
|
<div class="flex justify-center pt-3 pb-1">
|
||||||
|
<div class="h-1 w-10 rounded-full bg-gray-300 dark:bg-gray-600"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if title}
|
||||||
|
<p class="px-5 pt-1 pb-2 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="space-y-1 px-3 pb-3 pt-1">
|
||||||
|
{#each actions as action (action.label)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={action.disabled}
|
||||||
|
onclick={() => handle(action)}
|
||||||
|
class="flex w-full items-center gap-3 rounded-xl px-4 py-3 text-left text-base transition active:bg-gray-100 disabled:opacity-50 dark:active:bg-gray-700"
|
||||||
|
class:text-gray-900={action.tone !== 'danger'}
|
||||||
|
class:dark:text-gray-100={action.tone !== 'danger'}
|
||||||
|
class:text-red-600={action.tone === 'danger'}
|
||||||
|
class:dark:text-red-400={action.tone === 'danger'}
|
||||||
|
class:hover:bg-gray-50={!action.disabled}
|
||||||
|
class:dark:hover:bg-gray-800={!action.disabled}
|
||||||
|
>
|
||||||
|
{#if action.icon}
|
||||||
|
<span class="text-xl leading-none">{action.icon}</span>
|
||||||
|
{/if}
|
||||||
|
<span class="font-medium">{action.label}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={onClose}
|
||||||
|
class="mt-2 w-full rounded-xl border border-gray-200 py-3 text-sm font-medium text-gray-600 transition hover:bg-gray-50 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,30 +1,40 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { FeedUpload } from '$lib/types';
|
import type { FeedUpload } from '$lib/types';
|
||||||
|
import { dataMode } from '$lib/data-mode-store';
|
||||||
|
import { longpress } from '$lib/actions/longpress';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
uploads: FeedUpload[];
|
uploads: FeedUpload[];
|
||||||
onlike: (id: string) => void;
|
onlike: (id: string) => void;
|
||||||
oncomment: (id: string) => void;
|
oncomment: (id: string) => void;
|
||||||
onselect: (upload: FeedUpload) => void;
|
onselect: (upload: FeedUpload) => void;
|
||||||
|
oncontextmenu?: (upload: FeedUpload) => void;
|
||||||
threeCol?: boolean;
|
threeCol?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { uploads, onlike, oncomment, onselect, threeCol = false }: Props = $props();
|
let { uploads, onlike, oncomment, onselect, oncontextmenu, threeCol = false }: Props =
|
||||||
|
$props();
|
||||||
|
|
||||||
function isVideo(mime: string): boolean {
|
function isVideo(mime: string): boolean {
|
||||||
return mime.startsWith('video/');
|
return mime.startsWith('video/');
|
||||||
}
|
}
|
||||||
|
|
||||||
function imageUrl(upload: FeedUpload): string {
|
// Grid uses small thumbnails by design even in Original mode — full media is one tap
|
||||||
|
// away in the lightbox, where the data-mode picker decides for real.
|
||||||
|
function tileUrl(upload: FeedUpload): string {
|
||||||
if (upload.thumbnail_url) return upload.thumbnail_url;
|
if (upload.thumbnail_url) return upload.thumbnail_url;
|
||||||
if (upload.preview_url) return upload.preview_url;
|
if (upload.preview_url) return upload.preview_url;
|
||||||
return '';
|
return $dataMode === 'original' ? `/api/v1/upload/${upload.id}/original` : '';
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="grid gap-0.5 {threeCol ? 'grid-cols-3' : 'grid-cols-2 sm:grid-cols-3'}">
|
<div class="grid gap-0.5 {threeCol ? 'grid-cols-3' : 'grid-cols-2 sm:grid-cols-3'}">
|
||||||
{#each uploads as upload (upload.id)}
|
{#each uploads as upload (upload.id)}
|
||||||
<div class="group relative aspect-square cursor-pointer overflow-hidden rounded-lg bg-gray-100">
|
<div
|
||||||
|
class="group relative aspect-square cursor-pointer overflow-hidden rounded-lg bg-gray-100 dark:bg-gray-800"
|
||||||
|
use:longpress={{ duration: 500 }}
|
||||||
|
onlongpress={() => oncontextmenu?.(upload)}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
onclick={() => onselect(upload)}
|
onclick={() => onselect(upload)}
|
||||||
class="block h-full w-full"
|
class="block h-full w-full"
|
||||||
@@ -32,8 +42,8 @@
|
|||||||
>
|
>
|
||||||
{#if isVideo(upload.mime_type)}
|
{#if isVideo(upload.mime_type)}
|
||||||
<div class="flex h-full items-center justify-center bg-gray-800">
|
<div class="flex h-full items-center justify-center bg-gray-800">
|
||||||
{#if imageUrl(upload)}
|
{#if tileUrl(upload)}
|
||||||
<img src={imageUrl(upload)} alt="" class="h-full w-full object-cover" />
|
<img src={tileUrl(upload)} alt="" class="h-full w-full object-cover" />
|
||||||
{/if}
|
{/if}
|
||||||
<div class="absolute inset-0 flex items-center justify-center">
|
<div class="absolute inset-0 flex items-center justify-center">
|
||||||
<svg class="h-10 w-10 text-white/80" fill="currentColor" viewBox="0 0 24 24">
|
<svg class="h-10 w-10 text-white/80" fill="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -41,8 +51,8 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else if imageUrl(upload)}
|
{:else if tileUrl(upload)}
|
||||||
<img src={imageUrl(upload)} alt="" class="h-full w-full object-cover" loading="lazy" />
|
<img src={tileUrl(upload)} alt="" class="h-full w-full object-cover" loading="lazy" />
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex h-full items-center justify-center text-gray-400">
|
<div class="flex h-full items-center justify-center text-gray-400">
|
||||||
<svg class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
|||||||
@@ -1,22 +1,32 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { FeedUpload } from '$lib/types';
|
import type { FeedUpload } from '$lib/types';
|
||||||
|
import { dataMode, pickMediaUrl } from '$lib/data-mode-store';
|
||||||
|
import { longpress } from '$lib/actions/longpress';
|
||||||
|
import { doubletap } from '$lib/actions/doubletap';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
upload: FeedUpload;
|
upload: FeedUpload;
|
||||||
|
isOwn?: boolean;
|
||||||
onlike: (id: string) => void;
|
onlike: (id: string) => void;
|
||||||
oncomment: (id: string) => void;
|
oncomment: (id: string) => void;
|
||||||
onselect: (upload: FeedUpload) => void;
|
onselect: (upload: FeedUpload) => void;
|
||||||
|
oncontextmenu?: (upload: FeedUpload) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { upload, onlike, oncomment, onselect }: Props = $props();
|
let {
|
||||||
|
upload,
|
||||||
|
isOwn = false,
|
||||||
|
onlike,
|
||||||
|
oncomment,
|
||||||
|
onselect,
|
||||||
|
oncontextmenu
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
function isVideo(mime: string): boolean {
|
function isVideo(mime: string): boolean {
|
||||||
return mime.startsWith('video/');
|
return mime.startsWith('video/');
|
||||||
}
|
}
|
||||||
|
|
||||||
function mediaUrl(u: FeedUpload): string {
|
const mediaSrc = $derived(pickMediaUrl($dataMode, upload));
|
||||||
return u.preview_url ?? u.thumbnail_url ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function relativeTime(iso: string): string {
|
function relativeTime(iso: string): string {
|
||||||
const diff = Date.now() - new Date(iso).getTime();
|
const diff = Date.now() - new Date(iso).getTime();
|
||||||
@@ -40,40 +50,68 @@
|
|||||||
'bg-green-100 text-green-700',
|
'bg-green-100 text-green-700',
|
||||||
'bg-amber-100 text-amber-700',
|
'bg-amber-100 text-amber-700',
|
||||||
'bg-rose-100 text-rose-700',
|
'bg-rose-100 text-rose-700',
|
||||||
'bg-teal-100 text-teal-700',
|
'bg-teal-100 text-teal-700'
|
||||||
];
|
];
|
||||||
function avatarColor(name: string): string {
|
function avatarColor(name: string): string {
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
for (const ch of name) hash = (hash * 31 + ch.charCodeAt(0)) & 0xff;
|
for (const ch of name) hash = (hash * 31 + ch.charCodeAt(0)) & 0xff;
|
||||||
return COLORS[hash % COLORS.length];
|
return COLORS[hash % COLORS.length];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openContext() {
|
||||||
|
oncontextmenu?.(upload);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<article class="bg-white">
|
<article
|
||||||
|
class="bg-white dark:bg-gray-900"
|
||||||
|
use:longpress={{ duration: 500 }}
|
||||||
|
onlongpress={openContext}
|
||||||
|
>
|
||||||
<!-- Uploader row -->
|
<!-- Uploader row -->
|
||||||
<div class="flex items-center gap-3 px-4 py-3">
|
<div class="flex items-center justify-between gap-3 px-4 py-3">
|
||||||
<div
|
<div class="flex min-w-0 items-center gap-3">
|
||||||
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-sm font-bold
|
<div
|
||||||
{avatarColor(upload.uploader_name)}"
|
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-sm font-bold
|
||||||
>
|
{avatarColor(upload.uploader_name)}"
|
||||||
{initial(upload.uploader_name)}
|
>
|
||||||
</div>
|
{initial(upload.uploader_name)}
|
||||||
<div class="min-w-0">
|
</div>
|
||||||
<p class="truncate text-sm font-semibold text-gray-900">{upload.uploader_name}</p>
|
<div class="min-w-0">
|
||||||
<p class="text-xs text-gray-400">{relativeTime(upload.created_at)}</p>
|
<p class="truncate text-sm font-semibold text-gray-900 dark:text-gray-100">{upload.uploader_name}</p>
|
||||||
|
<p class="text-xs text-gray-400 dark:text-gray-500">{relativeTime(upload.created_at)}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if oncontextmenu}
|
||||||
|
<!-- Desktop kebab — same actions as the mobile long-press context sheet. -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={(e) => { e.stopPropagation(); openContext(); }}
|
||||||
|
class="rounded-full p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-500 dark:hover:bg-gray-800 dark:hover:text-gray-200"
|
||||||
|
aria-label="Mehr Aktionen"
|
||||||
|
>
|
||||||
|
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Media -->
|
<!-- Media -->
|
||||||
<button
|
<button
|
||||||
class="block w-full"
|
class="block w-full"
|
||||||
onclick={() => onselect(upload)}
|
onclick={() => onselect(upload)}
|
||||||
|
use:doubletap
|
||||||
|
ondoubletap={() => onlike(upload.id)}
|
||||||
aria-label="Bild vergrößern"
|
aria-label="Bild vergrößern"
|
||||||
>
|
>
|
||||||
{#if isVideo(upload.mime_type)}
|
{#if isVideo(upload.mime_type)}
|
||||||
<div class="relative aspect-video w-full bg-gray-900">
|
<div class="relative aspect-video w-full bg-gray-900">
|
||||||
{#if mediaUrl(upload)}
|
{#if upload.thumbnail_url || upload.preview_url}
|
||||||
<img src={mediaUrl(upload)} alt="" class="h-full w-full object-cover opacity-80" />
|
<img
|
||||||
|
src={upload.thumbnail_url ?? upload.preview_url ?? ''}
|
||||||
|
alt=""
|
||||||
|
class="h-full w-full object-cover opacity-80"
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
<div class="absolute inset-0 flex items-center justify-center">
|
<div class="absolute inset-0 flex items-center justify-center">
|
||||||
<span class="flex h-14 w-14 items-center justify-center rounded-full bg-black/50 text-white">
|
<span class="flex h-14 w-14 items-center justify-center rounded-full bg-black/50 text-white">
|
||||||
@@ -83,17 +121,17 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else if mediaUrl(upload)}
|
{:else if mediaSrc}
|
||||||
<img
|
<img
|
||||||
src={mediaUrl(upload)}
|
src={mediaSrc}
|
||||||
alt=""
|
alt=""
|
||||||
class="w-full object-cover"
|
class="w-full object-cover"
|
||||||
style="max-height: 80svh"
|
style="max-height: 80svh"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex aspect-square w-full items-center justify-center bg-gray-100">
|
<div class="flex aspect-square w-full items-center justify-center bg-gray-100 dark:bg-gray-800">
|
||||||
<svg class="h-12 w-12 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="h-12 w-12 text-gray-300 dark:text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
@@ -105,7 +143,7 @@
|
|||||||
<button
|
<button
|
||||||
onclick={() => onlike(upload.id)}
|
onclick={() => onlike(upload.id)}
|
||||||
class="flex items-center gap-1.5 text-sm font-medium transition-colors
|
class="flex items-center gap-1.5 text-sm font-medium transition-colors
|
||||||
{upload.liked_by_me ? 'text-red-500' : 'text-gray-500 hover:text-red-400'}"
|
{upload.liked_by_me ? 'text-red-500 dark:text-red-400' : 'text-gray-500 hover:text-red-400 dark:text-gray-400 dark:hover:text-red-400'}"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
class="h-5 w-5 {upload.liked_by_me ? 'fill-red-500' : ''}"
|
class="h-5 w-5 {upload.liked_by_me ? 'fill-red-500' : ''}"
|
||||||
@@ -120,21 +158,24 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onclick={() => oncomment(upload.id)}
|
onclick={() => oncomment(upload.id)}
|
||||||
class="flex items-center gap-1.5 text-sm font-medium text-gray-500 transition-colors hover:text-blue-500"
|
class="flex items-center gap-1.5 text-sm font-medium text-gray-500 transition-colors hover:text-blue-500 dark:text-gray-400 dark:hover:text-blue-400"
|
||||||
>
|
>
|
||||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
|
||||||
</svg>
|
</svg>
|
||||||
{upload.comment_count}
|
{upload.comment_count}
|
||||||
</button>
|
</button>
|
||||||
|
{#if isOwn}
|
||||||
|
<span class="ml-auto text-xs text-gray-400 dark:text-gray-500">Eigener Beitrag</span>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Caption -->
|
<!-- Caption -->
|
||||||
{#if upload.caption}
|
{#if upload.caption}
|
||||||
<p class="px-4 pb-3 text-sm text-gray-800 [overflow-wrap:anywhere]">
|
<p class="px-4 pb-3 text-sm text-gray-800 [overflow-wrap:anywhere] dark:text-gray-200">
|
||||||
{upload.caption}
|
{upload.caption}
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="border-b border-gray-100"></div>
|
<div class="border-b border-gray-100 dark:border-gray-800"></div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -19,8 +19,8 @@
|
|||||||
onclick={() => onselect(null)}
|
onclick={() => onselect(null)}
|
||||||
class="shrink-0 rounded-full px-3 py-1 text-sm font-medium transition {
|
class="shrink-0 rounded-full px-3 py-1 text-sm font-medium transition {
|
||||||
selected === null
|
selected === null
|
||||||
? 'bg-blue-600 text-white'
|
? 'bg-blue-600 text-white dark:bg-blue-500'
|
||||||
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
|
: 'bg-gray-200 text-gray-700 hover:bg-gray-300 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700'
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
Alle
|
Alle
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { FeedUpload } from '$lib/types';
|
import type { FeedUpload } from '$lib/types';
|
||||||
import { api, ApiError } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import { getUserId } from '$lib/auth';
|
import { getUserId } from '$lib/auth';
|
||||||
|
import { dataMode, pickMediaUrl } from '$lib/data-mode-store';
|
||||||
|
import { doubletap } from '$lib/actions/doubletap';
|
||||||
|
|
||||||
interface CommentDto {
|
interface CommentDto {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -24,6 +26,15 @@
|
|||||||
let newComment = $state('');
|
let newComment = $state('');
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
let userId = getUserId();
|
let userId = getUserId();
|
||||||
|
let heartBurst = $state(false);
|
||||||
|
|
||||||
|
const mediaSrc = $derived(pickMediaUrl($dataMode, upload));
|
||||||
|
|
||||||
|
function triggerHeartBurst() {
|
||||||
|
heartBurst = true;
|
||||||
|
onlike(upload.id);
|
||||||
|
setTimeout(() => (heartBurst = false), 700);
|
||||||
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
loadComments();
|
loadComments();
|
||||||
@@ -77,10 +88,19 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@keyframes heart-burst {
|
||||||
|
0% { transform: scale(0.5); opacity: 0; }
|
||||||
|
30% { transform: scale(1.2); opacity: 1; }
|
||||||
|
70% { transform: scale(1); opacity: 1; }
|
||||||
|
100% { transform: scale(1.4); opacity: 0; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
<svelte:window onkeydown={handleKeydown} />
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4" role="dialog">
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4" role="dialog">
|
||||||
<div class="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl bg-white">
|
<div class="flex max-h-[90vh] w-full max-w-2xl flex-col overflow-hidden rounded-xl bg-white dark:bg-gray-900">
|
||||||
<!-- Media -->
|
<!-- Media -->
|
||||||
<div class="relative bg-black">
|
<div class="relative bg-black">
|
||||||
<button onclick={onclose} class="absolute right-2 top-2 z-10 rounded-full bg-black/50 p-1.5 text-white hover:bg-black/70">
|
<button onclick={onclose} class="absolute right-2 top-2 z-10 rounded-full bg-black/50 p-1.5 text-white hover:bg-black/70">
|
||||||
@@ -88,36 +108,59 @@
|
|||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{#if isVideo(upload.mime_type)}
|
<div
|
||||||
<video
|
class="relative"
|
||||||
src={upload.preview_url ?? ''}
|
use:doubletap
|
||||||
controls
|
ondoubletap={triggerHeartBurst}
|
||||||
class="max-h-[50vh] w-full object-contain"
|
>
|
||||||
poster={upload.thumbnail_url ?? undefined}
|
{#if isVideo(upload.mime_type)}
|
||||||
></video>
|
<video
|
||||||
{:else}
|
src={mediaSrc}
|
||||||
<img
|
controls
|
||||||
src={upload.preview_url ?? ''}
|
class="max-h-[50vh] w-full object-contain"
|
||||||
alt=""
|
poster={upload.thumbnail_url ?? undefined}
|
||||||
class="max-h-[50vh] w-full object-contain"
|
></video>
|
||||||
/>
|
{:else}
|
||||||
{/if}
|
<img
|
||||||
|
src={mediaSrc}
|
||||||
|
alt=""
|
||||||
|
class="max-h-[50vh] w-full object-contain select-none"
|
||||||
|
draggable="false"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if heartBurst}
|
||||||
|
<span
|
||||||
|
class="pointer-events-none absolute inset-0 flex items-center justify-center"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="h-24 w-24 text-red-500 drop-shadow-lg"
|
||||||
|
style="animation: heart-burst 700ms ease-out forwards;"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path d="M12 21s-7-4.534-9.5-9.034C.5 9.466 2.5 5 7 5c2.09 0 3.534 1.083 5 3 1.466-1.917 2.91-3 5-3 4.5 0 6.5 4.466 4.5 6.966C19 16.466 12 21 12 21z" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Info + Comments -->
|
<!-- Info + Comments -->
|
||||||
<div class="flex flex-1 flex-col overflow-hidden">
|
<div class="flex flex-1 flex-col overflow-hidden">
|
||||||
<div class="border-b border-gray-100 p-3">
|
<div class="border-b border-gray-100 p-3 dark:border-gray-800">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<span class="font-medium text-gray-900">{upload.uploader_name}</span>
|
<span class="font-medium text-gray-900 dark:text-gray-100">{upload.uploader_name}</span>
|
||||||
<span class="ml-2 text-xs text-gray-400">{formatTime(upload.created_at)}</span>
|
<span class="ml-2 text-xs text-gray-400 dark:text-gray-500">{formatTime(upload.created_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onclick={() => onlike(upload.id)}
|
onclick={() => onlike(upload.id)}
|
||||||
class="flex items-center gap-1 rounded-full px-2.5 py-1 text-sm transition {
|
class="flex items-center gap-1 rounded-full px-2.5 py-1 text-sm transition {
|
||||||
upload.liked_by_me
|
upload.liked_by_me
|
||||||
? 'bg-red-50 text-red-600'
|
? 'bg-red-50 text-red-600 dark:bg-red-950/40 dark:text-red-300'
|
||||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<svg class="h-4 w-4 {upload.liked_by_me ? 'fill-current' : ''}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="h-4 w-4 {upload.liked_by_me ? 'fill-current' : ''}" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
@@ -127,27 +170,27 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{#if upload.caption}
|
{#if upload.caption}
|
||||||
<p class="mt-1 text-sm text-gray-700">{upload.caption}</p>
|
<p class="mt-1 text-sm text-gray-700 dark:text-gray-300">{upload.caption}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Comments list -->
|
<!-- Comments list -->
|
||||||
<div class="flex-1 overflow-y-auto p-3">
|
<div class="flex-1 overflow-y-auto p-3">
|
||||||
{#if comments.length === 0}
|
{#if comments.length === 0}
|
||||||
<p class="text-center text-sm text-gray-400">Noch keine Kommentare.</p>
|
<p class="text-center text-sm text-gray-400 dark:text-gray-500">Noch keine Kommentare.</p>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
{#each comments as comment (comment.id)}
|
{#each comments as comment (comment.id)}
|
||||||
<div class="flex items-start gap-2">
|
<div class="flex items-start gap-2">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
<span class="text-sm font-medium text-gray-900">{comment.uploader_name}</span>
|
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">{comment.uploader_name}</span>
|
||||||
<span class="ml-1 text-sm text-gray-700">{comment.body}</span>
|
<span class="ml-1 text-sm text-gray-700 dark:text-gray-300">{comment.body}</span>
|
||||||
<div class="mt-0.5 text-xs text-gray-400">{formatTime(comment.created_at)}</div>
|
<div class="mt-0.5 text-xs text-gray-400 dark:text-gray-500">{formatTime(comment.created_at)}</div>
|
||||||
</div>
|
</div>
|
||||||
{#if comment.user_id === userId}
|
{#if comment.user_id === userId}
|
||||||
<button
|
<button
|
||||||
onclick={() => deleteComment(comment.id)}
|
onclick={() => deleteComment(comment.id)}
|
||||||
class="shrink-0 text-gray-400 hover:text-red-500"
|
class="shrink-0 text-gray-400 hover:text-red-500 dark:text-gray-500 dark:hover:text-red-400"
|
||||||
aria-label="Löschen"
|
aria-label="Löschen"
|
||||||
>
|
>
|
||||||
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
@@ -164,19 +207,19 @@
|
|||||||
<!-- Comment input -->
|
<!-- Comment input -->
|
||||||
<form
|
<form
|
||||||
onsubmit={(e) => { e.preventDefault(); submitComment(); }}
|
onsubmit={(e) => { e.preventDefault(); submitComment(); }}
|
||||||
class="flex gap-2 border-t border-gray-100 p-3"
|
class="flex gap-2 border-t border-gray-100 p-3 dark:border-gray-800"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
bind:value={newComment}
|
bind:value={newComment}
|
||||||
placeholder="Kommentar schreiben..."
|
placeholder="Kommentar schreiben..."
|
||||||
maxlength={500}
|
maxlength={500}
|
||||||
class="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none"
|
class="flex-1 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-blue-500 focus:outline-none dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-500"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading || !newComment.trim()}
|
disabled={loading || !newComment.trim()}
|
||||||
class="rounded-lg bg-blue-600 px-3 py-2 text-sm font-medium text-white transition hover:bg-blue-700 disabled:opacity-50"
|
class="rounded-lg bg-blue-600 px-3 py-2 text-sm font-medium text-white transition hover:bg-blue-700 disabled:opacity-50 dark:bg-blue-500 dark:hover:bg-blue-400"
|
||||||
>
|
>
|
||||||
Senden
|
Senden
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,32 +1,63 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
|
import { privacyNote } from '$lib/privacy-note-store';
|
||||||
|
import { themePreference, type ThemePreference } from '$lib/theme-store';
|
||||||
|
|
||||||
const GUIDE_SEEN_KEY = 'eventsnap_guide_seen';
|
const GUIDE_SEEN_KEY = 'eventsnap_guide_seen';
|
||||||
|
|
||||||
|
type Step =
|
||||||
|
| { kind: 'text'; icon: string; title: string; body: string }
|
||||||
|
| { kind: 'theme'; icon: string; title: string };
|
||||||
|
|
||||||
let visible = $state(false);
|
let visible = $state(false);
|
||||||
let step = $state(0);
|
let step = $state(0);
|
||||||
|
|
||||||
const steps = [
|
// The PIN step gets an extra line when the admin has set a Datenschutzhinweis. Done
|
||||||
|
// reactively so the nudge appears as soon as the note loads from `/me/context`,
|
||||||
|
// even if the user opens the onboarding before the request returns.
|
||||||
|
let hasPrivacyNote = $derived($privacyNote.trim().length > 0);
|
||||||
|
|
||||||
|
let steps: Step[] = $derived([
|
||||||
{
|
{
|
||||||
|
kind: 'text',
|
||||||
icon: '📸',
|
icon: '📸',
|
||||||
title: 'Willkommen bei EventSnap!',
|
title: 'Willkommen bei EventSnap!',
|
||||||
body: 'Hier kannst du Fotos und Videos mit allen Gästen teilen — in Echtzeit, ganz ohne App-Store.'
|
body: 'Hier kannst du Fotos und Videos mit allen Gästen teilen — in Echtzeit, ganz ohne App-Store.'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
kind: 'text',
|
||||||
icon: '⬆️',
|
icon: '⬆️',
|
||||||
title: 'Fotos & Videos hochladen',
|
title: 'Fotos & Videos hochladen',
|
||||||
body: 'Tippe auf den Plus-Button unten in der Mitte, um Fotos aus deiner Galerie zu wählen oder direkt mit der Kamera aufzunehmen. Mehrere Dateien auf einmal sind kein Problem!'
|
body: 'Tippe auf den Plus-Button unten in der Mitte, um Fotos aus deiner Galerie zu wählen oder direkt mit der Kamera aufzunehmen. Mehrere Dateien auf einmal sind kein Problem!'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
kind: 'text',
|
||||||
icon: '#️⃣',
|
icon: '#️⃣',
|
||||||
title: 'Hashtags nutzen',
|
title: 'Hashtags nutzen',
|
||||||
body: 'Füge in deiner Bildunterschrift #hashtags ein, um Fotos zu gruppieren — z.B. #tanz, #buffet oder #reden. Du kannst danach filtern.'
|
body: 'Füge in deiner Bildunterschrift #hashtags ein, um Fotos zu gruppieren — z.B. #tanz, #buffet oder #reden. Du kannst danach filtern.'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
kind: 'theme',
|
||||||
|
icon: '🌗',
|
||||||
|
title: 'Helles oder dunkles Design?'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'text',
|
||||||
icon: '🔑',
|
icon: '🔑',
|
||||||
title: 'Deinen PIN merken!',
|
title: 'Deinen PIN merken!',
|
||||||
body: 'Du hast beim Registrieren einen 4-stelligen PIN erhalten. Speichere ihn — du brauchst ihn, um dein Konto auf einem anderen Gerät wiederherzustellen. Er ist immer unter „Mein Konto" zu finden.'
|
body:
|
||||||
|
'Du hast beim Registrieren einen 4-stelligen PIN erhalten. Speichere ihn — du brauchst ihn, um dein Konto auf einem anderen Gerät wiederherzustellen. Er ist immer unter „Mein Konto" zu finden.' +
|
||||||
|
(hasPrivacyNote ? ' Den Datenschutzhinweis findest du ebenfalls unter „Mein Konto".' : '')
|
||||||
}
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Derived in the script so TypeScript can narrow on `.kind` inside the template.
|
||||||
|
let currentStep = $derived(steps[step]);
|
||||||
|
|
||||||
|
const THEME_OPTIONS: Array<{ value: ThemePreference; label: string; hint: string; icon: string }> = [
|
||||||
|
{ value: 'system', label: 'System', hint: 'Folgt der Geräteeinstellung', icon: '🖥️' },
|
||||||
|
{ value: 'light', label: 'Hell', hint: 'Heller Hintergrund', icon: '☀️' },
|
||||||
|
{ value: 'dark', label: 'Dunkel', hint: 'Dunkler Hintergrund', icon: '🌙' }
|
||||||
];
|
];
|
||||||
|
|
||||||
if (browser && !localStorage.getItem(GUIDE_SEEN_KEY)) {
|
if (browser && !localStorage.getItem(GUIDE_SEEN_KEY)) {
|
||||||
@@ -50,32 +81,80 @@
|
|||||||
{#if visible}
|
{#if visible}
|
||||||
<!-- Backdrop -->
|
<!-- Backdrop -->
|
||||||
<div class="fixed inset-0 z-50 flex items-end justify-center bg-black/60 sm:items-center">
|
<div class="fixed inset-0 z-50 flex items-end justify-center bg-black/60 sm:items-center">
|
||||||
<div class="w-full max-w-sm rounded-t-3xl bg-white p-6 shadow-2xl sm:rounded-2xl">
|
<div class="w-full max-w-sm rounded-t-3xl bg-white p-6 shadow-2xl dark:bg-gray-900 sm:rounded-2xl">
|
||||||
<!-- Step indicator -->
|
<!-- Step indicator -->
|
||||||
<div class="mb-5 flex justify-center gap-1.5">
|
<div class="mb-5 flex justify-center gap-1.5">
|
||||||
{#each steps as _, i}
|
{#each steps as _, i}
|
||||||
<div class="h-1.5 rounded-full transition-all {i === step ? 'w-6 bg-blue-600' : 'w-1.5 bg-gray-200'}"></div>
|
<div
|
||||||
|
class="h-1.5 rounded-full transition-all {i === step
|
||||||
|
? 'w-6 bg-blue-600 dark:bg-blue-500'
|
||||||
|
: 'w-1.5 bg-gray-200 dark:bg-gray-700'}"
|
||||||
|
></div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<div class="mb-6 text-center">
|
<div class="mb-6 text-center">
|
||||||
<div class="mb-3 text-5xl">{steps[step].icon}</div>
|
<div class="mb-3 text-5xl">{currentStep.icon}</div>
|
||||||
<h2 class="mb-2 text-xl font-bold text-gray-900">{steps[step].title}</h2>
|
<h2 class="mb-2 text-xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
<p class="text-sm leading-relaxed text-gray-600">{steps[step].body}</p>
|
{currentStep.title}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{#if currentStep.kind === 'text'}
|
||||||
|
<p class="text-sm leading-relaxed text-gray-600 dark:text-gray-300">
|
||||||
|
{currentStep.body}
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p class="mb-4 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
Du kannst die Wahl jederzeit unter „Mein Konto" ändern.
|
||||||
|
</p>
|
||||||
|
<div class="space-y-2 text-left" role="radiogroup" aria-label="Design">
|
||||||
|
{#each THEME_OPTIONS as opt (opt.value)}
|
||||||
|
{@const selected = $themePreference === opt.value}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={selected}
|
||||||
|
onclick={() => themePreference.set(opt.value)}
|
||||||
|
class="flex w-full cursor-pointer items-center gap-3 rounded-xl border-2 p-3 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-200
|
||||||
|
{selected
|
||||||
|
? 'border-blue-600 bg-blue-50 dark:border-blue-500 dark:bg-blue-950/40'
|
||||||
|
: 'border-gray-200 hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800'}"
|
||||||
|
>
|
||||||
|
<span class="text-2xl leading-none">{opt.icon}</span>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">{opt.label}</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">{opt.hint}</p>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2
|
||||||
|
{selected
|
||||||
|
? 'border-blue-600 bg-blue-600 dark:border-blue-500 dark:bg-blue-500'
|
||||||
|
: 'border-gray-300 dark:border-gray-600'}"
|
||||||
|
>
|
||||||
|
{#if selected}
|
||||||
|
<svg class="h-3 w-3 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Buttons -->
|
<!-- Buttons -->
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onclick={dismiss}
|
onclick={dismiss}
|
||||||
class="flex-1 rounded-xl border border-gray-200 py-3 text-sm text-gray-500 hover:bg-gray-50"
|
class="flex-1 rounded-xl border border-gray-200 py-3 text-sm text-gray-500 hover:bg-gray-50 dark:border-gray-700 dark:text-gray-400 dark:hover:bg-gray-800"
|
||||||
>
|
>
|
||||||
Überspringen
|
Überspringen
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onclick={next}
|
onclick={next}
|
||||||
class="flex-1 rounded-xl bg-blue-600 py-3 text-sm font-semibold text-white hover:bg-blue-700"
|
class="flex-1 rounded-xl bg-blue-600 py-3 text-sm font-semibold text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-400"
|
||||||
>
|
>
|
||||||
{step < steps.length - 1 ? 'Weiter' : 'Los geht\'s!'}
|
{step < steps.length - 1 ? 'Weiter' : 'Los geht\'s!'}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -80,54 +80,54 @@
|
|||||||
|
|
||||||
<!-- Sheet -->
|
<!-- Sheet -->
|
||||||
<div
|
<div
|
||||||
class="fixed inset-x-0 bottom-0 z-50 rounded-t-2xl bg-white transition-transform duration-300"
|
class="fixed inset-x-0 bottom-0 z-50 rounded-t-2xl bg-white transition-transform duration-300 dark:bg-gray-900"
|
||||||
class:translate-y-full={!open}
|
class:translate-y-full={!open}
|
||||||
class:translate-y-0={open}
|
class:translate-y-0={open}
|
||||||
style="padding-bottom: env(safe-area-inset-bottom)"
|
style="padding-bottom: env(safe-area-inset-bottom)"
|
||||||
>
|
>
|
||||||
<!-- Drag handle -->
|
<!-- Drag handle -->
|
||||||
<div class="flex justify-center pt-3 pb-1">
|
<div class="flex justify-center pt-3 pb-1">
|
||||||
<div class="h-1 w-10 rounded-full bg-gray-300"></div>
|
<div class="h-1 w-10 rounded-full bg-gray-300 dark:bg-gray-600"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-3 px-4 pb-4 pt-2">
|
<div class="space-y-3 px-4 pb-4 pt-2">
|
||||||
<!-- Gallery option -->
|
<!-- Gallery option -->
|
||||||
<button
|
<button
|
||||||
onclick={openGallery}
|
onclick={openGallery}
|
||||||
class="flex w-full items-center gap-4 rounded-xl bg-gray-50 px-5 py-4 text-left transition hover:bg-gray-100 active:bg-gray-200"
|
class="flex w-full items-center gap-4 rounded-xl bg-gray-50 px-5 py-4 text-left transition hover:bg-gray-100 active:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 dark:active:bg-gray-600"
|
||||||
>
|
>
|
||||||
<span class="flex h-11 w-11 items-center justify-center rounded-full bg-blue-100 text-blue-600">
|
<span class="flex h-11 w-11 items-center justify-center rounded-full bg-blue-100 text-blue-600 dark:bg-blue-900/40 dark:text-blue-300">
|
||||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="m2.25 15.75 5.159-5.159a2.25 2.25 0 0 1 3.182 0l5.159 5.159m-1.5-1.5 1.409-1.409a2.25 2.25 0 0 1 3.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 0 0 1.5-1.5V6a1.5 1.5 0 0 0-1.5-1.5H3.75A1.5 1.5 0 0 0 2.25 6v12a1.5 1.5 0 0 0 1.5 1.5Zm10.5-11.25h.008v.008h-.008V8.25Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" />
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<p class="font-semibold text-gray-900">Galerie</p>
|
<p class="font-semibold text-gray-900 dark:text-gray-100">Galerie</p>
|
||||||
<p class="text-sm text-gray-500">Foto oder Video wählen</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">Foto oder Video wählen</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Camera option -->
|
<!-- Camera option -->
|
||||||
<button
|
<button
|
||||||
onclick={openCamera}
|
onclick={openCamera}
|
||||||
class="flex w-full items-center gap-4 rounded-xl bg-gray-50 px-5 py-4 text-left transition hover:bg-gray-100 active:bg-gray-200"
|
class="flex w-full items-center gap-4 rounded-xl bg-gray-50 px-5 py-4 text-left transition hover:bg-gray-100 active:bg-gray-200 dark:bg-gray-800 dark:hover:bg-gray-700 dark:active:bg-gray-600"
|
||||||
>
|
>
|
||||||
<span class="flex h-11 w-11 items-center justify-center rounded-full bg-purple-100 text-purple-600">
|
<span class="flex h-11 w-11 items-center justify-center rounded-full bg-purple-100 text-purple-600 dark:bg-purple-900/40 dark:text-purple-300">
|
||||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6.827 6.175A2.31 2.31 0 0 1 5.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 0 0-1.134-.175 2.31 2.31 0 0 1-1.64-1.055l-.822-1.316a2.192 2.192 0 0 0-1.736-1.039 48.774 48.774 0 0 0-5.232 0 2.192 2.192 0 0 0-1.736 1.039l-.821 1.316Z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6.827 6.175A2.31 2.31 0 0 1 5.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 0 0-1.134-.175 2.31 2.31 0 0 1-1.64-1.055l-.822-1.316a2.192 2.192 0 0 0-1.736-1.039 48.774 48.774 0 0 0-5.232 0 2.192 2.192 0 0 0-1.736 1.039l-.821 1.316Z" />
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 12.75a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0ZM18.75 10.5h.008v.008h-.008V10.5Z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 12.75a4.5 4.5 0 1 1-9 0 4.5 4.5 0 0 1 9 0ZM18.75 10.5h.008v.008h-.008V10.5Z" />
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<p class="font-semibold text-gray-900">Kamera</p>
|
<p class="font-semibold text-gray-900 dark:text-gray-100">Kamera</p>
|
||||||
<p class="text-sm text-gray-500">Jetzt aufnehmen</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">Jetzt aufnehmen</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Cancel -->
|
<!-- Cancel -->
|
||||||
<button
|
<button
|
||||||
onclick={close}
|
onclick={close}
|
||||||
class="w-full rounded-xl border border-gray-200 py-3 text-sm font-medium text-gray-600 transition hover:bg-gray-50"
|
class="w-full rounded-xl border border-gray-200 py-3 text-sm font-medium text-gray-600 transition hover:bg-gray-50 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"
|
||||||
>
|
>
|
||||||
Abbrechen
|
Abbrechen
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
56
frontend/src/lib/data-mode-store.ts
Normal file
56
frontend/src/lib/data-mode-store.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
// Per-device "Datenmodus" — Saver loads compressed previews (default), Original loads
|
||||||
|
// the full file via the auth-gated `/api/v1/upload/{id}/original` endpoint.
|
||||||
|
//
|
||||||
|
// Stored per-device in localStorage (not per-user) because data plans are a property
|
||||||
|
// of the device the guest is currently holding, not their identity.
|
||||||
|
//
|
||||||
|
// Used by:
|
||||||
|
// - Feed cards (FeedListCard / FeedGrid) to pick which URL to render
|
||||||
|
// - Lightbox
|
||||||
|
// - Diashow
|
||||||
|
// See [docs/FEATURES.md §2.5] for the user-facing model.
|
||||||
|
|
||||||
|
import { writable } from 'svelte/store';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
|
export type DataMode = 'saver' | 'original';
|
||||||
|
|
||||||
|
const KEY = 'eventsnap_data_mode';
|
||||||
|
const DEFAULT: DataMode = 'saver';
|
||||||
|
|
||||||
|
function readInitial(): DataMode {
|
||||||
|
if (!browser) return DEFAULT;
|
||||||
|
const raw = localStorage.getItem(KEY);
|
||||||
|
return raw === 'original' || raw === 'saver' ? raw : DEFAULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dataMode = writable<DataMode>(readInitial());
|
||||||
|
|
||||||
|
if (browser) {
|
||||||
|
dataMode.subscribe((value) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(KEY, value);
|
||||||
|
} catch {
|
||||||
|
// localStorage may be unavailable (Safari private mode); ignore.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the URL for a feed upload given the current data mode and the URL variants
|
||||||
|
* the backend returned. Centralised so every consumer (cards, lightbox, diashow)
|
||||||
|
* follows the same fallback rule:
|
||||||
|
* Original mode → original API route. Falls back to preview if no upload id is
|
||||||
|
* available (defensive — shouldn't happen in practice).
|
||||||
|
* Saver mode → preview URL (compressed), falling back to thumbnail and then
|
||||||
|
* original.
|
||||||
|
*/
|
||||||
|
export function pickMediaUrl(
|
||||||
|
mode: DataMode,
|
||||||
|
upload: { id: string; preview_url: string | null; thumbnail_url: string | null }
|
||||||
|
): string {
|
||||||
|
if (mode === 'original') {
|
||||||
|
return `/api/v1/upload/${upload.id}/original`;
|
||||||
|
}
|
||||||
|
return upload.preview_url ?? upload.thumbnail_url ?? `/api/v1/upload/${upload.id}/original`;
|
||||||
|
}
|
||||||
106
frontend/src/lib/diashow/queue.ts
Normal file
106
frontend/src/lib/diashow/queue.ts
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
// Two-queue slide state machine. Pure logic — no DOM, no Svelte, no network. Lets us
|
||||||
|
// unit-test the policy without spinning up a browser.
|
||||||
|
//
|
||||||
|
// Policy: live posts always drain first; the shuffle queue refills from `allKnown`
|
||||||
|
// (minus the most recent N items) once it empties. A new live post pushes onto the
|
||||||
|
// live queue and waits for the next slide transition — it never interrupts the
|
||||||
|
// current slide.
|
||||||
|
|
||||||
|
import type { FeedUpload } from '$lib/types';
|
||||||
|
|
||||||
|
const RECENT_RING_SIZE = 5;
|
||||||
|
|
||||||
|
export class SlideQueue {
|
||||||
|
/** Live queue — FIFO. New uploads land here via `pushLive`. */
|
||||||
|
private liveQueue: FeedUpload[] = [];
|
||||||
|
/** Shuffle queue — refilled from `allKnown` minus `recentlyShown` when emptied. */
|
||||||
|
private shuffleQueue: FeedUpload[] = [];
|
||||||
|
/** Every slide we've ever seen, keyed by id. Source for shuffle refills. */
|
||||||
|
private allKnown: Map<string, FeedUpload> = new Map();
|
||||||
|
/** Ring buffer of the last N shown ids — excluded from the next shuffle pool. */
|
||||||
|
private recentlyShown: string[] = [];
|
||||||
|
|
||||||
|
/** Seed the shuffle pool from an initial fetch of the feed. */
|
||||||
|
seed(initial: FeedUpload[]): void {
|
||||||
|
for (const slide of initial) {
|
||||||
|
this.allKnown.set(slide.id, slide);
|
||||||
|
}
|
||||||
|
this.shuffleQueue = shuffle(Array.from(this.allKnown.values()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add a slide pushed by a new SSE upload-processed event. */
|
||||||
|
pushLive(slide: FeedUpload): void {
|
||||||
|
if (this.allKnown.has(slide.id)) return;
|
||||||
|
this.allKnown.set(slide.id, slide);
|
||||||
|
this.liveQueue.push(slide);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pop the next slide. Returns null while both queues are empty (event has no posts). */
|
||||||
|
next(): FeedUpload | null {
|
||||||
|
// 1. Drain live first.
|
||||||
|
const live = this.liveQueue.shift();
|
||||||
|
if (live) {
|
||||||
|
this.markShown(live.id);
|
||||||
|
return live;
|
||||||
|
}
|
||||||
|
// 2. Refill shuffle queue from `allKnown` minus recently shown.
|
||||||
|
if (this.shuffleQueue.length === 0) {
|
||||||
|
this.shuffleQueue = shuffle(
|
||||||
|
Array.from(this.allKnown.values()).filter(
|
||||||
|
(s) => !this.recentlyShown.includes(s.id)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
// If everything is recently shown (small event), fall back to the full pool.
|
||||||
|
if (this.shuffleQueue.length === 0) {
|
||||||
|
this.shuffleQueue = shuffle(Array.from(this.allKnown.values()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const next = this.shuffleQueue.shift() ?? null;
|
||||||
|
if (next) this.markShown(next.id);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a slide that was deleted or hidden. Returns true if it was the current
|
||||||
|
* "head" and the caller should advance immediately (UX: don't keep showing a
|
||||||
|
* post that the host just deleted).
|
||||||
|
*/
|
||||||
|
remove(id: string, currentId: string | null): { wasCurrent: boolean } {
|
||||||
|
this.allKnown.delete(id);
|
||||||
|
this.liveQueue = this.liveQueue.filter((s) => s.id !== id);
|
||||||
|
this.shuffleQueue = this.shuffleQueue.filter((s) => s.id !== id);
|
||||||
|
this.recentlyShown = this.recentlyShown.filter((sid) => sid !== id);
|
||||||
|
return { wasCurrent: currentId === id };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Look up a slide by id — for the diashow page to render the current slide. */
|
||||||
|
get(id: string): FeedUpload | undefined {
|
||||||
|
return this.allKnown.get(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Snapshot — useful for stats / debugging. */
|
||||||
|
stats() {
|
||||||
|
return {
|
||||||
|
known: this.allKnown.size,
|
||||||
|
live: this.liveQueue.length,
|
||||||
|
shuffle: this.shuffleQueue.length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private markShown(id: string): void {
|
||||||
|
this.recentlyShown.push(id);
|
||||||
|
while (this.recentlyShown.length > RECENT_RING_SIZE) {
|
||||||
|
this.recentlyShown.shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fisher–Yates. Mutates a copy of the input so callers can pass `allKnown.values()`. */
|
||||||
|
function shuffle<T>(arr: T[]): T[] {
|
||||||
|
const out = arr.slice();
|
||||||
|
for (let i = out.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[out[i], out[j]] = [out[j], out[i]];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
38
frontend/src/lib/diashow/transitions/crossfade.svelte
Normal file
38
frontend/src/lib/diashow/transitions/crossfade.svelte
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// Simplest transition: render whatever the parent gave us, full-bleed, with a CSS
|
||||||
|
// opacity fade. Tied to the parent's `{#key}` block — when the src changes, the
|
||||||
|
// parent re-mounts this component and the `in:` transition runs.
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
src: string;
|
||||||
|
isVideo: boolean;
|
||||||
|
durationMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { src, isVideo, durationMs }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 flex items-center justify-center bg-black"
|
||||||
|
style="animation: crossfade-in {durationMs}ms ease-out forwards;"
|
||||||
|
>
|
||||||
|
{#if isVideo}
|
||||||
|
<!-- svelte-ignore a11y_media_has_caption -->
|
||||||
|
<video
|
||||||
|
{src}
|
||||||
|
autoplay
|
||||||
|
muted
|
||||||
|
playsinline
|
||||||
|
class="h-full w-full object-contain"
|
||||||
|
></video>
|
||||||
|
{:else}
|
||||||
|
<img {src} alt="" class="h-full w-full object-contain" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@keyframes crossfade-in {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
47
frontend/src/lib/diashow/transitions/index.ts
Normal file
47
frontend/src/lib/diashow/transitions/index.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
// Pluggable transition registry. The diashow consults this list to populate its
|
||||||
|
// settings popover and to render the current transition.
|
||||||
|
//
|
||||||
|
// Adding a new animation:
|
||||||
|
// 1. Drop a Svelte file alongside crossfade.svelte / kenburns.svelte.
|
||||||
|
// 2. Add one entry to `transitions` below.
|
||||||
|
// 3. Rebuild — the popover updates automatically.
|
||||||
|
//
|
||||||
|
// This is the extensibility principle from docs/FEATURES.md §2.9 made concrete:
|
||||||
|
// no diashow code needs to change to add an animation.
|
||||||
|
|
||||||
|
import type { Component } from 'svelte';
|
||||||
|
import Crossfade from './crossfade.svelte';
|
||||||
|
import KenBurns from './kenburns.svelte';
|
||||||
|
|
||||||
|
export interface SlideTransition {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
defaultDurationMs: number;
|
||||||
|
component: Component<TransitionProps>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Props every transition Svelte component receives. */
|
||||||
|
export interface TransitionProps {
|
||||||
|
src: string;
|
||||||
|
isVideo: boolean;
|
||||||
|
durationMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const transitions: SlideTransition[] = [
|
||||||
|
{
|
||||||
|
id: 'crossfade',
|
||||||
|
label: 'Überblendung',
|
||||||
|
defaultDurationMs: 400,
|
||||||
|
component: Crossfade as unknown as Component<TransitionProps>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'kenburns',
|
||||||
|
label: 'Ken Burns',
|
||||||
|
defaultDurationMs: 600,
|
||||||
|
component: KenBurns as unknown as Component<TransitionProps>
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export function findTransition(id: string): SlideTransition {
|
||||||
|
return transitions.find((t) => t.id === id) ?? transitions[0];
|
||||||
|
}
|
||||||
51
frontend/src/lib/diashow/transitions/kenburns.svelte
Normal file
51
frontend/src/lib/diashow/transitions/kenburns.svelte
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// Crossfade + slow zoom and pan (the "Ken Burns" effect). Image-only — videos
|
||||||
|
// fall back to the crossfade behaviour to avoid double-animation.
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
src: string;
|
||||||
|
isVideo: boolean;
|
||||||
|
durationMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { src, isVideo, durationMs }: Props = $props();
|
||||||
|
|
||||||
|
// Mild random pan so each slide feels different. Range chosen so the image never
|
||||||
|
// pans out of frame given the object-fit: cover.
|
||||||
|
const panX = Math.round((Math.random() - 0.5) * 6); // -3% .. +3%
|
||||||
|
const panY = Math.round((Math.random() - 0.5) * 6);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 flex items-center justify-center overflow-hidden bg-black"
|
||||||
|
style="animation: kb-fade {durationMs}ms ease-out forwards;"
|
||||||
|
>
|
||||||
|
{#if isVideo}
|
||||||
|
<!-- svelte-ignore a11y_media_has_caption -->
|
||||||
|
<video
|
||||||
|
{src}
|
||||||
|
autoplay
|
||||||
|
muted
|
||||||
|
playsinline
|
||||||
|
class="h-full w-full object-contain"
|
||||||
|
></video>
|
||||||
|
{:else}
|
||||||
|
<img
|
||||||
|
{src}
|
||||||
|
alt=""
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
style="animation: kb-zoom 10s ease-out forwards; transform-origin: {50 + panX}% {50 + panY}%;"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@keyframes kb-fade {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes kb-zoom {
|
||||||
|
from { transform: scale(1.0); }
|
||||||
|
to { transform: scale(1.1); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
54
frontend/src/lib/diashow/wakelock.ts
Normal file
54
frontend/src/lib/diashow/wakelock.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
// Thin wrapper around the Screen Wake Lock API. Held by the diashow page so a phone
|
||||||
|
// driving a projector doesn't sleep. No-op on unsupported browsers (Firefox, older
|
||||||
|
// Safari).
|
||||||
|
//
|
||||||
|
// Wake locks die when the document goes hidden; the page re-acquires on visible to
|
||||||
|
// keep the screen on across short interruptions.
|
||||||
|
|
||||||
|
interface SentinelLike {
|
||||||
|
release: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let sentinel: SentinelLike | null = null;
|
||||||
|
let visibilityHandler: (() => void) | null = null;
|
||||||
|
|
||||||
|
export async function acquireWakeLock(): Promise<void> {
|
||||||
|
const wakeLock = (navigator as Navigator & { wakeLock?: { request: (t: string) => Promise<SentinelLike> } }).wakeLock;
|
||||||
|
if (!wakeLock) return;
|
||||||
|
try {
|
||||||
|
sentinel = await wakeLock.request('screen');
|
||||||
|
} catch {
|
||||||
|
// User denied, or already released — nothing useful to do.
|
||||||
|
sentinel = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-acquire when the page becomes visible again (the OS releases the lock
|
||||||
|
// while the tab is hidden).
|
||||||
|
if (!visibilityHandler) {
|
||||||
|
visibilityHandler = async () => {
|
||||||
|
if (document.visibilityState === 'visible' && sentinel === null) {
|
||||||
|
try {
|
||||||
|
sentinel = await wakeLock.request('screen');
|
||||||
|
} catch {
|
||||||
|
sentinel = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('visibilitychange', visibilityHandler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function releaseWakeLock(): Promise<void> {
|
||||||
|
if (sentinel) {
|
||||||
|
try {
|
||||||
|
await sentinel.release();
|
||||||
|
} catch {
|
||||||
|
// ignore — release after release is fine
|
||||||
|
}
|
||||||
|
sentinel = null;
|
||||||
|
}
|
||||||
|
if (visibilityHandler) {
|
||||||
|
document.removeEventListener('visibilitychange', visibilityHandler);
|
||||||
|
visibilityHandler = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
10
frontend/src/lib/privacy-note-store.ts
Normal file
10
frontend/src/lib/privacy-note-store.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
// Holds the admin-configured Datenschutzhinweis as raw text. Populated by
|
||||||
|
// `GET /api/v1/me/context` on app start and updated live via the SSE `event-updated`
|
||||||
|
// signal so admin edits propagate without a reload.
|
||||||
|
//
|
||||||
|
// Empty string ('') means "not configured" — the My Account page hides the section
|
||||||
|
// entirely in that case.
|
||||||
|
|
||||||
|
import { writable } from 'svelte/store';
|
||||||
|
|
||||||
|
export const privacyNote = writable<string>('');
|
||||||
38
frontend/src/lib/quota-store.ts
Normal file
38
frontend/src/lib/quota-store.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
// Live snapshot of the per-user storage quota. Refreshed on app start, after every
|
||||||
|
// upload completes, and whenever the account page mounts. Mirrors backend
|
||||||
|
// `handlers::me::QuotaDto`.
|
||||||
|
//
|
||||||
|
// `enabled = false` means quota enforcement is currently off in admin config; the UI
|
||||||
|
// hides the widget entirely in that case.
|
||||||
|
|
||||||
|
import { writable } from 'svelte/store';
|
||||||
|
import { api } from './api';
|
||||||
|
|
||||||
|
export interface QuotaSnapshot {
|
||||||
|
enabled: boolean;
|
||||||
|
used_bytes: number;
|
||||||
|
limit_bytes: number | null;
|
||||||
|
active_uploaders: number;
|
||||||
|
free_disk_bytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const empty: QuotaSnapshot = {
|
||||||
|
enabled: false,
|
||||||
|
used_bytes: 0,
|
||||||
|
limit_bytes: null,
|
||||||
|
active_uploaders: 0,
|
||||||
|
free_disk_bytes: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
export const quotaStore = writable<QuotaSnapshot>(empty);
|
||||||
|
|
||||||
|
/** Refresh from the server. Swallows errors so a transient network blip doesn't
|
||||||
|
* break the account page; the previous snapshot just stays in place. */
|
||||||
|
export async function refreshQuota(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const snap = await api.get<QuotaSnapshot>('/me/quota');
|
||||||
|
quotaStore.set(snap);
|
||||||
|
} catch {
|
||||||
|
// keep previous snapshot
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,19 @@
|
|||||||
|
// Thin EventSource wrapper with a per-event-type registration pattern.
|
||||||
|
//
|
||||||
|
// Subscribers register via `onSseEvent(type, handler)` and receive the raw payload
|
||||||
|
// string. The list of event types we know how to relay lives in `KNOWN_EVENTS` so
|
||||||
|
// adding one new is one constant entry — keeps the file friendly to extension.
|
||||||
|
//
|
||||||
|
// Lifecycle:
|
||||||
|
// - Connection survives backgrounding via the visibility listener at the bottom of
|
||||||
|
// this file (closes on hidden, reopens on visible).
|
||||||
|
// - On reopen we fire a `feed-delta` synthetic event with the gap since last seen
|
||||||
|
// to whoever subscribes. The feed page is the typical consumer; it merges the
|
||||||
|
// delta into its in-memory list.
|
||||||
|
|
||||||
import { getToken } from './auth';
|
import { getToken } from './auth';
|
||||||
|
import { api } from './api';
|
||||||
|
import type { DeltaResponse } from './types';
|
||||||
|
|
||||||
type EventHandler = (data: string) => void;
|
type EventHandler = (data: string) => void;
|
||||||
|
|
||||||
@@ -6,13 +21,41 @@ let eventSource: EventSource | null = null;
|
|||||||
let lastEventTime: string | null = null;
|
let lastEventTime: string | null = null;
|
||||||
const handlers: Map<string, EventHandler[]> = new Map();
|
const handlers: Map<string, EventHandler[]> = new Map();
|
||||||
|
|
||||||
|
/** Consecutive reconnect attempts since last successful onopen. Reset on success. */
|
||||||
|
let reconnectAttempt = 0;
|
||||||
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SSE event names emitted by the backend. Add new ones here as `state.sse_tx.send`
|
||||||
|
* call sites grow — every entry becomes a relay registration below.
|
||||||
|
*/
|
||||||
|
const KNOWN_EVENTS = [
|
||||||
|
'new-upload',
|
||||||
|
'upload-processed',
|
||||||
|
'upload-error',
|
||||||
|
'upload-deleted',
|
||||||
|
'like-update',
|
||||||
|
'new-comment',
|
||||||
|
'event-closed',
|
||||||
|
'event-opened',
|
||||||
|
'event-updated',
|
||||||
|
'export-progress',
|
||||||
|
'export-available',
|
||||||
|
'pin-reset'
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synthetic event types — not emitted by the server, dispatched locally to fan out
|
||||||
|
* cross-cutting state changes (e.g. delta-fetch results after a reconnect).
|
||||||
|
*/
|
||||||
|
export type SyntheticEvent = 'feed-delta';
|
||||||
|
|
||||||
export function onSseEvent(eventType: string, handler: EventHandler): () => void {
|
export function onSseEvent(eventType: string, handler: EventHandler): () => void {
|
||||||
if (!handlers.has(eventType)) {
|
if (!handlers.has(eventType)) {
|
||||||
handlers.set(eventType, []);
|
handlers.set(eventType, []);
|
||||||
}
|
}
|
||||||
handlers.get(eventType)!.push(handler);
|
handlers.get(eventType)!.push(handler);
|
||||||
|
|
||||||
// Return unsubscribe function
|
|
||||||
return () => {
|
return () => {
|
||||||
const list = handlers.get(eventType);
|
const list = handlers.get(eventType);
|
||||||
if (list) {
|
if (list) {
|
||||||
@@ -26,26 +69,37 @@ export function connectSse(): void {
|
|||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (!token || eventSource) return;
|
if (!token || eventSource) return;
|
||||||
|
|
||||||
// EventSource doesn't support custom headers, so pass token as query param
|
// EventSource doesn't support custom headers, so pass token as query param.
|
||||||
// The backend will need to accept this — or we use a polyfill / fetch-based SSE
|
|
||||||
// For simplicity, use native EventSource with token in URL
|
|
||||||
eventSource = new EventSource(`/api/v1/stream?token=${encodeURIComponent(token)}`);
|
eventSource = new EventSource(`/api/v1/stream?token=${encodeURIComponent(token)}`);
|
||||||
|
|
||||||
eventSource.onopen = () => {
|
eventSource.onopen = () => {
|
||||||
|
// Successful connection — reset the backoff counter.
|
||||||
|
reconnectAttempt = 0;
|
||||||
|
// If we have a previous timestamp this is a reconnect — fetch the gap.
|
||||||
|
const since = lastEventTime;
|
||||||
|
if (since) {
|
||||||
|
void deltaFetchAndFan(since);
|
||||||
|
}
|
||||||
lastEventTime = new Date().toISOString();
|
lastEventTime = new Date().toISOString();
|
||||||
};
|
};
|
||||||
|
|
||||||
eventSource.addEventListener('new-upload', (e) => dispatch('new-upload', e.data));
|
for (const eventName of KNOWN_EVENTS) {
|
||||||
eventSource.addEventListener('upload-processed', (e) => dispatch('upload-processed', e.data));
|
eventSource.addEventListener(eventName, (e) =>
|
||||||
eventSource.addEventListener('like-update', (e) => dispatch('like-update', e.data));
|
dispatch(eventName, (e as MessageEvent).data)
|
||||||
eventSource.addEventListener('new-comment', (e) => dispatch('new-comment', e.data));
|
);
|
||||||
eventSource.addEventListener('export-available', (e) => dispatch('export-available', e.data));
|
}
|
||||||
|
|
||||||
eventSource.onerror = () => {
|
eventSource.onerror = () => {
|
||||||
// EventSource auto-reconnects, but we track the time for delta-fetch
|
// EventSource auto-reconnects but the connection state can stay broken; close
|
||||||
|
// and try again ourselves with exponential backoff capped at 60s. Prevents
|
||||||
|
// retry storms (and lets the backend recover quietly) when the server is down
|
||||||
|
// for a while or when 100+ guests reconnect simultaneously after an outage.
|
||||||
disconnectSse();
|
disconnectSse();
|
||||||
// Reconnect after a short delay
|
reconnectAttempt++;
|
||||||
setTimeout(connectSse, 3000);
|
const delay = Math.min(60_000, 1_000 * 2 ** (reconnectAttempt - 1));
|
||||||
|
const jitter = Math.random() * 500;
|
||||||
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||||
|
reconnectTimer = setTimeout(connectSse, delay + jitter);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,6 +108,10 @@ export function disconnectSse(): void {
|
|||||||
eventSource.close();
|
eventSource.close();
|
||||||
eventSource = null;
|
eventSource = null;
|
||||||
}
|
}
|
||||||
|
if (reconnectTimer) {
|
||||||
|
clearTimeout(reconnectTimer);
|
||||||
|
reconnectTimer = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLastEventTime(): string | null {
|
export function getLastEventTime(): string | null {
|
||||||
@@ -74,12 +132,33 @@ function dispatch(eventType: string, data: string): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Page Visibility API integration
|
/**
|
||||||
|
* Fetch all feed activity since `since` and fan it out as a synthetic `feed-delta`
|
||||||
|
* event. Subscribers (typically the feed page) merge the result into their
|
||||||
|
* in-memory list. Swallows errors — a failed delta is non-fatal; the next live
|
||||||
|
* SSE event will keep the feed moving.
|
||||||
|
*/
|
||||||
|
async function deltaFetchAndFan(since: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const response = await api.get<DeltaResponse>(
|
||||||
|
`/feed/delta?since=${encodeURIComponent(since)}`
|
||||||
|
);
|
||||||
|
dispatch('feed-delta', JSON.stringify(response));
|
||||||
|
} catch {
|
||||||
|
// non-fatal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page Visibility API: close while hidden, reopen on focus. On reopen `connectSse`'s
|
||||||
|
// `onopen` runs the delta fetch.
|
||||||
if (typeof document !== 'undefined') {
|
if (typeof document !== 'undefined') {
|
||||||
document.addEventListener('visibilitychange', () => {
|
document.addEventListener('visibilitychange', () => {
|
||||||
if (document.hidden) {
|
if (document.hidden) {
|
||||||
disconnectSse();
|
disconnectSse();
|
||||||
} else {
|
} else {
|
||||||
|
// User-initiated reconnect — clear backoff so we don't wait out a long
|
||||||
|
// retry delay that was scheduled from a prior background error.
|
||||||
|
reconnectAttempt = 0;
|
||||||
connectSse();
|
connectSse();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
71
frontend/src/lib/theme-store.ts
Normal file
71
frontend/src/lib/theme-store.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
// Per-device theme preference. Mirrors the data-mode store pattern.
|
||||||
|
//
|
||||||
|
// Three options:
|
||||||
|
// - 'system' follows `prefers-color-scheme` (default for new visitors)
|
||||||
|
// - 'light' force light
|
||||||
|
// - 'dark' force dark
|
||||||
|
//
|
||||||
|
// `appliedTheme` is the *resolved* light/dark for the current moment — derived from
|
||||||
|
// `themePreference` + the OS preference when 'system' is selected. Consumers that
|
||||||
|
// just want "is it dark right now?" should read `$appliedTheme`.
|
||||||
|
//
|
||||||
|
// The `applyTheme` side-effect toggles a `dark` class on the <html> element so the
|
||||||
|
// Tailwind v4 dark variant (configured in `tailwind-theme.css`) kicks in for every
|
||||||
|
// `dark:` utility across the app.
|
||||||
|
|
||||||
|
import { writable, derived, get } from 'svelte/store';
|
||||||
|
import { browser } from '$app/environment';
|
||||||
|
|
||||||
|
export type ThemePreference = 'system' | 'light' | 'dark';
|
||||||
|
export type AppliedTheme = 'light' | 'dark';
|
||||||
|
|
||||||
|
const KEY = 'eventsnap_theme';
|
||||||
|
const DEFAULT: ThemePreference = 'system';
|
||||||
|
|
||||||
|
function readInitial(): ThemePreference {
|
||||||
|
if (!browser) return DEFAULT;
|
||||||
|
const raw = localStorage.getItem(KEY);
|
||||||
|
return raw === 'light' || raw === 'dark' || raw === 'system' ? raw : DEFAULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
function systemPrefersDark(): boolean {
|
||||||
|
if (!browser) return false;
|
||||||
|
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const themePreference = writable<ThemePreference>(readInitial());
|
||||||
|
|
||||||
|
/** Resolved light/dark — recomputed when the preference or OS theme changes. */
|
||||||
|
export const appliedTheme = derived(themePreference, ($pref, set) => {
|
||||||
|
const compute = () => set($pref === 'system' ? (systemPrefersDark() ? 'dark' : 'light') : $pref);
|
||||||
|
compute();
|
||||||
|
if (!browser || $pref !== 'system') return; // no OS listener needed when forced
|
||||||
|
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
const listener = () => compute();
|
||||||
|
mq.addEventListener('change', listener);
|
||||||
|
return () => mq.removeEventListener('change', listener);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Side-effect: keep the <html> class + localStorage + meta-color in sync. */
|
||||||
|
export function initTheme(): void {
|
||||||
|
if (!browser) return;
|
||||||
|
themePreference.subscribe((pref) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(KEY, pref);
|
||||||
|
} catch {
|
||||||
|
// localStorage may be unavailable (Safari private mode); ignore.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
appliedTheme.subscribe((mode) => {
|
||||||
|
document.documentElement.classList.toggle('dark', mode === 'dark');
|
||||||
|
// Update the browser chrome / status bar color so iOS Safari + Android stop
|
||||||
|
// painting it white on a dark page.
|
||||||
|
const meta = document.querySelector('meta[name="theme-color"]');
|
||||||
|
if (meta) meta.setAttribute('content', mode === 'dark' ? '#111827' : '#ffffff');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convenience for one-off reads outside reactive contexts. */
|
||||||
|
export function isDark(): boolean {
|
||||||
|
return get(appliedTheme) === 'dark';
|
||||||
|
}
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
|
// Type mirrors of the Rust DTOs. Keep these in sync by hand — comments call out the
|
||||||
|
// originating source file so future-you can grep both sides. See
|
||||||
|
// `frontend/src/lib/README.md` for the convention.
|
||||||
|
|
||||||
|
// mirrors backend/src/handlers/feed.rs::FeedUpload
|
||||||
export interface FeedUpload {
|
export interface FeedUpload {
|
||||||
id: string;
|
id: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
@@ -12,12 +17,46 @@ export interface FeedUpload {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mirrors backend/src/handlers/feed.rs::FeedResponse
|
||||||
export interface FeedResponse {
|
export interface FeedResponse {
|
||||||
uploads: FeedUpload[];
|
uploads: FeedUpload[];
|
||||||
next_cursor: string | null;
|
next_cursor: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mirrors backend/src/handlers/feed.rs::DeltaResponse
|
||||||
|
export interface DeltaResponse {
|
||||||
|
uploads: FeedUpload[];
|
||||||
|
deleted_ids: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// mirrors backend/src/handlers/feed.rs::HashtagCount
|
||||||
export interface HashtagCount {
|
export interface HashtagCount {
|
||||||
tag: string;
|
tag: string;
|
||||||
count: number;
|
count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mirrors backend/src/handlers/me.rs::QuotaDto
|
||||||
|
export interface QuotaDto {
|
||||||
|
enabled: boolean;
|
||||||
|
used_bytes: number;
|
||||||
|
limit_bytes: number | null;
|
||||||
|
active_uploaders: number;
|
||||||
|
free_disk_bytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// mirrors backend/src/handlers/me.rs::MeContextDto
|
||||||
|
export interface MeContextDto {
|
||||||
|
user_id: string;
|
||||||
|
display_name: string;
|
||||||
|
role: 'guest' | 'host' | 'admin';
|
||||||
|
/** Plain text — preserve whitespace and newlines on render; do **not** parse as HTML. */
|
||||||
|
privacy_note: string;
|
||||||
|
quota_enabled: boolean;
|
||||||
|
storage_quota_enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// mirrors backend/src/handlers/host.rs::PinResetResponse
|
||||||
|
export interface PinResetResponse {
|
||||||
|
/** One-time plaintext PIN. Show it to the operator once, then forget it. */
|
||||||
|
pin: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { openDB, type IDBPDatabase } from 'idb';
|
import { openDB, type IDBPDatabase } from 'idb';
|
||||||
import { writable, get } from 'svelte/store';
|
import { writable, get } from 'svelte/store';
|
||||||
import { getToken } from './auth';
|
import { getToken, getUserId } from './auth';
|
||||||
|
import { refreshQuota } from './quota-store';
|
||||||
|
|
||||||
export interface QueueItem {
|
export interface QueueItem {
|
||||||
id: string;
|
id: string;
|
||||||
|
userId: string;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
fileSize: number;
|
fileSize: number;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
@@ -28,16 +30,37 @@ let db: IDBPDatabase | null = null;
|
|||||||
|
|
||||||
async function getDb(): Promise<IDBPDatabase> {
|
async function getDb(): Promise<IDBPDatabase> {
|
||||||
if (db) return db;
|
if (db) return db;
|
||||||
db = await openDB(DB_NAME, 1, {
|
// v1 → v2: add `userId` index so each guest's queue is isolated on shared devices.
|
||||||
upgrade(database) {
|
// Pre-existing entries (no userId) are dropped on upgrade; nothing useful was ever
|
||||||
if (!database.objectStoreNames.contains(STORE_NAME)) {
|
// persisted across logouts before this version.
|
||||||
|
db = await openDB(DB_NAME, 2, {
|
||||||
|
upgrade(database, oldVersion) {
|
||||||
|
if (oldVersion < 1) {
|
||||||
database.createObjectStore(STORE_NAME, { keyPath: 'id' });
|
database.createObjectStore(STORE_NAME, { keyPath: 'id' });
|
||||||
}
|
}
|
||||||
|
if (oldVersion < 2) {
|
||||||
|
// Wipe any pre-v2 entries — they have no userId field and would belong
|
||||||
|
// to a now-indeterminate user. Safer to drop than to misattribute.
|
||||||
|
const tx = database.transaction(STORE_NAME, 'readwrite');
|
||||||
|
tx.objectStore(STORE_NAME).clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return db;
|
return db;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wipe every queue entry — both IndexedDB rows and the in-memory store. Called on
|
||||||
|
* explicit logout so a second guest using the same device doesn't inherit (or be
|
||||||
|
* blamed for) the previous guest's pending uploads.
|
||||||
|
*/
|
||||||
|
export async function clearQueue(): Promise<void> {
|
||||||
|
const database = await getDb();
|
||||||
|
await database.clear(STORE_NAME);
|
||||||
|
queueItems.set([]);
|
||||||
|
rateLimitRetryAt.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
class RateLimitError extends Error {
|
class RateLimitError extends Error {
|
||||||
retryAfterSecs: number;
|
retryAfterSecs: number;
|
||||||
constructor(secs: number) {
|
constructor(secs: number) {
|
||||||
@@ -48,18 +71,25 @@ class RateLimitError extends Error {
|
|||||||
|
|
||||||
export async function loadQueue(): Promise<void> {
|
export async function loadQueue(): Promise<void> {
|
||||||
const database = await getDb();
|
const database = await getDb();
|
||||||
|
const myUserId = getUserId();
|
||||||
const all = await database.getAll(STORE_NAME);
|
const all = await database.getAll(STORE_NAME);
|
||||||
const items: QueueItem[] = all.map((entry) => ({
|
// Only surface entries that belong to the current user. Entries from a previous
|
||||||
id: entry.id,
|
// guest on this device are filtered out (and would also be wiped on their next
|
||||||
fileName: entry.fileName,
|
// explicit logout via `clearQueue`).
|
||||||
fileSize: entry.fileSize,
|
const items: QueueItem[] = all
|
||||||
mimeType: entry.mimeType,
|
.filter((entry) => entry.userId && entry.userId === myUserId)
|
||||||
caption: entry.caption ?? '',
|
.map((entry) => ({
|
||||||
hashtags: entry.hashtags ?? '',
|
id: entry.id,
|
||||||
status: entry.status === 'uploading' ? 'pending' : entry.status,
|
userId: entry.userId,
|
||||||
progress: entry.status === 'done' ? 100 : 0,
|
fileName: entry.fileName,
|
||||||
error: entry.error
|
fileSize: entry.fileSize,
|
||||||
}));
|
mimeType: entry.mimeType,
|
||||||
|
caption: entry.caption ?? '',
|
||||||
|
hashtags: entry.hashtags ?? '',
|
||||||
|
status: entry.status === 'uploading' ? 'pending' : entry.status,
|
||||||
|
progress: entry.status === 'done' ? 100 : 0,
|
||||||
|
error: entry.error
|
||||||
|
}));
|
||||||
queueItems.set(items);
|
queueItems.set(items);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,9 +99,12 @@ export async function addToQueue(
|
|||||||
hashtags: string
|
hashtags: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const database = await getDb();
|
const database = await getDb();
|
||||||
|
const userId = getUserId();
|
||||||
|
if (!userId) return; // not authenticated — nothing to do
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
const entry = {
|
const entry = {
|
||||||
id,
|
id,
|
||||||
|
userId,
|
||||||
fileName: file.name,
|
fileName: file.name,
|
||||||
fileSize: file.size,
|
fileSize: file.size,
|
||||||
mimeType: file.type,
|
mimeType: file.type,
|
||||||
@@ -86,6 +119,7 @@ export async function addToQueue(
|
|||||||
...items,
|
...items,
|
||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
|
userId,
|
||||||
fileName: file.name,
|
fileName: file.name,
|
||||||
fileSize: file.size,
|
fileSize: file.size,
|
||||||
mimeType: file.type,
|
mimeType: file.type,
|
||||||
@@ -177,13 +211,21 @@ async function uploadItem(id: string): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateItemStatus(id, 'uploading');
|
|
||||||
|
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
if (!token) {
|
const currentUserId = getUserId();
|
||||||
|
if (!token || !currentUserId) {
|
||||||
updateItemStatus(id, 'error', 'Nicht angemeldet.');
|
updateItemStatus(id, 'error', 'Nicht angemeldet.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Defense-in-depth: if the device's signed-in user changed since this entry was
|
||||||
|
// queued, refuse to upload it under the new identity. `loadQueue` already filters
|
||||||
|
// by user; this guards the in-memory store path too.
|
||||||
|
if (entry.userId && entry.userId !== currentUserId) {
|
||||||
|
updateItemStatus(id, 'error', 'Anderer Nutzer angemeldet.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateItemStatus(id, 'uploading');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
@@ -236,6 +278,9 @@ async function uploadItem(id: string): Promise<void> {
|
|||||||
delete entry.blob;
|
delete entry.blob;
|
||||||
await database.put(STORE_NAME, entry);
|
await database.put(STORE_NAME, entry);
|
||||||
updateItemStatus(id, 'done');
|
updateItemStatus(id, 'done');
|
||||||
|
// Refresh the per-user quota snapshot so the My Account widget reflects this
|
||||||
|
// upload's bytes without a manual reload.
|
||||||
|
void refreshQuota();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof RateLimitError) {
|
if (e instanceof RateLimitError) {
|
||||||
// Reset to pending so it will be retried when the queue resumes
|
// Reset to pending so it will be retried when the queue resumes
|
||||||
|
|||||||
@@ -1,16 +1,24 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import favicon from '$lib/assets/favicon.svg';
|
import favicon from '$lib/assets/favicon.svg';
|
||||||
import '../app.css';
|
import '../app.css';
|
||||||
import { initAuth } from '$lib/auth';
|
import { initAuth, getToken, getUserId, clearPin } from '$lib/auth';
|
||||||
import { onMount } from 'svelte';
|
import { initTheme } from '$lib/theme-store';
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import BottomNav from '$lib/components/BottomNav.svelte';
|
import BottomNav from '$lib/components/BottomNav.svelte';
|
||||||
import UploadSheet from '$lib/components/UploadSheet.svelte';
|
import UploadSheet from '$lib/components/UploadSheet.svelte';
|
||||||
import { showBottomNav } from '$lib/ui-store';
|
import { showBottomNav } from '$lib/ui-store';
|
||||||
import { isAuthenticated } from '$lib/auth';
|
import { isAuthenticated } from '$lib/auth';
|
||||||
import { queueItems, isProcessing } from '$lib/upload-queue';
|
import { queueItems, isProcessing } from '$lib/upload-queue';
|
||||||
|
import { privacyNote } from '$lib/privacy-note-store';
|
||||||
|
import { refreshQuota } from '$lib/quota-store';
|
||||||
|
import { onSseEvent } from '$lib/sse';
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
import type { MeContextDto } from '$lib/types';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|
||||||
|
let unsubs: Array<() => void> = [];
|
||||||
|
|
||||||
// Slim progress bar: ratio of completed items to total, shown while processing.
|
// Slim progress bar: ratio of completed items to total, shown while processing.
|
||||||
let progressPct = $derived.by(() => {
|
let progressPct = $derived.by(() => {
|
||||||
const total = $queueItems.length;
|
const total = $queueItems.length;
|
||||||
@@ -19,8 +27,41 @@
|
|||||||
return Math.round((done / total) * 100);
|
return Math.round((done / total) * 100);
|
||||||
});
|
});
|
||||||
|
|
||||||
onMount(() => {
|
onMount(async () => {
|
||||||
initAuth();
|
initAuth();
|
||||||
|
// Hooks up the appliedTheme → <html class="dark"> sync. Must run early so the
|
||||||
|
// first paint after hydration matches the saved preference.
|
||||||
|
initTheme();
|
||||||
|
// Hydrate cross-cutting stores once on boot if the user is already authenticated.
|
||||||
|
// Page-level mounts will refresh again as needed.
|
||||||
|
if (getToken()) {
|
||||||
|
try {
|
||||||
|
const ctx = await api.get<MeContextDto>('/me/context');
|
||||||
|
privacyNote.set(ctx.privacy_note);
|
||||||
|
} catch {
|
||||||
|
// non-fatal; users without a session land on /join anyway
|
||||||
|
}
|
||||||
|
void refreshQuota();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global pin-reset listener — clears the now-invalid plaintext PIN from
|
||||||
|
// localStorage no matter which route the user is currently on. The reactive
|
||||||
|
// `currentPin` store carries the change into any page that reads it (My
|
||||||
|
// Account in particular).
|
||||||
|
unsubs.push(
|
||||||
|
onSseEvent('pin-reset', (data) => {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(data) as { user_id: string };
|
||||||
|
if (payload.user_id === getUserId()) clearPin();
|
||||||
|
} catch {
|
||||||
|
// ignore malformed payload
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
for (const unsub of unsubs) unsub();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,36 +1,105 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { getToken, getPin, getDisplayName, getExpiry, getRole, clearAuth } from '$lib/auth';
|
import { getToken, getDisplayName, getExpiry, getRole, clearAuth, currentPin } from '$lib/auth';
|
||||||
|
import { clearQueue } from '$lib/upload-queue';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import { onMount } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import { dataMode } from '$lib/data-mode-store';
|
||||||
|
import { themePreference, type ThemePreference } from '$lib/theme-store';
|
||||||
|
import { privacyNote } from '$lib/privacy-note-store';
|
||||||
|
import { quotaStore, refreshQuota } from '$lib/quota-store';
|
||||||
|
import { onSseEvent } from '$lib/sse';
|
||||||
|
import type { MeContextDto } from '$lib/types';
|
||||||
|
|
||||||
let pin = $state<string | null>(null);
|
|
||||||
let displayName = $state<string | null>(null);
|
let displayName = $state<string | null>(null);
|
||||||
let role = $state<'guest' | 'host' | 'admin' | null>(null);
|
let role = $state<'guest' | 'host' | 'admin' | null>(null);
|
||||||
let expiry = $state<Date | null>(null);
|
let expiry = $state<Date | null>(null);
|
||||||
let pinCopied = $state(false);
|
let pinCopied = $state(false);
|
||||||
let leaveConfirmOpen = $state(false);
|
let leaveConfirmOpen = $state(false);
|
||||||
|
let dataModeWarningOpen = $state(false);
|
||||||
|
|
||||||
onMount(() => {
|
// `pin` is sourced from the shared `currentPin` store so a global pin-reset SSE
|
||||||
|
// event (handled in the layout) clears the display live without a reload.
|
||||||
|
const pin = currentPin;
|
||||||
|
|
||||||
|
const unsubs: Array<() => void> = [];
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
if (!getToken()) {
|
if (!getToken()) {
|
||||||
goto('/join');
|
goto('/join');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
pin = getPin();
|
|
||||||
displayName = getDisplayName();
|
displayName = getDisplayName();
|
||||||
role = getRole();
|
role = getRole();
|
||||||
expiry = getExpiry();
|
expiry = getExpiry();
|
||||||
|
|
||||||
|
// Refresh server-driven state. Quota + privacy note may have changed since last visit.
|
||||||
|
try {
|
||||||
|
const ctx = await api.get<MeContextDto>('/me/context');
|
||||||
|
privacyNote.set(ctx.privacy_note);
|
||||||
|
} catch {
|
||||||
|
// non-fatal
|
||||||
|
}
|
||||||
|
void refreshQuota();
|
||||||
|
|
||||||
|
// If admin edits the privacy note while we're sitting on the page, pick it up.
|
||||||
|
unsubs.push(
|
||||||
|
onSseEvent('event-updated', async () => {
|
||||||
|
try {
|
||||||
|
const ctx = await api.get<MeContextDto>('/me/context');
|
||||||
|
privacyNote.set(ctx.privacy_note);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// Note: `pin-reset` is handled globally in the root layout.
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
for (const u of unsubs) u();
|
||||||
|
});
|
||||||
|
|
||||||
|
function chooseDataMode(mode: 'saver' | 'original') {
|
||||||
|
if (mode === 'original' && $dataMode !== 'original') {
|
||||||
|
dataModeWarningOpen = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dataMode.set(mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmOriginalMode() {
|
||||||
|
dataMode.set('original');
|
||||||
|
dataModeWarningOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number | null | undefined): string {
|
||||||
|
if (bytes == null || bytes <= 0) return '0 MB';
|
||||||
|
const mb = bytes / (1024 * 1024);
|
||||||
|
if (mb < 1024) return `${mb.toFixed(mb < 10 ? 1 : 0)} MB`;
|
||||||
|
return `${(mb / 1024).toFixed(1)} GB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const quotaPercent = $derived(
|
||||||
|
$quotaStore.limit_bytes && $quotaStore.limit_bytes > 0
|
||||||
|
? Math.min(100, ($quotaStore.used_bytes / $quotaStore.limit_bytes) * 100)
|
||||||
|
: 0
|
||||||
|
);
|
||||||
|
|
||||||
function copyPin() {
|
function copyPin() {
|
||||||
if (!pin) return;
|
const value = $pin;
|
||||||
navigator.clipboard.writeText(pin);
|
if (!value) return;
|
||||||
|
navigator.clipboard.writeText(value);
|
||||||
pinCopied = true;
|
pinCopied = true;
|
||||||
setTimeout(() => (pinCopied = false), 2000);
|
setTimeout(() => (pinCopied = false), 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
try { await api.delete('/session'); } catch { /* ignore */ }
|
try { await api.delete('/session'); } catch { /* ignore */ }
|
||||||
|
// Wipe the IndexedDB upload queue so a second guest using the same device can't
|
||||||
|
// inherit (or be blamed for) this guest's pending uploads. Not done on a 401
|
||||||
|
// auto-clear — that path preserves the queue in case the user re-authenticates.
|
||||||
|
try { await clearQueue(); } catch { /* ignore */ }
|
||||||
clearAuth();
|
clearAuth();
|
||||||
goto('/join');
|
goto('/join');
|
||||||
}
|
}
|
||||||
@@ -64,17 +133,17 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="min-h-screen bg-gray-50 pb-24">
|
<div class="min-h-screen bg-gray-50 pb-24 dark:bg-gray-950">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="border-b border-gray-200 bg-white">
|
<div class="border-b border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
|
||||||
<div class="mx-auto flex max-w-lg items-center px-4 py-4">
|
<div class="mx-auto flex max-w-lg items-center px-4 py-4">
|
||||||
<h1 class="text-xl font-bold text-gray-900">Mein Konto</h1>
|
<h1 class="text-xl font-bold text-gray-900 dark:text-gray-100">Mein Konto</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mx-auto max-w-lg space-y-3 p-4">
|
<div class="mx-auto max-w-lg space-y-3 p-4">
|
||||||
<!-- Profile card -->
|
<!-- Profile card -->
|
||||||
<div class="rounded-xl border border-gray-200 bg-white p-5">
|
<div class="rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<div
|
<div
|
||||||
class="flex h-14 w-14 shrink-0 items-center justify-center rounded-full text-xl font-bold
|
class="flex h-14 w-14 shrink-0 items-center justify-center rounded-full text-xl font-bold
|
||||||
@@ -83,47 +152,47 @@
|
|||||||
{displayName ? displayName[0].toUpperCase() : '?'}
|
{displayName ? displayName[0].toUpperCase() : '?'}
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<p class="truncate text-lg font-bold text-gray-900">{displayName ?? 'Unbekannt'}</p>
|
<p class="truncate text-lg font-bold text-gray-900 dark:text-gray-100">{displayName ?? 'Unbekannt'}</p>
|
||||||
<span class="mt-0.5 inline-block rounded-full px-2.5 py-0.5 text-xs font-semibold {roleColor(role)}">
|
<span class="mt-0.5 inline-block rounded-full px-2.5 py-0.5 text-xs font-semibold {roleColor(role)}">
|
||||||
{roleLabel(role)}
|
{roleLabel(role)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if expiry}
|
{#if expiry}
|
||||||
<p class="mt-3 text-xs text-gray-400">Sitzung gültig bis {formatDate(expiry)}</p>
|
<p class="mt-3 text-xs text-gray-400 dark:text-gray-500">Sitzung gültig bis {formatDate(expiry)}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Dashboards section (host + admin only) -->
|
<!-- Dashboards section (host + admin only) -->
|
||||||
{#if role === 'host' || role === 'admin'}
|
{#if role === 'host' || role === 'admin'}
|
||||||
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white">
|
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
|
||||||
<div class="border-b border-gray-100 px-5 py-3">
|
<div class="border-b border-gray-100 px-5 py-3 dark:border-gray-700">
|
||||||
<h2 class="text-xs font-semibold uppercase tracking-wide text-gray-500">Dashboards</h2>
|
<h2 class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Dashboards</h2>
|
||||||
</div>
|
</div>
|
||||||
<a
|
<a
|
||||||
href="/host"
|
href="/host"
|
||||||
class="flex items-center gap-3 px-5 py-4 transition hover:bg-gray-50"
|
class="flex items-center gap-3 px-5 py-4 transition hover:bg-gray-50 dark:hover:bg-gray-700/50"
|
||||||
>
|
>
|
||||||
<!-- Star icon -->
|
<!-- Star icon -->
|
||||||
<svg class="h-5 w-5 text-amber-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
<svg class="h-5 w-5 text-amber-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.562.562 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.562.562 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.562.562 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.562.562 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span class="flex-1 font-medium text-gray-900">Host-Dashboard</span>
|
<span class="flex-1 font-medium text-gray-900 dark:text-gray-100">Host-Dashboard</span>
|
||||||
<svg class="h-4 w-4 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
<svg class="h-4 w-4 text-gray-300 dark:text-gray-600" 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" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
{#if role === 'admin'}
|
{#if role === 'admin'}
|
||||||
<a
|
<a
|
||||||
href="/admin"
|
href="/admin"
|
||||||
class="flex items-center gap-3 border-t border-gray-100 px-5 py-4 transition hover:bg-gray-50"
|
class="flex items-center gap-3 border-t border-gray-100 px-5 py-4 transition hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-700/50"
|
||||||
>
|
>
|
||||||
<!-- Shield icon -->
|
<!-- Shield icon -->
|
||||||
<svg class="h-5 w-5 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
<svg class="h-5 w-5 text-blue-600 dark:text-blue-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span class="flex-1 font-medium text-gray-900">Admin-Dashboard</span>
|
<span class="flex-1 font-medium text-gray-900 dark:text-gray-100">Admin-Dashboard</span>
|
||||||
<svg class="h-4 w-4 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
<svg class="h-4 w-4 text-gray-300 dark:text-gray-600" 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" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
@@ -132,44 +201,169 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- PIN card -->
|
<!-- PIN card -->
|
||||||
<div class="rounded-xl border border-amber-200 bg-amber-50 p-5">
|
<div class="rounded-xl border border-amber-200 bg-amber-50 p-5 dark:border-amber-800/60 dark:bg-amber-950/30">
|
||||||
<h2 class="mb-1 font-semibold text-amber-900">Wiederherstellungs-PIN</h2>
|
<h2 class="mb-1 font-semibold text-amber-900 dark:text-amber-200">Wiederherstellungs-PIN</h2>
|
||||||
<p class="mb-3 text-sm text-amber-700">
|
<p class="mb-3 text-sm text-amber-700 dark:text-amber-300/90">
|
||||||
Du brauchst diesen PIN, um dein Konto auf einem anderen Gerät wiederherzustellen. Schreib ihn auf!
|
Du brauchst diesen PIN, um dein Konto auf einem anderen Gerät wiederherzustellen. Schreib ihn auf!
|
||||||
</p>
|
</p>
|
||||||
{#if pin}
|
{#if $pin}
|
||||||
<div class="flex items-center justify-between rounded-lg bg-white px-4 py-3 shadow-sm">
|
<div class="flex items-center justify-between rounded-lg bg-white px-4 py-3 shadow-sm dark:bg-gray-900">
|
||||||
<span class="font-mono text-4xl font-bold tracking-widest text-gray-900">{pin}</span>
|
<span class="font-mono text-4xl font-bold tracking-widest text-gray-900 dark:text-gray-100">{$pin}</span>
|
||||||
<button
|
<button
|
||||||
onclick={copyPin}
|
onclick={copyPin}
|
||||||
class="rounded-md bg-amber-100 px-3 py-1.5 text-sm font-medium text-amber-800 transition hover:bg-amber-200"
|
class="rounded-md bg-amber-100 px-3 py-1.5 text-sm font-medium text-amber-800 transition hover:bg-amber-200 dark:bg-amber-900/40 dark:text-amber-200 dark:hover:bg-amber-900/60"
|
||||||
>
|
>
|
||||||
{pinCopied ? 'Kopiert!' : 'Kopieren'}
|
{pinCopied ? 'Kopiert!' : 'Kopieren'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="rounded-lg bg-white px-4 py-3 text-sm text-gray-400 shadow-sm">
|
<div class="rounded-lg bg-white px-4 py-3 text-sm text-gray-400 shadow-sm dark:bg-gray-900 dark:text-gray-500">
|
||||||
PIN nicht gespeichert. Nutze die Wiederherstellungs-Seite, um dich mit deinem PIN anzumelden.
|
PIN nicht gespeichert. Nutze die Wiederherstellungs-Seite, um dich mit deinem PIN anzumelden.
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Diashow tile — primarily mobile, where the feed header doesn't show the icon. -->
|
||||||
|
<a
|
||||||
|
href="/diashow"
|
||||||
|
class="flex items-center gap-3 rounded-xl border border-gray-200 bg-white px-5 py-4 transition hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-700/50 sm:hidden"
|
||||||
|
>
|
||||||
|
<svg class="h-5 w-5 text-purple-500 dark:text-purple-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 7.5A2.25 2.25 0 0 1 6 5.25h12A2.25 2.25 0 0 1 20.25 7.5v9A2.25 2.25 0 0 1 18 18.75H6A2.25 2.25 0 0 1 3.75 16.5v-9Z" />
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M10 9.75 14.5 12 10 14.25v-4.5Z" />
|
||||||
|
</svg>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="font-medium text-gray-900 dark:text-gray-100">Diashow starten</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">Vollbild-Präsentation der Beiträge</p>
|
||||||
|
</div>
|
||||||
|
<svg class="h-4 w-4 text-gray-300 dark:text-gray-600" 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>
|
||||||
|
|
||||||
|
<!-- Theme / Design -->
|
||||||
|
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
|
||||||
|
<div class="border-b border-gray-100 px-5 py-3 dark:border-gray-700">
|
||||||
|
<h2 class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Design</h2>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-3 gap-2 p-3" role="radiogroup" aria-label="Design">
|
||||||
|
{#each [
|
||||||
|
{ value: 'system', label: 'System', icon: '🖥️' },
|
||||||
|
{ value: 'light', label: 'Hell', icon: '☀️' },
|
||||||
|
{ value: 'dark', label: 'Dunkel', icon: '🌙' }
|
||||||
|
] as opt (opt.value)}
|
||||||
|
{@const selected = $themePreference === opt.value}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={selected}
|
||||||
|
onclick={() => themePreference.set(opt.value as ThemePreference)}
|
||||||
|
class="flex flex-col items-center gap-1 rounded-lg border-2 px-2 py-3 text-sm transition focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-200
|
||||||
|
{selected
|
||||||
|
? 'border-blue-600 bg-blue-50 text-blue-700 dark:border-blue-500 dark:bg-blue-950/40 dark:text-blue-200'
|
||||||
|
: 'border-gray-200 text-gray-700 hover:bg-gray-50 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-700/50'}"
|
||||||
|
>
|
||||||
|
<span class="text-2xl leading-none">{opt.icon}</span>
|
||||||
|
<span class="font-medium">{opt.label}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Data mode -->
|
||||||
|
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
|
||||||
|
<div class="border-b border-gray-100 px-5 py-3 dark:border-gray-700">
|
||||||
|
<h2 class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Datennutzung</h2>
|
||||||
|
</div>
|
||||||
|
<!--
|
||||||
|
Custom buttons styled as radios. The store is the single source of truth;
|
||||||
|
we don't lean on `<input type="radio">` because the native checked-state
|
||||||
|
model fights the "show confirm sheet first, then maybe commit" UX
|
||||||
|
(Svelte's reactive `checked={…}` and the browser's interactive state
|
||||||
|
model drift apart on cancel and on subsequent switches).
|
||||||
|
-->
|
||||||
|
<div class="px-5 py-4 space-y-2" role="radiogroup" aria-label="Datenmodus">
|
||||||
|
{#each [
|
||||||
|
{ value: 'saver', title: 'Datensparer (empfohlen)', body: 'Lädt komprimierte Vorschauen. Schnell und mobildatenfreundlich.' },
|
||||||
|
{ value: 'original', title: 'Original', body: 'Lädt die Originaldateien. Bessere Qualität, höherer Datenverbrauch.' }
|
||||||
|
] as opt (opt.value)}
|
||||||
|
{@const selected = $dataMode === opt.value}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
aria-checked={selected}
|
||||||
|
onclick={() => chooseDataMode(opt.value as 'saver' | 'original')}
|
||||||
|
class="flex w-full cursor-pointer items-start gap-3 rounded-lg p-1 text-left transition hover:bg-gray-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-200 dark:hover:bg-gray-700/50"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="mt-1 flex h-4 w-4 shrink-0 items-center justify-center rounded-full border-2 transition {selected ? 'border-blue-600 dark:border-blue-400' : 'border-gray-300 dark:border-gray-600'}"
|
||||||
|
>
|
||||||
|
{#if selected}
|
||||||
|
<span class="block h-2 w-2 rounded-full bg-blue-600 dark:bg-blue-400"></span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p class="font-medium text-gray-900 dark:text-gray-100">{opt.title}</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">{opt.body}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Per-user quota widget -->
|
||||||
|
{#if $quotaStore.enabled && $quotaStore.limit_bytes != null}
|
||||||
|
<div class="rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
|
||||||
|
<h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
|
Dein Speicherkontingent
|
||||||
|
</h2>
|
||||||
|
<div class="flex items-baseline justify-between">
|
||||||
|
<span class="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||||
|
{formatBytes($quotaStore.used_bytes)}
|
||||||
|
</span>
|
||||||
|
<span class="text-sm text-gray-500 dark:text-gray-400">von {formatBytes($quotaStore.limit_bytes)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 h-2 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-700">
|
||||||
|
<div
|
||||||
|
class="h-full transition-all"
|
||||||
|
class:bg-blue-500={quotaPercent < 80}
|
||||||
|
class:bg-amber-500={quotaPercent >= 80 && quotaPercent < 95}
|
||||||
|
class:bg-red-500={quotaPercent >= 95}
|
||||||
|
style="width: {quotaPercent}%"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
Geschätzt für {$quotaStore.active_uploaders} aktive Beitragende.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Datenschutzhinweis (preformatted, plain text) -->
|
||||||
|
{#if $privacyNote.trim()}
|
||||||
|
<div class="rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
|
||||||
|
<h2 class="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
|
Datenschutzhinweis
|
||||||
|
</h2>
|
||||||
|
<pre class="whitespace-pre-wrap font-sans text-sm text-gray-700 dark:text-gray-300">{$privacyNote}</pre>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Konto section -->
|
<!-- Konto section -->
|
||||||
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white">
|
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
|
||||||
<div class="border-b border-gray-100 px-5 py-3">
|
<div class="border-b border-gray-100 px-5 py-3 dark:border-gray-700">
|
||||||
<h2 class="text-xs font-semibold uppercase tracking-wide text-gray-500">Konto</h2>
|
<h2 class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Konto</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Recover / device switch -->
|
<!-- Recover / device switch -->
|
||||||
<a
|
<a
|
||||||
href="/recover"
|
href="/recover"
|
||||||
class="flex items-center gap-3 px-5 py-4 transition hover:bg-gray-50"
|
class="flex items-center gap-3 px-5 py-4 transition hover:bg-gray-50 dark:hover:bg-gray-700/50"
|
||||||
>
|
>
|
||||||
<svg class="h-5 w-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
<svg class="h-5 w-5 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 8.25h3m-3 3h3m-3 3h3" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 8.25h3m-3 3h3m-3 3h3" />
|
||||||
</svg>
|
</svg>
|
||||||
<span class="flex-1 text-sm font-medium text-gray-700">Gerät wechseln / PIN nutzen</span>
|
<span class="flex-1 text-sm font-medium text-gray-700 dark:text-gray-300">Gerät wechseln / PIN nutzen</span>
|
||||||
<svg class="h-4 w-4 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
<svg class="h-4 w-4 text-gray-300 dark:text-gray-600" 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" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
@@ -177,22 +371,22 @@
|
|||||||
<!-- Leave / logout -->
|
<!-- Leave / logout -->
|
||||||
<button
|
<button
|
||||||
onclick={() => (leaveConfirmOpen = true)}
|
onclick={() => (leaveConfirmOpen = true)}
|
||||||
class="flex w-full items-center gap-3 border-t border-gray-100 px-5 py-4 text-left transition hover:bg-red-50"
|
class="flex w-full items-center gap-3 border-t border-gray-100 px-5 py-4 text-left transition hover:bg-red-50 dark:border-gray-700 dark:hover:bg-red-950/30"
|
||||||
>
|
>
|
||||||
<svg class="h-5 w-5 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
<svg class="h-5 w-5 text-red-500 dark:text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9" />
|
||||||
</svg>
|
</svg>
|
||||||
<span class="flex-1 text-sm font-medium text-red-600">Event verlassen</span>
|
<span class="flex-1 text-sm font-medium text-red-600 dark:text-red-400">Event verlassen</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Leave-confirm bottom sheet -->
|
<!-- Data-mode warning bottom sheet — shown once when the user picks Original. -->
|
||||||
{#if leaveConfirmOpen}
|
{#if dataModeWarningOpen}
|
||||||
<div class="fixed inset-0 z-50 flex items-end bg-black/40" onclick={() => (leaveConfirmOpen = false)} aria-hidden="true">
|
<div class="fixed inset-0 z-50 flex items-end bg-black/40" onclick={() => (dataModeWarningOpen = false)} aria-hidden="true">
|
||||||
<div
|
<div
|
||||||
class="w-full rounded-t-2xl bg-white px-5 pb-10 pt-6"
|
class="w-full rounded-t-2xl bg-white px-5 pb-10 pt-6 dark:bg-gray-900"
|
||||||
onclick={(e) => e.stopPropagation()}
|
onclick={(e) => e.stopPropagation()}
|
||||||
onkeydown={(e) => e.stopPropagation()}
|
onkeydown={(e) => e.stopPropagation()}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
@@ -200,21 +394,55 @@
|
|||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
>
|
>
|
||||||
<div class="mb-4 flex justify-center">
|
<div class="mb-4 flex justify-center">
|
||||||
<div class="h-1 w-10 rounded-full bg-gray-300"></div>
|
<div class="h-1 w-10 rounded-full bg-gray-300 dark:bg-gray-600"></div>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="mb-1 text-center text-lg font-bold text-gray-900">Event verlassen?</h3>
|
<h3 class="mb-1 text-center text-lg font-bold text-gray-900 dark:text-gray-100">Original-Dateien laden?</h3>
|
||||||
<p class="mb-6 text-center text-sm text-gray-500">
|
<p class="mb-6 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||||
Du wirst abgemeldet. Mit deinem PIN kannst du jederzeit zurückkehren.
|
Original-Dateien können deutlich mehr Datenvolumen verbrauchen. Am besten im WLAN aktivieren.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onclick={handleLogout}
|
onclick={confirmOriginalMode}
|
||||||
class="mb-3 w-full rounded-xl bg-red-600 py-3 text-sm font-semibold text-white transition hover:bg-red-700"
|
class="mb-3 w-full rounded-xl bg-blue-600 py-3 text-sm font-semibold text-white transition hover:bg-blue-700"
|
||||||
>
|
>
|
||||||
Abmelden
|
Aktivieren
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onclick={() => (leaveConfirmOpen = false)}
|
onclick={() => (dataModeWarningOpen = false)}
|
||||||
class="w-full rounded-xl border border-gray-200 py-3 text-sm font-medium text-gray-700 transition hover:bg-gray-50"
|
class="w-full rounded-xl border border-gray-200 py-3 text-sm font-medium text-gray-700 transition hover:bg-gray-50 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Leave-confirm bottom sheet -->
|
||||||
|
{#if leaveConfirmOpen}
|
||||||
|
<div class="fixed inset-0 z-50 flex items-end bg-black/40" onclick={() => (leaveConfirmOpen = false)} aria-hidden="true">
|
||||||
|
<div
|
||||||
|
class="w-full rounded-t-2xl bg-white px-5 pb-10 pt-6 dark:bg-gray-900"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
onkeydown={(e) => e.stopPropagation()}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<div class="mb-4 flex justify-center">
|
||||||
|
<div class="h-1 w-10 rounded-full bg-gray-300 dark:bg-gray-600"></div>
|
||||||
|
</div>
|
||||||
|
<h3 class="mb-1 text-center text-lg font-bold text-gray-900 dark:text-gray-100">Event verlassen?</h3>
|
||||||
|
<p class="mb-6 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Du wirst abgemeldet. Mit deinem PIN kannst du jederzeit zurückkehren.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onclick={handleLogout}
|
||||||
|
class="mb-3 w-full rounded-xl bg-red-600 py-3 text-sm font-semibold text-white transition hover:bg-red-700 dark:bg-red-500 dark:hover:bg-red-400"
|
||||||
|
>
|
||||||
|
Abmelden
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => (leaveConfirmOpen = false)}
|
||||||
|
class="w-full rounded-xl border border-gray-200 py-3 text-sm font-medium text-gray-700 transition hover:bg-gray-50 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"
|
||||||
>
|
>
|
||||||
Abbrechen
|
Abbrechen
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -34,16 +34,68 @@
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CONFIG_LABELS: Record<string, string> = {
|
type ConfigKind = 'number' | 'bool' | 'text';
|
||||||
max_image_size_mb: 'Max. Bildgröße (MB)',
|
interface ConfigField {
|
||||||
max_video_size_mb: 'Max. Videogröße (MB)',
|
key: string;
|
||||||
upload_rate_per_hour: 'Upload-Limit pro Stunde',
|
label: string;
|
||||||
feed_rate_per_min: 'Feed-Anfragen pro Minute',
|
kind: ConfigKind;
|
||||||
export_rate_per_day: 'Export-Downloads pro Tag',
|
hint?: string;
|
||||||
quota_tolerance: 'Speicherkontingent-Toleranz (0–1)',
|
}
|
||||||
estimated_guest_count: 'Geschätzte Gästezahl',
|
interface ConfigGroup {
|
||||||
compression_concurrency: 'Kompressions-Worker'
|
title: string;
|
||||||
};
|
fields: ConfigField[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grouped sections — adding a new key is one entry in the right group, no other
|
||||||
|
// code changes required. The form renders each field based on `kind`.
|
||||||
|
const CONFIG_GROUPS: ConfigGroup[] = [
|
||||||
|
{
|
||||||
|
title: 'Limits & Größen',
|
||||||
|
fields: [
|
||||||
|
{ key: 'max_image_size_mb', label: 'Max. Bildgröße (MB)', kind: 'number' },
|
||||||
|
{ key: 'max_video_size_mb', label: 'Max. Videogröße (MB)', kind: 'number' },
|
||||||
|
{ key: 'compression_concurrency', label: 'Kompressions-Worker', kind: 'number' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Rate-Limits',
|
||||||
|
fields: [
|
||||||
|
{ key: 'rate_limits_enabled', label: 'Rate-Limits aktiv', kind: 'bool', hint: 'Hauptschalter — wenn aus, sind alle Rate-Limits deaktiviert.' },
|
||||||
|
{ key: 'upload_rate_enabled', label: 'Upload-Limit aktiv', kind: 'bool' },
|
||||||
|
{ key: 'feed_rate_enabled', label: 'Feed-Limit aktiv', kind: 'bool' },
|
||||||
|
{ key: 'export_rate_enabled', label: 'Export-Limit aktiv', kind: 'bool' },
|
||||||
|
{ key: 'join_rate_enabled', label: 'Join-Limit aktiv', kind: 'bool' },
|
||||||
|
{ key: 'upload_rate_per_hour', label: 'Upload-Limit pro Stunde', kind: 'number' },
|
||||||
|
{ key: 'feed_rate_per_min', label: 'Feed-Anfragen pro Minute', kind: 'number' },
|
||||||
|
{ key: 'export_rate_per_day', label: 'Export-Downloads pro Tag', kind: 'number' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Quoten',
|
||||||
|
fields: [
|
||||||
|
{ key: 'quota_enabled', label: 'Quoten aktiv', kind: 'bool', hint: 'Hauptschalter — wenn aus, wird nichts geprüft.' },
|
||||||
|
{ key: 'storage_quota_enabled', label: 'Speicher-Quote aktiv', kind: 'bool' },
|
||||||
|
{ key: 'upload_count_quota_enabled', label: 'Upload-Anzahl-Quote aktiv', kind: 'bool', hint: 'Reserviert für künftige Anzahl-Limits.' },
|
||||||
|
{ key: 'quota_tolerance', label: 'Toleranz (0–1)', kind: 'number' },
|
||||||
|
{ key: 'estimated_guest_count', label: 'Geschätzte Gästezahl', kind: 'number' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Datenschutzhinweis',
|
||||||
|
fields: [
|
||||||
|
{ key: 'privacy_note', label: 'Datenschutzhinweis (freier Text)', kind: 'text', hint: 'Wird wörtlich im Konto-Bereich angezeigt. Kein HTML — Leerzeichen und Zeilenumbrüche werden übernommen.' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
function isTrue(v: string | undefined): boolean {
|
||||||
|
if (!v) return false;
|
||||||
|
return ['true', '1', 'yes', 'on'].includes(v.trim().toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleBool(key: string) {
|
||||||
|
configDraft = { ...configDraft, [key]: isTrue(configDraft[key]) ? 'false' : 'true' };
|
||||||
|
}
|
||||||
|
|
||||||
type AdminTab = 'stats' | 'config' | 'export' | 'users';
|
type AdminTab = 'stats' | 'config' | 'export' | 'users';
|
||||||
const TAB_LABELS: Record<AdminTab, string> = { stats: 'Stats', config: 'Config', export: 'Export', users: 'Nutzer' };
|
const TAB_LABELS: Record<AdminTab, string> = { stats: 'Stats', config: 'Config', export: 'Export', users: 'Nutzer' };
|
||||||
@@ -74,6 +126,12 @@
|
|||||||
let banHideUploads = $state(false);
|
let banHideUploads = $state(false);
|
||||||
let banSubmitting = $state(false);
|
let banSubmitting = $state(false);
|
||||||
|
|
||||||
|
// PIN reset state — `pinModal` holds the freshly-issued plaintext PIN. We forget it
|
||||||
|
// the moment the modal closes.
|
||||||
|
let pinResetTarget = $state<UserSummary | null>(null);
|
||||||
|
let pinResetSubmitting = $state(false);
|
||||||
|
let pinModal = $state<{ name: string; pin: string } | null>(null);
|
||||||
|
|
||||||
const myRole = getRole();
|
const myRole = getRole();
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
@@ -200,6 +258,39 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function askResetPin(user: UserSummary) {
|
||||||
|
pinResetTarget = user;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmResetPin() {
|
||||||
|
if (!pinResetTarget) return;
|
||||||
|
pinResetSubmitting = true;
|
||||||
|
try {
|
||||||
|
const res = await api.post<{ pin: string }>(`/host/users/${pinResetTarget.id}/pin-reset`);
|
||||||
|
pinModal = { name: pinResetTarget.display_name, pin: res.pin };
|
||||||
|
pinResetTarget = null;
|
||||||
|
} catch (e: unknown) {
|
||||||
|
showToast(e instanceof Error ? e.message : 'Fehler beim Zurücksetzen.');
|
||||||
|
} finally {
|
||||||
|
pinResetSubmitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyPinModal() {
|
||||||
|
if (!pinModal) return;
|
||||||
|
navigator.clipboard.writeText(pinModal.pin);
|
||||||
|
showToast('PIN kopiert.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True iff the current caller may reset this target's PIN. Mirrors the backend
|
||||||
|
* rules in `handlers::host::reset_user_pin`. */
|
||||||
|
function canResetPinFor(target: UserSummary): boolean {
|
||||||
|
if (target.role === 'admin') return false;
|
||||||
|
if (myRole === 'admin') return true; // any non-admin
|
||||||
|
if (myRole === 'host') return target.role === 'guest';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
function formatBytes(bytes: number): string {
|
function formatBytes(bytes: number): string {
|
||||||
if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
|
if (bytes >= 1024 ** 3) return `${(bytes / 1024 ** 3).toFixed(1)} GB`;
|
||||||
if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
|
if (bytes >= 1024 ** 2) return `${(bytes / 1024 ** 2).toFixed(1)} MB`;
|
||||||
@@ -217,10 +308,10 @@
|
|||||||
|
|
||||||
function statusBadgeClass(status: string): string {
|
function statusBadgeClass(status: string): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'done': return 'bg-green-100 text-green-700';
|
case 'done': return 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-200';
|
||||||
case 'running': return 'bg-blue-100 text-blue-700';
|
case 'running': return 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-200';
|
||||||
case 'failed': return 'bg-red-100 text-red-700';
|
case 'failed': return 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-200';
|
||||||
default: return 'bg-gray-100 text-gray-600';
|
default: return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,21 +326,63 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- PIN reset confirmation -->
|
||||||
|
{#if pinResetTarget}
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||||
|
<div class="w-full max-w-sm rounded-2xl bg-white p-6 shadow-xl dark:bg-gray-900">
|
||||||
|
<h2 class="mb-1 text-lg font-bold text-gray-900 dark:text-gray-100">PIN zurücksetzen</h2>
|
||||||
|
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Eine neue PIN für <strong>{pinResetTarget.display_name}</strong> wird erzeugt. Die alte PIN funktioniert dann nicht mehr.
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button onclick={() => (pinResetTarget = null)} 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</button>
|
||||||
|
<button onclick={confirmResetPin} disabled={pinResetSubmitting} class="flex-1 rounded-lg bg-amber-500 py-2 text-sm font-medium text-white hover:bg-amber-600 disabled:opacity-50 dark:bg-amber-500 dark:hover:bg-amber-400">
|
||||||
|
{pinResetSubmitting ? 'Wird erzeugt…' : 'Neue PIN erzeugen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- One-time PIN display modal -->
|
||||||
|
{#if pinModal}
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
|
||||||
|
<div class="w-full max-w-sm rounded-2xl bg-white p-6 shadow-xl dark:bg-gray-900">
|
||||||
|
<h2 class="mb-1 text-lg font-bold text-gray-900 dark:text-gray-100">Neue PIN für {pinModal.name}</h2>
|
||||||
|
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Zeige diese PIN dem Benutzer. Sie wird nur einmal angezeigt — beim Schließen wird sie verworfen.
|
||||||
|
</p>
|
||||||
|
<div class="mb-4 flex items-center justify-between rounded-lg bg-amber-50 px-4 py-3 dark:bg-amber-950/30">
|
||||||
|
<span class="font-mono text-3xl font-bold tracking-widest text-gray-900 dark:text-gray-100">{pinModal.pin}</span>
|
||||||
|
<button onclick={copyPinModal} class="rounded-md bg-amber-100 px-3 py-1.5 text-sm font-medium text-amber-800 hover:bg-amber-200 dark:bg-amber-900/40 dark:text-amber-200 dark:hover:bg-amber-900/60">
|
||||||
|
Kopieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onclick={() => (pinModal = null)}
|
||||||
|
class="w-full rounded-lg bg-blue-600 py-2 text-sm font-semibold text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-400"
|
||||||
|
>
|
||||||
|
Schließen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Ban modal -->
|
<!-- Ban modal -->
|
||||||
{#if banTarget}
|
{#if banTarget}
|
||||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||||
<div class="w-full max-w-sm rounded-2xl bg-white p-6 shadow-xl">
|
<div class="w-full max-w-sm rounded-2xl bg-white p-6 shadow-xl dark:bg-gray-900">
|
||||||
<h2 class="mb-1 text-lg font-bold text-gray-900">Benutzer sperren</h2>
|
<h2 class="mb-1 text-lg font-bold text-gray-900 dark:text-gray-100">Benutzer sperren</h2>
|
||||||
<p class="mb-4 text-sm text-gray-600">
|
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||||
Was soll mit den Uploads von <strong>{banTarget.display_name}</strong> passieren?
|
Was soll mit den Uploads von <strong>{banTarget.display_name}</strong> passieren?
|
||||||
</p>
|
</p>
|
||||||
<label class="mb-4 flex cursor-pointer items-center gap-3 rounded-lg border border-gray-200 p-3">
|
<label class="mb-4 flex cursor-pointer items-center gap-3 rounded-lg border border-gray-200 p-3 dark:border-gray-700">
|
||||||
<input type="checkbox" bind:checked={banHideUploads} class="h-4 w-4 rounded border-gray-300 text-red-600 focus:ring-red-500" />
|
<input type="checkbox" bind:checked={banHideUploads} class="h-4 w-4 rounded border-gray-300 text-red-600 focus:ring-red-500 dark:border-gray-600" />
|
||||||
<span class="text-sm text-gray-700">Uploads aus der Galerie ausblenden</span>
|
<span class="text-sm text-gray-700 dark:text-gray-300">Uploads aus der Galerie ausblenden</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button onclick={() => (banTarget = null)} class="flex-1 rounded-lg border border-gray-300 py-2 text-sm text-gray-700 hover:bg-gray-50">Abbrechen</button>
|
<button onclick={() => (banTarget = null)} 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</button>
|
||||||
<button onclick={confirmBan} disabled={banSubmitting} class="flex-1 rounded-lg bg-red-600 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50">
|
<button onclick={confirmBan} disabled={banSubmitting} class="flex-1 rounded-lg bg-red-600 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50 dark:bg-red-500 dark:hover:bg-red-400">
|
||||||
{banSubmitting ? 'Wird gesperrt…' : 'Sperren'}
|
{banSubmitting ? 'Wird gesperrt…' : 'Sperren'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -259,36 +392,36 @@
|
|||||||
|
|
||||||
<!-- Toast -->
|
<!-- Toast -->
|
||||||
{#if toast}
|
{#if toast}
|
||||||
<div class="fixed bottom-24 left-1/2 z-50 -translate-x-1/2 rounded-full bg-gray-900 px-5 py-2.5 text-sm text-white shadow-lg">
|
<div class="fixed bottom-24 left-1/2 z-50 -translate-x-1/2 rounded-full bg-gray-900 px-5 py-2.5 text-sm text-white shadow-lg dark:bg-gray-100 dark:text-gray-900">
|
||||||
{toast}
|
{toast}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="min-h-screen bg-gray-50 pb-24">
|
<div class="min-h-screen bg-gray-50 pb-24 dark:bg-gray-950">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="border-b border-gray-200 bg-white">
|
<div class="border-b border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
|
||||||
<div class="mx-auto flex max-w-3xl items-center gap-3 px-4 py-4">
|
<div class="mx-auto flex max-w-3xl items-center gap-3 px-4 py-4">
|
||||||
<button
|
<button
|
||||||
onclick={() => goto('/account')}
|
onclick={() => goto('/account')}
|
||||||
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-gray-500 transition hover:bg-gray-100"
|
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-gray-500 transition hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
|
||||||
aria-label="Zurück"
|
aria-label="Zurück"
|
||||||
>
|
>
|
||||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<h1 class="text-xl font-bold text-gray-900">Admin-Dashboard</h1>
|
<h1 class="text-xl font-bold text-gray-900 dark:text-gray-100">Admin-Dashboard</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Inner tab bar -->
|
<!-- Inner tab bar -->
|
||||||
<div class="sticky top-0 z-20 overflow-x-auto border-b border-gray-200 bg-white">
|
<div class="sticky top-0 z-20 overflow-x-auto border-b border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
|
||||||
<div class="mx-auto flex max-w-3xl min-w-max">
|
<div class="mx-auto flex max-w-3xl min-w-max">
|
||||||
{#each Object.entries(TAB_LABELS) as [tab, label]}
|
{#each Object.entries(TAB_LABELS) as [tab, label]}
|
||||||
<button
|
<button
|
||||||
onclick={() => (activeTab = tab as AdminTab)}
|
onclick={() => (activeTab = tab as AdminTab)}
|
||||||
class="px-5 py-3 text-sm font-medium whitespace-nowrap border-b-2 transition-colors
|
class="px-5 py-3 text-sm font-medium whitespace-nowrap border-b-2 transition-colors
|
||||||
{activeTab === tab ? 'border-blue-600 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700'}"
|
{activeTab === tab ? 'border-blue-600 text-blue-600 dark:border-blue-400 dark:text-blue-400' : 'border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'}"
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
@@ -298,7 +431,7 @@
|
|||||||
|
|
||||||
<div class="mx-auto max-w-3xl p-4">
|
<div class="mx-auto max-w-3xl p-4">
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="py-16 text-center text-gray-400">Laden…</div>
|
<div class="py-16 text-center text-gray-400 dark:text-gray-500">Laden…</div>
|
||||||
{:else if error}
|
{:else if error}
|
||||||
<div class="rounded-lg bg-red-50 p-4 text-sm text-red-700">{error}</div>
|
<div class="rounded-lg bg-red-50 p-4 text-sm text-red-700">{error}</div>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -308,63 +441,103 @@
|
|||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
{#if stats}
|
{#if stats}
|
||||||
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
<div class="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
<div class="rounded-xl bg-white border border-gray-200 p-4 text-center">
|
<div class="rounded-xl bg-white border border-gray-200 p-4 text-center dark:border-gray-700 dark:bg-gray-800">
|
||||||
<p class="text-3xl font-bold text-gray-900">{stats.user_count}</p>
|
<p class="text-3xl font-bold text-gray-900 dark:text-gray-100">{stats.user_count}</p>
|
||||||
<p class="mt-0.5 text-xs text-gray-500">Gäste</p>
|
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">Gäste</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-xl bg-white border border-gray-200 p-4 text-center">
|
<div class="rounded-xl bg-white border border-gray-200 p-4 text-center dark:border-gray-700 dark:bg-gray-800">
|
||||||
<p class="text-3xl font-bold text-gray-900">{stats.upload_count}</p>
|
<p class="text-3xl font-bold text-gray-900 dark:text-gray-100">{stats.upload_count}</p>
|
||||||
<p class="mt-0.5 text-xs text-gray-500">Uploads</p>
|
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">Uploads</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-xl bg-white border border-gray-200 p-4 text-center">
|
<div class="rounded-xl bg-white border border-gray-200 p-4 text-center dark:border-gray-700 dark:bg-gray-800">
|
||||||
<p class="text-3xl font-bold text-gray-900">{stats.comment_count}</p>
|
<p class="text-3xl font-bold text-gray-900 dark:text-gray-100">{stats.comment_count}</p>
|
||||||
<p class="mt-0.5 text-xs text-gray-500">Kommentare</p>
|
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">Kommentare</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-xl bg-white border border-gray-200 p-4 text-center">
|
<div class="rounded-xl bg-white border border-gray-200 p-4 text-center dark:border-gray-700 dark:bg-gray-800">
|
||||||
<p class="text-3xl font-bold text-gray-900">{diskPct(stats)} %</p>
|
<p class="text-3xl font-bold text-gray-900 dark:text-gray-100">{diskPct(stats)} %</p>
|
||||||
<p class="mt-0.5 text-xs text-gray-500">Speicher</p>
|
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">Speicher</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Disk bar -->
|
<!-- Disk bar -->
|
||||||
<div class="rounded-xl border border-gray-200 bg-white p-5">
|
<div class="rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
|
||||||
<div class="mb-1 flex items-center justify-between text-xs text-gray-500">
|
<div class="mb-1 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
|
||||||
<span>Speicherauslastung</span>
|
<span>Speicherauslastung</span>
|
||||||
<span>{formatBytes(stats.disk_used_bytes)} / {formatBytes(stats.disk_total_bytes)}</span>
|
<span>{formatBytes(stats.disk_used_bytes)} / {formatBytes(stats.disk_total_bytes)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="h-2.5 overflow-hidden rounded-full bg-gray-200">
|
<div class="h-2.5 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
|
||||||
<div
|
<div
|
||||||
class="h-full rounded-full transition-all {diskPct(stats) >= 90 ? 'bg-red-500' : diskPct(stats) >= 75 ? 'bg-amber-500' : 'bg-blue-500'}"
|
class="h-full rounded-full transition-all {diskPct(stats) >= 90 ? 'bg-red-500' : diskPct(stats) >= 75 ? 'bg-amber-500' : 'bg-blue-500'}"
|
||||||
style="width: {diskPct(stats)}%"
|
style="width: {diskPct(stats)}%"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1.5 text-xs text-gray-400">{formatBytes(stats.disk_free_bytes)} frei</p>
|
<p class="mt-1.5 text-xs text-gray-400 dark:text-gray-500">{formatBytes(stats.disk_free_bytes)} frei</p>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Config tab ───────────────────────────────────────────────── -->
|
<!-- ── Config tab ───────────────────────────────────────────────── -->
|
||||||
{:else if activeTab === 'config'}
|
{:else if activeTab === 'config'}
|
||||||
<div class="relative">
|
<div class="relative space-y-3 pb-20">
|
||||||
<div class="space-y-3 rounded-xl border border-gray-200 bg-white p-5 pb-20">
|
{#each CONFIG_GROUPS as group (group.title)}
|
||||||
{#each Object.entries(CONFIG_LABELS) as [key, label]}
|
<div class="rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
|
||||||
<div>
|
<div class="border-b border-gray-100 px-5 py-3 dark:border-gray-700">
|
||||||
<label for={key} class="mb-1 block text-sm font-medium text-gray-700">{label}</label>
|
<h3 class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{group.title}</h3>
|
||||||
<input
|
|
||||||
id={key}
|
|
||||||
type="number"
|
|
||||||
step="any"
|
|
||||||
bind:value={configDraft[key]}
|
|
||||||
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-200"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
<div class="space-y-4 px-5 py-4">
|
||||||
</div>
|
{#each group.fields as field (field.key)}
|
||||||
|
<div>
|
||||||
|
{#if field.kind === 'bool'}
|
||||||
|
<label class="flex cursor-pointer items-start gap-3" for={field.key}>
|
||||||
|
<input
|
||||||
|
id={field.key}
|
||||||
|
type="checkbox"
|
||||||
|
class="mt-1 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
|
||||||
|
checked={isTrue(configDraft[field.key])}
|
||||||
|
onchange={() => toggleBool(field.key)}
|
||||||
|
/>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">{field.label}</span>
|
||||||
|
{#if field.hint}
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">{field.hint}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
{:else if field.kind === 'text'}
|
||||||
|
<label for={field.key} class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">{field.label}</label>
|
||||||
|
<textarea
|
||||||
|
id={field.key}
|
||||||
|
rows="6"
|
||||||
|
bind:value={configDraft[field.key]}
|
||||||
|
class="w-full resize-none rounded-lg border border-gray-300 bg-white px-3 py-2 font-mono text-sm text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-200 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100"
|
||||||
|
></textarea>
|
||||||
|
{#if field.hint}
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{field.hint}</p>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<label for={field.key} class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">{field.label}</label>
|
||||||
|
<input
|
||||||
|
id={field.key}
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
bind:value={configDraft[field.key]}
|
||||||
|
class="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-200 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100"
|
||||||
|
/>
|
||||||
|
{#if field.hint}
|
||||||
|
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">{field.hint}</p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
|
||||||
<!-- Sticky save button -->
|
<!-- Sticky save button -->
|
||||||
<div class="sticky bottom-0 border-t border-gray-100 bg-white px-5 py-3">
|
<div class="sticky bottom-0 -mx-4 border-t border-gray-100 bg-white px-5 py-3 dark:border-gray-800 dark:bg-gray-900 sm:mx-0 sm:rounded-b-xl">
|
||||||
<button
|
<button
|
||||||
onclick={saveConfig}
|
onclick={saveConfig}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
class="w-full rounded-xl bg-blue-600 py-3 text-sm font-semibold text-white transition hover:bg-blue-700 disabled:opacity-50"
|
class="w-full rounded-xl bg-blue-600 py-3 text-sm font-semibold text-white transition hover:bg-blue-700 disabled:opacity-50 dark:bg-blue-500 dark:hover:bg-blue-400"
|
||||||
>
|
>
|
||||||
{saving ? 'Wird gespeichert…' : 'Speichern'}
|
{saving ? 'Wird gespeichert…' : 'Speichern'}
|
||||||
</button>
|
</button>
|
||||||
@@ -375,52 +548,52 @@
|
|||||||
{:else if activeTab === 'export'}
|
{:else if activeTab === 'export'}
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<!-- Gallery release -->
|
<!-- Gallery release -->
|
||||||
<div class="rounded-xl border border-gray-200 bg-white p-5">
|
<div class="rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
|
||||||
<h3 class="mb-3 font-semibold text-gray-900">Galerie</h3>
|
<h3 class="mb-3 font-semibold text-gray-900 dark:text-gray-100">Galerie</h3>
|
||||||
<button
|
<button
|
||||||
onclick={releaseGallery}
|
onclick={releaseGallery}
|
||||||
class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-blue-700"
|
class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-400"
|
||||||
>
|
>
|
||||||
Galerie freigeben
|
Galerie freigeben
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Export jobs -->
|
<!-- Export jobs -->
|
||||||
<div class="rounded-xl border border-gray-200 bg-white p-5">
|
<div class="rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-700 dark:bg-gray-800">
|
||||||
<div class="mb-4 flex items-center justify-between">
|
<div class="mb-4 flex items-center justify-between">
|
||||||
<h3 class="font-semibold text-gray-900">Export-Jobs</h3>
|
<h3 class="font-semibold text-gray-900 dark:text-gray-100">Export-Jobs</h3>
|
||||||
<button
|
<button
|
||||||
onclick={refreshExportJobs}
|
onclick={refreshExportJobs}
|
||||||
disabled={exportJobsRefreshing}
|
disabled={exportJobsRefreshing}
|
||||||
class="text-xs text-blue-600 hover:underline disabled:opacity-50"
|
class="text-xs text-blue-600 hover:underline disabled:opacity-50 dark:text-blue-400"
|
||||||
>
|
>
|
||||||
{exportJobsRefreshing ? 'Lädt…' : 'Aktualisieren'}
|
{exportJobsRefreshing ? 'Lädt…' : 'Aktualisieren'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{#if exportJobs.length === 0}
|
{#if exportJobs.length === 0}
|
||||||
<p class="text-sm text-gray-400">Noch keine Export-Jobs.</p>
|
<p class="text-sm text-gray-400 dark:text-gray-500">Noch keine Export-Jobs.</p>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
{#each exportJobs as job}
|
{#each exportJobs as job}
|
||||||
<div class="rounded-lg border border-gray-100 p-3">
|
<div class="rounded-lg border border-gray-100 p-3 dark:border-gray-700">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="text-sm font-medium text-gray-900">{jobLabel(job.type)}</span>
|
<span class="text-sm font-medium text-gray-900 dark:text-gray-100">{jobLabel(job.type)}</span>
|
||||||
<span class="rounded-full px-2 py-0.5 text-xs font-medium {statusBadgeClass(job.status)}">
|
<span class="rounded-full px-2 py-0.5 text-xs font-medium {statusBadgeClass(job.status)}">
|
||||||
{statusLabel(job.status)}
|
{statusLabel(job.status)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{#if job.status === 'running'}
|
{#if job.status === 'running'}
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<div class="mb-1 flex justify-between text-xs text-gray-500">
|
<div class="mb-1 flex justify-between text-xs text-gray-500 dark:text-gray-400">
|
||||||
<span>Fortschritt</span><span>{job.progress_pct} %</span>
|
<span>Fortschritt</span><span>{job.progress_pct} %</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="h-1.5 overflow-hidden rounded-full bg-gray-200">
|
<div class="h-1.5 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
|
||||||
<div class="h-full rounded-full bg-blue-500 transition-all" style="width: {job.progress_pct}%"></div>
|
<div class="h-full rounded-full bg-blue-500 transition-all" style="width: {job.progress_pct}%"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if job.error_message}
|
{#if job.error_message}
|
||||||
<p class="mt-1 text-xs text-red-600">{job.error_message}</p>
|
<p class="mt-1 text-xs text-red-600 dark:text-red-400">{job.error_message}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -431,66 +604,71 @@
|
|||||||
|
|
||||||
<!-- ── Nutzer tab ───────────────────────────────────────────────── -->
|
<!-- ── Nutzer tab ───────────────────────────────────────────────── -->
|
||||||
{:else if activeTab === 'users'}
|
{:else if activeTab === 'users'}
|
||||||
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white">
|
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
|
||||||
<!-- Search -->
|
<!-- Search -->
|
||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
<div class="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2">
|
<div class="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 dark:border-gray-700 dark:bg-gray-900">
|
||||||
<svg class="h-4 w-4 shrink-0 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
<svg class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
||||||
</svg>
|
</svg>
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
placeholder="Nutzer suchen…"
|
placeholder="Nutzer suchen…"
|
||||||
bind:value={userSearch}
|
bind:value={userSearch}
|
||||||
class="min-w-0 flex-1 bg-transparent text-sm text-gray-900 placeholder-gray-400 outline-none"
|
class="min-w-0 flex-1 bg-transparent text-sm text-gray-900 placeholder-gray-400 outline-none dark:text-gray-100 dark:placeholder-gray-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if filteredUsers.length === 0}
|
{#if filteredUsers.length === 0}
|
||||||
<p class="px-5 py-8 text-center text-sm text-gray-400">Keine Treffer.</p>
|
<p class="px-5 py-8 text-center text-sm text-gray-400 dark:text-gray-500">Keine Treffer.</p>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="divide-y divide-gray-100">
|
<div class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
{#each filteredUsers as user}
|
{#each filteredUsers as user}
|
||||||
<div class="flex items-center gap-3 px-5 py-3">
|
<div class="flex items-center gap-3 px-5 py-3">
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="flex flex-wrap items-center gap-1.5">
|
<div class="flex flex-wrap items-center gap-1.5">
|
||||||
<span class="font-medium text-gray-900">{user.display_name}</span>
|
<span class="font-medium text-gray-900 dark:text-gray-100">{user.display_name}</span>
|
||||||
{#if user.role === 'host'}
|
{#if user.role === 'host'}
|
||||||
<span class="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700">Host</span>
|
<span class="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">Host</span>
|
||||||
{:else if user.role === 'admin'}
|
{:else if user.role === 'admin'}
|
||||||
<span class="rounded-full bg-purple-100 px-2 py-0.5 text-xs font-medium text-purple-700">Admin</span>
|
<span class="rounded-full bg-purple-100 px-2 py-0.5 text-xs font-medium text-purple-700 dark:bg-purple-900/40 dark:text-purple-200">Admin</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if user.is_banned}
|
{#if user.is_banned}
|
||||||
<span class="rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700">Gesperrt</span>
|
<span class="rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700 dark:bg-red-900/40 dark:text-red-200">Gesperrt</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-gray-400">
|
<p class="text-xs text-gray-400 dark:text-gray-500">
|
||||||
{user.upload_count} Upload{user.upload_count !== 1 ? 's' : ''} · {formatBytes(user.total_upload_bytes)}
|
{user.upload_count} Upload{user.upload_count !== 1 ? 's' : ''} · {formatBytes(user.total_upload_bytes)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex shrink-0 gap-1.5">
|
<div class="flex shrink-0 flex-wrap justify-end gap-1.5">
|
||||||
{#if user.role !== 'admin'}
|
{#if user.role !== 'admin'}
|
||||||
{#if user.is_banned}
|
{#if user.is_banned}
|
||||||
<button onclick={() => unban(user)} class="rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-200">
|
<button onclick={() => unban(user)} class="rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600">
|
||||||
Entsperren
|
Entsperren
|
||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
{#if user.role === 'guest'}
|
{#if user.role === 'guest'}
|
||||||
<button onclick={() => promoteToHost(user)} class="rounded-lg bg-blue-50 px-3 py-1.5 text-xs font-medium text-blue-700 hover:bg-blue-100">
|
<button onclick={() => promoteToHost(user)} class="rounded-lg bg-blue-50 px-3 py-1.5 text-xs font-medium text-blue-700 hover:bg-blue-100 dark:bg-blue-900/40 dark:text-blue-200 dark:hover:bg-blue-900/60">
|
||||||
Host
|
Host
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
{#if user.role === 'host' && myRole === 'admin'}
|
|
||||||
<button onclick={() => demoteToGuest(user)} class="rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-200">
|
|
||||||
Degradieren
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
<button onclick={() => openBanModal(user)} class="rounded-lg bg-red-50 px-3 py-1.5 text-xs font-medium text-red-700 hover:bg-red-100">
|
|
||||||
Sperren
|
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if user.role === 'host'}
|
||||||
|
<button onclick={() => demoteToGuest(user)} class="rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600">
|
||||||
|
Degradieren
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if canResetPinFor(user)}
|
||||||
|
<button onclick={() => askResetPin(user)} class="rounded-lg bg-amber-50 px-3 py-1.5 text-xs font-medium text-amber-700 hover:bg-amber-100 dark:bg-amber-900/40 dark:text-amber-200 dark:hover:bg-amber-900/60">
|
||||||
|
PIN zurücksetzen
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button onclick={() => openBanModal(user)} class="rounded-lg bg-red-50 px-3 py-1.5 text-xs font-medium text-red-700 hover:bg-red-100 dark:bg-red-950/40 dark:text-red-300 dark:hover:bg-red-950/60">
|
||||||
|
Sperren
|
||||||
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -34,10 +34,10 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex min-h-screen items-center justify-center bg-gray-50 px-4">
|
<div class="flex min-h-screen items-center justify-center bg-gray-50 px-4 dark:bg-gray-950">
|
||||||
<div class="w-full max-w-sm">
|
<div class="w-full max-w-sm">
|
||||||
<h1 class="mb-2 text-center text-2xl font-bold text-gray-900">Admin-Login</h1>
|
<h1 class="mb-2 text-center text-2xl font-bold text-gray-900 dark:text-gray-100">Admin-Login</h1>
|
||||||
<p class="mb-6 text-center text-gray-500 text-sm">Nur für Veranstalter</p>
|
<p class="mb-6 text-center text-gray-500 text-sm dark:text-gray-400">Nur für Veranstalter</p>
|
||||||
|
|
||||||
<form onsubmit={(e) => { e.preventDefault(); handleLogin(); }}>
|
<form onsubmit={(e) => { e.preventDefault(); handleLogin(); }}>
|
||||||
<input
|
<input
|
||||||
@@ -45,24 +45,24 @@
|
|||||||
bind:value={password}
|
bind:value={password}
|
||||||
placeholder="Passwort"
|
placeholder="Passwort"
|
||||||
autocomplete="current-password"
|
autocomplete="current-password"
|
||||||
class="mb-3 w-full rounded-lg border border-gray-300 px-4 py-3 text-lg focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200"
|
class="mb-3 w-full rounded-lg border border-gray-300 bg-white px-4 py-3 text-lg text-gray-900 placeholder-gray-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:placeholder-gray-500"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
<p class="mb-3 text-sm text-red-600">{error}</p>
|
<p class="mb-3 text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading || !password}
|
disabled={loading || !password}
|
||||||
class="w-full rounded-lg bg-blue-600 px-4 py-3 text-lg font-medium text-white transition hover:bg-blue-700 disabled:opacity-50"
|
class="w-full rounded-lg bg-blue-600 px-4 py-3 text-lg font-medium text-white transition hover:bg-blue-700 disabled:opacity-50 dark:bg-blue-500 dark:hover:bg-blue-400"
|
||||||
>
|
>
|
||||||
{loading ? 'Wird angemeldet…' : 'Anmelden'}
|
{loading ? 'Wird angemeldet…' : 'Anmelden'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p class="mt-4 text-center text-sm text-gray-500">
|
<p class="mt-4 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||||
<a href="/join" class="text-blue-600 hover:underline">Zurück zum Event</a>
|
<a href="/join" class="text-blue-600 hover:underline dark:text-blue-400">Zurück zum Event</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
223
frontend/src/routes/diashow/+page.svelte
Normal file
223
frontend/src/routes/diashow/+page.svelte
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
import { showBottomNav } from '$lib/ui-store';
|
||||||
|
import { dataMode, pickMediaUrl } from '$lib/data-mode-store';
|
||||||
|
import { onSseEvent } from '$lib/sse';
|
||||||
|
import { SlideQueue } from '$lib/diashow/queue';
|
||||||
|
import { transitions, findTransition } from '$lib/diashow/transitions';
|
||||||
|
import { acquireWakeLock, releaseWakeLock } from '$lib/diashow/wakelock';
|
||||||
|
import type { FeedUpload, FeedResponse } from '$lib/types';
|
||||||
|
|
||||||
|
const DWELL_OPTIONS = [3000, 6000, 10000];
|
||||||
|
|
||||||
|
let queue = new SlideQueue();
|
||||||
|
let current = $state<FeedUpload | null>(null);
|
||||||
|
let dwellMs = $state(6000);
|
||||||
|
let transitionId = $state('crossfade');
|
||||||
|
let paused = $state(false);
|
||||||
|
let showOverlay = $state(false);
|
||||||
|
let overlayHideTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let advanceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
const unsubs: Array<() => void> = [];
|
||||||
|
|
||||||
|
const transitionDef = $derived(findTransition(transitionId));
|
||||||
|
|
||||||
|
const mediaSrc = $derived(current ? pickMediaUrl($dataMode, current) : '');
|
||||||
|
const isVideo = $derived(current?.mime_type.startsWith('video/') ?? false);
|
||||||
|
|
||||||
|
function isEmpty(): boolean {
|
||||||
|
return queue.stats().known === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleNext() {
|
||||||
|
clearTimer();
|
||||||
|
if (paused) return;
|
||||||
|
// Videos: advance on `ended` or after `max(dwell, 12s)` — whichever first.
|
||||||
|
const ms = isVideo ? Math.max(dwellMs, 12000) : dwellMs;
|
||||||
|
advanceTimer = setTimeout(advance, ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearTimer() {
|
||||||
|
if (advanceTimer) {
|
||||||
|
clearTimeout(advanceTimer);
|
||||||
|
advanceTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function advance() {
|
||||||
|
const next = queue.next();
|
||||||
|
current = next;
|
||||||
|
if (current) scheduleNext();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadInitial() {
|
||||||
|
try {
|
||||||
|
const feed = await api.get<FeedResponse>('/feed?limit=200');
|
||||||
|
queue.seed(feed.uploads);
|
||||||
|
advance();
|
||||||
|
} catch {
|
||||||
|
// Silent — placeholder stays shown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// `upload-processed` carries only `{ upload_id }`; we re-fetch from /feed to get the
|
||||||
|
// preview/thumbnail URLs that just became available. We deliberately do NOT listen
|
||||||
|
// to `new-upload` here — its payload arrives before compression finishes
|
||||||
|
// (preview_url is still null), and `SlideQueue.pushLive` dedupes by id, so the
|
||||||
|
// preview would never be picked up if we enqueued the pre-processed version first.
|
||||||
|
async function handleUploadProcessed(data: string) {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(data) as { upload_id: string };
|
||||||
|
if (!payload.upload_id) return;
|
||||||
|
const feed = await api.get<FeedResponse>('/feed?limit=20');
|
||||||
|
const found = feed.uploads.find((u) => u.id === payload.upload_id);
|
||||||
|
if (found) {
|
||||||
|
queue.pushLive(found);
|
||||||
|
if (!current) advance();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore — silent recovery; the next event will retry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUploadDeleted(data: string) {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(data) as { upload_id: string };
|
||||||
|
const result = queue.remove(payload.upload_id, current?.id ?? null);
|
||||||
|
if (result.wasCurrent) advance();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function revealOverlay() {
|
||||||
|
showOverlay = true;
|
||||||
|
if (overlayHideTimer) clearTimeout(overlayHideTimer);
|
||||||
|
overlayHideTimer = setTimeout(() => (showOverlay = false), 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePause() {
|
||||||
|
paused = !paused;
|
||||||
|
if (paused) {
|
||||||
|
clearTimer();
|
||||||
|
} else {
|
||||||
|
scheduleNext();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function exit() {
|
||||||
|
void goto('/feed');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
if (showOverlay) {
|
||||||
|
showOverlay = false;
|
||||||
|
} else {
|
||||||
|
exit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
togglePause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
showBottomNav.set(false);
|
||||||
|
void acquireWakeLock();
|
||||||
|
unsubs.push(onSseEvent('upload-processed', handleUploadProcessed));
|
||||||
|
unsubs.push(onSseEvent('upload-deleted', handleUploadDeleted));
|
||||||
|
void loadInitial();
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
showBottomNav.set(true);
|
||||||
|
clearTimer();
|
||||||
|
if (overlayHideTimer) clearTimeout(overlayHideTimer);
|
||||||
|
void releaseWakeLock();
|
||||||
|
for (const unsub of unsubs) unsub();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:window onkeydown={handleKeydown} />
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-[60] flex items-center justify-center bg-black text-white"
|
||||||
|
role="presentation"
|
||||||
|
onclick={revealOverlay}
|
||||||
|
>
|
||||||
|
{#if current}
|
||||||
|
{#key current.id + '|' + transitionDef.id}
|
||||||
|
<transitionDef.component
|
||||||
|
src={mediaSrc}
|
||||||
|
{isVideo}
|
||||||
|
durationMs={transitionDef.defaultDurationMs}
|
||||||
|
/>
|
||||||
|
{/key}
|
||||||
|
{:else if isEmpty()}
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-2xl font-semibold">Noch keine Beiträge</p>
|
||||||
|
<p class="mt-2 text-white/60">Neue Beiträge erscheinen hier automatisch.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="text-white/60">Lade…</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if showOverlay}
|
||||||
|
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
|
||||||
|
<div
|
||||||
|
class="absolute inset-x-0 bottom-0 flex flex-col gap-3 bg-gradient-to-t from-black/90 via-black/60 to-transparent p-6 pb-10"
|
||||||
|
onclick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={togglePause}
|
||||||
|
class="rounded-full bg-white/10 px-4 py-2 text-sm font-medium hover:bg-white/20"
|
||||||
|
>
|
||||||
|
{paused ? '▶ Fortsetzen' : '⏸ Pause'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-2 rounded-full bg-white/10 px-3 py-2 text-sm">
|
||||||
|
Dauer
|
||||||
|
<select
|
||||||
|
bind:value={dwellMs}
|
||||||
|
onchange={scheduleNext}
|
||||||
|
class="bg-transparent text-sm font-medium focus:outline-none"
|
||||||
|
>
|
||||||
|
{#each DWELL_OPTIONS as ms (ms)}
|
||||||
|
<option value={ms} class="bg-black">{ms / 1000} s</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex items-center gap-2 rounded-full bg-white/10 px-3 py-2 text-sm">
|
||||||
|
Übergang
|
||||||
|
<select bind:value={transitionId} class="bg-transparent text-sm font-medium focus:outline-none">
|
||||||
|
{#each transitions as t (t.id)}
|
||||||
|
<option value={t.id} class="bg-black">{t.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={exit}
|
||||||
|
class="ml-auto rounded-full bg-white/10 px-4 py-2 text-sm font-medium hover:bg-white/20"
|
||||||
|
>
|
||||||
|
✕ Beenden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if current}
|
||||||
|
<p class="text-xs text-white/70">
|
||||||
|
{current.uploader_name}
|
||||||
|
{#if current.caption}· {current.caption}{/if}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -109,26 +109,26 @@
|
|||||||
<!-- HTML guide modal -->
|
<!-- HTML guide modal -->
|
||||||
{#if showHtmlGuide}
|
{#if showHtmlGuide}
|
||||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||||
<div class="w-full max-w-sm rounded-2xl bg-white p-6 shadow-xl">
|
<div class="w-full max-w-sm rounded-2xl bg-white p-6 shadow-xl dark:bg-gray-900">
|
||||||
<h2 class="mb-3 text-lg font-bold text-gray-900">Hinweis zum HTML-Viewer</h2>
|
<h2 class="mb-3 text-lg font-bold text-gray-900 dark:text-gray-100">Hinweis zum HTML-Viewer</h2>
|
||||||
<ol class="mb-4 space-y-2 text-sm text-gray-700">
|
<ol class="mb-4 space-y-2 text-sm text-gray-700 dark:text-gray-300">
|
||||||
<li class="flex gap-2"><span class="font-bold text-blue-600">1.</span> ZIP-Datei entpacken (Windows: Rechtsklick → "Alle extrahieren"; Mac: Doppelklick).</li>
|
<li class="flex gap-2"><span class="font-bold text-blue-600 dark:text-blue-400">1.</span> ZIP-Datei entpacken (Windows: Rechtsklick → "Alle extrahieren"; Mac: Doppelklick).</li>
|
||||||
<li class="flex gap-2"><span class="font-bold text-blue-600">2.</span> <strong>index.html</strong> im Browser öffnen.</li>
|
<li class="flex gap-2"><span class="font-bold text-blue-600 dark:text-blue-400">2.</span> <strong>index.html</strong> im Browser öffnen.</li>
|
||||||
<li class="flex gap-2"><span class="font-bold text-blue-600">3.</span> Kein Internet nötig — alles ist lokal gespeichert.</li>
|
<li class="flex gap-2"><span class="font-bold text-blue-600 dark:text-blue-400">3.</span> Kein Internet nötig — alles ist lokal gespeichert.</li>
|
||||||
</ol>
|
</ol>
|
||||||
<p class="mb-4 rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-700">
|
<p class="mb-4 rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-700 dark:bg-amber-950/30 dark:text-amber-300">
|
||||||
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)}
|
onclick={() => (showHtmlGuide = false)}
|
||||||
class="flex-1 rounded-lg border border-gray-300 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
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}
|
onclick={confirmHtmlDownload}
|
||||||
class="flex-1 rounded-lg bg-blue-600 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
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>
|
||||||
@@ -137,48 +137,48 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="min-h-screen bg-gray-50 pb-24">
|
<div class="min-h-screen bg-gray-50 pb-24 dark:bg-gray-950">
|
||||||
<div class="border-b border-gray-200 bg-white">
|
<div class="border-b border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
|
||||||
<div class="mx-auto flex max-w-lg items-center px-4 py-4">
|
<div class="mx-auto flex max-w-lg items-center px-4 py-4">
|
||||||
<h1 class="text-xl font-bold text-gray-900">Export</h1>
|
<h1 class="text-xl font-bold text-gray-900 dark:text-gray-100">Export</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mx-auto max-w-lg space-y-4 p-4">
|
<div class="mx-auto max-w-lg space-y-4 p-4">
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="py-16 text-center text-gray-400">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 class="rounded-xl border border-gray-200 bg-white p-6 text-center">
|
<div class="rounded-xl border border-gray-200 bg-white p-6 text-center dark:border-gray-700 dark:bg-gray-800">
|
||||||
<svg class="mx-auto mb-3 h-12 w-12 text-gray-300" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg class="mx-auto mb-3 h-12 w-12 text-gray-300 dark:text-gray-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||||
</svg>
|
</svg>
|
||||||
<p class="font-medium text-gray-700">Export noch nicht verfügbar</p>
|
<p class="font-medium text-gray-700 dark:text-gray-300">Export noch nicht verfügbar</p>
|
||||||
<p class="mt-1 text-sm text-gray-500">Schau nach der Veranstaltung noch einmal vorbei.</p>
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Schau nach der Veranstaltung noch einmal vorbei.</p>
|
||||||
</div>
|
</div>
|
||||||
{:else if status}
|
{:else if status}
|
||||||
<p class="text-sm text-gray-500">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 class="rounded-xl border border-gray-200 bg-white p-5">
|
<div 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">ZIP-Archiv</h2>
|
<h2 class="font-semibold text-gray-900 dark:text-gray-100">ZIP-Archiv</h2>
|
||||||
<p class="mt-0.5 text-sm text-gray-500">Alle Original-Fotos und Videos in strukturierten Ordnern.</p>
|
<p class="mt-0.5 text-sm text-gray-500 dark:text-gray-400">Alle Original-Fotos und Videos in strukturierten Ordnern.</p>
|
||||||
<p class="mt-1 text-xs {status.zip.status === 'done' ? 'text-green-600' : status.zip.status === 'failed' ? 'text-red-500' : 'text-gray-400'}">
|
<p class="mt-1 text-xs {status.zip.status === 'done' ? 'text-green-600 dark:text-green-400' : status.zip.status === 'failed' ? 'text-red-500 dark:text-red-400' : 'text-gray-400 dark:text-gray-500'}">
|
||||||
{statusText(status.zip)}
|
{statusText(status.zip)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<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' ? 'bg-blue-600 text-white hover:bg-blue-700' : 'bg-gray-100 text-gray-400 cursor-not-allowed'}"
|
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'}"
|
||||||
>
|
>
|
||||||
Download
|
Download
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{#if status.zip.status === 'running'}
|
{#if status.zip.status === 'running'}
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<div class="h-1.5 overflow-hidden rounded-full bg-gray-200">
|
<div class="h-1.5 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
|
||||||
<div class="h-full rounded-full bg-blue-500 transition-all" style="width: {status.zip.progress_pct}%"></div>
|
<div class="h-full rounded-full bg-blue-500 transition-all" style="width: {status.zip.progress_pct}%"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -186,26 +186,26 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- HTML card -->
|
<!-- HTML card -->
|
||||||
<div class="rounded-xl border border-gray-200 bg-white p-5">
|
<div 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">HTML-Viewer</h2>
|
<h2 class="font-semibold text-gray-900 dark:text-gray-100">HTML-Viewer</h2>
|
||||||
<p class="mt-0.5 text-sm text-gray-500">Schöne Offline-Galerie mit Filterung, Kommentaren und Likes — kein Internet nötig.</p>
|
<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>
|
||||||
<p class="mt-1 text-xs {status.html.status === 'done' ? 'text-green-600' : status.html.status === 'failed' ? 'text-red-500' : 'text-gray-400'}">
|
<p class="mt-1 text-xs {status.html.status === 'done' ? 'text-green-600 dark:text-green-400' : status.html.status === 'failed' ? 'text-red-500 dark:text-red-400' : 'text-gray-400 dark:text-gray-500'}">
|
||||||
{statusText(status.html)}
|
{statusText(status.html)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<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' ? 'bg-blue-600 text-white hover:bg-blue-700' : 'bg-gray-100 text-gray-400 cursor-not-allowed'}"
|
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'}"
|
||||||
>
|
>
|
||||||
Download
|
Download
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{#if status.html.status === 'running'}
|
{#if status.html.status === 'running'}
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
<div class="h-1.5 overflow-hidden rounded-full bg-gray-200">
|
<div class="h-1.5 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
|
||||||
<div class="h-full rounded-full bg-blue-500 transition-all" style="width: {status.html.progress_pct}%"></div>
|
<div class="h-full rounded-full bg-blue-500 transition-all" style="width: {status.html.progress_pct}%"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { getToken } from '$lib/auth';
|
import { getToken, getUserId } from '$lib/auth';
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import { connectSse, disconnectSse, onSseEvent } from '$lib/sse';
|
import { connectSse, disconnectSse, onSseEvent } from '$lib/sse';
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
@@ -9,7 +9,9 @@
|
|||||||
import HashtagChips from '$lib/components/HashtagChips.svelte';
|
import HashtagChips from '$lib/components/HashtagChips.svelte';
|
||||||
import LightboxModal from '$lib/components/LightboxModal.svelte';
|
import LightboxModal from '$lib/components/LightboxModal.svelte';
|
||||||
import OnboardingGuide from '$lib/components/OnboardingGuide.svelte';
|
import OnboardingGuide from '$lib/components/OnboardingGuide.svelte';
|
||||||
import type { FeedUpload, FeedResponse, HashtagCount } from '$lib/types';
|
import ContextSheet, { type ContextAction } from '$lib/components/ContextSheet.svelte';
|
||||||
|
import { refreshQuota } from '$lib/quota-store';
|
||||||
|
import type { FeedUpload, FeedResponse, HashtagCount, DeltaResponse } from '$lib/types';
|
||||||
|
|
||||||
let uploads = $state<FeedUpload[]>([]);
|
let uploads = $state<FeedUpload[]>([]);
|
||||||
let hashtags = $state<HashtagCount[]>([]);
|
let hashtags = $state<HashtagCount[]>([]);
|
||||||
@@ -31,6 +33,49 @@
|
|||||||
|
|
||||||
let unsubscribers: (() => void)[] = [];
|
let unsubscribers: (() => void)[] = [];
|
||||||
|
|
||||||
|
// Long-press / context-sheet state for post actions
|
||||||
|
let contextTarget = $state<FeedUpload | null>(null);
|
||||||
|
const myUserId = getUserId();
|
||||||
|
const contextActions = $derived<ContextAction[]>(buildContextActions(contextTarget));
|
||||||
|
|
||||||
|
function buildContextActions(target: FeedUpload | null): ContextAction[] {
|
||||||
|
if (!target) return [];
|
||||||
|
const actions: ContextAction[] = [
|
||||||
|
{
|
||||||
|
label: 'Original anzeigen',
|
||||||
|
icon: '⤓',
|
||||||
|
onClick: () => {
|
||||||
|
window.open(`/api/v1/upload/${target.id}/original`, '_blank');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
if (target.user_id === myUserId) {
|
||||||
|
actions.unshift({
|
||||||
|
label: 'Löschen',
|
||||||
|
icon: '🗑',
|
||||||
|
tone: 'danger',
|
||||||
|
onClick: () => deleteUpload(target.id)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openContextSheet(upload: FeedUpload) {
|
||||||
|
contextTarget = upload;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteUpload(id: string) {
|
||||||
|
if (!confirm('Diesen Beitrag wirklich löschen?')) return;
|
||||||
|
try {
|
||||||
|
await api.delete(`/upload/${id}`);
|
||||||
|
uploads = uploads.filter((u) => u.id !== id);
|
||||||
|
if (selectedUpload?.id === id) selectedUpload = null;
|
||||||
|
void refreshQuota();
|
||||||
|
} catch {
|
||||||
|
// ignore — toast handled by ApiError elsewhere
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Autocomplete derived from loaded uploads (no extra API calls) ────────
|
// ── Autocomplete derived from loaded uploads (no extra API calls) ────────
|
||||||
let allTags = $derived.by(() => {
|
let allTags = $derived.by(() => {
|
||||||
const freq = new Map<string, number>();
|
const freq = new Map<string, number>();
|
||||||
@@ -105,8 +150,31 @@
|
|||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}),
|
}),
|
||||||
onSseEvent('upload-processed', () => loadFeed(true)),
|
onSseEvent('upload-processed', () => loadFeed(true)),
|
||||||
|
onSseEvent('upload-deleted', (data) => {
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(data) as { upload_id: string };
|
||||||
|
uploads = uploads.filter((u) => u.id !== payload.upload_id);
|
||||||
|
if (selectedUpload?.id === payload.upload_id) selectedUpload = null;
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}),
|
||||||
onSseEvent('like-update', () => loadFeed(true)),
|
onSseEvent('like-update', () => loadFeed(true)),
|
||||||
onSseEvent('new-comment', () => loadFeed(true))
|
onSseEvent('new-comment', () => loadFeed(true)),
|
||||||
|
// Synthetic event from the SSE client after a foreground reconnect — merge
|
||||||
|
// any uploads + deletions we missed while the tab was hidden.
|
||||||
|
onSseEvent('feed-delta', (data) => {
|
||||||
|
try {
|
||||||
|
const delta = JSON.parse(data) as DeltaResponse;
|
||||||
|
if (delta.uploads.length) {
|
||||||
|
const seen = new Set(uploads.map((u) => u.id));
|
||||||
|
const fresh = delta.uploads.filter((u) => !seen.has(u.id));
|
||||||
|
if (fresh.length) uploads = [...fresh, ...uploads];
|
||||||
|
}
|
||||||
|
if (delta.deleted_ids.length) {
|
||||||
|
const dead = new Set(delta.deleted_ids);
|
||||||
|
uploads = uploads.filter((u) => !dead.has(u.id));
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
if (sentinel) {
|
if (sentinel) {
|
||||||
@@ -214,34 +282,49 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="min-h-screen bg-gray-50 pb-24">
|
<div class="min-h-screen bg-gray-50 pb-24 dark:bg-gray-950">
|
||||||
<!-- Sticky header -->
|
<!-- Sticky header -->
|
||||||
<div class="sticky top-0 z-30 border-b border-gray-200 bg-white/95 backdrop-blur">
|
<div class="sticky top-0 z-30 border-b border-gray-200 bg-white/95 backdrop-blur dark:border-gray-800 dark:bg-gray-900/95">
|
||||||
<div class="mx-auto flex max-w-2xl items-center justify-between px-4 py-3">
|
<div class="mx-auto flex max-w-2xl items-center justify-between px-4 py-3">
|
||||||
<h1 class="text-lg font-bold text-gray-900">Galerie</h1>
|
<h1 class="text-lg font-bold text-gray-900 dark:text-gray-100">Galerie</h1>
|
||||||
|
|
||||||
<!-- List / Grid toggle -->
|
<div class="flex items-center gap-2">
|
||||||
<div class="flex items-center gap-1 rounded-lg bg-gray-100 p-1">
|
<!-- Diashow entry — tablet/desktop only (mobile uses the Account page tile). -->
|
||||||
<button
|
<button
|
||||||
onclick={() => switchView('list')}
|
onclick={() => goto('/diashow')}
|
||||||
class="rounded-md p-1.5 transition-colors {viewMode === 'list' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-400 hover:text-gray-600'}"
|
class="hidden rounded-md p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100 sm:inline-flex"
|
||||||
aria-label="Listenansicht"
|
aria-label="Diashow starten"
|
||||||
|
title="Diashow"
|
||||||
>
|
>
|
||||||
<!-- bars-3 -->
|
|
||||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
<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.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 7.5A2.25 2.25 0 0 1 6 5.25h12A2.25 2.25 0 0 1 20.25 7.5v9A2.25 2.25 0 0 1 18 18.75H6A2.25 2.25 0 0 1 3.75 16.5v-9Z" />
|
||||||
</svg>
|
<path stroke-linecap="round" stroke-linejoin="round" d="M10 9.75 14.5 12 10 14.25v-4.5Z" />
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onclick={() => switchView('grid')}
|
|
||||||
class="rounded-md p-1.5 transition-colors {viewMode === 'grid' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-400 hover:text-gray-600'}"
|
|
||||||
aria-label="Rasteransicht"
|
|
||||||
>
|
|
||||||
<!-- squares-2x2 -->
|
|
||||||
<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.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25A2.25 2.25 0 0 1 13.5 18v-2.25Z" />
|
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- List / Grid toggle -->
|
||||||
|
<div class="flex items-center gap-1 rounded-lg bg-gray-100 p-1 dark:bg-gray-800">
|
||||||
|
<button
|
||||||
|
onclick={() => switchView('list')}
|
||||||
|
class="rounded-md p-1.5 transition-colors {viewMode === 'list' ? 'bg-white text-gray-900 shadow-sm dark:bg-gray-700 dark:text-gray-100' : 'text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300'}"
|
||||||
|
aria-label="Listenansicht"
|
||||||
|
>
|
||||||
|
<!-- bars-3 -->
|
||||||
|
<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.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick={() => switchView('grid')}
|
||||||
|
class="rounded-md p-1.5 transition-colors {viewMode === 'grid' ? 'bg-white text-gray-900 shadow-sm dark:bg-gray-700 dark:text-gray-100' : 'text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300'}"
|
||||||
|
aria-label="Rasteransicht"
|
||||||
|
>
|
||||||
|
<!-- squares-2x2 -->
|
||||||
|
<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.75 6A2.25 2.25 0 0 1 6 3.75h2.25A2.25 2.25 0 0 1 10.5 6v2.25a2.25 2.25 0 0 1-2.25 2.25H6a2.25 2.25 0 0 1-2.25-2.25V6ZM3.75 15.75A2.25 2.25 0 0 1 6 13.5h2.25a2.25 2.25 0 0 1 2.25 2.25V18a2.25 2.25 0 0 1-2.25 2.25H6A2.25 2.25 0 0 1 3.75 18v-2.25ZM13.5 6a2.25 2.25 0 0 1 2.25-2.25H18A2.25 2.25 0 0 1 20.25 6v2.25A2.25 2.25 0 0 1 18 10.5h-2.25a2.25 2.25 0 0 1-2.25-2.25V6ZM13.5 15.75a2.25 2.25 0 0 1 2.25-2.25H18a2.25 2.25 0 0 1 2.25 2.25V18A2.25 2.25 0 0 1 18 20.25h-2.25A2.25 2.25 0 0 1 13.5 18v-2.25Z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -256,8 +339,8 @@
|
|||||||
{#if viewMode === 'grid'}
|
{#if viewMode === 'grid'}
|
||||||
<div class="mx-auto max-w-2xl px-4 pb-3">
|
<div class="mx-auto max-w-2xl px-4 pb-3">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="flex items-center gap-2 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 focus-within:border-blue-400 focus-within:bg-white focus-within:ring-1 focus-within:ring-blue-200">
|
<div class="flex items-center gap-2 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 focus-within:border-blue-400 focus-within:bg-white focus-within:ring-1 focus-within:ring-blue-200 dark:border-gray-700 dark:bg-gray-800 dark:focus-within:border-blue-500 dark:focus-within:bg-gray-800">
|
||||||
<svg class="h-4 w-4 shrink-0 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
<svg class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
|
||||||
</svg>
|
</svg>
|
||||||
<input
|
<input
|
||||||
@@ -266,12 +349,12 @@
|
|||||||
bind:value={searchQuery}
|
bind:value={searchQuery}
|
||||||
onfocus={() => (showAutocomplete = true)}
|
onfocus={() => (showAutocomplete = true)}
|
||||||
onblur={() => setTimeout(() => (showAutocomplete = false), 150)}
|
onblur={() => setTimeout(() => (showAutocomplete = false), 150)}
|
||||||
class="min-w-0 flex-1 bg-transparent text-sm text-gray-900 placeholder-gray-400 outline-none"
|
class="min-w-0 flex-1 bg-transparent text-sm text-gray-900 placeholder-gray-400 outline-none dark:text-gray-100 dark:placeholder-gray-500"
|
||||||
/>
|
/>
|
||||||
{#if searchQuery}
|
{#if searchQuery}
|
||||||
<button
|
<button
|
||||||
onclick={() => { searchQuery = ''; }}
|
onclick={() => { searchQuery = ''; }}
|
||||||
class="shrink-0 text-gray-400 hover:text-gray-600"
|
class="shrink-0 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
|
||||||
aria-label="Suche löschen"
|
aria-label="Suche löschen"
|
||||||
>
|
>
|
||||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
@@ -283,20 +366,20 @@
|
|||||||
|
|
||||||
<!-- Autocomplete dropdown -->
|
<!-- Autocomplete dropdown -->
|
||||||
{#if showAutocomplete && suggestions.length > 0}
|
{#if showAutocomplete && suggestions.length > 0}
|
||||||
<div class="absolute left-0 right-0 top-full z-50 mt-1 overflow-hidden rounded-xl border border-gray-200 bg-white shadow-lg">
|
<div class="absolute left-0 right-0 top-full z-50 mt-1 overflow-hidden rounded-xl border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-900">
|
||||||
{#each suggestions as item}
|
{#each suggestions as item}
|
||||||
<button
|
<button
|
||||||
class="flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm hover:bg-gray-50"
|
class="flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||||
onmousedown={() => selectSuggestion(item)}
|
onmousedown={() => selectSuggestion(item)}
|
||||||
>
|
>
|
||||||
{#if item.type === 'user'}
|
{#if item.type === 'user'}
|
||||||
<svg class="h-4 w-4 shrink-0 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
<svg class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span class="font-medium text-gray-900">{item.value}</span>
|
<span class="font-medium text-gray-900 dark:text-gray-100">{item.value}</span>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="text-blue-500 font-medium">#</span>
|
<span class="font-medium text-blue-500 dark:text-blue-400">#</span>
|
||||||
<span class="font-medium text-gray-900">{item.value}</span>
|
<span class="font-medium text-gray-900 dark:text-gray-100">{item.value}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -308,9 +391,9 @@
|
|||||||
{#if activeFilters.length > 0}
|
{#if activeFilters.length > 0}
|
||||||
<div class="mt-2 flex flex-wrap items-center gap-1.5">
|
<div class="mt-2 flex flex-wrap items-center gap-1.5">
|
||||||
{#each activeFilters as filter}
|
{#each activeFilters as filter}
|
||||||
<span class="flex items-center gap-1 rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-700">
|
<span class="flex items-center gap-1 rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">
|
||||||
{filter.type === 'tag' ? '#' : ''}{filter.value}
|
{filter.type === 'tag' ? '#' : ''}{filter.value}
|
||||||
<button onclick={() => removeFilter(filter)} class="ml-0.5 hover:text-blue-900" aria-label="Filter entfernen">
|
<button onclick={() => removeFilter(filter)} class="ml-0.5 hover:text-blue-900 dark:hover:text-blue-100" aria-label="Filter entfernen">
|
||||||
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -318,7 +401,7 @@
|
|||||||
</span>
|
</span>
|
||||||
{/each}
|
{/each}
|
||||||
{#if activeFilters.length >= 2}
|
{#if activeFilters.length >= 2}
|
||||||
<button onclick={clearFilters} class="text-xs text-gray-400 hover:text-gray-600">
|
<button onclick={clearFilters} class="text-xs text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300">
|
||||||
Alle löschen
|
Alle löschen
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -331,8 +414,8 @@
|
|||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
{#if uploads.length === 0}
|
{#if uploads.length === 0}
|
||||||
<div class="py-20 text-center">
|
<div class="py-20 text-center">
|
||||||
<p class="text-lg text-gray-400">Noch keine Fotos.</p>
|
<p class="text-lg text-gray-400 dark:text-gray-500">Noch keine Fotos.</p>
|
||||||
<p class="mt-1 text-sm text-gray-400">Tippe auf den Plus-Button unten!</p>
|
<p class="mt-1 text-sm text-gray-400 dark:text-gray-500">Tippe auf den Plus-Button unten!</p>
|
||||||
</div>
|
</div>
|
||||||
{:else if viewMode === 'list'}
|
{:else if viewMode === 'list'}
|
||||||
<!-- List view: chronological full-width cards -->
|
<!-- List view: chronological full-width cards -->
|
||||||
@@ -340,9 +423,11 @@
|
|||||||
{#each uploads as upload (upload.id)}
|
{#each uploads as upload (upload.id)}
|
||||||
<FeedListCard
|
<FeedListCard
|
||||||
{upload}
|
{upload}
|
||||||
|
isOwn={upload.user_id === myUserId}
|
||||||
onlike={handleLike}
|
onlike={handleLike}
|
||||||
oncomment={openComments}
|
oncomment={openComments}
|
||||||
onselect={(u) => (selectedUpload = u)}
|
onselect={(u) => (selectedUpload = u)}
|
||||||
|
oncontextmenu={openContextSheet}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -351,8 +436,8 @@
|
|||||||
<div class="mx-auto max-w-2xl">
|
<div class="mx-auto max-w-2xl">
|
||||||
{#if displayUploads.length === 0}
|
{#if displayUploads.length === 0}
|
||||||
<div class="py-16 text-center">
|
<div class="py-16 text-center">
|
||||||
<p class="text-sm text-gray-400">Keine Treffer für die gewählten Filter.</p>
|
<p class="text-sm text-gray-400 dark:text-gray-500">Keine Treffer für die gewählten Filter.</p>
|
||||||
<button onclick={clearFilters} class="mt-2 text-sm text-blue-600 hover:underline">Filter zurücksetzen</button>
|
<button onclick={clearFilters} class="mt-2 text-sm text-blue-600 hover:underline dark:text-blue-400">Filter zurücksetzen</button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<FeedGrid
|
<FeedGrid
|
||||||
@@ -360,6 +445,7 @@
|
|||||||
onlike={handleLike}
|
onlike={handleLike}
|
||||||
oncomment={openComments}
|
oncomment={openComments}
|
||||||
onselect={(u) => (selectedUpload = u)}
|
onselect={(u) => (selectedUpload = u)}
|
||||||
|
oncontextmenu={openContextSheet}
|
||||||
threeCol={true}
|
threeCol={true}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -371,7 +457,7 @@
|
|||||||
<div bind:this={sentinel} class="h-4"></div>
|
<div bind:this={sentinel} class="h-4"></div>
|
||||||
{#if loadingMore}
|
{#if loadingMore}
|
||||||
<div class="py-4 text-center">
|
<div class="py-4 text-center">
|
||||||
<div class="inline-block h-6 w-6 animate-spin rounded-full border-2 border-gray-300 border-t-blue-600"></div>
|
<div class="inline-block h-6 w-6 animate-spin rounded-full border-2 border-gray-300 border-t-blue-600 dark:border-gray-700 dark:border-t-blue-400"></div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -386,5 +472,12 @@
|
|||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Context sheet for post long-press / kebab tap -->
|
||||||
|
<ContextSheet
|
||||||
|
open={contextTarget !== null}
|
||||||
|
actions={contextActions}
|
||||||
|
onClose={() => (contextTarget = null)}
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- First-visit onboarding guide -->
|
<!-- First-visit onboarding guide -->
|
||||||
<OnboardingGuide />
|
<OnboardingGuide />
|
||||||
|
|||||||
@@ -45,10 +45,24 @@
|
|||||||
let banHideUploads = $state(false);
|
let banHideUploads = $state(false);
|
||||||
let banSubmitting = $state(false);
|
let banSubmitting = $state(false);
|
||||||
|
|
||||||
|
// PIN reset modal state. `pinModal` holds the freshly-issued plaintext PIN; it is
|
||||||
|
// shown once and forgotten on close.
|
||||||
|
let pinResetTarget = $state<UserSummary | null>(null);
|
||||||
|
let pinResetSubmitting = $state(false);
|
||||||
|
let pinModal = $state<{ name: string; pin: string } | null>(null);
|
||||||
|
|
||||||
let toast = $state<string | null>(null);
|
let toast = $state<string | null>(null);
|
||||||
|
|
||||||
const myRole = getRole();
|
const myRole = getRole();
|
||||||
|
|
||||||
|
/** Mirrors backend `handlers::host::reset_user_pin` authorisation rules. */
|
||||||
|
function canResetPinFor(target: UserSummary): boolean {
|
||||||
|
if (target.role === 'admin') return false;
|
||||||
|
if (myRole === 'admin') return true;
|
||||||
|
if (myRole === 'host') return target.role === 'guest';
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
const token = getToken();
|
const token = getToken();
|
||||||
const role = getRole();
|
const role = getRole();
|
||||||
@@ -155,6 +169,30 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function askResetPin(user: UserSummary) {
|
||||||
|
pinResetTarget = user;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmResetPin() {
|
||||||
|
if (!pinResetTarget) return;
|
||||||
|
pinResetSubmitting = true;
|
||||||
|
try {
|
||||||
|
const res = await api.post<{ pin: string }>(`/host/users/${pinResetTarget.id}/pin-reset`);
|
||||||
|
pinModal = { name: pinResetTarget.display_name, pin: res.pin };
|
||||||
|
pinResetTarget = null;
|
||||||
|
} catch (e: unknown) {
|
||||||
|
showToast(e instanceof Error ? e.message : 'Fehler beim Zurücksetzen.');
|
||||||
|
} finally {
|
||||||
|
pinResetSubmitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyPinModal() {
|
||||||
|
if (!pinModal) return;
|
||||||
|
navigator.clipboard.writeText(pinModal.pin);
|
||||||
|
showToast('PIN kopiert.');
|
||||||
|
}
|
||||||
|
|
||||||
function formatBytes(bytes: number): string {
|
function formatBytes(bytes: number): string {
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
@@ -162,33 +200,75 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- PIN reset confirmation -->
|
||||||
|
{#if pinResetTarget}
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||||
|
<div class="w-full max-w-sm rounded-2xl bg-white p-6 shadow-xl dark:bg-gray-900">
|
||||||
|
<h2 class="mb-1 text-lg font-bold text-gray-900 dark:text-gray-100">PIN zurücksetzen</h2>
|
||||||
|
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Eine neue PIN für <strong>{pinResetTarget.display_name}</strong> wird erzeugt. Die alte PIN funktioniert dann nicht mehr.
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button onclick={() => (pinResetTarget = null)} 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</button>
|
||||||
|
<button onclick={confirmResetPin} disabled={pinResetSubmitting} class="flex-1 rounded-lg bg-amber-500 py-2 text-sm font-medium text-white hover:bg-amber-600 disabled:opacity-50 dark:bg-amber-500 dark:hover:bg-amber-400">
|
||||||
|
{pinResetSubmitting ? 'Wird erzeugt…' : 'Neue PIN erzeugen'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- One-time PIN display modal -->
|
||||||
|
{#if pinModal}
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
|
||||||
|
<div class="w-full max-w-sm rounded-2xl bg-white p-6 shadow-xl dark:bg-gray-900">
|
||||||
|
<h2 class="mb-1 text-lg font-bold text-gray-900 dark:text-gray-100">Neue PIN für {pinModal.name}</h2>
|
||||||
|
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Zeige diese PIN dem Benutzer. Sie wird nur einmal angezeigt — beim Schließen wird sie verworfen.
|
||||||
|
</p>
|
||||||
|
<div class="mb-4 flex items-center justify-between rounded-lg bg-amber-50 px-4 py-3 dark:bg-amber-950/30">
|
||||||
|
<span class="font-mono text-3xl font-bold tracking-widest text-gray-900 dark:text-gray-100">{pinModal.pin}</span>
|
||||||
|
<button onclick={copyPinModal} class="rounded-md bg-amber-100 px-3 py-1.5 text-sm font-medium text-amber-800 hover:bg-amber-200 dark:bg-amber-900/40 dark:text-amber-200 dark:hover:bg-amber-900/60">
|
||||||
|
Kopieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onclick={() => (pinModal = null)}
|
||||||
|
class="w-full rounded-lg bg-blue-600 py-2 text-sm font-semibold text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-400"
|
||||||
|
>
|
||||||
|
Schließen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Ban modal -->
|
<!-- Ban modal -->
|
||||||
{#if banTarget}
|
{#if banTarget}
|
||||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||||
<div class="w-full max-w-sm rounded-2xl bg-white p-6 shadow-xl">
|
<div class="w-full max-w-sm rounded-2xl bg-white p-6 shadow-xl dark:bg-gray-900">
|
||||||
<h2 class="mb-1 text-lg font-bold text-gray-900">Benutzer sperren</h2>
|
<h2 class="mb-1 text-lg font-bold text-gray-900 dark:text-gray-100">Benutzer sperren</h2>
|
||||||
<p class="mb-4 text-sm text-gray-600">
|
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||||
Was soll mit den Uploads von <strong>{banTarget.display_name}</strong> passieren?
|
Was soll mit den Uploads von <strong>{banTarget.display_name}</strong> passieren?
|
||||||
</p>
|
</p>
|
||||||
<label class="mb-4 flex cursor-pointer items-center gap-3 rounded-lg border border-gray-200 p-3">
|
<label class="mb-4 flex cursor-pointer items-center gap-3 rounded-lg border border-gray-200 p-3 dark:border-gray-700">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
bind:checked={banHideUploads}
|
bind:checked={banHideUploads}
|
||||||
class="h-4 w-4 rounded border-gray-300 text-red-600 focus:ring-red-500"
|
class="h-4 w-4 rounded border-gray-300 text-red-600 focus:ring-red-500 dark:border-gray-600"
|
||||||
/>
|
/>
|
||||||
<span class="text-sm text-gray-700">Uploads aus der Galerie ausblenden</span>
|
<span class="text-sm text-gray-700 dark:text-gray-300">Uploads aus der Galerie ausblenden</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onclick={() => (banTarget = null)}
|
onclick={() => (banTarget = null)}
|
||||||
class="flex-1 rounded-lg border border-gray-300 py-2 text-sm text-gray-700 hover:bg-gray-50"
|
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={confirmBan}
|
onclick={confirmBan}
|
||||||
disabled={banSubmitting}
|
disabled={banSubmitting}
|
||||||
class="flex-1 rounded-lg bg-red-600 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50"
|
class="flex-1 rounded-lg bg-red-600 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50 dark:bg-red-500 dark:hover:bg-red-400"
|
||||||
>
|
>
|
||||||
{banSubmitting ? 'Wird gesperrt…' : 'Sperren'}
|
{banSubmitting ? 'Wird gesperrt…' : 'Sperren'}
|
||||||
</button>
|
</button>
|
||||||
@@ -199,18 +279,18 @@
|
|||||||
|
|
||||||
<!-- Toast -->
|
<!-- Toast -->
|
||||||
{#if toast}
|
{#if toast}
|
||||||
<div class="fixed bottom-24 left-1/2 z-50 -translate-x-1/2 rounded-full bg-gray-900 px-5 py-2.5 text-sm text-white shadow-lg">
|
<div class="fixed bottom-24 left-1/2 z-50 -translate-x-1/2 rounded-full bg-gray-900 px-5 py-2.5 text-sm text-white shadow-lg dark:bg-gray-100 dark:text-gray-900">
|
||||||
{toast}
|
{toast}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="min-h-screen bg-gray-50 pb-24">
|
<div class="min-h-screen bg-gray-50 pb-24 dark:bg-gray-950">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="border-b border-gray-200 bg-white">
|
<div class="border-b border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
|
||||||
<div class="mx-auto flex max-w-3xl items-center gap-3 px-4 py-4">
|
<div class="mx-auto flex max-w-3xl items-center gap-3 px-4 py-4">
|
||||||
<button
|
<button
|
||||||
onclick={() => goto('/account')}
|
onclick={() => goto('/account')}
|
||||||
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-gray-500 transition hover:bg-gray-100"
|
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-gray-500 transition hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
|
||||||
aria-label="Zurück"
|
aria-label="Zurück"
|
||||||
>
|
>
|
||||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
@@ -218,9 +298,9 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<h1 class="text-xl font-bold text-gray-900">Host-Dashboard</h1>
|
<h1 class="text-xl font-bold text-gray-900 dark:text-gray-100">Host-Dashboard</h1>
|
||||||
{#if event}
|
{#if event}
|
||||||
<p class="truncate text-sm text-gray-500">{event.name}</p>
|
<p class="truncate text-sm text-gray-500 dark:text-gray-400">{event.name}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -228,71 +308,71 @@
|
|||||||
|
|
||||||
<div class="mx-auto max-w-3xl space-y-3 p-4">
|
<div class="mx-auto max-w-3xl space-y-3 p-4">
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="py-16 text-center text-gray-400">Laden…</div>
|
<div class="py-16 text-center text-gray-400 dark:text-gray-500">Laden…</div>
|
||||||
{:else if error}
|
{:else if error}
|
||||||
<div class="rounded-lg bg-red-50 p-4 text-sm text-red-700">{error}</div>
|
<div class="rounded-lg bg-red-50 p-4 text-sm text-red-700 dark:bg-red-950/30 dark:text-red-300">{error}</div>
|
||||||
{:else if event}
|
{:else if event}
|
||||||
|
|
||||||
<!-- ── Statistiken ─────────────────────────────────────────────── -->
|
<!-- ── Statistiken ─────────────────────────────────────────────── -->
|
||||||
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white">
|
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
|
||||||
<button
|
<button
|
||||||
onclick={() => (statsOpen = !statsOpen)}
|
onclick={() => (statsOpen = !statsOpen)}
|
||||||
class="flex w-full items-center justify-between px-5 py-4"
|
class="flex w-full items-center justify-between px-5 py-4"
|
||||||
>
|
>
|
||||||
<h2 class="font-semibold text-gray-900">Statistiken</h2>
|
<h2 class="font-semibold text-gray-900 dark:text-gray-100">Statistiken</h2>
|
||||||
<svg
|
<svg
|
||||||
class="h-5 w-5 text-gray-400 transition-transform duration-200 {statsOpen ? 'rotate-180' : ''}"
|
class="h-5 w-5 text-gray-400 dark:text-gray-500 transition-transform duration-200 {statsOpen ? 'rotate-180' : ''}"
|
||||||
fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"
|
fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"
|
||||||
>
|
>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div class="overflow-hidden transition-[max-height] duration-200 {statsOpen ? 'max-h-[500px]' : 'max-h-0'}">
|
<div class="overflow-hidden transition-[max-height] duration-200 {statsOpen ? 'max-h-[500px]' : 'max-h-0'}">
|
||||||
<div class="grid grid-cols-2 gap-3 border-t border-gray-100 p-4 sm:grid-cols-4">
|
<div class="grid grid-cols-2 gap-3 border-t border-gray-100 p-4 dark:border-gray-700 sm:grid-cols-4">
|
||||||
<div class="rounded-xl bg-gray-50 p-4 text-center">
|
<div class="rounded-xl bg-gray-50 p-4 text-center dark:bg-gray-900/60">
|
||||||
<p class="text-2xl font-bold text-gray-900">{users.length}</p>
|
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{users.length}</p>
|
||||||
<p class="mt-0.5 text-xs text-gray-500">Gäste</p>
|
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">Gäste</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-xl bg-gray-50 p-4 text-center">
|
<div class="rounded-xl bg-gray-50 p-4 text-center dark:bg-gray-900/60">
|
||||||
<p class="text-2xl font-bold text-gray-900">{users.reduce((s, u) => s + u.upload_count, 0)}</p>
|
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100">{users.reduce((s, u) => s + u.upload_count, 0)}</p>
|
||||||
<p class="mt-0.5 text-xs text-gray-500">Uploads</p>
|
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">Uploads</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-xl bg-gray-50 p-4 text-center">
|
<div class="rounded-xl bg-gray-50 p-4 text-center dark:bg-gray-900/60">
|
||||||
<p class="text-2xl font-bold {event.uploads_locked ? 'text-red-600' : 'text-green-600'}">
|
<p class="text-2xl font-bold {event.uploads_locked ? 'text-red-600 dark:text-red-400' : 'text-green-600 dark:text-green-400'}">
|
||||||
{event.uploads_locked ? 'Gesperrt' : 'Offen'}
|
{event.uploads_locked ? 'Gesperrt' : 'Offen'}
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-0.5 text-xs text-gray-500">Uploads</p>
|
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">Uploads</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="rounded-xl bg-gray-50 p-4 text-center">
|
<div class="rounded-xl bg-gray-50 p-4 text-center dark:bg-gray-900/60">
|
||||||
<p class="text-2xl font-bold {event.export_released ? 'text-blue-600' : 'text-gray-400'}">
|
<p class="text-2xl font-bold {event.export_released ? 'text-blue-600 dark:text-blue-400' : 'text-gray-400 dark:text-gray-500'}">
|
||||||
{event.export_released ? 'Ja' : 'Nein'}
|
{event.export_released ? 'Ja' : 'Nein'}
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-0.5 text-xs text-gray-500">Freigegeben</p>
|
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">Freigegeben</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Event-Einstellungen ─────────────────────────────────────── -->
|
<!-- ── Event-Einstellungen ─────────────────────────────────────── -->
|
||||||
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white">
|
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
|
||||||
<button
|
<button
|
||||||
onclick={() => (settingsOpen = !settingsOpen)}
|
onclick={() => (settingsOpen = !settingsOpen)}
|
||||||
class="flex w-full items-center justify-between px-5 py-4"
|
class="flex w-full items-center justify-between px-5 py-4"
|
||||||
>
|
>
|
||||||
<h2 class="font-semibold text-gray-900">Event-Einstellungen</h2>
|
<h2 class="font-semibold text-gray-900 dark:text-gray-100">Event-Einstellungen</h2>
|
||||||
<svg
|
<svg
|
||||||
class="h-5 w-5 text-gray-400 transition-transform duration-200 {settingsOpen ? 'rotate-180' : ''}"
|
class="h-5 w-5 text-gray-400 dark:text-gray-500 transition-transform duration-200 {settingsOpen ? 'rotate-180' : ''}"
|
||||||
fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"
|
fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"
|
||||||
>
|
>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div class="overflow-hidden transition-[max-height] duration-200 {settingsOpen ? 'max-h-[500px]' : 'max-h-0'}">
|
<div class="overflow-hidden transition-[max-height] duration-200 {settingsOpen ? 'max-h-[500px]' : 'max-h-0'}">
|
||||||
<div class="flex flex-wrap gap-3 border-t border-gray-100 p-5">
|
<div class="flex flex-wrap gap-3 border-t border-gray-100 p-5 dark:border-gray-700">
|
||||||
<button
|
<button
|
||||||
onclick={toggleEventLock}
|
onclick={toggleEventLock}
|
||||||
class="rounded-lg px-4 py-2 text-sm font-medium transition
|
class="rounded-lg px-4 py-2 text-sm font-medium transition
|
||||||
{event.uploads_locked ? 'bg-green-600 text-white hover:bg-green-700' : 'bg-amber-500 text-white hover:bg-amber-600'}"
|
{event.uploads_locked ? 'bg-green-600 text-white hover:bg-green-700 dark:bg-green-500 dark:hover:bg-green-400' : 'bg-amber-500 text-white hover:bg-amber-600 dark:bg-amber-500 dark:hover:bg-amber-400'}"
|
||||||
>
|
>
|
||||||
{event.uploads_locked ? 'Uploads wieder öffnen' : 'Uploads sperren'}
|
{event.uploads_locked ? 'Uploads wieder öffnen' : 'Uploads sperren'}
|
||||||
</button>
|
</button>
|
||||||
@@ -300,7 +380,7 @@
|
|||||||
onclick={releaseGallery}
|
onclick={releaseGallery}
|
||||||
disabled={event.export_released}
|
disabled={event.export_released}
|
||||||
class="rounded-lg px-4 py-2 text-sm font-medium transition
|
class="rounded-lg px-4 py-2 text-sm font-medium transition
|
||||||
{event.export_released ? 'cursor-default bg-gray-100 text-gray-400' : 'bg-blue-600 text-white hover:bg-blue-700'}"
|
{event.export_released ? 'cursor-default bg-gray-100 text-gray-400 dark:bg-gray-800 dark:text-gray-500' : 'bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-400'}"
|
||||||
>
|
>
|
||||||
{event.export_released ? 'Galerie bereits freigegeben' : 'Galerie freigeben'}
|
{event.export_released ? 'Galerie bereits freigegeben' : 'Galerie freigeben'}
|
||||||
</button>
|
</button>
|
||||||
@@ -309,63 +389,63 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Nutzerverwaltung ───────────────────────────────────────── -->
|
<!-- ── Nutzerverwaltung ───────────────────────────────────────── -->
|
||||||
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white">
|
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800">
|
||||||
<button
|
<button
|
||||||
onclick={() => (usersOpen = !usersOpen)}
|
onclick={() => (usersOpen = !usersOpen)}
|
||||||
class="flex w-full items-center justify-between px-5 py-4"
|
class="flex w-full items-center justify-between px-5 py-4"
|
||||||
>
|
>
|
||||||
<h2 class="font-semibold text-gray-900">Nutzerverwaltung</h2>
|
<h2 class="font-semibold text-gray-900 dark:text-gray-100">Nutzerverwaltung</h2>
|
||||||
<svg
|
<svg
|
||||||
class="h-5 w-5 text-gray-400 transition-transform duration-200 {usersOpen ? 'rotate-180' : ''}"
|
class="h-5 w-5 text-gray-400 dark:text-gray-500 transition-transform duration-200 {usersOpen ? 'rotate-180' : ''}"
|
||||||
fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"
|
fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"
|
||||||
>
|
>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div class="overflow-hidden transition-[max-height] duration-300 {usersOpen ? 'max-h-[9999px]' : 'max-h-0'}">
|
<div class="overflow-hidden transition-[max-height] duration-300 {usersOpen ? 'max-h-[9999px]' : 'max-h-0'}">
|
||||||
<div class="border-t border-gray-100">
|
<div class="border-t border-gray-100 dark:border-gray-700">
|
||||||
<!-- Search -->
|
<!-- Search -->
|
||||||
<div class="px-4 py-3">
|
<div class="px-4 py-3">
|
||||||
<div class="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2">
|
<div class="flex items-center gap-2 rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 dark:border-gray-700 dark:bg-gray-900">
|
||||||
<svg class="h-4 w-4 shrink-0 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
<svg class="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
|
||||||
</svg>
|
</svg>
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
placeholder="Nutzer suchen…"
|
placeholder="Nutzer suchen…"
|
||||||
bind:value={userSearch}
|
bind:value={userSearch}
|
||||||
class="min-w-0 flex-1 bg-transparent text-sm text-gray-900 placeholder-gray-400 outline-none"
|
class="min-w-0 flex-1 bg-transparent text-sm text-gray-900 placeholder-gray-400 outline-none dark:text-gray-100 dark:placeholder-gray-500"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if filteredUsers.length === 0}
|
{#if filteredUsers.length === 0}
|
||||||
<p class="px-5 py-8 text-center text-sm text-gray-400">Keine Treffer.</p>
|
<p class="px-5 py-8 text-center text-sm text-gray-400 dark:text-gray-500">Keine Treffer.</p>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="divide-y divide-gray-100">
|
<div class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
{#each filteredUsers as user}
|
{#each filteredUsers as user}
|
||||||
<div class="flex items-center gap-3 px-5 py-3">
|
<div class="flex items-center gap-3 px-5 py-3">
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="flex flex-wrap items-center gap-1.5">
|
<div class="flex flex-wrap items-center gap-1.5">
|
||||||
<span class="font-medium text-gray-900">{user.display_name}</span>
|
<span class="font-medium text-gray-900 dark:text-gray-100">{user.display_name}</span>
|
||||||
{#if user.role === 'host'}
|
{#if user.role === 'host'}
|
||||||
<span class="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700">Host</span>
|
<span class="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/40 dark:text-blue-200">Host</span>
|
||||||
{:else if user.role === 'admin'}
|
{:else if user.role === 'admin'}
|
||||||
<span class="rounded-full bg-purple-100 px-2 py-0.5 text-xs font-medium text-purple-700">Admin</span>
|
<span class="rounded-full bg-purple-100 px-2 py-0.5 text-xs font-medium text-purple-700 dark:bg-purple-900/40 dark:text-purple-200">Admin</span>
|
||||||
{/if}
|
{/if}
|
||||||
{#if user.is_banned}
|
{#if user.is_banned}
|
||||||
<span class="rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700">Gesperrt</span>
|
<span class="rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700 dark:bg-red-900/40 dark:text-red-200">Gesperrt</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-gray-400">
|
<p class="text-xs text-gray-400 dark:text-gray-500">
|
||||||
{user.upload_count} Upload{user.upload_count !== 1 ? 's' : ''} · {formatBytes(user.total_upload_bytes)}
|
{user.upload_count} Upload{user.upload_count !== 1 ? 's' : ''} · {formatBytes(user.total_upload_bytes)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex shrink-0 gap-1.5">
|
<div class="flex shrink-0 flex-wrap justify-end gap-1.5">
|
||||||
{#if user.role !== 'admin'}
|
{#if user.role !== 'admin'}
|
||||||
{#if user.is_banned}
|
{#if user.is_banned}
|
||||||
<button
|
<button
|
||||||
onclick={() => unban(user)}
|
onclick={() => unban(user)}
|
||||||
class="rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-200"
|
class="rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
|
||||||
>
|
>
|
||||||
Entsperren
|
Entsperren
|
||||||
</button>
|
</button>
|
||||||
@@ -373,22 +453,31 @@
|
|||||||
{#if user.role === 'guest' && (myRole === 'host' || myRole === 'admin')}
|
{#if user.role === 'guest' && (myRole === 'host' || myRole === 'admin')}
|
||||||
<button
|
<button
|
||||||
onclick={() => promoteToHost(user)}
|
onclick={() => promoteToHost(user)}
|
||||||
class="rounded-lg bg-blue-50 px-3 py-1.5 text-xs font-medium text-blue-700 hover:bg-blue-100"
|
class="rounded-lg bg-blue-50 px-3 py-1.5 text-xs font-medium text-blue-700 hover:bg-blue-100 dark:bg-blue-900/40 dark:text-blue-200 dark:hover:bg-blue-900/60"
|
||||||
>
|
>
|
||||||
Host
|
Host
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if user.role === 'host' && myRole === 'admin'}
|
{#if user.role === 'host'}
|
||||||
|
<!-- Hosts may demote other Hosts (never themselves); backend enforces. -->
|
||||||
<button
|
<button
|
||||||
onclick={() => demoteToGuest(user)}
|
onclick={() => demoteToGuest(user)}
|
||||||
class="rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-200"
|
class="rounded-lg bg-gray-100 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
|
||||||
>
|
>
|
||||||
Degradieren
|
Degradieren
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if canResetPinFor(user)}
|
||||||
|
<button
|
||||||
|
onclick={() => askResetPin(user)}
|
||||||
|
class="rounded-lg bg-amber-50 px-3 py-1.5 text-xs font-medium text-amber-700 hover:bg-amber-100 dark:bg-amber-900/40 dark:text-amber-200 dark:hover:bg-amber-900/60"
|
||||||
|
>
|
||||||
|
PIN zurücksetzen
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
<button
|
<button
|
||||||
onclick={() => openBanModal(user)}
|
onclick={() => openBanModal(user)}
|
||||||
class="rounded-lg bg-red-50 px-3 py-1.5 text-xs font-medium text-red-700 hover:bg-red-100"
|
class="rounded-lg bg-red-50 px-3 py-1.5 text-xs font-medium text-red-700 hover:bg-red-100 dark:bg-red-950/40 dark:text-red-300 dark:hover:bg-red-950/60"
|
||||||
>
|
>
|
||||||
Sperren
|
Sperren
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -86,20 +86,20 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex min-h-screen items-center justify-center bg-gray-50 px-4">
|
<div class="flex min-h-screen items-center justify-center bg-gray-50 px-4 dark:bg-gray-950">
|
||||||
<div class="w-full max-w-sm">
|
<div class="w-full max-w-sm">
|
||||||
|
|
||||||
{#if nameTaken}
|
{#if nameTaken}
|
||||||
<!-- Name-taken state: sign in with PIN or choose a different name -->
|
<!-- Name-taken state: sign in with PIN or choose a different name -->
|
||||||
<div class="mb-5 rounded-lg border border-amber-200 bg-amber-50 p-4">
|
<div class="mb-5 rounded-lg border border-amber-200 bg-amber-50 p-4 dark:border-amber-800/60 dark:bg-amber-950/30">
|
||||||
<p class="font-semibold text-amber-900">„{takenName}" ist bereits vergeben.</p>
|
<p class="font-semibold text-amber-900 dark:text-amber-200">„{takenName}" ist bereits vergeben.</p>
|
||||||
<p class="mt-1 text-sm text-amber-800">
|
<p class="mt-1 text-sm text-amber-800 dark:text-amber-300/90">
|
||||||
Wähle einen anderen Namen, z. B. einen Spitznamen oder füge deinen Nachnamen hinzu
|
Wähle einen anderen Namen, z. B. einen Spitznamen oder füge deinen Nachnamen hinzu
|
||||||
(„{takenName} M." oder „{takenName} aus Berlin").
|
(„{takenName} M." oder „{takenName} aus Berlin").
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="mb-3 text-sm font-medium text-gray-700">
|
<p class="mb-3 text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
Falls du das bist, melde dich mit deinem PIN an:
|
Falls du das bist, melde dich mit deinem PIN an:
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -111,17 +111,17 @@
|
|||||||
maxlength={4}
|
maxlength={4}
|
||||||
inputmode="numeric"
|
inputmode="numeric"
|
||||||
pattern="[0-9]*"
|
pattern="[0-9]*"
|
||||||
class="mb-3 w-full rounded-lg border border-gray-300 px-4 py-3 text-center text-2xl font-mono tracking-widest focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200"
|
class="mb-3 w-full rounded-lg border border-gray-300 bg-white px-4 py-3 text-center text-2xl font-mono tracking-widest text-gray-900 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#if recoveryError}
|
{#if recoveryError}
|
||||||
<p class="mb-3 text-sm text-red-600">{recoveryError}</p>
|
<p class="mb-3 text-sm text-red-600 dark:text-red-400">{recoveryError}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={recoveryLoading || recoveryPin.length < 4}
|
disabled={recoveryLoading || recoveryPin.length < 4}
|
||||||
class="mb-3 w-full rounded-lg bg-blue-600 px-4 py-3 font-medium text-white transition hover:bg-blue-700 disabled:opacity-50"
|
class="mb-3 w-full rounded-lg bg-blue-600 px-4 py-3 font-medium text-white transition hover:bg-blue-700 disabled:opacity-50 dark:bg-blue-500 dark:hover:bg-blue-400"
|
||||||
>
|
>
|
||||||
{recoveryLoading ? 'Wird angemeldet...' : 'Anmelden'}
|
{recoveryLoading ? 'Wird angemeldet...' : 'Anmelden'}
|
||||||
</button>
|
</button>
|
||||||
@@ -129,15 +129,15 @@
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onclick={tryDifferentName}
|
onclick={tryDifferentName}
|
||||||
class="w-full rounded-lg border border-gray-300 px-4 py-3 font-medium text-gray-700 transition hover:bg-gray-50"
|
class="w-full rounded-lg border border-gray-300 px-4 py-3 font-medium text-gray-700 transition hover:bg-gray-50 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"
|
||||||
>
|
>
|
||||||
Anderen Namen wählen
|
Anderen Namen wählen
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Normal join form -->
|
<!-- Normal join form -->
|
||||||
<h1 class="mb-2 text-center text-2xl font-bold text-gray-900">Willkommen!</h1>
|
<h1 class="mb-2 text-center text-2xl font-bold text-gray-900 dark:text-gray-100">Willkommen!</h1>
|
||||||
<p class="mb-6 text-center text-gray-600">Gib deinen Namen ein, um dem Event beizutreten.</p>
|
<p class="mb-6 text-center text-gray-600 dark:text-gray-400">Gib deinen Namen ein, um dem Event beizutreten.</p>
|
||||||
|
|
||||||
<form onsubmit={(e) => { e.preventDefault(); handleJoin(); }}>
|
<form onsubmit={(e) => { e.preventDefault(); handleJoin(); }}>
|
||||||
<input
|
<input
|
||||||
@@ -145,25 +145,24 @@
|
|||||||
bind:value={displayName}
|
bind:value={displayName}
|
||||||
placeholder="Dein Name"
|
placeholder="Dein Name"
|
||||||
maxlength={50}
|
maxlength={50}
|
||||||
class="mb-3 w-full rounded-lg border border-gray-300 px-4 py-3 text-lg focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200"
|
class="mb-3 w-full rounded-lg border border-gray-300 bg-white px-4 py-3 text-lg text-gray-900 placeholder-gray-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:placeholder-gray-500"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
<p class="mb-3 text-sm text-red-600">{error}</p>
|
<p class="mb-3 text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading || !displayName.trim()}
|
disabled={loading || !displayName.trim()}
|
||||||
class="w-full rounded-lg bg-blue-600 px-4 py-3 text-lg font-medium text-white transition hover:bg-blue-700 disabled:opacity-50"
|
class="w-full rounded-lg bg-blue-600 px-4 py-3 text-lg font-medium text-white transition hover:bg-blue-700 disabled:opacity-50 dark:bg-blue-500 dark:hover:bg-blue-400"
|
||||||
>
|
>
|
||||||
{loading ? 'Wird geladen...' : 'Beitreten'}
|
{loading ? 'Wird geladen...' : 'Beitreten'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p class="mt-4 text-center text-sm text-gray-500">
|
<p class="mt-4 text-center text-sm">
|
||||||
Schon dabei?
|
<a href="/recover" class="text-blue-600 hover:underline dark:text-blue-400">Ich habe bereits einen Account</a>
|
||||||
<a href="/recover" class="text-blue-600 hover:underline">Mit PIN wiederherstellen</a>
|
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -172,17 +171,17 @@
|
|||||||
|
|
||||||
{#if showPinModal}
|
{#if showPinModal}
|
||||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4">
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4">
|
||||||
<div class="w-full max-w-sm rounded-xl bg-white p-6 shadow-lg">
|
<div class="w-full max-w-sm rounded-xl bg-white p-6 shadow-lg dark:bg-gray-900">
|
||||||
<h2 class="mb-2 text-xl font-bold text-gray-900">Dein Wiederherstellungs-PIN</h2>
|
<h2 class="mb-2 text-xl font-bold text-gray-900 dark:text-gray-100">Dein Wiederherstellungs-PIN</h2>
|
||||||
<p class="mb-4 text-sm text-gray-600">
|
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||||
Merke dir diesen PIN! Du brauchst ihn, um dein Konto auf einem anderen Gerät wiederherzustellen.
|
Merke dir diesen PIN! Du brauchst ihn, um dein Konto auf einem anderen Gerät wiederherzustellen.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="mb-4 flex items-center justify-center gap-3 rounded-lg bg-gray-100 p-4">
|
<div class="mb-4 flex items-center justify-center gap-3 rounded-lg bg-gray-100 p-4 dark:bg-gray-800">
|
||||||
<span class="text-4xl font-mono font-bold tracking-widest text-gray-900">{pin}</span>
|
<span class="text-4xl font-mono font-bold tracking-widest text-gray-900 dark:text-gray-100">{pin}</span>
|
||||||
<button
|
<button
|
||||||
onclick={copyPin}
|
onclick={copyPin}
|
||||||
class="rounded-md bg-gray-200 px-3 py-1 text-sm font-medium text-gray-700 hover:bg-gray-300"
|
class="rounded-md bg-gray-200 px-3 py-1 text-sm font-medium text-gray-700 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
|
||||||
>
|
>
|
||||||
{copied ? 'Kopiert!' : 'Kopieren'}
|
{copied ? 'Kopiert!' : 'Kopieren'}
|
||||||
</button>
|
</button>
|
||||||
@@ -190,7 +189,7 @@
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onclick={goToFeed}
|
onclick={goToFeed}
|
||||||
class="w-full rounded-lg bg-blue-600 px-4 py-3 font-medium text-white transition hover:bg-blue-700"
|
class="w-full rounded-lg bg-blue-600 px-4 py-3 font-medium text-white transition hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-400"
|
||||||
>
|
>
|
||||||
Weiter zur Galerie
|
Weiter zur Galerie
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -39,10 +39,10 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex min-h-screen items-center justify-center bg-gray-50 px-4">
|
<div class="flex min-h-screen items-center justify-center bg-gray-50 px-4 dark:bg-gray-950">
|
||||||
<div class="w-full max-w-sm">
|
<div class="w-full max-w-sm">
|
||||||
<h1 class="mb-2 text-center text-2xl font-bold text-gray-900">Konto wiederherstellen</h1>
|
<h1 class="mb-2 text-center text-2xl font-bold text-gray-900 dark:text-gray-100">Konto wiederherstellen</h1>
|
||||||
<p class="mb-6 text-center text-gray-600">Gib deinen Namen und deinen PIN ein.</p>
|
<p class="mb-6 text-center text-gray-600 dark:text-gray-400">Gib deinen Namen und deinen PIN ein.</p>
|
||||||
|
|
||||||
<form onsubmit={(e) => { e.preventDefault(); handleRecover(); }}>
|
<form onsubmit={(e) => { e.preventDefault(); handleRecover(); }}>
|
||||||
<input
|
<input
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
bind:value={displayName}
|
bind:value={displayName}
|
||||||
placeholder="Dein Name"
|
placeholder="Dein Name"
|
||||||
maxlength={50}
|
maxlength={50}
|
||||||
class="mb-3 w-full rounded-lg border border-gray-300 px-4 py-3 text-lg focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200"
|
class="mb-3 w-full rounded-lg border border-gray-300 bg-white px-4 py-3 text-lg text-gray-900 placeholder-gray-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:placeholder-gray-500"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -59,25 +59,25 @@
|
|||||||
maxlength={4}
|
maxlength={4}
|
||||||
inputmode="numeric"
|
inputmode="numeric"
|
||||||
pattern="[0-9]*"
|
pattern="[0-9]*"
|
||||||
class="mb-3 w-full rounded-lg border border-gray-300 px-4 py-3 text-center text-2xl font-mono tracking-widest focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200"
|
class="mb-3 w-full rounded-lg border border-gray-300 bg-white px-4 py-3 text-center text-2xl font-mono tracking-widest text-gray-900 placeholder-gray-400 focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:placeholder-gray-500"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
<p class="mb-3 text-sm text-red-600">{error}</p>
|
<p class="mb-3 text-sm text-red-600 dark:text-red-400">{error}</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading || !displayName.trim() || pin.length < 4}
|
disabled={loading || !displayName.trim() || pin.length < 4}
|
||||||
class="w-full rounded-lg bg-blue-600 px-4 py-3 text-lg font-medium text-white transition hover:bg-blue-700 disabled:opacity-50"
|
class="w-full rounded-lg bg-blue-600 px-4 py-3 text-lg font-medium text-white transition hover:bg-blue-700 disabled:opacity-50 dark:bg-blue-500 dark:hover:bg-blue-400"
|
||||||
>
|
>
|
||||||
{loading ? 'Wird geladen...' : 'Wiederherstellen'}
|
{loading ? 'Wird geladen...' : 'Wiederherstellen'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p class="mt-4 text-center text-sm text-gray-500">
|
<p class="mt-4 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||||
Noch kein Konto?
|
Noch kein Konto?
|
||||||
<a href="/join" class="text-blue-600 hover:underline">Neu beitreten</a>
|
<a href="/join" class="text-blue-600 hover:underline dark:text-blue-400">Neu beitreten</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import { pendingFiles, pendingCaption, clearPending } from '$lib/pending-upload-store';
|
import { pendingFiles, pendingCaption, clearPending } from '$lib/pending-upload-store';
|
||||||
import { get } from 'svelte/store';
|
import { get } from 'svelte/store';
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
|
import { quotaStore, refreshQuota } from '$lib/quota-store';
|
||||||
import type { PendingFile } from '$lib/pending-upload-store';
|
import type { PendingFile } from '$lib/pending-upload-store';
|
||||||
|
|
||||||
interface StagedFile extends PendingFile {
|
interface StagedFile extends PendingFile {
|
||||||
@@ -17,6 +18,8 @@
|
|||||||
let submitting = $state(false);
|
let submitting = $state(false);
|
||||||
let captionEl: HTMLTextAreaElement;
|
let captionEl: HTMLTextAreaElement;
|
||||||
|
|
||||||
|
const MAX_CAPTION_LENGTH = 2000;
|
||||||
|
|
||||||
// Quick-tag chips derived from caption as the user types
|
// Quick-tag chips derived from caption as the user types
|
||||||
let captionTags = $derived.by(() => {
|
let captionTags = $derived.by(() => {
|
||||||
const matches = [...caption.matchAll(/#(\w+)/g)];
|
const matches = [...caption.matchAll(/#(\w+)/g)];
|
||||||
@@ -30,6 +33,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
loadQueue();
|
loadQueue();
|
||||||
|
void refreshQuota();
|
||||||
|
|
||||||
// Pull staged files from the pending store (written by UploadSheet)
|
// Pull staged files from the pending store (written by UploadSheet)
|
||||||
const pf = get(pendingFiles);
|
const pf = get(pendingFiles);
|
||||||
@@ -39,6 +43,16 @@
|
|||||||
|
|
||||||
// Auto-focus caption textarea after a short delay (let layout settle)
|
// Auto-focus caption textarea after a short delay (let layout settle)
|
||||||
setTimeout(() => captionEl?.focus(), 80);
|
setTimeout(() => captionEl?.focus(), 80);
|
||||||
|
|
||||||
|
// Revoke blob URLs if user abandons the upload page
|
||||||
|
const handleBeforeUnload = () => {
|
||||||
|
clearPending();
|
||||||
|
};
|
||||||
|
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
@@ -58,9 +72,11 @@
|
|||||||
|
|
||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
if (stagedFiles.length === 0 || submitting) return;
|
if (stagedFiles.length === 0 || submitting) return;
|
||||||
|
if (caption.length > MAX_CAPTION_LENGTH) return;
|
||||||
submitting = true;
|
submitting = true;
|
||||||
|
const hashtagsString = captionTags.join(',');
|
||||||
for (const sf of stagedFiles) {
|
for (const sf of stagedFiles) {
|
||||||
await addToQueue(sf.file, caption, '');
|
await addToQueue(sf.file, caption, hashtagsString);
|
||||||
}
|
}
|
||||||
clearPending();
|
clearPending();
|
||||||
goto('/feed');
|
goto('/feed');
|
||||||
@@ -69,28 +85,42 @@
|
|||||||
function isVideo(file: File): boolean {
|
function isVideo(file: File): boolean {
|
||||||
return file.type.startsWith('video/');
|
return file.type.startsWith('video/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number | null | undefined): string {
|
||||||
|
if (bytes == null || bytes <= 0) return '0 MB';
|
||||||
|
const mb = bytes / (1024 * 1024);
|
||||||
|
if (mb < 1024) return `${mb.toFixed(mb < 10 ? 1 : 0)} MB`;
|
||||||
|
return `${(mb / 1024).toFixed(1)} GB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalStagedBytes = $derived(stagedFiles.reduce((sum, sf) => sum + sf.file.size, 0));
|
||||||
|
const quotaPercent = $derived(
|
||||||
|
$quotaStore.limit_bytes && $quotaStore.limit_bytes > 0
|
||||||
|
? Math.min(100, (($quotaStore.used_bytes + totalStagedBytes) / $quotaStore.limit_bytes) * 100)
|
||||||
|
: 0
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Full-screen composer — bottom nav is suppressed -->
|
<!-- Full-screen composer — bottom nav is suppressed -->
|
||||||
<div class="flex min-h-screen flex-col bg-white">
|
<div class="flex min-h-screen flex-col bg-white dark:bg-gray-950">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex items-center justify-between border-b border-gray-100 px-4 py-3">
|
<div class="flex items-center justify-between border-b border-gray-100 px-4 py-3 dark:border-gray-800">
|
||||||
<button
|
<button
|
||||||
onclick={cancel}
|
onclick={cancel}
|
||||||
class="flex h-9 w-9 items-center justify-center rounded-full text-gray-500 transition hover:bg-gray-100"
|
class="flex h-9 w-9 items-center justify-center rounded-full text-gray-500 transition hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-800"
|
||||||
aria-label="Abbrechen"
|
aria-label="Abbrechen"
|
||||||
>
|
>
|
||||||
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<h1 class="text-base font-semibold text-gray-900">Neuer Beitrag</h1>
|
<h1 class="text-base font-semibold text-gray-900 dark:text-gray-100">Neuer Beitrag</h1>
|
||||||
<!-- Submit button in header for desktop convenience -->
|
<!-- Submit button in header for desktop convenience -->
|
||||||
<button
|
<button
|
||||||
onclick={handleSubmit}
|
onclick={handleSubmit}
|
||||||
disabled={stagedFiles.length === 0 || submitting}
|
disabled={stagedFiles.length === 0 || submitting}
|
||||||
class="rounded-lg bg-blue-600 px-4 py-1.5 text-sm font-semibold text-white transition
|
class="rounded-lg bg-blue-600 px-4 py-1.5 text-sm font-semibold text-white transition
|
||||||
hover:bg-blue-700 disabled:opacity-40"
|
hover:bg-blue-700 disabled:opacity-40 dark:bg-blue-500 dark:hover:bg-blue-400"
|
||||||
>
|
>
|
||||||
{submitting ? 'Wird hochgeladen…' : 'Hochladen'}
|
{submitting ? 'Wird hochgeladen…' : 'Hochladen'}
|
||||||
</button>
|
</button>
|
||||||
@@ -101,9 +131,9 @@
|
|||||||
{#if stagedFiles.length > 0}
|
{#if stagedFiles.length > 0}
|
||||||
<div class="flex gap-2 overflow-x-auto px-4 py-3 scrollbar-none">
|
<div class="flex gap-2 overflow-x-auto px-4 py-3 scrollbar-none">
|
||||||
{#each stagedFiles as sf, i}
|
{#each stagedFiles as sf, i}
|
||||||
<div class="relative h-20 w-20 shrink-0 overflow-hidden rounded-xl bg-gray-100">
|
<div class="relative h-20 w-20 shrink-0 overflow-hidden rounded-xl bg-gray-100 dark:bg-gray-800">
|
||||||
{#if isVideo(sf.file)}
|
{#if isVideo(sf.file)}
|
||||||
<div class="flex h-full w-full items-center justify-center bg-gray-800">
|
<div class="flex h-full w-full items-center justify-center bg-gray-800 dark:bg-gray-700">
|
||||||
<svg class="h-7 w-7 text-white/70" fill="currentColor" viewBox="0 0 24 24">
|
<svg class="h-7 w-7 text-white/70" fill="currentColor" viewBox="0 0 24 24">
|
||||||
<path d="M8 5v14l11-7z" />
|
<path d="M8 5v14l11-7z" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -123,20 +153,20 @@
|
|||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<div class="border-b border-gray-100"></div>
|
<div class="border-b border-gray-100 dark:border-gray-800"></div>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- No files: prompt to go back and pick some -->
|
<!-- No files: prompt to go back and pick some -->
|
||||||
<div class="flex flex-1 flex-col items-center justify-center gap-4 p-8 text-center">
|
<div class="flex flex-1 flex-col items-center justify-center gap-4 p-8 text-center">
|
||||||
<svg class="h-16 w-16 text-gray-200" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
|
<svg class="h-16 w-16 text-gray-200 dark:text-gray-700" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M9 9.75h.008v.008H9V9.75zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zM6 20.25h12A2.25 2.25 0 0020.25 18V6A2.25 2.25 0 0018 3.75H6A2.25 2.25 0 003.75 6v12A2.25 2.25 0 006 20.25z" />
|
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M9 9.75h.008v.008H9V9.75zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0zM6 20.25h12A2.25 2.25 0 0020.25 18V6A2.25 2.25 0 0018 3.75H6A2.25 2.25 0 003.75 6v12A2.25 2.25 0 006 20.25z" />
|
||||||
</svg>
|
</svg>
|
||||||
<div>
|
<div>
|
||||||
<p class="font-medium text-gray-500">Keine Dateien ausgewählt</p>
|
<p class="font-medium text-gray-500 dark:text-gray-400">Keine Dateien ausgewählt</p>
|
||||||
<p class="mt-1 text-sm text-gray-400">Geh zurück und tippe auf den Plus-Button.</p>
|
<p class="mt-1 text-sm text-gray-400 dark:text-gray-500">Geh zurück und tippe auf den Plus-Button.</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onclick={cancel}
|
onclick={cancel}
|
||||||
class="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50"
|
class="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"
|
||||||
>
|
>
|
||||||
Zurück
|
Zurück
|
||||||
</button>
|
</button>
|
||||||
@@ -148,34 +178,60 @@
|
|||||||
<textarea
|
<textarea
|
||||||
bind:this={captionEl}
|
bind:this={captionEl}
|
||||||
bind:value={caption}
|
bind:value={caption}
|
||||||
|
maxlength={MAX_CAPTION_LENGTH}
|
||||||
placeholder="Beschreibung hinzufügen… (#hashtags möglich)"
|
placeholder="Beschreibung hinzufügen… (#hashtags möglich)"
|
||||||
rows="4"
|
rows="4"
|
||||||
class="w-full resize-none rounded-xl border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-900
|
class="w-full resize-none rounded-xl border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-900
|
||||||
placeholder-gray-400 focus:border-blue-400 focus:bg-white focus:outline-none focus:ring-1 focus:ring-blue-200"
|
placeholder-gray-400 focus:border-blue-400 focus:bg-white focus:outline-none focus:ring-1 focus:ring-blue-200
|
||||||
|
dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-500 dark:focus:bg-gray-800"
|
||||||
></textarea>
|
></textarea>
|
||||||
|
<div class="mt-1 text-xs text-gray-500 text-right dark:text-gray-400">
|
||||||
|
{caption.length} / {MAX_CAPTION_LENGTH}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Quick-tag chips (derived from typed caption) -->
|
<!-- Quick-tag chips (derived from typed caption) -->
|
||||||
{#if captionTags.length > 0}
|
{#if captionTags.length > 0}
|
||||||
<div class="flex flex-wrap gap-1.5 px-4 pt-2">
|
<div class="flex flex-wrap gap-1.5 px-4 pt-2">
|
||||||
{#each captionTags as tag}
|
{#each captionTags as tag}
|
||||||
<span class="rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-600">
|
<span class="rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-600 dark:bg-blue-950/40 dark:text-blue-300">
|
||||||
#{tag}
|
#{tag}
|
||||||
</span>
|
</span>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- Per-user quota — hidden when admin disabled enforcement -->
|
||||||
|
{#if $quotaStore.enabled && $quotaStore.limit_bytes != null}
|
||||||
|
<div class="px-4 pt-3 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span>Speicher: {formatBytes($quotaStore.used_bytes + totalStagedBytes)} / {formatBytes($quotaStore.limit_bytes)}</span>
|
||||||
|
<span class:text-amber-600={quotaPercent >= 80} class:dark:text-amber-400={quotaPercent >= 80} class:text-red-600={quotaPercent >= 95} class:dark:text-red-400={quotaPercent >= 95}>
|
||||||
|
{Math.round(quotaPercent)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1 h-1.5 overflow-hidden rounded-full bg-gray-100 dark:bg-gray-800">
|
||||||
|
<div
|
||||||
|
class="h-full transition-all"
|
||||||
|
class:bg-blue-500={quotaPercent < 80}
|
||||||
|
class:bg-amber-500={quotaPercent >= 80 && quotaPercent < 95}
|
||||||
|
class:bg-red-500={quotaPercent >= 95}
|
||||||
|
style="width: {quotaPercent}%"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div class="h-8"></div>
|
<div class="h-8"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sticky submit button at bottom (mobile-primary) -->
|
<!-- Sticky submit button at bottom (mobile-primary) -->
|
||||||
<div class="border-t border-gray-100 px-4 py-3">
|
<div class="border-t border-gray-100 px-4 py-3 dark:border-gray-800">
|
||||||
<button
|
<button
|
||||||
onclick={handleSubmit}
|
onclick={handleSubmit}
|
||||||
disabled={stagedFiles.length === 0 || submitting}
|
disabled={stagedFiles.length === 0 || submitting}
|
||||||
class="flex w-full items-center justify-center gap-2 rounded-xl bg-blue-600 py-3.5 text-sm font-semibold
|
class="flex w-full items-center justify-center gap-2 rounded-xl bg-blue-600 py-3.5 text-sm font-semibold
|
||||||
text-white transition hover:bg-blue-700 active:scale-[0.98] disabled:opacity-40"
|
text-white transition hover:bg-blue-700 active:scale-[0.98] disabled:opacity-40 dark:bg-blue-500 dark:hover:bg-blue-400"
|
||||||
>
|
>
|
||||||
{#if submitting}
|
{#if submitting}
|
||||||
<svg class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
<svg class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
|
|||||||
60
frontend/src/tailwind-theme.css
Normal file
60
frontend/src/tailwind-theme.css
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
/* Shared design tokens for the live app and the offline HTML-viewer export.
|
||||||
|
* Both `frontend/src/app.css` and `frontend/export-viewer/src/app.css` import this file
|
||||||
|
* so the keepsake stays visually in sync with the live app. Edit tokens here, rebuild
|
||||||
|
* the export-viewer, and re-commit `backend/static/export-viewer/`.
|
||||||
|
*
|
||||||
|
* Tailwind v4 reads `@theme` blocks to populate utility classes; everything declared
|
||||||
|
* here becomes a `bg-primary`, `text-accent`, `rounded-card`, etc.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* Class-based dark variant. Tailwind v4 defaults to `prefers-color-scheme`; we want
|
||||||
|
* the user's explicit selection (saved in `theme-store.ts`) to win, so we re-bind
|
||||||
|
* the `dark:` variant to apply whenever `<html>` has the `dark` class.
|
||||||
|
* `:where(...)` keeps the specificity low so existing utilities still override. */
|
||||||
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
/* Brand palette — the blue used for primary buttons, FAB, active tabs. */
|
||||||
|
--color-primary-50: #eff6ff;
|
||||||
|
--color-primary-100: #dbeafe;
|
||||||
|
--color-primary-500: #3b82f6;
|
||||||
|
--color-primary-600: #2563eb;
|
||||||
|
--color-primary-700: #1d4ed8;
|
||||||
|
|
||||||
|
/* Accent for hashtag chips and highlights. */
|
||||||
|
--color-accent-500: #a855f7;
|
||||||
|
--color-accent-600: #9333ea;
|
||||||
|
|
||||||
|
/* Surface scale matches the existing gray-* usage. Listed here so the viewer
|
||||||
|
* picks up the same shade in case Tailwind defaults ever drift. */
|
||||||
|
--color-surface-0: #ffffff;
|
||||||
|
--color-surface-50: #f9fafb;
|
||||||
|
--color-surface-100: #f3f4f6;
|
||||||
|
--color-surface-200: #e5e7eb;
|
||||||
|
|
||||||
|
/* Typography. Keepsake should feel like the live app — same defaults. */
|
||||||
|
--font-sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
|
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", "Courier New", monospace;
|
||||||
|
|
||||||
|
/* Radii — keep cards and bottom sheets consistent. */
|
||||||
|
--radius-card: 0.75rem; /* rounded-card */
|
||||||
|
--radius-sheet: 1.25rem; /* rounded-sheet */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Baseline body background + text colour so pages that haven't been re-themed yet
|
||||||
|
* at least don't render light-on-light or dark-on-dark. Pages and cards still set
|
||||||
|
* their own backgrounds via `bg-*` utilities, but this catches any gaps. */
|
||||||
|
@layer base {
|
||||||
|
html {
|
||||||
|
background-color: #f9fafb; /* matches bg-gray-50 */
|
||||||
|
color: #111827; /* matches text-gray-900 */
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
html.dark {
|
||||||
|
background-color: #030712; /* matches bg-gray-950 */
|
||||||
|
color: #f3f4f6; /* matches text-gray-100 */
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user