From 419bb7e366a94bf5cc3ae1bef5c09c6654a61fb1 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 13 Apr 2026 21:42:12 +0500 Subject: [PATCH] feat: seasonal decoration overlay + logo animation, admin site-theme config page --- ui/src/app.css | 46 +++ .../lib/components/SeasonalDecoration.svelte | 285 ++++++++++++++++++ ui/src/lib/server/pocketbase.ts | 84 ++++++ ui/src/routes/+layout.server.ts | 9 +- ui/src/routes/+layout.svelte | 37 ++- ui/src/routes/admin/+layout.svelte | 5 + .../routes/admin/site-theme/+page.server.ts | 7 + ui/src/routes/admin/site-theme/+page.svelte | 160 ++++++++++ ui/src/routes/api/site-config/+server.ts | 57 ++++ 9 files changed, 687 insertions(+), 3 deletions(-) create mode 100644 ui/src/lib/components/SeasonalDecoration.svelte create mode 100644 ui/src/routes/admin/site-theme/+page.server.ts create mode 100644 ui/src/routes/admin/site-theme/+page.svelte create mode 100644 ui/src/routes/api/site-config/+server.ts diff --git a/ui/src/app.css b/ui/src/app.css index 8ad22ac..d5e1cb8 100644 --- a/ui/src/app.css +++ b/ui/src/app.css @@ -250,6 +250,52 @@ html { animation: progress-bar 4s cubic-bezier(0.1, 0.05, 0.1, 1) forwards; } +/* ── Logo animation classes (used in nav + admin preview) ───────────── */ +@keyframes logo-glow-pulse { + 0%, 100% { text-shadow: 0 0 6px color-mix(in srgb, var(--color-brand) 60%, transparent); } + 50% { text-shadow: 0 0 18px color-mix(in srgb, var(--color-brand) 90%, transparent), 0 0 32px color-mix(in srgb, var(--color-brand) 40%, transparent); } +} +.logo-anim-glow { + animation: logo-glow-pulse 2.4s ease-in-out infinite; +} + +@keyframes logo-shimmer { + 0% { background-position: -200% center; } + 100% { background-position: 200% center; } +} +.logo-anim-shimmer { + background: linear-gradient( + 90deg, + var(--color-brand) 0%, + color-mix(in srgb, var(--color-brand) 40%, white) 40%, + var(--color-brand) 50%, + color-mix(in srgb, var(--color-brand) 40%, white) 60%, + var(--color-brand) 100% + ); + background-size: 200% auto; + background-clip: text; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + animation: logo-shimmer 2.2s linear infinite; +} + +@keyframes logo-pulse-scale { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.06); } +} +.logo-anim-pulse { + display: inline-block; + animation: logo-pulse-scale 1.8s ease-in-out infinite; +} + +@keyframes logo-rainbow { + 0% { filter: hue-rotate(0deg); } + 100% { filter: hue-rotate(360deg); } +} +.logo-anim-rainbow { + animation: logo-rainbow 4s linear infinite; +} + /* ── Respect reduced motion — disable all decorative animations ─────── */ @media (prefers-reduced-motion: reduce) { *, diff --git a/ui/src/lib/components/SeasonalDecoration.svelte b/ui/src/lib/components/SeasonalDecoration.svelte new file mode 100644 index 0000000..e6b498e --- /dev/null +++ b/ui/src/lib/components/SeasonalDecoration.svelte @@ -0,0 +1,285 @@ + + + + diff --git a/ui/src/lib/server/pocketbase.ts b/ui/src/lib/server/pocketbase.ts index f986ec1..d7c4ee0 100644 --- a/ui/src/lib/server/pocketbase.ts +++ b/ui/src/lib/server/pocketbase.ts @@ -2498,6 +2498,90 @@ export async function getUserStats( }; } +// ─── Site Config ───────────────────────────────────────────────────────────── +// +// A single singleton record in the `site_config` collection holds global +// display settings (seasonal decoration, logo animation, etc.). +// The record is lazily created on first write; reads return safe defaults if +// the collection/record doesn't exist yet. + +export interface SiteConfig { + /** Seasonal decoration particle effect: null = off */ + decoration: 'snow' | 'sakura' | 'fireflies' | 'leaves' | 'stars' | null; + /** Special CSS class applied to the nav logo text */ + logoAnimation: 'none' | 'glow' | 'rainbow' | 'pulse' | 'shimmer'; + /** Human-readable label for the current event/season shown in a small badge */ + eventLabel: string; +} + +const SITE_CONFIG_DEFAULTS: SiteConfig = { + decoration: null, + logoAnimation: 'none', + eventLabel: '', +}; + +// In-memory short cache so every SSR request doesn't hammer PocketBase +let _siteConfigCache: { value: SiteConfig; exp: number } | null = null; +const SITE_CONFIG_CACHE_TTL = 60_000; // 60 seconds + +export async function getSiteConfig(): Promise { + if (_siteConfigCache && Date.now() < _siteConfigCache.exp) { + return _siteConfigCache.value; + } + try { + const list = await pbGet<{ items: Array<{ id: string } & SiteConfig> }>( + '/api/collections/site_config/records?perPage=1' + ); + const row = list.items?.[0]; + const value: SiteConfig = row + ? { + decoration: row.decoration ?? null, + logoAnimation: row.logoAnimation ?? 'none', + eventLabel: row.eventLabel ?? '', + } + : { ...SITE_CONFIG_DEFAULTS }; + _siteConfigCache = { value, exp: Date.now() + SITE_CONFIG_CACHE_TTL }; + return value; + } catch { + // Collection may not exist yet — return defaults silently + return { ...SITE_CONFIG_DEFAULTS }; + } +} + +export async function saveSiteConfig(patch: Partial): Promise { + // Bust cache + _siteConfigCache = null; + + // Ensure collection exists and find the singleton record + let existingId: string | null = null; + try { + const list = await pbGet<{ items: Array<{ id: string }> }>( + '/api/collections/site_config/records?perPage=1' + ); + existingId = list.items?.[0]?.id ?? null; + } catch { + // Collection doesn't exist yet — create it via PocketBase API + await pbPost('/api/collections', { + name: 'site_config', + type: 'base', + fields: [ + { name: 'decoration', type: 'text' }, + { name: 'logoAnimation', type: 'text' }, + { name: 'eventLabel', type: 'text' }, + ], + }); + } + + if (existingId) { + await pbPatch(`/api/collections/site_config/records/${existingId}`, patch); + } else { + await pbPost('/api/collections/site_config/records', { + ...SITE_CONFIG_DEFAULTS, + ...patch, + }); + } +} + // ─── AI Jobs ────────────────────────────────────────────────────────────────── const AI_JOBS_CACHE_KEY = 'admin:ai_jobs'; diff --git a/ui/src/routes/+layout.server.ts b/ui/src/routes/+layout.server.ts index 88e3972..f66673b 100644 --- a/ui/src/routes/+layout.server.ts +++ b/ui/src/routes/+layout.server.ts @@ -1,6 +1,6 @@ import { redirect } from '@sveltejs/kit'; import type { LayoutServerLoad } from './$types'; -import { getSettings } from '$lib/server/pocketbase'; +import { getSettings, getSiteConfig } from '$lib/server/pocketbase'; import { log } from '$lib/server/logger'; // Routes that are accessible without being logged in @@ -60,6 +60,11 @@ export const load: LayoutServerLoad = async ({ locals, url, cookies }) => { return { user: locals.user, isPro: locals.isPro, - settings + settings, + siteConfig: await getSiteConfig().catch(() => ({ + decoration: null as null, + logoAnimation: 'none' as const, + eventLabel: '', + })), }; }; diff --git a/ui/src/routes/+layout.svelte b/ui/src/routes/+layout.svelte index 6be4dfe..7eb19a9 100644 --- a/ui/src/routes/+layout.svelte +++ b/ui/src/routes/+layout.svelte @@ -14,6 +14,7 @@ import ListeningMode from '$lib/components/ListeningMode.svelte'; import SearchModal from '$lib/components/SearchModal.svelte'; import NotificationsModal from '$lib/components/NotificationsModal.svelte'; + import SeasonalDecoration from '$lib/components/SeasonalDecoration.svelte'; import { fly, fade } from 'svelte/transition'; let { children, data }: { children: Snippet; data: LayoutData } = $props(); @@ -94,6 +95,20 @@ let listeningModeOpen = $state(false); let listeningModeChapters = $state(false); + // ── Site config (seasonal decoration + logo animation) ────────────────── + // svelte-ignore state_referenced_locally + let siteDecoration = $state(data.siteConfig?.decoration ?? null); + // svelte-ignore state_referenced_locally + let siteLogoAnim = $state(data.siteConfig?.logoAnimation ?? 'none'); + // svelte-ignore state_referenced_locally + let siteEventLabel = $state(data.siteConfig?.eventLabel ?? ''); + // Refresh when invalidateAll() re-runs layout load (e.g. after admin saves) + $effect(() => { + siteDecoration = data.siteConfig?.decoration ?? null; + siteLogoAnim = data.siteConfig?.logoAnimation ?? 'none'; + siteEventLabel = data.siteConfig?.eventLabel ?? ''; + }); + // Build time formatted in the user's local timezone (populated on mount so // SSR and CSR don't produce a mismatch — SSR renders nothing, hydration fills it in). let buildTimeLocal = $state(''); @@ -522,8 +537,18 @@ {/if}