Files
libnovel/ui/src/routes/admin/site-theme/+page.svelte

161 lines
7.0 KiB
Svelte

<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>