feat: seasonal decoration overlay + logo animation, admin site-theme config page
This commit is contained in:
7
ui/src/routes/admin/site-theme/+page.server.ts
Normal file
7
ui/src/routes/admin/site-theme/+page.server.ts
Normal 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 };
|
||||
};
|
||||
160
ui/src/routes/admin/site-theme/+page.svelte
Normal file
160
ui/src/routes/admin/site-theme/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user