feat: seasonal decoration overlay + logo animation, admin site-theme config page

This commit is contained in:
root
2026-04-13 21:42:12 +05:00
parent 734ba68eed
commit 419bb7e366
9 changed files with 687 additions and 3 deletions

View File

@@ -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) {
*,

View File

@@ -0,0 +1,285 @@
<script lang="ts">
/**
* SeasonalDecoration — full-viewport canvas particle overlay.
*
* Modes:
* snow — white circular snowflakes drifting down with gentle sway
* sakura — pink/white ellipse petals falling and rotating
* fireflies — small glowing dots floating up, pulsing opacity
* leaves — orange/red/yellow tear-drop shapes tumbling down
* stars — white stars twinkling in place (fixed positions, opacity animation)
*/
type Mode = 'snow' | 'sakura' | 'fireflies' | 'leaves' | 'stars';
interface Props { mode: Mode }
let { mode }: Props = $props();
let canvas = $state<HTMLCanvasElement | null>(null);
let raf = 0;
// ── Particle types ──────────────────────────────────────────────────────
interface Particle {
x: number; y: number; r: number;
vx: number; vy: number;
angle: number; vAngle: number;
opacity: number; vOpacity: number;
color: string;
// star-specific
twinkleOffset?: number;
}
// ── Palette helpers ──────────────────────────────────────────────────────
function rand(min: number, max: number) { return min + Math.random() * (max - min); }
function randInt(min: number, max: number) { return Math.floor(rand(min, max + 1)); }
const SNOW_COLORS = ['rgba(255,255,255,0.85)', 'rgba(200,220,255,0.75)', 'rgba(220,235,255,0.8)'];
const SAKURA_COLORS = ['rgba(255,182,193,0.85)', 'rgba(255,200,210,0.8)', 'rgba(255,240,245,0.9)', 'rgba(255,160,180,0.75)'];
const FIREFLY_COLORS = ['rgba(180,255,100,0.9)', 'rgba(220,255,150,0.85)', 'rgba(255,255,180,0.8)'];
const LEAF_COLORS = ['rgba(210,80,20,0.85)', 'rgba(190,120,30,0.8)', 'rgba(220,160,40,0.85)', 'rgba(180,60,10,0.8)', 'rgba(240,140,30,0.9)'];
const STAR_COLORS = ['rgba(255,255,255,0.9)', 'rgba(255,240,180,0.85)', 'rgba(180,210,255,0.8)'];
// ── Spawn helpers ────────────────────────────────────────────────────────
function spawnSnow(W: number, H: number): Particle {
return {
x: rand(0, W), y: rand(-H * 0.2, -4),
r: rand(1.5, 5),
vx: rand(-0.4, 0.4), vy: rand(0.6, 2.0),
angle: 0, vAngle: 0,
opacity: rand(0.5, 1), vOpacity: 0,
color: SNOW_COLORS[randInt(0, SNOW_COLORS.length - 1)],
};
}
function spawnSakura(W: number, H: number): Particle {
return {
x: rand(0, W), y: rand(-H * 0.2, -4),
r: rand(3, 7),
vx: rand(-0.6, 0.6), vy: rand(0.5, 1.6),
angle: rand(0, Math.PI * 2), vAngle: rand(-0.03, 0.03),
opacity: rand(0.6, 1), vOpacity: 0,
color: SAKURA_COLORS[randInt(0, SAKURA_COLORS.length - 1)],
};
}
function spawnFirefly(W: number, H: number): Particle {
return {
x: rand(0, W), y: rand(H * 0.3, H),
r: rand(1.5, 3.5),
vx: rand(-0.3, 0.3), vy: rand(-0.8, -0.2),
angle: 0, vAngle: 0,
opacity: rand(0.2, 0.8), vOpacity: rand(0.008, 0.025) * (Math.random() < 0.5 ? 1 : -1),
color: FIREFLY_COLORS[randInt(0, FIREFLY_COLORS.length - 1)],
};
}
function spawnLeaf(W: number, H: number): Particle {
return {
x: rand(0, W), y: rand(-H * 0.2, -4),
r: rand(4, 9),
vx: rand(-1.2, 1.2), vy: rand(0.8, 2.5),
angle: rand(0, Math.PI * 2), vAngle: rand(-0.05, 0.05),
opacity: rand(0.6, 1), vOpacity: 0,
color: LEAF_COLORS[randInt(0, LEAF_COLORS.length - 1)],
};
}
function spawnStar(W: number, H: number): Particle {
return {
x: rand(0, W), y: rand(0, H),
r: rand(0.8, 2.5),
vx: 0, vy: 0,
angle: 0, vAngle: 0,
opacity: rand(0.1, 0.9),
vOpacity: rand(0.004, 0.015) * (Math.random() < 0.5 ? 1 : -1),
color: STAR_COLORS[randInt(0, STAR_COLORS.length - 1)],
twinkleOffset: rand(0, Math.PI * 2),
};
}
// ── Draw helpers ─────────────────────────────────────────────────────────
function drawSnow(ctx: CanvasRenderingContext2D, p: Particle) {
ctx.beginPath();
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fillStyle = p.color;
ctx.globalAlpha = p.opacity;
ctx.fill();
}
function drawSakura(ctx: CanvasRenderingContext2D, p: Particle) {
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.angle);
ctx.beginPath();
ctx.ellipse(0, 0, p.r * 1.8, p.r, 0, 0, Math.PI * 2);
ctx.fillStyle = p.color;
ctx.globalAlpha = p.opacity;
ctx.fill();
ctx.restore();
}
function drawFirefly(ctx: CanvasRenderingContext2D, p: Particle) {
// Glow effect: large soft circle + small bright core
const grd = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.r * 4);
grd.addColorStop(0, p.color);
grd.addColorStop(1, 'rgba(0,0,0,0)');
ctx.beginPath();
ctx.arc(p.x, p.y, p.r * 4, 0, Math.PI * 2);
ctx.fillStyle = grd;
ctx.globalAlpha = p.opacity * 0.6;
ctx.fill();
ctx.beginPath();
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fillStyle = p.color;
ctx.globalAlpha = p.opacity;
ctx.fill();
}
function drawLeaf(ctx: CanvasRenderingContext2D, p: Particle) {
ctx.save();
ctx.translate(p.x, p.y);
ctx.rotate(p.angle);
ctx.beginPath();
ctx.moveTo(0, -p.r * 1.5);
ctx.bezierCurveTo(p.r * 1.2, -p.r * 0.5, p.r * 1.2, p.r * 0.5, 0, p.r * 1.5);
ctx.bezierCurveTo(-p.r * 1.2, p.r * 0.5, -p.r * 1.2, -p.r * 0.5, 0, -p.r * 1.5);
ctx.fillStyle = p.color;
ctx.globalAlpha = p.opacity;
ctx.fill();
ctx.restore();
}
function drawStar(ctx: CanvasRenderingContext2D, p: Particle, t: number) {
const pulse = 0.5 + 0.5 * Math.sin(t * 0.002 + (p.twinkleOffset ?? 0));
ctx.beginPath();
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fillStyle = p.color;
ctx.globalAlpha = p.opacity * pulse;
ctx.fill();
}
// ── Particle count by mode ────────────────────────────────────────────────
const COUNT: Record<Mode, number> = {
snow: 120, sakura: 60, fireflies: 50, leaves: 45, stars: 150,
};
// ── Main effect ──────────────────────────────────────────────────────────
$effect(() => {
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
let W = window.innerWidth;
let H = window.innerHeight;
canvas.width = W;
canvas.height = H;
const onResize = () => {
W = window.innerWidth;
H = window.innerHeight;
canvas!.width = W;
canvas!.height = H;
// Reseed stars on resize since they're positionally fixed
if (mode === 'stars') {
particles.length = 0;
for (let i = 0; i < COUNT.stars; i++) particles.push(spawnStar(W, H));
}
};
window.addEventListener('resize', onResize);
const n = COUNT[mode];
const particles: Particle[] = [];
// Pre-scatter initial particles across the full height
for (let i = 0; i < n; i++) {
let p: Particle;
switch (mode) {
case 'snow': p = spawnSnow(W, H); p.y = rand(0, H); break;
case 'sakura': p = spawnSakura(W, H); p.y = rand(0, H); break;
case 'fireflies': p = spawnFirefly(W, H); break;
case 'leaves': p = spawnLeaf(W, H); p.y = rand(0, H); break;
case 'stars': p = spawnStar(W, H); break;
}
particles.push(p);
}
let t = 0;
function tick() {
ctx!.clearRect(0, 0, W, H);
ctx!.save();
for (let i = 0; i < particles.length; i++) {
const p = particles[i];
switch (mode) {
case 'snow': {
// Gentle horizontal sway
p.vx = Math.sin(t * 0.001 + p.y * 0.01) * 0.5;
p.x += p.vx; p.y += p.vy;
if (p.y > H + 10) particles[i] = spawnSnow(W, H);
else drawSnow(ctx!, p);
break;
}
case 'sakura': {
p.vx = Math.sin(t * 0.0008 + p.y * 0.008) * 0.8;
p.x += p.vx; p.y += p.vy;
p.angle += p.vAngle;
if (p.y > H + 20) particles[i] = spawnSakura(W, H);
else drawSakura(ctx!, p);
break;
}
case 'fireflies': {
p.x += p.vx + Math.sin(t * 0.002 + i) * 0.3;
p.y += p.vy;
p.opacity += p.vOpacity;
if (p.opacity >= 1) { p.opacity = 1; p.vOpacity *= -1; }
if (p.opacity <= 0.1) { p.opacity = 0.1; p.vOpacity *= -1; }
if (p.y < -10) particles[i] = spawnFirefly(W, H);
else drawFirefly(ctx!, p);
break;
}
case 'leaves': {
p.vx = Math.sin(t * 0.001 + p.y * 0.01) * 1.2 + p.vx * 0.02;
p.x += p.vx; p.y += p.vy;
p.angle += p.vAngle;
if (p.y > H + 20) particles[i] = spawnLeaf(W, H);
else drawLeaf(ctx!, p);
break;
}
case 'stars': {
drawStar(ctx!, p, t);
break;
}
}
}
ctx!.restore();
t++;
raf = requestAnimationFrame(tick);
}
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener('resize', onResize);
};
});
</script>
<!--
Fixed full-viewport overlay, pointer-events-none so all clicks pass through.
z-index 40 keeps it below the sticky nav (z-50) but above page content.
-->
<canvas
bind:this={canvas}
class="fixed inset-0 z-40 pointer-events-none"
aria-hidden="true"
></canvas>

View File

@@ -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<SiteConfig> {
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<SiteConfig>): Promise<void> {
// 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';

View File

@@ -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: '',
})),
};
};

View File

@@ -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}
<header class="border-b border-(--color-border) bg-(--color-surface) sticky top-0 z-50">
<nav class="max-w-6xl mx-auto px-4 h-14 flex items-center gap-6">
<a href="/" class="text-(--color-brand) font-bold text-lg tracking-tight hover:text-(--color-brand-dim) shrink-0">
<a href="/" class="text-(--color-brand) font-bold text-lg tracking-tight hover:text-(--color-brand-dim) shrink-0 flex items-center gap-1.5
{siteLogoAnim === 'glow' ? 'logo-anim-glow' : ''}
{siteLogoAnim === 'shimmer' ? 'logo-anim-shimmer' : ''}
{siteLogoAnim === 'pulse' ? 'logo-anim-pulse' : ''}
{siteLogoAnim === 'rainbow' ? 'logo-anim-rainbow' : ''}
">
libnovel
{#if siteEventLabel}
<span class="text-[10px] font-semibold px-1.5 py-0.5 rounded-full bg-(--color-brand)/15 text-(--color-brand) border border-(--color-brand)/30 leading-none tracking-wide">
{siteEventLabel}
</span>
{/if}
</a>
{#if page.data.book?.title && /\/books\/[^/]+\/chapters\//.test(page.url.pathname)}
@@ -1173,3 +1198,13 @@
searchOpen = true;
}
}} />
<!-- Seasonal decoration overlay — rendered above page content, below nav -->
{#if siteDecoration}
<SeasonalDecoration mode={siteDecoration} />
{/if}
<style>
/* Logo animation keyframes are defined globally in app.css */
/* This block intentionally left minimal — all logo-anim-* classes live in app.css */
</style>

View File

@@ -52,6 +52,11 @@
href: '/admin/changelog',
label: () => m.admin_nav_changelog(),
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 4h10a2 2 0 012 2v12a2 2 0 01-2 2H7a2 2 0 01-2-2V6a2 2 0 012-2z" />`
},
{
href: '/admin/site-theme',
label: () => 'Site Theme',
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />`
}
];

View File

@@ -0,0 +1,7 @@
import type { PageServerLoad } from './$types';
import { getSiteConfig } from '$lib/server/pocketbase';
export const load: PageServerLoad = async () => {
const config = await getSiteConfig();
return { config };
};

View File

@@ -0,0 +1,160 @@
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
type Decoration = 'snow' | 'sakura' | 'fireflies' | 'leaves' | 'stars' | null;
type LogoAnimation = 'none' | 'glow' | 'rainbow' | 'pulse' | 'shimmer';
let decoration = $state<Decoration>(data.config.decoration);
let logoAnimation = $state<LogoAnimation>(data.config.logoAnimation);
let eventLabel = $state(data.config.eventLabel ?? '');
let saving = $state(false);
let saved = $state(false);
let errMsg = $state('');
const DECORATIONS: { id: Decoration; label: string; emoji: string; desc: string }[] = [
{ id: null, label: 'Off', emoji: '✕', desc: 'No decoration' },
{ id: 'snow', label: 'Snow', emoji: '❄️', desc: 'Falling snowflakes — winter' },
{ id: 'sakura', label: 'Sakura', emoji: '🌸', desc: 'Cherry blossom petals — spring' },
{ id: 'fireflies', label: 'Fireflies', emoji: '✨', desc: 'Glowing fireflies — summer' },
{ id: 'leaves', label: 'Leaves', emoji: '🍂', desc: 'Falling autumn leaves — fall' },
{ id: 'stars', label: 'Stars', emoji: '⭐', desc: 'Twinkling stars — events / fantasy' },
];
const LOGO_ANIMATIONS: { id: LogoAnimation; label: string; desc: string }[] = [
{ id: 'none', label: 'None', desc: 'Default brand colour, no animation' },
{ id: 'glow', label: 'Glow', desc: 'Soft pulsing amber glow' },
{ id: 'shimmer', label: 'Shimmer', desc: 'Left-to-right shine sweep' },
{ id: 'pulse', label: 'Pulse', desc: 'Subtle scale pulse' },
{ id: 'rainbow', label: 'Rainbow', desc: 'Slow hue-rotate colour cycle' },
];
async function save() {
saving = true; saved = false; errMsg = '';
try {
const res = await fetch('/api/site-config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ decoration, logoAnimation, eventLabel }),
});
if (!res.ok) {
const d = await res.json().catch(() => ({}));
errMsg = d.message ?? `Error ${res.status}`;
} else {
saved = true;
setTimeout(() => { saved = false; }, 3000);
}
} catch (e) {
errMsg = String(e);
} finally {
saving = false;
}
}
</script>
<svelte:head>
<title>Site Theme — Admin</title>
</svelte:head>
<div class="max-w-2xl">
<div class="mb-8">
<h1 class="text-2xl font-bold text-(--color-text) mb-1">Site Theme</h1>
<p class="text-sm text-(--color-muted)">
Control seasonal decorations and the nav logo animation globally.
Changes take effect for all users within ~60 seconds (server cache TTL).
</p>
</div>
<!-- ── Decoration ────────────────────────────────────────────────────── -->
<section class="mb-8">
<h2 class="text-sm font-semibold text-(--color-text) uppercase tracking-widest mb-3">Particle Decoration</h2>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
{#each DECORATIONS as d}
<button
type="button"
onclick={() => { decoration = d.id; }}
class="flex items-start gap-3 p-3 rounded-lg border text-left transition-all
{decoration === d.id
? 'border-(--color-brand) bg-(--color-surface-2) text-(--color-text)'
: 'border-(--color-border) bg-(--color-surface-2)/40 text-(--color-muted) hover:border-(--color-brand)/40 hover:text-(--color-text)'}"
>
<span class="text-xl leading-none shrink-0 mt-0.5">{d.emoji}</span>
<div class="min-w-0">
<p class="text-sm font-semibold leading-snug">{d.label}</p>
<p class="text-xs opacity-70 leading-snug mt-0.5">{d.desc}</p>
</div>
</button>
{/each}
</div>
</section>
<!-- ── Logo Animation ────────────────────────────────────────────────── -->
<section class="mb-8">
<h2 class="text-sm font-semibold text-(--color-text) uppercase tracking-widest mb-3">Logo Animation</h2>
<div class="flex flex-col gap-2">
{#each LOGO_ANIMATIONS as a}
<button
type="button"
onclick={() => { logoAnimation = a.id; }}
class="flex items-center gap-4 p-3 rounded-lg border text-left transition-all
{logoAnimation === a.id
? 'border-(--color-brand) bg-(--color-surface-2) text-(--color-text)'
: 'border-(--color-border) bg-(--color-surface-2)/40 text-(--color-muted) hover:border-(--color-brand)/40 hover:text-(--color-text)'}"
>
<!-- Preview of the logo text with the animation class applied -->
<span class="font-bold text-lg tracking-tight w-24 shrink-0 text-(--color-brand)
{a.id === 'glow' ? 'logo-anim-glow' : ''}
{a.id === 'shimmer' ? 'logo-anim-shimmer' : ''}
{a.id === 'pulse' ? 'logo-anim-pulse' : ''}
{a.id === 'rainbow' ? 'logo-anim-rainbow' : ''}
">libnovel</span>
<div>
<p class="text-sm font-semibold">{a.label}</p>
<p class="text-xs opacity-70">{a.desc}</p>
</div>
{#if logoAnimation === a.id}
<svg class="w-4 h-4 ml-auto shrink-0 text-(--color-brand)" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
</svg>
{/if}
</button>
{/each}
</div>
</section>
<!-- ── Event Label ───────────────────────────────────────────────────── -->
<section class="mb-8">
<h2 class="text-sm font-semibold text-(--color-text) uppercase tracking-widest mb-1">Event Label <span class="normal-case font-normal text-(--color-muted)">(optional)</span></h2>
<p class="text-xs text-(--color-muted) mb-3">Short text shown as a small badge next to the logo, e.g. "Winter 2025" or "Sakura Festival". Leave blank to hide.</p>
<input
type="text"
maxlength="64"
placeholder="e.g. Winter 2025"
bind:value={eventLabel}
class="w-full px-3 py-2 rounded-lg bg-(--color-surface-2) border border-(--color-border) text-(--color-text) text-sm placeholder:text-(--color-muted) focus:outline-none focus:border-(--color-brand)/60"
/>
</section>
<!-- ── Save ──────────────────────────────────────────────────────────── -->
<div class="flex items-center gap-3">
<button
type="button"
onclick={save}
disabled={saving}
class="px-5 py-2 rounded-lg bg-(--color-brand) text-(--color-surface) font-semibold text-sm hover:bg-(--color-brand-dim) disabled:opacity-50 transition-colors"
>
{saving ? 'Saving…' : 'Save Changes'}
</button>
{#if saved}
<span class="text-sm text-green-400 flex items-center gap-1.5">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>
Saved
</span>
{/if}
{#if errMsg}
<span class="text-sm text-red-400">{errMsg}</span>
{/if}
</div>
</div>

View File

@@ -0,0 +1,57 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { getSiteConfig, saveSiteConfig } from '$lib/server/pocketbase';
import { log } from '$lib/server/logger';
/**
* GET /api/site-config
* Public — returns current site-wide decoration/animation settings.
*/
export const GET: RequestHandler = async () => {
try {
const config = await getSiteConfig();
return json(config);
} catch (e) {
log.error('site-config', 'GET failed', { err: String(e) });
error(500, 'Failed to load site config');
}
};
/**
* PUT /api/site-config
* Admin only — updates decoration + logoAnimation + eventLabel.
*/
export const PUT: RequestHandler = async ({ request, locals }) => {
if (!locals.user || locals.user.role !== 'admin') {
error(403, 'Forbidden');
}
const body = await request.json().catch(() => null);
if (!body) error(400, 'Invalid JSON body');
const validDecorations = ['snow', 'sakura', 'fireflies', 'leaves', 'stars', null];
if (body.decoration !== undefined && !validDecorations.includes(body.decoration)) {
error(400, `Invalid decoration — must be one of: ${validDecorations.filter(Boolean).join(', ')}, or null`);
}
const validLogoAnimations = ['none', 'glow', 'rainbow', 'pulse', 'shimmer'];
if (body.logoAnimation !== undefined && !validLogoAnimations.includes(body.logoAnimation)) {
error(400, `Invalid logoAnimation — must be one of: ${validLogoAnimations.join(', ')}`);
}
if (body.eventLabel !== undefined && typeof body.eventLabel !== 'string') {
error(400, 'eventLabel must be a string');
}
try {
await saveSiteConfig({
decoration: body.decoration ?? null,
logoAnimation: body.logoAnimation ?? 'none',
eventLabel: typeof body.eventLabel === 'string' ? body.eventLabel.slice(0, 64) : '',
});
return json({ ok: true });
} catch (e) {
log.error('site-config', 'PUT failed', { err: String(e) });
error(500, 'Failed to save site config');
}
};