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:
@@ -16,6 +16,7 @@
|
||||
import { api } from '$lib/api';
|
||||
import type { MeContextDto } from '$lib/types';
|
||||
import { eventState, markClosed, markOpened, refreshEventState } from '$lib/event-state-store';
|
||||
import { loadEventConfig } from '$lib/event-config-store';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
@@ -37,6 +38,10 @@
|
||||
// Hooks up the appliedTheme → <html class="dark"> sync. Must run early so the
|
||||
// first paint after hydration matches the saved preference.
|
||||
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.
|
||||
// Page-level mounts will refresh again as needed.
|
||||
if (getToken()) {
|
||||
@@ -80,7 +85,10 @@
|
||||
markClosed();
|
||||
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())
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
import ConfirmSheet from '$lib/components/ConfirmSheet.svelte';
|
||||
import Modal from '$lib/components/Modal.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 {
|
||||
user_count: number;
|
||||
@@ -122,6 +125,100 @@
|
||||
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';
|
||||
const TAB_LABELS: Record<AdminTab, string> = {
|
||||
stats: 'Stats',
|
||||
@@ -215,6 +312,7 @@
|
||||
api.get<UserSummary[]>('/host/users')
|
||||
]);
|
||||
configDraft = { ...config };
|
||||
void loadTheme();
|
||||
} catch (e: unknown) {
|
||||
error = e instanceof Error ? e.message : 'Fehler beim Laden.';
|
||||
} finally {
|
||||
@@ -591,6 +689,147 @@
|
||||
<!-- ── Config tab ───────────────────────────────────────────────── -->
|
||||
{:else if activeTab === 'config'}
|
||||
<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)}
|
||||
<div class="card">
|
||||
<div class="border-b border-gray-100 px-5 py-3 dark:border-gray-700">
|
||||
|
||||
Reference in New Issue
Block a user