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:
@@ -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 @@
|
||||
</svg>
|
||||
{upload.like_count}
|
||||
</button>
|
||||
<button
|
||||
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
|
||||
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 $commentsEnabled}
|
||||
<button
|
||||
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
|
||||
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}
|
||||
<span class="ml-auto text-xs text-gray-400 dark:text-gray-500">Eigener Beitrag</span>
|
||||
{/if}
|
||||
|
||||
@@ -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}
|
||||
</div>
|
||||
|
||||
<!-- Comments list -->
|
||||
<div class="flex-1 overflow-y-auto p-3">
|
||||
{#if comments.length === 0}
|
||||
<p class="text-center text-sm text-gray-400 dark:text-gray-500">Noch keine Kommentare.</p>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each comments as comment (comment.id)}
|
||||
<div class="flex items-start gap-2">
|
||||
<div class="flex-1">
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||
>{comment.uploader_name}</span
|
||||
>
|
||||
<span class="ml-1 text-sm text-gray-700 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)}
|
||||
<!-- Comments list + composer — hidden entirely when comments are disabled instance-wide -->
|
||||
{#if $commentsEnabled}
|
||||
<div class="flex-1 overflow-y-auto p-3">
|
||||
{#if comments.length === 0}
|
||||
<p class="text-center text-sm text-gray-400 dark:text-gray-500">
|
||||
Noch keine Kommentare.
|
||||
</p>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each comments as comment (comment.id)}
|
||||
<div class="flex items-start gap-2">
|
||||
<div class="flex-1">
|
||||
<span class="text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||
>{comment.uploader_name}</span
|
||||
>
|
||||
<span class="ml-1 text-sm text-gray-700 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>
|
||||
{#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>
|
||||
{#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>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Comment input -->
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submitComment();
|
||||
}}
|
||||
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}
|
||||
<!-- Comment input -->
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submitComment();
|
||||
}}
|
||||
class="border-t border-gray-100 p-3 dark:border-gray-800"
|
||||
>
|
||||
{newComment.length}/{COMMENT_MAX}
|
||||
</div>
|
||||
</form>
|
||||
<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>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
60
frontend/src/lib/event-config-store.ts
Normal file
60
frontend/src/lib/event-config-store.ts
Normal 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));
|
||||
}
|
||||
120
frontend/src/lib/theme/palette.ts
Normal file
120
frontend/src/lib/theme/palette.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user