From 6e0a7602717274aeaf657200b8b944e1609951d9 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sun, 19 Jul 2026 15:21:44 +0200 Subject: [PATCH 1/4] chore(dev): correct container env in docker-compose.dev.yml Running in a container picked up host-oriented values from .env via env_file: - MEDIA_PATH pointed at a host path that doesn't exist in the container, so every upload 500'd with EACCES; point it at the /media volume mount. - ADMIN_PASSWORD_HASH lost its $-delimited bcrypt segments to Compose interpolation (admin login 401'd); re-supply it with $$ escaping. - COMMENTS_ENABLED=false to smoke-test the comment kill-switch. NOTE: production compose + .env carry the same MEDIA_PATH / admin-hash pitfalls. Co-Authored-By: Claude Opus 4.8 --- docker-compose.dev.yml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 2d341c3..f670348 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -11,3 +11,31 @@ services: # is tolerated (warned) rather than rejected. environment: APP_ENV: development + # `.env` sets DATABASE_URL to @localhost for the run-backend-natively workflow + # (the db port is published above for that). When the app runs IN a container, + # localhost is the app itself — point it at the `db` service instead. Creds are + # interpolated from .env so nothing is hardcoded. + DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} + # `.env` sets MEDIA_PATH to a HOST path (/home/fabi/EventSnap/media) for the + # run-backend-natively workflow. In a container that path doesn't exist and the + # app user can't create it from `/`, so every upload 500s with EACCES. The media + # volume is mounted at /media (see docker-compose.yml) — point the app there. + MEDIA_PATH: /media + # Recent Docker Compose interpolates env_file values, so the `$` segments of the + # bcrypt ADMIN_PASSWORD_HASH in .env get eaten (the salt reads as an unset var) — + # every admin login then 401s. Re-supply it here with `$` doubled to `$$` so Compose + # passes the literal hash. NOTE: production (docker-compose.yml + .env) has the SAME + # bug — escape the hash as `$$` in .env, or set it via `environment:` there too. + ADMIN_PASSWORD_HASH: "$$2b$$12$$PAteqCNpsbm6d0HTJcywfOaUovjAU.iNVlsL7EDYaRC/z4P/xv7ye" + # Smoke-testing the comment kill-switch: boot-time flag, so it needs a restart + # (not an admin-UI toggle). Backend rejects new comments (403) and the frontend + # hides the whole comment UI. Flip back to true (or drop this line) to restore. + COMMENTS_ENABLED: "false" + + caddy: + # The prod caddy service has no env_file, so the Caddyfile's `{$DOMAIN}` expands + # to empty and the site block collapses into a malformed global block. Supply it + # for local dev (from .env → localhost, which Caddy serves with a local self-signed + # cert). NOTE: the prod compose likely needs DOMAIN wired to caddy too. + environment: + DOMAIN: ${DOMAIN} From 57a907eca5774558256e2cb49dfaef43fa869139 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sun, 19 Jul 2026 15:21:55 +0200 Subject: [PATCH 2/4] feat(theme+comments): runtime colour theme and COMMENTS_ENABLED switch Colour theme is configurable at runtime from two seed colours (brand + accent); neutrals stay fixed for contrast safety. Tailwind v4 var()-based tokens let a :root:root override recolour everything with no rebuild; the 50->950 ramps are derived via a color-mix ladder. Config lives in the DB config table (admin UI: Config > Farbschema, presets + custom pickers + live preview), served on the public /event endpoint with env defaults (THEME_PRESET/PRIMARY/ACCENT), propagated live via event-updated SSE, and cached in localStorage for a no-flash boot. The keepsake export mirrors the same ladder in Rust so offline archives match the event theme. COMMENTS_ENABLED (env, default true) is a boot-time kill-switch: the backend rejects new comments with 403 and the frontend hides the comment button (feed card) and panel/composer (lightbox). Existing comments stay in the DB, hidden, and return when re-enabled. Co-Authored-By: Claude Opus 4.8 --- backend/src/config.rs | 31 +++ backend/src/handlers/admin.rs | 56 +++- backend/src/handlers/public.rs | 28 +- backend/src/handlers/social.rs | 6 + backend/src/services/export.rs | 113 ++++++++- frontend/src/app.html | 13 + .../src/lib/components/FeedListCard.svelte | 35 ++- .../src/lib/components/LightboxModal.svelte | 153 +++++------ frontend/src/lib/event-config-store.ts | 60 +++++ frontend/src/lib/theme/palette.ts | 120 +++++++++ frontend/src/routes/+layout.svelte | 10 +- frontend/src/routes/admin/+page.svelte | 239 ++++++++++++++++++ 12 files changed, 765 insertions(+), 99 deletions(-) create mode 100644 frontend/src/lib/event-config-store.ts create mode 100644 frontend/src/lib/theme/palette.ts diff --git a/backend/src/config.rs b/backend/src/config.rs index 0eb4529..6b9c382 100644 --- a/backend/src/config.rs +++ b/backend/src/config.rs @@ -63,8 +63,25 @@ pub struct AppConfig { pub app_port: u16, /// Number of concurrent media compression workers (read once at boot). pub compression_concurrency: usize, + /// Master switch for the comment feature (env `COMMENTS_ENABLED`, default true). + /// When false the backend rejects new comments and the frontend hides the whole + /// comment UI. Existing comments stay in the DB (hidden), so flipping it back + /// restores them. Boot-time immutable, like `compression_concurrency`. + pub comments_enabled: bool, + /// Default colour theme, used as the fallback when the DB config keys are unset. + /// Runtime overrides live in the `config` table (admin UI); these env vars only + /// seed the initial default. `preset` is an id the frontend knows (e.g. + /// "champagne-gold", "rose", … or "custom"); the two seeds are `#rrggbb` brand + + /// accent colours the whole palette is derived from. + pub default_theme_preset: String, + pub default_theme_primary: String, + pub default_theme_accent: String, } +/// The shipped default brand/accent seed (champagne gold — matches the hand-tuned +/// ramp in tailwind-theme.css). Kept here so an unset env still yields the current look. +const DEFAULT_THEME_SEED: &str = "#8a6a2b"; + impl AppConfig { pub fn from_env() -> Result { let app_env = std::env::var("APP_ENV").unwrap_or_else(|_| "development".to_string()); @@ -100,6 +117,20 @@ impl AppConfig { .and_then(|v| v.parse().ok()) .filter(|&n| n >= 1) .unwrap_or(2), + comments_enabled: std::env::var("COMMENTS_ENABLED") + .map(|v| { + !matches!( + v.trim().to_ascii_lowercase().as_str(), + "false" | "0" | "no" | "off" + ) + }) + .unwrap_or(true), + default_theme_preset: std::env::var("THEME_PRESET") + .unwrap_or_else(|_| "champagne-gold".to_string()), + default_theme_primary: std::env::var("THEME_PRIMARY") + .unwrap_or_else(|_| DEFAULT_THEME_SEED.to_string()), + default_theme_accent: std::env::var("THEME_ACCENT") + .unwrap_or_else(|_| DEFAULT_THEME_SEED.to_string()), }) } } diff --git a/backend/src/handlers/admin.rs b/backend/src/handlers/admin.rs index 700184d..fb519c1 100644 --- a/backend/src/handlers/admin.rs +++ b/backend/src/handlers/admin.rs @@ -138,10 +138,27 @@ pub async fn patch_config( "storage_quota_enabled", "upload_count_quota_enabled", ]; - const TEXT_KEYS: &[&str] = &["privacy_note"]; + const TEXT_KEYS: &[&str] = &[ + "privacy_note", + "theme_preset", + "theme_primary", + "theme_accent", + ]; const PRIVACY_NOTE_MAX_LEN: usize = 16 * 1024; // 16 KiB free text is plenty + // Preset ids the frontend knows how to render (mirror of PRESETS in + // frontend/src/lib/theme/palette.ts). "custom" means "use the theme_primary/accent + // seeds verbatim". Kept in sync by hand — a new preset must be added in both places. + const THEME_PRESETS: &[&str] = &[ + "champagne-gold", + "rose", + "sage", + "dusk-blue", + "classic-silver", + "custom", + ]; let mut privacy_note_changed = false; + let mut theme_changed = false; // Validate every key first so a bad value in the batch can't leave a partial // update behind — validation must fully precede any write. @@ -186,8 +203,23 @@ pub async fn patch_config( "Wert für {key} ist zu lang (max. {PRIVACY_NOTE_MAX_LEN} Zeichen)." ))); } - if key_str == "privacy_note" { - privacy_note_changed = true; + match key_str { + "privacy_note" => privacy_note_changed = true, + "theme_preset" => { + if !THEME_PRESETS.contains(&value.trim()) { + return Err(AppError::BadRequest(format!("Ungültiges Theme: {value}."))); + } + theme_changed = true; + } + "theme_primary" | "theme_accent" => { + if !is_hex_color(value.trim()) { + return Err(AppError::BadRequest(format!( + "Ungültige Farbe für {key}: muss #rrggbb sein." + ))); + } + theme_changed = true; + } + _ => {} } } else { return Err(AppError::BadRequest(format!( @@ -217,16 +249,30 @@ pub async fn patch_config( // 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 { + if privacy_note_changed || theme_changed { + let mut keys: Vec<&str> = Vec::new(); + if privacy_note_changed { + keys.push("privacy_note"); + } + if theme_changed { + keys.push("theme"); + } let _ = state.sse_tx.send(crate::state::SseEvent::new( "event-updated", - serde_json::json!({ "keys": ["privacy_note"] }).to_string(), + serde_json::json!({ "keys": keys }).to_string(), )); } Ok(StatusCode::NO_CONTENT) } +/// A strict `#rrggbb` hex-colour check (6 hex digits, leading `#`). Deliberately not +/// accepting shorthand/`#rgba` so the value is safe to drop straight into CSS. +fn is_hex_color(s: &str) -> bool { + let bytes = s.as_bytes(); + bytes.len() == 7 && bytes[0] == b'#' && bytes[1..].iter().all(|b| b.is_ascii_hexdigit()) +} + pub async fn get_export_jobs( State(state): State, RequireAdmin(_auth): RequireAdmin, diff --git a/backend/src/handlers/public.rs b/backend/src/handlers/public.rs index a609049..61181ba 100644 --- a/backend/src/handlers/public.rs +++ b/backend/src/handlers/public.rs @@ -4,21 +4,41 @@ use axum::Json; use axum::extract::State; use serde::Serialize; +use crate::services::config; use crate::state::AppState; #[derive(Serialize)] pub struct PublicEventDto { pub name: String, pub slug: String, + /// Whether the comment feature is on (env `COMMENTS_ENABLED`). The frontend hides + /// the whole comment UI when false; exposed here so even the pre-auth shell knows. + pub comments_enabled: bool, + /// Active colour theme. `preset` is an id the frontend maps to a palette (or + /// "custom"); `primary`/`accent` are the `#rrggbb` seeds the ramps derive from. + /// Resolved as DB-config override → env default. Public so the theme applies on + /// the very first (pre-auth) paint without a flash. + pub theme_preset: String, + pub theme_primary: String, + pub theme_accent: String, } -/// Public event identity, used by the pre-auth join/recover screens so a guest can -/// see *which* event they're joining. Only the display name and slug are exposed — -/// nothing user-scoped — so this is safe without a token. Served straight from the -/// instance config (no DB round-trip needed). +/// Public event identity + presentation config, used by the pre-auth join/recover +/// screens (which event am I joining, what does it look like). Only non-user-scoped +/// fields are exposed, so this is safe without a token. Identity comes straight from +/// instance config; the theme is resolved from the runtime `config` table (admin UI) +/// falling back to the env-seeded default. pub async fn get_public_event(State(state): State) -> Json { + let cache = &state.config_cache; Json(PublicEventDto { name: state.config.event_name.clone(), slug: state.config.event_slug.clone(), + comments_enabled: state.config.comments_enabled, + theme_preset: config::get_str(cache, "theme_preset", &state.config.default_theme_preset) + .await, + theme_primary: config::get_str(cache, "theme_primary", &state.config.default_theme_primary) + .await, + theme_accent: config::get_str(cache, "theme_accent", &state.config.default_theme_accent) + .await, }) } diff --git a/backend/src/handlers/social.rs b/backend/src/handlers/social.rs index d43f79b..d1cb7d1 100644 --- a/backend/src/handlers/social.rs +++ b/backend/src/handlers/social.rs @@ -129,6 +129,12 @@ pub async fn add_comment( Path(upload_id): Path, Json(body): Json, ) -> Result<(StatusCode, Json), AppError> { + // Comments can be disabled instance-wide (env COMMENTS_ENABLED). The frontend hides + // the UI, but gate the API too so a stale client or direct call can't slip one in. + if !state.config.comments_enabled { + return Err(AppError::Forbidden("Kommentare sind deaktiviert.".into())); + } + let user = crate::models::user::User::find_by_id(&state.pool, auth.user_id) .await? .ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?; diff --git a/backend/src/services/export.rs b/backend/src/services/export.rs index f196dcf..db8a0ca 100644 --- a/backend/src/services/export.rs +++ b/backend/src/services/export.rs @@ -888,6 +888,10 @@ async fn run_html_export_inner( let data_json = serde_json::to_string_pretty(&viewer_data).context("failed to serialize data.json")?; + // Match the live app's colour theme in the offline keepsake. + let (theme_primary, theme_accent) = resolve_theme_seeds(pool).await; + let theme_css = theme_override_css(&theme_primary, &theme_accent); + let _ = update_progress(pool, event_id, "html", epoch, 72).await; // 5. Create ZIP (per-generation paths — see run_zip_export) @@ -903,7 +907,7 @@ async fn run_html_export_inner( // `window.__EXPORT_DATA__` global into index.html. Guests double-click // index.html (file://), where a cross-origin fetch() of a sibling file is // blocked — so the data must be inlined rather than fetched from data.json. - write_viewer_with_data(&VIEWER_DIR, &mut zip, &data_json).await?; + write_viewer_with_data(&VIEWER_DIR, &mut zip, &data_json, theme_css.as_deref()).await?; let _ = update_progress(pool, event_id, "html", epoch, 75).await; @@ -1313,6 +1317,7 @@ async fn write_viewer_with_data( dir: &include_dir::Dir<'_>, zip: &mut ZipFileWriter, data_json: &str, + theme_css: Option<&str>, ) -> Result<()> { for file in dir.files() { let path = file.path().to_string_lossy().to_string(); @@ -1321,10 +1326,18 @@ async fn write_viewer_with_data( .context("export-viewer index.html is not valid UTF-8")?; // Escape `` can't break out of the tag. let safe = data_json.replace("window.__EXPORT_DATA__={safe};"); + // Match the live app's colour theme: inject the same `:root:root{…}` override + // the app builds at runtime so a rose/sage/custom event exports a rose/sage + // keepsake (not the embedded default gold). The CSS is generated purely from + // hex seeds (theme_override_css), so there's nothing to escape. + let mut head_inject = String::new(); + if let Some(css) = theme_css { + head_inject.push_str(&format!("")); + } + head_inject.push_str(&format!("")); let injected = match html.find("") { - Some(idx) => format!("{}{}{}", &html[..idx], script, &html[idx..]), - None => format!("{script}{html}"), + Some(idx) => format!("{}{}{}", &html[..idx], head_inject, &html[idx..]), + None => format!("{head_inject}{html}"), }; let builder = ZipEntryBuilder::new(path.into(), Compression::Deflate); let mut entry = zip.write_entry_stream(builder).await?; @@ -1340,11 +1353,101 @@ async fn write_viewer_with_data( } } for sub_dir in dir.dirs() { - Box::pin(write_viewer_with_data(sub_dir, zip, data_json)).await?; + Box::pin(write_viewer_with_data(sub_dir, zip, data_json, theme_css)).await?; } Ok(()) } +/// Default champagne-gold seed — matches the hand-tuned ramp already embedded in the +/// viewer, so a default-themed event injects no override. +const KEEPSAKE_DEFAULT_SEED: &str = "#8a6a2b"; + +// stop → color-mix instruction. MIRRORS `LADDER` in frontend/src/lib/theme/palette.ts — +// keep the two in sync so an exported keepsake matches the live app pixel-for-pixel. +const THEME_LADDER: &[(u16, Option<&str>)] = &[ + (50, Some("white 90%")), + (100, Some("white 80%")), + (200, Some("white 62%")), + (300, Some("white 42%")), + (400, Some("white 22%")), + (500, Some("white 9%")), + (600, None), + (700, Some("black 15%")), + (800, Some("black 30%")), + (900, Some("black 45%")), + (950, Some("black 63%")), +]; + +fn theme_stop_value(seed: &str, mix: Option<&str>) -> String { + match mix { + Some(m) => format!("color-mix(in oklab, {seed}, {m})"), + None => seed.to_string(), + } +} + +fn theme_ramp(families: &[&str], seed: &str) -> String { + let mut out = String::new(); + for (stop, mix) in THEME_LADDER { + let val = theme_stop_value(seed, *mix); + for fam in families { + out.push_str(&format!("--color-{fam}-{stop}:{val};")); + } + } + out +} + +fn is_hex_color(s: &str) -> bool { + let b = s.as_bytes(); + b.len() == 7 && b[0] == b'#' && b[1..].iter().all(|c| c.is_ascii_hexdigit()) +} + +/// Build the keepsake's `:root:root{…}` colour override from two seed colours, or None +/// for the default gold (viewer already carries that ramp) or an invalid seed (fall back +/// to the embedded default rather than emit unsafe CSS). MIRRORS `buildPaletteCss` in +/// frontend/src/lib/theme/palette.ts. +fn theme_override_css(primary: &str, accent: &str) -> Option { + if !is_hex_color(primary) || !is_hex_color(accent) { + return None; + } + if primary.eq_ignore_ascii_case(KEEPSAKE_DEFAULT_SEED) + && accent.eq_ignore_ascii_case(KEEPSAKE_DEFAULT_SEED) + { + return None; + } + let accent500 = theme_stop_value(accent, Some("white 9%")); + let mut css = String::from(":root:root{"); + css.push_str(&theme_ramp(&["blue", "primary"], primary)); + css.push_str(&theme_ramp(&["purple"], accent)); + css.push_str(&format!( + "--color-violet-500:{accent500};--color-violet-600:{accent};" + )); + css.push_str(&format!( + "--color-accent-500:{accent500};--color-accent-600:{accent};" + )); + css.push('}'); + Some(css) +} + +/// Read the active theme seeds from the runtime `config` table (set by the admin UI), +/// falling back to the default gold. NOTE: an env-only default (THEME_PRIMARY set but +/// never saved via the admin UI) isn't stored in this table, so such a keepsake would +/// use gold; admin-set themes — the normal path — match the app exactly. +async fn resolve_theme_seeds(pool: &PgPool) -> (String, String) { + async fn read(pool: &PgPool, key: &str) -> String { + sqlx::query_scalar::<_, String>("SELECT value FROM config WHERE key = $1") + .bind(key) + .fetch_optional(pool) + .await + .ok() + .flatten() + .unwrap_or_else(|| KEEPSAKE_DEFAULT_SEED.to_string()) + } + ( + read(pool, "theme_primary").await, + read(pool, "theme_accent").await, + ) +} + fn ext_from_path(path: &str) -> &str { path.rsplit('.').next().unwrap_or("bin") } diff --git a/frontend/src/app.html b/frontend/src/app.html index 157f701..24532de 100644 --- a/frontend/src/app.html +++ b/frontend/src/app.html @@ -33,6 +33,19 @@ (pref === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches); if (dark) document.documentElement.classList.add('dark'); } catch (_) {} + // Colour-theme FOUC guard: re-apply the cached palette override before paint so + // a custom/preset theme doesn't flash the default gold. The value is a ready-to- + // inject `:root:root{…}` string from event-config-store (empty for the default + // theme); refreshed from /event once the app mounts. + try { + var css = localStorage.getItem('eventsnap_palette_css'); + if (css) { + var s = document.createElement('style'); + s.id = 'es-theme'; + s.textContent = css; + document.head.appendChild(s); + } + } catch (_) {} })(); %sveltekit.head% diff --git a/frontend/src/lib/components/FeedListCard.svelte b/frontend/src/lib/components/FeedListCard.svelte index a230662..dc8aa7a 100644 --- a/frontend/src/lib/components/FeedListCard.svelte +++ b/frontend/src/lib/components/FeedListCard.svelte @@ -7,6 +7,7 @@ import { avatarPalette, initials } from '$lib/avatar'; import { vibrate } from '$lib/haptics'; import { now } from '$lib/now'; + import { commentsEnabled } from '$lib/event-config-store'; import HeartBurst from './HeartBurst.svelte'; interface Props { @@ -205,19 +206,27 @@ {upload.like_count} - + {#if $commentsEnabled} + + {/if} {#if isOwn} Eigener Beitrag {/if} diff --git a/frontend/src/lib/components/LightboxModal.svelte b/frontend/src/lib/components/LightboxModal.svelte index 4aa9acc..fa1d159 100644 --- a/frontend/src/lib/components/LightboxModal.svelte +++ b/frontend/src/lib/components/LightboxModal.svelte @@ -11,6 +11,7 @@ import { modalInert } from '$lib/actions/modal-inert'; import { toastError } from '$lib/toast-store'; import { vibrate } from '$lib/haptics'; + import { commentsEnabled } from '$lib/event-config-store'; import HeartBurst from './HeartBurst.svelte'; const COMMENT_MAX = 500; @@ -224,81 +225,91 @@ {/if} - -
- {#if comments.length === 0} -

Noch keine Kommentare.

- {:else} -
- {#each comments as comment (comment.id)} -
-
- {comment.uploader_name} - {comment.body} -
- {formatTime(comment.created_at)} + + {#if $commentsEnabled} +
+ {#if comments.length === 0} +

+ Noch keine Kommentare. +

+ {:else} +
+ {#each comments as comment (comment.id)} +
+
+ {comment.uploader_name} + {comment.body} +
+ {formatTime(comment.created_at)} +
+ {#if comment.user_id === userId} + + {/if}
- {#if comment.user_id === userId} - - {/if} -
- {/each} -
- {/if} -
+ {/each} +
+ {/if} +
- -
{ - e.preventDefault(); - submitComment(); - }} - class="border-t border-gray-100 p-3 dark:border-gray-800" - > -
- - -
-
= 450 && newComment.length < COMMENT_MAX} - class:dark:text-amber-400={newComment.length >= 450 && newComment.length < COMMENT_MAX} - class:text-red-600={newComment.length >= COMMENT_MAX} - class:dark:text-red-400={newComment.length >= COMMENT_MAX} + + { + e.preventDefault(); + submitComment(); + }} + class="border-t border-gray-100 p-3 dark:border-gray-800" > - {newComment.length}/{COMMENT_MAX} -
-
+
+ + +
+
= 450 && newComment.length < COMMENT_MAX} + class:dark:text-amber-400={newComment.length >= 450 && newComment.length < COMMENT_MAX} + class:text-red-600={newComment.length >= COMMENT_MAX} + class:dark:text-red-400={newComment.length >= COMMENT_MAX} + > + {newComment.length}/{COMMENT_MAX} +
+ + {/if}
diff --git a/frontend/src/lib/event-config-store.ts b/frontend/src/lib/event-config-store.ts new file mode 100644 index 0000000..a086b7d --- /dev/null +++ b/frontend/src/lib/event-config-store.ts @@ -0,0 +1,60 @@ +/** + * Public event presentation config: the colour theme and the comments feature flag, + * served unauthenticated from `GET /api/v1/event`. Loaded once on boot (and refreshed + * on the `event-updated` SSE) so the theme applies before the user has even joined and + * the comment UI reflects the instance setting. + * + * The resolved theme CSS is cached in localStorage so the boot script in app.html can + * paint the right colours before the JS bundle loads (no flash of the default palette). + */ +import { writable } from 'svelte/store'; +import { buildPaletteCss, applyPaletteCss, type ThemeConfig } from './theme/palette'; + +export const PALETTE_CACHE_KEY = 'eventsnap_palette_css'; + +export type EventConfig = { + name: string; + slug: string; + commentsEnabled: boolean; + theme: ThemeConfig; +}; + +export const eventConfig = writable(null); +/** Convenience flag for the comment UI. Optimistic `true` until /event resolves. */ +export const commentsEnabled = writable(true); + +/** Fetch /event, apply the theme, and cache the resolved CSS for the next boot. */ +export async function loadEventConfig(): Promise { + try { + const res = await fetch('/api/v1/event'); + if (!res.ok) return; + const b = await res.json(); + const theme: ThemeConfig = { + preset: b.theme_preset ?? 'champagne-gold', + primary: b.theme_primary ?? '#8a6a2b', + accent: b.theme_accent ?? '#8a6a2b' + }; + eventConfig.set({ + name: b.name, + slug: b.slug, + commentsEnabled: b.comments_enabled ?? true, + theme + }); + commentsEnabled.set(b.comments_enabled ?? true); + + const css = buildPaletteCss(theme); + applyPaletteCss(css); + try { + localStorage.setItem(PALETTE_CACHE_KEY, css); + } catch { + /* private mode / quota — the theme still applied for this session */ + } + } catch { + /* offline / server down — keep whatever the boot script already painted */ + } +} + +/** Apply a theme immediately without persisting — used by the admin live preview. */ +export function previewTheme(theme: ThemeConfig): void { + applyPaletteCss(buildPaletteCss(theme)); +} diff --git a/frontend/src/lib/theme/palette.ts b/frontend/src/lib/theme/palette.ts new file mode 100644 index 0000000..508af95 --- /dev/null +++ b/frontend/src/lib/theme/palette.ts @@ -0,0 +1,120 @@ +/** + * Runtime colour theming. + * + * The app's colours are Tailwind v4 `@theme` tokens that compile to CSS custom + * properties (`--color-primary-600` …) which every utility references as + * `var(...)`. So we can recolour the whole app at runtime by overriding those + * variables — no rebuild. We derive a full 50→950 ramp for the brand ("primary", + * which the app authored as `blue-*`) and accent (`purple-*`/`violet-*`/`accent-*`) + * families from two seed colours, using CSS `color-mix()` so the browser does the + * tint/shade math. + * + * Neutrals (`gray-*`, the pearl→graphite system) are deliberately NOT themed — + * keeping them fixed guarantees text/background contrast never regresses, and the + * design language treats greys as the constant that "carries the system". + * + * The default (champagne gold) produces an EMPTY override so the hand-tuned ramp in + * tailwind-theme.css shows through verbatim — zero regression for existing installs. + */ + +export type ThemeConfig = { preset: string; primary: string; accent: string }; + +/** Champagne-gold seed — matches `--color-primary-600` in tailwind-theme.css. */ +export const DEFAULT_SEED = '#8a6a2b'; + +export type Preset = { id: string; label: string; primary: string; accent: string }; + +/** + * Curated palettes. The seed is the brand colour at ramp stop 600 (the primary + * button / FAB), chosen dark enough that white button text clears WCAG AA. + * MIRROR: the ids are validated server-side in backend/src/handlers/admin.rs + * (THEME_PRESETS) — add a preset in both places. + */ +export const PRESETS: Preset[] = [ + { id: 'champagne-gold', label: 'Champagner-Gold', primary: '#8a6a2b', accent: '#8a6a2b' }, + { id: 'rose', label: 'Rosé', primary: '#9e3f5c', accent: '#9e3f5c' }, + { id: 'sage', label: 'Salbeigrün', primary: '#4d6a4d', accent: '#4d6a4d' }, + { id: 'dusk-blue', label: 'Dämmerblau', primary: '#395880', accent: '#395880' }, + { id: 'classic-silver', label: 'Klassisch Silber', primary: '#5b5b61', accent: '#5b5b61' } +]; + +/** Resolve a stored theme config to its two effective seed colours. */ +export function resolveSeeds(cfg: ThemeConfig): { primary: string; accent: string } { + if (cfg.preset === 'custom') return { primary: cfg.primary, accent: cfg.accent }; + const p = PRESETS.find((x) => x.id === cfg.preset); + return p + ? { primary: p.primary, accent: p.accent } + : { primary: DEFAULT_SEED, accent: DEFAULT_SEED }; +} + +// stop → color-mix instruction against the seed. 600 is the seed itself; lighter +// stops mix toward white, darker toward black — in oklab for a perceptually even ramp. +const LADDER: Array<[number, string | null]> = [ + [50, 'white 90%'], + [100, 'white 80%'], + [200, 'white 62%'], + [300, 'white 42%'], + [400, 'white 22%'], + [500, 'white 9%'], + [600, null], + [700, 'black 15%'], + [800, 'black 30%'], + [900, 'black 45%'], + [950, 'black 63%'] +]; + +function stopValue(seed: string, mix: string | null): string { + return mix ? `color-mix(in oklab, ${seed}, ${mix})` : seed; +} + +function ramp(families: string[], seed: string): string { + let out = ''; + for (const [stop, mix] of LADDER) { + const val = stopValue(seed, mix); + for (const fam of families) out += `--color-${fam}-${stop}:${val};`; + } + return out; +} + +/** + * Build the `:root:root { … }` override for a theme. `:root:root` (specificity 0,2,0) + * beats Tailwind's `:root` `@theme` defaults regardless of stylesheet order, so the + * override wins whether it's injected before (boot) or after the bundle. + * + * Returns '' for the default gold, meaning "no override" → the verbatim hand-tuned + * ramp. Callers treat '' as "remove any injected style". + */ +export function buildPaletteCss(cfg: ThemeConfig): string { + const { primary, accent } = resolveSeeds(cfg); + const isDefault = primary.toLowerCase() === DEFAULT_SEED && accent.toLowerCase() === DEFAULT_SEED; + if (isDefault) return ''; + + const accent500 = stopValue(accent, 'white 9%'); + return ( + ':root:root{' + + ramp(['blue', 'primary'], primary) + + ramp(['purple'], accent) + + // violet + accent aliases are only used at 500/600 in the app + `--color-violet-500:${accent500};--color-violet-600:${accent};` + + `--color-accent-500:${accent500};--color-accent-600:${accent};` + + '}' + ); +} + +const STYLE_ID = 'es-theme'; + +/** Inject/replace/remove the theme override ` diff --git a/frontend/src/lib/diashow/wakelock.ts b/frontend/src/lib/diashow/wakelock.ts index 4185cdc..a47dac3 100644 --- a/frontend/src/lib/diashow/wakelock.ts +++ b/frontend/src/lib/diashow/wakelock.ts @@ -7,33 +7,43 @@ interface SentinelLike { release: () => Promise; + // WakeLockSentinel is an EventTarget; it fires `release` when the lock ends — including + // when the OS drops it because the tab was hidden. + addEventListener?: (type: 'release', listener: () => void) => void; } +type WakeLock = { request: (t: string) => Promise }; + let sentinel: SentinelLike | null = null; let visibilityHandler: (() => void) | null = null; -export async function acquireWakeLock(): Promise { - const wakeLock = ( - navigator as Navigator & { wakeLock?: { request: (t: string) => Promise } } - ).wakeLock; - if (!wakeLock) return; +async function request(wakeLock: WakeLock): Promise { try { sentinel = await wakeLock.request('screen'); + // The OS auto-releases the lock when the tab is hidden. Without clearing our handle + // on that event, the visibility handler's `sentinel === null` guard would never be + // true and the lock would never be re-acquired — the screen could then sleep after + // the first time the tab lost focus. Null it on release so re-acquire can fire. + sentinel.addEventListener?.('release', () => { + sentinel = null; + }); } catch { // User denied, or already released — nothing useful to do. sentinel = null; } +} + +export async function acquireWakeLock(): Promise { + const wakeLock = (navigator as Navigator & { wakeLock?: WakeLock }).wakeLock; + if (!wakeLock) return; + await request(wakeLock); // Re-acquire when the page becomes visible again (the OS releases the lock // while the tab is hidden). if (!visibilityHandler) { - visibilityHandler = async () => { + visibilityHandler = () => { if (document.visibilityState === 'visible' && sentinel === null) { - try { - sentinel = await wakeLock.request('screen'); - } catch { - sentinel = null; - } + void request(wakeLock); } }; document.addEventListener('visibilitychange', visibilityHandler); diff --git a/frontend/src/routes/diashow/+page.svelte b/frontend/src/routes/diashow/+page.svelte index 1ed41ff..3c4b821 100644 --- a/frontend/src/routes/diashow/+page.svelte +++ b/frontend/src/routes/diashow/+page.svelte @@ -15,17 +15,36 @@ let queue = new SlideQueue(); let current = $state(null); + // Two-layer crossfade. Previously the diashow rendered only `current` inside a + // `{#key current.id}` block, so on every advance Svelte destroyed the old slide the + // instant the id changed and mounted the new one starting at opacity 0 over the black + // backdrop — the old image vanished to black, then the new one faded up (and only + // popped in once it decoded). That's the "black flash". Instead we keep the outgoing + // slide fully opaque *underneath* the incoming one while it fades in, then drop it — + // there's always an opaque frame on screen, so the black never shows through. + type SlideLayer = { key: string; src: string; isVideo: boolean; phase: 'enter' | 'exit' }; + let layers = $state([]); + let dropPrevTimer: ReturnType | null = null; + // Guards the async (decode-gated) layer commit against a newer advance superseding it. + let slideToken = 0; let dwellMs = $state(6000); let transitionId = $state('crossfade'); let paused = $state(false); let showOverlay = $state(false); - let overlayHideTimer: ReturnType | null = null; let advanceTimer: ReturnType | null = null; const unsubs: Array<() => void> = []; + // Controls auto-hide: the top-right cluster shows on pointer/keyboard activity and fades + // after a few seconds idle (the cursor hides with it) so a projector shows only photos. + let controlsVisible = $state(true); + let controlsHideTimer: ReturnType | null = null; + // Fullscreen toggle via the Fullscreen API — an in-app button, independent of the + // browser's own F11 chrome. `isFullscreen` mirrors the actual state via fullscreenchange. + let isFullscreen = $state(false); + let rootEl: HTMLDivElement | undefined = $state(); + const transitionDef = $derived(findTransition(transitionId)); - const mediaSrc = $derived(current ? pickMediaUrl($dataMode, current) : ''); const isVideo = $derived(current?.mime_type.startsWith('video/') ?? false); function isEmpty(): boolean { @@ -49,10 +68,63 @@ function advance() { const next = queue.next(); - current = next; + showSlide(next); if (current) scheduleNext(); } + // Swap in the next slide as the top layer while holding the outgoing one beneath it + // for the length of the transition. For images we decode the incoming frame first so + // the fade reveals a painted picture rather than a blank one; the outgoing slide stays + // visible until then, so there's never a black gap. Videos can't be pre-decoded, so + // they swap in immediately. + function showSlide(next: FeedUpload | null) { + const token = ++slideToken; + if (dropPrevTimer) { + clearTimeout(dropPrevTimer); + dropPrevTimer = null; + } + current = next; + if (!next) { + layers = []; + return; + } + const slide: SlideLayer = { + key: next.id, + src: pickMediaUrl($dataMode, next), + isVideo: next.mime_type.startsWith('video/'), + phase: 'enter' + }; + const prev = layers.length ? layers[layers.length - 1] : null; + // Re-showing the same slide (e.g. a feed re-fetch resolved to the current head) — + // just replace it; never stack a slide on top of itself. + if (prev && prev.key === slide.key) { + layers = [slide]; + return; + } + const commit = () => { + if (token !== slideToken) return; // superseded by a newer advance — drop this frame + // Mark the outgoing frame as exiting so slide transitions push it off in step with + // the incoming one. Crossfade/Ken Burns ignore `phase` and just hold it opaque. + const exiting = prev ? { ...prev, phase: 'exit' as const } : null; + layers = exiting ? [exiting, slide] : [slide]; + if (exiting) { + dropPrevTimer = setTimeout(() => { + dropPrevTimer = null; + layers = layers.filter((l) => l.key !== exiting.key); + }, transitionDef.defaultDurationMs + 80); + } + }; + if (slide.isVideo) { + commit(); + return; + } + const pre = new Image(); + pre.src = slide.src; + // decode() resolves once the bitmap is ready to paint; on failure just commit anyway + // (a broken image should still advance the show rather than stall it). + pre.decode().then(commit).catch(commit); + } + // A video finished before its dwell/12s cap — advance immediately (unless paused). // The {#key current.id} block destroys the old