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 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-19 15:21:55 +02:00
parent 6e0a760271
commit 57a907eca5
12 changed files with 765 additions and 99 deletions

View File

@@ -63,8 +63,25 @@ pub struct AppConfig {
pub app_port: u16, pub app_port: u16,
/// Number of concurrent media compression workers (read once at boot). /// Number of concurrent media compression workers (read once at boot).
pub compression_concurrency: usize, 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 { impl AppConfig {
pub fn from_env() -> Result<Self> { pub fn from_env() -> Result<Self> {
let app_env = std::env::var("APP_ENV").unwrap_or_else(|_| "development".to_string()); 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()) .and_then(|v| v.parse().ok())
.filter(|&n| n >= 1) .filter(|&n| n >= 1)
.unwrap_or(2), .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()),
}) })
} }
} }

View File

@@ -138,10 +138,27 @@ pub async fn patch_config(
"storage_quota_enabled", "storage_quota_enabled",
"upload_count_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 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 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 // Validate every key first so a bad value in the batch can't leave a partial
// update behind — validation must fully precede any write. // 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)." "Wert für {key} ist zu lang (max. {PRIVACY_NOTE_MAX_LEN} Zeichen)."
))); )));
} }
if key_str == "privacy_note" { match key_str {
privacy_note_changed = true; "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 { } else {
return Err(AppError::BadRequest(format!( 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 // 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. // (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( let _ = state.sse_tx.send(crate::state::SseEvent::new(
"event-updated", "event-updated",
serde_json::json!({ "keys": ["privacy_note"] }).to_string(), serde_json::json!({ "keys": keys }).to_string(),
)); ));
} }
Ok(StatusCode::NO_CONTENT) 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( pub async fn get_export_jobs(
State(state): State<AppState>, State(state): State<AppState>,
RequireAdmin(_auth): RequireAdmin, RequireAdmin(_auth): RequireAdmin,

View File

@@ -4,21 +4,41 @@ use axum::Json;
use axum::extract::State; use axum::extract::State;
use serde::Serialize; use serde::Serialize;
use crate::services::config;
use crate::state::AppState; use crate::state::AppState;
#[derive(Serialize)] #[derive(Serialize)]
pub struct PublicEventDto { pub struct PublicEventDto {
pub name: String, pub name: String,
pub slug: 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 /// Public event identity + presentation config, used by the pre-auth join/recover
/// see *which* event they're joining. Only the display name and slug are exposed — /// screens (which event am I joining, what does it look like). Only non-user-scoped
/// nothing user-scoped — so this is safe without a token. Served straight from the /// fields are exposed, so this is safe without a token. Identity comes straight from
/// instance config (no DB round-trip needed). /// 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<AppState>) -> Json<PublicEventDto> { pub async fn get_public_event(State(state): State<AppState>) -> Json<PublicEventDto> {
let cache = &state.config_cache;
Json(PublicEventDto { Json(PublicEventDto {
name: state.config.event_name.clone(), name: state.config.event_name.clone(),
slug: state.config.event_slug.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,
}) })
} }

View File

@@ -129,6 +129,12 @@ pub async fn add_comment(
Path(upload_id): Path<Uuid>, Path(upload_id): Path<Uuid>,
Json(body): Json<AddCommentRequest>, Json(body): Json<AddCommentRequest>,
) -> Result<(StatusCode, Json<CommentDto>), AppError> { ) -> Result<(StatusCode, Json<CommentDto>), 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) let user = crate::models::user::User::find_by_id(&state.pool, auth.user_id)
.await? .await?
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?; .ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;

View File

@@ -888,6 +888,10 @@ async fn run_html_export_inner(
let data_json = let data_json =
serde_json::to_string_pretty(&viewer_data).context("failed to serialize 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; let _ = update_progress(pool, event_id, "html", epoch, 72).await;
// 5. Create ZIP (per-generation paths — see run_zip_export) // 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 // `window.__EXPORT_DATA__` global into index.html. Guests double-click
// index.html (file://), where a cross-origin fetch() of a sibling file is // 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. // 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; let _ = update_progress(pool, event_id, "html", epoch, 75).await;
@@ -1313,6 +1317,7 @@ async fn write_viewer_with_data(
dir: &include_dir::Dir<'_>, dir: &include_dir::Dir<'_>,
zip: &mut ZipFileWriter<tokio::fs::File>, zip: &mut ZipFileWriter<tokio::fs::File>,
data_json: &str, data_json: &str,
theme_css: Option<&str>,
) -> Result<()> { ) -> Result<()> {
for file in dir.files() { for file in dir.files() {
let path = file.path().to_string_lossy().to_string(); let path = file.path().to_string_lossy().to_string();
@@ -1321,10 +1326,18 @@ async fn write_viewer_with_data(
.context("export-viewer index.html is not valid UTF-8")?; .context("export-viewer index.html is not valid UTF-8")?;
// Escape `</` so a caption containing `</script>` can't break out of the tag. // Escape `</` so a caption containing `</script>` can't break out of the tag.
let safe = data_json.replace("</", "<\\/"); let safe = data_json.replace("</", "<\\/");
let script = format!("<script>window.__EXPORT_DATA__={safe};</script>"); // 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!("<style id=\"es-theme\">{css}</style>"));
}
head_inject.push_str(&format!("<script>window.__EXPORT_DATA__={safe};</script>"));
let injected = match html.find("</head>") { let injected = match html.find("</head>") {
Some(idx) => format!("{}{}{}", &html[..idx], script, &html[idx..]), Some(idx) => format!("{}{}{}", &html[..idx], head_inject, &html[idx..]),
None => format!("{script}{html}"), None => format!("{head_inject}{html}"),
}; };
let builder = ZipEntryBuilder::new(path.into(), Compression::Deflate); let builder = ZipEntryBuilder::new(path.into(), Compression::Deflate);
let mut entry = zip.write_entry_stream(builder).await?; let mut entry = zip.write_entry_stream(builder).await?;
@@ -1340,11 +1353,101 @@ async fn write_viewer_with_data(
} }
} }
for sub_dir in dir.dirs() { 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(()) 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<String> {
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 { fn ext_from_path(path: &str) -> &str {
path.rsplit('.').next().unwrap_or("bin") path.rsplit('.').next().unwrap_or("bin")
} }

View File

@@ -33,6 +33,19 @@
(pref === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches); (pref === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
if (dark) document.documentElement.classList.add('dark'); if (dark) document.documentElement.classList.add('dark');
} catch (_) {} } 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 (_) {}
})(); })();
</script> </script>
%sveltekit.head% %sveltekit.head%

View File

@@ -7,6 +7,7 @@
import { avatarPalette, initials } from '$lib/avatar'; import { avatarPalette, initials } from '$lib/avatar';
import { vibrate } from '$lib/haptics'; import { vibrate } from '$lib/haptics';
import { now } from '$lib/now'; import { now } from '$lib/now';
import { commentsEnabled } from '$lib/event-config-store';
import HeartBurst from './HeartBurst.svelte'; import HeartBurst from './HeartBurst.svelte';
interface Props { interface Props {
@@ -205,19 +206,27 @@
</svg> </svg>
{upload.like_count} {upload.like_count}
</button> </button>
<button {#if $commentsEnabled}
onclick={() => oncomment(upload.id)} <button
class="flex items-center gap-1.5 text-sm font-medium text-gray-500 transition-colors hover:text-blue-500 active:text-blue-500 dark:text-gray-400 dark:hover:text-blue-400 dark:active:text-blue-400" onclick={() => oncomment(upload.id)}
> class="flex items-center gap-1.5 text-sm font-medium text-gray-500 transition-colors hover:text-blue-500 active:text-blue-500 dark:text-gray-400 dark:hover:text-blue-400 dark:active:text-blue-400"
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"> >
<path <svg
stroke-linecap="round" class="h-5 w-5"
stroke-linejoin="round" fill="none"
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" viewBox="0 0 24 24"
/> stroke="currentColor"
</svg> stroke-width="2"
{upload.comment_count} >
</button> <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>
{upload.comment_count}
</button>
{/if}
{#if isOwn} {#if isOwn}
<span class="ml-auto text-xs text-gray-400 dark:text-gray-500">Eigener Beitrag</span> <span class="ml-auto text-xs text-gray-400 dark:text-gray-500">Eigener Beitrag</span>
{/if} {/if}

View File

@@ -11,6 +11,7 @@
import { modalInert } from '$lib/actions/modal-inert'; import { modalInert } from '$lib/actions/modal-inert';
import { toastError } from '$lib/toast-store'; import { toastError } from '$lib/toast-store';
import { vibrate } from '$lib/haptics'; import { vibrate } from '$lib/haptics';
import { commentsEnabled } from '$lib/event-config-store';
import HeartBurst from './HeartBurst.svelte'; import HeartBurst from './HeartBurst.svelte';
const COMMENT_MAX = 500; const COMMENT_MAX = 500;
@@ -224,81 +225,91 @@
{/if} {/if}
</div> </div>
<!-- Comments list --> <!-- Comments list + composer — hidden entirely when comments are disabled instance-wide -->
<div class="flex-1 overflow-y-auto p-3"> {#if $commentsEnabled}
{#if comments.length === 0} <div class="flex-1 overflow-y-auto p-3">
<p class="text-center text-sm text-gray-400 dark:text-gray-500">Noch keine Kommentare.</p> {#if comments.length === 0}
{:else} <p class="text-center text-sm text-gray-400 dark:text-gray-500">
<div class="space-y-3"> Noch keine Kommentare.
{#each comments as comment (comment.id)} </p>
<div class="flex items-start gap-2"> {:else}
<div class="flex-1"> <div class="space-y-3">
<span class="text-sm font-medium text-gray-900 dark:text-gray-100" {#each comments as comment (comment.id)}
>{comment.uploader_name}</span <div class="flex items-start gap-2">
> <div class="flex-1">
<span class="ml-1 text-sm text-gray-700 dark:text-gray-300">{comment.body}</span> <span class="text-sm font-medium text-gray-900 dark:text-gray-100"
<div class="mt-0.5 text-xs text-gray-400 dark:text-gray-500"> >{comment.uploader_name}</span
{formatTime(comment.created_at)} >
<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 dark:text-gray-500">
{formatTime(comment.created_at)}
</div>
</div> </div>
{#if comment.user_id === userId}
<button
onclick={() => deleteComment(comment.id)}
class="shrink-0 text-gray-400 hover:text-red-500 dark:text-gray-500 dark:hover:text-red-400"
aria-label="Löschen"
>
<svg
class="h-3.5 w-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{/if}
</div> </div>
{#if comment.user_id === userId} {/each}
<button </div>
onclick={() => deleteComment(comment.id)} {/if}
class="shrink-0 text-gray-400 hover:text-red-500 dark:text-gray-500 dark:hover:text-red-400" </div>
aria-label="Löschen"
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
{/if}
</div>
{/each}
</div>
{/if}
</div>
<!-- Comment input --> <!-- Comment input -->
<form <form
onsubmit={(e) => { onsubmit={(e) => {
e.preventDefault(); e.preventDefault();
submitComment(); submitComment();
}} }}
class="border-t border-gray-100 p-3 dark:border-gray-800" class="border-t border-gray-100 p-3 dark:border-gray-800"
>
<div class="flex gap-2">
<input
type="text"
bind:value={newComment}
placeholder="Kommentar schreiben..."
maxlength={COMMENT_MAX}
class="input flex-1"
/>
<button
type="submit"
disabled={loading || !newComment.trim()}
class="btn btn-primary btn-sm"
>
Senden
</button>
</div>
<div
class="mt-1 text-right text-xs"
class:text-gray-400={newComment.length < 450}
class:dark:text-gray-500={newComment.length < 450}
class:text-amber-600={newComment.length >= 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} <div class="flex gap-2">
</div> <input
</form> type="text"
bind:value={newComment}
placeholder="Kommentar schreiben..."
maxlength={COMMENT_MAX}
class="input flex-1"
/>
<button
type="submit"
disabled={loading || !newComment.trim()}
class="btn btn-primary btn-sm"
>
Senden
</button>
</div>
<div
class="mt-1 text-right text-xs"
class:text-gray-400={newComment.length < 450}
class:dark:text-gray-500={newComment.length < 450}
class:text-amber-600={newComment.length >= 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}
</div>
</form>
{/if}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -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<EventConfig | null>(null);
/** Convenience flag for the comment UI. Optimistic `true` until /event resolves. */
export const commentsEnabled = writable<boolean>(true);
/** Fetch /event, apply the theme, and cache the resolved CSS for the next boot. */
export async function loadEventConfig(): Promise<void> {
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));
}

View File

@@ -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 `<style>`. Empty css removes it. */
export function applyPaletteCss(css: string): void {
if (typeof document === 'undefined') return;
let el = document.getElementById(STYLE_ID) as HTMLStyleElement | null;
if (!css) {
el?.remove();
return;
}
if (!el) {
el = document.createElement('style');
el.id = STYLE_ID;
document.head.appendChild(el);
}
el.textContent = css;
}

View File

@@ -16,6 +16,7 @@
import { api } from '$lib/api'; import { api } from '$lib/api';
import type { MeContextDto } from '$lib/types'; import type { MeContextDto } from '$lib/types';
import { eventState, markClosed, markOpened, refreshEventState } from '$lib/event-state-store'; import { eventState, markClosed, markOpened, refreshEventState } from '$lib/event-state-store';
import { loadEventConfig } from '$lib/event-config-store';
let { children } = $props(); let { children } = $props();
@@ -37,6 +38,10 @@
// Hooks up the appliedTheme → <html class="dark"> sync. Must run early so the // Hooks up the appliedTheme → <html class="dark"> sync. Must run early so the
// first paint after hydration matches the saved preference. // first paint after hydration matches the saved preference.
initTheme(); initTheme();
// Load the public event config (colour theme + comments flag) and apply the
// palette. Applies for authed and pre-auth (/join) alike; the app.html boot script
// already painted the cached palette, this reconciles it with the server.
void loadEventConfig();
// Hydrate cross-cutting stores once on boot if the user is already authenticated. // Hydrate cross-cutting stores once on boot if the user is already authenticated.
// Page-level mounts will refresh again as needed. // Page-level mounts will refresh again as needed.
if (getToken()) { if (getToken()) {
@@ -80,7 +85,10 @@
markClosed(); markClosed();
void refreshEventState(); void refreshEventState();
}), }),
onSseEvent('event-opened', () => markOpened()) onSseEvent('event-opened', () => markOpened()),
// A host changed the theme (or privacy note) — re-fetch the public config so the
// palette updates live on every open client, not just after a reload.
onSseEvent('event-updated', () => void loadEventConfig())
); );
}); });

View File

@@ -7,6 +7,9 @@
import ConfirmSheet from '$lib/components/ConfirmSheet.svelte'; import ConfirmSheet from '$lib/components/ConfirmSheet.svelte';
import Modal from '$lib/components/Modal.svelte'; import Modal from '$lib/components/Modal.svelte';
import IconButton from '$lib/components/IconButton.svelte'; import IconButton from '$lib/components/IconButton.svelte';
import { PRESETS, DEFAULT_SEED, buildPaletteCss, type ThemeConfig } from '$lib/theme/palette';
import { previewTheme, PALETTE_CACHE_KEY, loadEventConfig } from '$lib/event-config-store';
import { onDestroy } from 'svelte';
interface StatsDto { interface StatsDto {
user_count: number; user_count: number;
@@ -122,6 +125,100 @@
configDraft = { ...configDraft, [key]: isTrue(configDraft[key]) ? 'false' : 'true' }; configDraft = { ...configDraft, [key]: isTrue(configDraft[key]) ? 'false' : 'true' };
} }
// ── Colour theme ─────────────────────────────────────────────────────────
// The theme lives in the same `config` table but needs a bespoke UI (swatches +
// pickers + live preview) rather than the generic field renderer. Selecting a
// preset or nudging a colour recolours the whole admin page instantly (preview);
// Save persists it and refreshes every open client via the event-updated SSE.
let themePreset = $state('champagne-gold');
let themePrimary = $state(DEFAULT_SEED);
let themeAccent = $state(DEFAULT_SEED);
let themeSaving = $state(false);
// The persisted theme, so leaving without saving reverts the live preview.
let savedTheme: ThemeConfig = {
preset: 'champagne-gold',
primary: DEFAULT_SEED,
accent: DEFAULT_SEED
};
function currentTheme(): ThemeConfig {
return { preset: themePreset, primary: themePrimary, accent: themeAccent };
}
async function loadTheme() {
try {
const b = await api.get<{
theme_preset: string;
theme_primary: string;
theme_accent: string;
}>('/event');
themePreset = b.theme_preset ?? 'champagne-gold';
themePrimary = b.theme_primary ?? DEFAULT_SEED;
themeAccent = b.theme_accent ?? DEFAULT_SEED;
savedTheme = currentTheme();
} catch {
/* keep defaults — the theme card just shows champagne-gold */
}
}
function selectPreset(id: string) {
themePreset = id;
if (id !== 'custom') {
const p = PRESETS.find((x) => x.id === id);
if (p) {
themePrimary = p.primary;
themeAccent = p.accent;
}
}
previewTheme(currentTheme());
}
// A colour picker moved → force preset to 'custom' and preview live.
function onCustomColor() {
themePreset = 'custom';
previewTheme(currentTheme());
}
async function saveTheme() {
themeSaving = true;
try {
const theme = currentTheme();
await api.patch('/admin/config', {
theme_preset: theme.preset,
theme_primary: theme.primary,
theme_accent: theme.accent
});
savedTheme = theme;
try {
localStorage.setItem(PALETTE_CACHE_KEY, buildPaletteCss(theme));
} catch {
/* non-fatal */
}
toast('Farbschema gespeichert.', 'success');
} catch (e: unknown) {
toastError(e);
} finally {
themeSaving = false;
}
}
function resetTheme() {
selectPreset('champagne-gold');
}
const themeDirty = $derived(
themePreset !== savedTheme.preset ||
themePrimary.toLowerCase() !== savedTheme.primary.toLowerCase() ||
themeAccent.toLowerCase() !== savedTheme.accent.toLowerCase()
);
// Leaving the admin page without saving must not strand an unsaved preview on the
// rest of the app — revert to the persisted theme. loadEventConfig re-applies the
// authoritative palette + cache.
onDestroy(() => {
void loadEventConfig();
});
type AdminTab = 'stats' | 'config' | 'export' | 'users'; type AdminTab = 'stats' | 'config' | 'export' | 'users';
const TAB_LABELS: Record<AdminTab, string> = { const TAB_LABELS: Record<AdminTab, string> = {
stats: 'Stats', stats: 'Stats',
@@ -215,6 +312,7 @@
api.get<UserSummary[]>('/host/users') api.get<UserSummary[]>('/host/users')
]); ]);
configDraft = { ...config }; configDraft = { ...config };
void loadTheme();
} catch (e: unknown) { } catch (e: unknown) {
error = e instanceof Error ? e.message : 'Fehler beim Laden.'; error = e instanceof Error ? e.message : 'Fehler beim Laden.';
} finally { } finally {
@@ -591,6 +689,147 @@
<!-- ── Config tab ───────────────────────────────────────────────── --> <!-- ── Config tab ───────────────────────────────────────────────── -->
{:else if activeTab === 'config'} {:else if activeTab === 'config'}
<div class="relative space-y-3 pb-20"> <div class="relative space-y-3 pb-20">
<!-- ── Colour theme ─────────────────────────────────────────── -->
<div class="card">
<div class="border-b border-gray-100 px-5 py-3 dark:border-gray-700">
<h3
class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400"
>
Farbschema
</h3>
</div>
<div class="space-y-4 px-5 py-4">
<p class="text-xs text-gray-500 dark:text-gray-400">
Wähle eine Vorlage oder eigene Farben. Die Vorschau wird sofort angewendet;
„Speichern“ übernimmt sie für alle Gäste. Neutrale Töne (Grau/Silber) bleiben
bewusst unverändert.
</p>
<!-- Preset swatches -->
<div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
{#each PRESETS as preset (preset.id)}
<button
type="button"
onclick={() => selectPreset(preset.id)}
class="flex items-center gap-2 rounded-lg border px-3 py-2 text-left text-sm transition-colors {themePreset ===
preset.id
? 'border-primary-400 bg-primary-50 dark:bg-primary-950/40'
: 'border-gray-200 hover:border-gray-300 dark:border-gray-700 dark:hover:border-gray-600'}"
aria-pressed={themePreset === preset.id}
>
<span
class="h-5 w-5 shrink-0 rounded-full ring-1 ring-black/10"
style="background-color: {preset.primary}"
></span>
<span class="min-w-0 truncate text-gray-800 dark:text-gray-200"
>{preset.label}</span
>
</button>
{/each}
<button
type="button"
onclick={() => selectPreset('custom')}
class="flex items-center gap-2 rounded-lg border px-3 py-2 text-left text-sm transition-colors {themePreset ===
'custom'
? 'border-primary-400 bg-primary-50 dark:bg-primary-950/40'
: 'border-gray-200 hover:border-gray-300 dark:border-gray-700 dark:hover:border-gray-600'}"
aria-pressed={themePreset === 'custom'}
>
<span
class="h-5 w-5 shrink-0 rounded-full ring-1 ring-black/10"
style="background: conic-gradient(from 0deg, #b0506a, #c6a24a, #5c7a5c, #3f5f8a, #b0506a)"
></span>
<span class="text-gray-800 dark:text-gray-200">Eigene</span>
</button>
</div>
<!-- Custom colour pickers -->
{#if themePreset === 'custom'}
<div class="grid grid-cols-2 gap-3">
<label class="text-sm">
<span class="mb-1 block font-medium text-gray-700 dark:text-gray-300"
>Primär</span
>
<span class="flex items-center gap-2">
<input
type="color"
bind:value={themePrimary}
oninput={onCustomColor}
class="h-9 w-9 cursor-pointer rounded border border-gray-200 bg-transparent p-0.5 dark:border-gray-700"
aria-label="Primärfarbe"
/>
<input
type="text"
bind:value={themePrimary}
oninput={onCustomColor}
class="input font-mono text-sm uppercase"
maxlength="7"
/>
</span>
</label>
<label class="text-sm">
<span class="mb-1 block font-medium text-gray-700 dark:text-gray-300"
>Akzent</span
>
<span class="flex items-center gap-2">
<input
type="color"
bind:value={themeAccent}
oninput={onCustomColor}
class="h-9 w-9 cursor-pointer rounded border border-gray-200 bg-transparent p-0.5 dark:border-gray-700"
aria-label="Akzentfarbe"
/>
<input
type="text"
bind:value={themeAccent}
oninput={onCustomColor}
class="input font-mono text-sm uppercase"
maxlength="7"
/>
</span>
</label>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
Die gewählte Farbe ist der Button-/Signalton; hellere und dunklere Abstufungen
werden automatisch daraus abgeleitet. Für gute Lesbarkeit reicht ein kräftiger,
eher dunkler Ton (weiße Button-Schrift).
</p>
{/if}
<!-- Mini live preview -->
<div
class="flex flex-wrap items-center gap-2 rounded-lg bg-gray-50 p-3 dark:bg-gray-800/50"
>
<button type="button" class="btn btn-primary btn-sm" tabindex="-1"
>Beispiel-Button</button
>
<span class="chip chip-active" style="pointer-events: none">#hochzeit</span>
<span class="text-sm font-medium text-primary-600 dark:text-primary-400"
>Akzenttext</span
>
</div>
<div class="flex gap-2">
<button
type="button"
onclick={saveTheme}
disabled={themeSaving || !themeDirty}
class="btn btn-primary btn-sm"
>
{themeSaving ? 'Wird gespeichert…' : 'Farbschema speichern'}
</button>
<button
type="button"
onclick={resetTheme}
class="btn btn-secondary btn-sm"
disabled={themePreset === 'champagne-gold'}
>
Zurücksetzen
</button>
</div>
</div>
</div>
{#each CONFIG_GROUPS as group (group.title)} {#each CONFIG_GROUPS as group (group.title)}
<div class="card"> <div class="card">
<div class="border-b border-gray-100 px-5 py-3 dark:border-gray-700"> <div class="border-b border-gray-100 px-5 py-3 dark:border-gray-700">