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:
@@ -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<Self> {
|
||||
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()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AppState>,
|
||||
RequireAdmin(_auth): RequireAdmin,
|
||||
|
||||
@@ -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<AppState>) -> Json<PublicEventDto> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -129,6 +129,12 @@ pub async fn add_comment(
|
||||
Path(upload_id): Path<Uuid>,
|
||||
Json(body): Json<AddCommentRequest>,
|
||||
) -> 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)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::NotFound("Benutzer nicht gefunden.".into()))?;
|
||||
|
||||
@@ -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<tokio::fs::File>,
|
||||
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 `</` so a caption containing `</script>` can't break out of the tag.
|
||||
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>") {
|
||||
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<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 {
|
||||
path.rsplit('.').next().unwrap_or("bin")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user