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

@@ -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';