feat(mvp): phase 7 - UI polish & ambient backgrounds

Add layout system (sidebar, header, main layout), dark/light/system theme
with HSL customization, 3 ambient backgrounds (mesh gradient, particle field,
aurora), Cmd/Ctrl+K search dialog, page transitions, card hover effects,
status pulse animations, skeleton loaders, and responsive design. Polish
all existing pages with consistent theming.
This commit is contained in:
2026-03-24 21:37:16 +03:00
parent c5166ba3a9
commit 0bd30c5e17
41 changed files with 2106 additions and 391 deletions
+2 -2
View File
@@ -43,7 +43,7 @@
href={app.url}
target="_blank"
rel="noopener noreferrer"
class="group flex flex-col rounded-lg border border-border bg-card p-4 transition-colors hover:border-primary/50 hover:bg-accent/50"
class="card-hover group flex flex-col rounded-xl border border-border bg-card p-4 transition-colors hover:border-primary/50"
title={app.description ?? app.name}
>
<div class="mb-3 flex items-start justify-between">
@@ -67,7 +67,7 @@
<AppHealthBadge status={currentStatus} />
</div>
<h3 class="truncate text-sm font-semibold text-card-foreground group-hover:text-primary">
<h3 class="truncate text-sm font-semibold text-card-foreground transition-colors group-hover:text-primary">
{app.name}
</h3>
+5 -5
View File
@@ -8,18 +8,18 @@
const config = $derived.by(() => {
switch (status) {
case 'online':
return { color: 'bg-green-500', text: 'Online' };
return { color: 'bg-green-500', cssClass: 'status-online', text: 'Online' };
case 'offline':
return { color: 'bg-red-500', text: 'Offline' };
return { color: 'bg-red-500', cssClass: '', text: 'Offline' };
case 'degraded':
return { color: 'bg-yellow-500', text: 'Degraded' };
return { color: 'bg-yellow-500', cssClass: '', text: 'Degraded' };
default:
return { color: 'bg-gray-500', text: 'Unknown' };
return { color: 'bg-gray-500', cssClass: '', text: 'Unknown' };
}
});
</script>
<span class="inline-flex items-center gap-1.5 text-xs">
<span class="inline-block h-2 w-2 rounded-full {config.color}"></span>
<span class="inline-block h-2 w-2 rounded-full {config.color} {config.cssClass}"></span>
<span class="text-muted-foreground">{config.text}</span>
</span>
@@ -0,0 +1,18 @@
<script lang="ts">
import { theme } from '$lib/stores/theme.svelte.js';
import MeshGradient from './MeshGradient.svelte';
import ParticleField from './ParticleField.svelte';
import AuroraEffect from './AuroraEffect.svelte';
</script>
{#if theme.backgroundType !== 'none'}
<div class="pointer-events-none fixed inset-0 z-0 overflow-hidden" aria-hidden="true">
{#if theme.backgroundType === 'mesh'}
<MeshGradient />
{:else if theme.backgroundType === 'particles'}
<ParticleField />
{:else if theme.backgroundType === 'aurora'}
<AuroraEffect />
{/if}
</div>
{/if}
@@ -0,0 +1,62 @@
<script lang="ts">
import { theme } from '$lib/stores/theme.svelte.js';
const hue = $derived(theme.primaryHue);
const sat = $derived(theme.primarySaturation);
const isDark = $derived(theme.isDark);
</script>
<div class="absolute inset-0 overflow-hidden">
<!-- First aurora band -->
<div
class="absolute -top-1/4 left-0 h-3/4 w-full opacity-[0.08]"
style="
background: linear-gradient(
180deg,
transparent 0%,
hsla({hue}, {sat}%, {isDark ? 60 : 50}%, 0.6) 30%,
hsla({hue + 40}, {sat}%, {isDark ? 50 : 40}%, 0.4) 60%,
transparent 100%
);
background-size: 400% 100%;
animation: aurora-shift 15s ease-in-out infinite;
filter: blur(40px);
transform: skewY(-5deg);
"
></div>
<!-- Second aurora band -->
<div
class="absolute -top-1/3 left-0 h-3/4 w-full opacity-[0.06]"
style="
background: linear-gradient(
180deg,
transparent 0%,
hsla({hue + 80}, {sat * 0.8}%, {isDark ? 55 : 45}%, 0.5) 35%,
hsla({hue + 120}, {sat * 0.6}%, {isDark ? 45 : 35}%, 0.3) 65%,
transparent 100%
);
background-size: 300% 100%;
animation: aurora-shift 20s ease-in-out infinite reverse;
filter: blur(50px);
transform: skewY(3deg);
"
></div>
<!-- Third aurora band -->
<div
class="absolute top-0 left-0 h-1/2 w-full opacity-[0.04]"
style="
background: linear-gradient(
180deg,
transparent 0%,
hsla({hue - 30}, {sat}%, {isDark ? 65 : 55}%, 0.4) 40%,
transparent 100%
);
background-size: 250% 100%;
animation: aurora-shift 12s ease-in-out infinite;
filter: blur(60px);
transform: skewY(-8deg);
"
></div>
</div>
@@ -0,0 +1,71 @@
<script lang="ts">
import { theme } from '$lib/stores/theme.svelte.js';
interface Blob {
x: number;
y: number;
vx: number;
vy: number;
hueOffset: number;
size: number;
}
const blobCount = 4;
let blobs = $state<Blob[]>([]);
let animFrame: number;
function initBlobs(): Blob[] {
return Array.from({ length: blobCount }, (_, i) => ({
x: 20 + Math.random() * 60,
y: 20 + Math.random() * 60,
vx: (Math.random() - 0.5) * 0.02,
vy: (Math.random() - 0.5) * 0.02,
hueOffset: i * 40,
size: 35 + Math.random() * 20
}));
}
function animate() {
blobs = blobs.map((blob) => {
let { x, y, vx, vy } = blob;
x += vx;
y += vy;
if (x < 5 || x > 95) vx = -vx;
if (y < 5 || y > 95) vy = -vy;
return { ...blob, x, y, vx, vy };
});
animFrame = requestAnimationFrame(animate);
}
$effect(() => {
blobs = initBlobs();
animFrame = requestAnimationFrame(animate);
return () => {
cancelAnimationFrame(animFrame);
};
});
</script>
<div class="absolute inset-0">
<svg class="h-full w-full" xmlns="http://www.w3.org/2000/svg">
<defs>
<filter id="mesh-blur">
<feGaussianBlur stdDeviation="60" />
</filter>
</defs>
{#each blobs as blob, i}
<circle
cx="{blob.x}%"
cy="{blob.y}%"
r="{blob.size}%"
fill="hsla({theme.primaryHue + blob.hueOffset}, {theme.primarySaturation}%, {theme.isDark ? 40 : 60}%, 0.12)"
filter="url(#mesh-blur)"
/>
{/each}
</svg>
</div>
@@ -0,0 +1,110 @@
<script lang="ts">
import { theme } from '$lib/stores/theme.svelte.js';
let canvas: HTMLCanvasElement;
let animFrame: number;
interface Particle {
x: number;
y: number;
vx: number;
vy: number;
radius: number;
}
const PARTICLE_COUNT = 70;
const CONNECTION_DISTANCE = 120;
let particles: Particle[] = [];
function initParticles(w: number, h: number): Particle[] {
return Array.from({ length: PARTICLE_COUNT }, () => ({
x: Math.random() * w,
y: Math.random() * h,
vx: (Math.random() - 0.5) * 0.4,
vy: (Math.random() - 0.5) * 0.4,
radius: 1.5 + Math.random() * 1.5
}));
}
function drawFrame(ctx: CanvasRenderingContext2D, w: number, h: number) {
ctx.clearRect(0, 0, w, h);
const hue = theme.primaryHue;
const sat = theme.primarySaturation;
const isDark = theme.isDark;
const lightness = isDark ? 70 : 40;
const baseAlpha = isDark ? 0.35 : 0.25;
// Update positions
for (const p of particles) {
p.x += p.vx;
p.y += p.vy;
if (p.x < 0 || p.x > w) p.vx = -p.vx;
if (p.y < 0 || p.y > h) p.vy = -p.vy;
}
// Draw connections
ctx.strokeStyle = `hsla(${hue}, ${sat}%, ${lightness}%, ${baseAlpha * 0.3})`;
ctx.lineWidth = 0.5;
for (let i = 0; i < particles.length; i++) {
for (let j = i + 1; j < particles.length; j++) {
const dx = particles[i].x - particles[j].x;
const dy = particles[i].y - particles[j].y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < CONNECTION_DISTANCE) {
const alpha = (1 - dist / CONNECTION_DISTANCE) * baseAlpha * 0.4;
ctx.strokeStyle = `hsla(${hue}, ${sat}%, ${lightness}%, ${alpha})`;
ctx.beginPath();
ctx.moveTo(particles[i].x, particles[i].y);
ctx.lineTo(particles[j].x, particles[j].y);
ctx.stroke();
}
}
}
// Draw particles
for (const p of particles) {
ctx.beginPath();
ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2);
ctx.fillStyle = `hsla(${hue}, ${sat}%, ${lightness}%, ${baseAlpha})`;
ctx.fill();
}
animFrame = requestAnimationFrame(() => drawFrame(ctx, w, h));
}
$effect(() => {
if (!canvas) return;
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
canvas.width = width;
canvas.height = height;
particles = initParticles(width, height);
}
});
resizeObserver.observe(canvas.parentElement!);
const ctx = canvas.getContext('2d');
if (!ctx) return;
const rect = canvas.parentElement!.getBoundingClientRect();
canvas.width = rect.width;
canvas.height = rect.height;
particles = initParticles(canvas.width, canvas.height);
animFrame = requestAnimationFrame(() => drawFrame(ctx, canvas.width, canvas.height));
return () => {
cancelAnimationFrame(animFrame);
resizeObserver.disconnect();
};
});
</script>
<canvas bind:this={canvas} class="absolute inset-0 h-full w-full"></canvas>
+2 -2
View File
@@ -34,8 +34,8 @@
<div class="space-y-6">
{#if sections.length === 0}
<div class="rounded-lg border border-gray-700 bg-gray-800/50 p-12 text-center">
<p class="text-gray-400">This board has no sections yet.</p>
<div class="rounded-xl border border-border bg-card/50 p-12 text-center">
<p class="text-muted-foreground">This board has no sections yet.</p>
</div>
{:else}
{#each sections as section (section.id)}
+7 -7
View File
@@ -20,36 +20,36 @@
<a
href="/boards/{board.id}"
class="group block rounded-lg border border-gray-700 bg-gray-800/50 p-5 transition-colors hover:border-indigo-500/50 hover:bg-gray-800"
class="card-hover group block rounded-xl border border-border bg-card p-5 transition-colors hover:border-primary/50"
>
<div class="flex items-start gap-3">
{#if board.icon}
<span class="text-xl">{board.icon}</span>
{:else}
<span class="flex h-8 w-8 items-center justify-center rounded-md bg-gray-700 text-sm text-gray-400">
<span class="flex h-8 w-8 items-center justify-center rounded-md bg-muted text-sm text-muted-foreground">
B
</span>
{/if}
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<h3 class="truncate font-semibold text-white group-hover:text-indigo-300 transition-colors">
<h3 class="truncate font-semibold text-foreground transition-colors group-hover:text-primary">
{board.name}
</h3>
{#if board.isDefault}
<span class="shrink-0 rounded bg-indigo-600/20 px-1.5 py-0.5 text-xs text-indigo-400">
<span class="shrink-0 rounded bg-primary/15 px-1.5 py-0.5 text-xs text-primary">
Default
</span>
{/if}
{#if board.isGuestAccessible}
<span class="shrink-0 rounded bg-green-600/20 px-1.5 py-0.5 text-xs text-green-400">
<span class="shrink-0 rounded bg-accent px-1.5 py-0.5 text-xs text-accent-foreground">
Guest
</span>
{/if}
</div>
{#if board.description}
<p class="mt-1 line-clamp-2 text-sm text-gray-400">{board.description}</p>
<p class="mt-1 line-clamp-2 text-sm text-muted-foreground">{board.description}</p>
{/if}
<p class="mt-2 text-xs text-gray-500">
<p class="mt-2 text-xs text-muted-foreground/70">
{sectionCount} section{sectionCount === 1 ? '' : 's'}
</p>
</div>
+4 -4
View File
@@ -16,9 +16,9 @@
<span class="text-2xl">{icon}</span>
{/if}
<div>
<h1 class="text-3xl font-bold text-white">{name}</h1>
<h1 class="text-3xl font-bold text-foreground">{name}</h1>
{#if description}
<p class="mt-1 text-sm text-gray-400">{description}</p>
<p class="mt-1 text-sm text-muted-foreground">{description}</p>
{/if}
</div>
</div>
@@ -26,14 +26,14 @@
<div class="flex items-center gap-2">
<a
href="/boards"
class="rounded-lg bg-gray-700 px-3 py-2 text-sm text-gray-200 hover:bg-gray-600 transition-colors"
class="rounded-lg border border-border px-3 py-2 text-sm text-foreground transition-colors hover:bg-accent"
>
All Boards
</a>
{#if canEdit}
<a
href="/boards/{boardId}/edit"
class="rounded-lg bg-indigo-600 px-3 py-2 text-sm font-medium text-white hover:bg-indigo-500 transition-colors"
class="rounded-lg bg-primary px-3 py-2 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
>
Edit
</a>
+192
View File
@@ -0,0 +1,192 @@
<script lang="ts">
import ThemeToggle from './ThemeToggle.svelte';
import SearchTrigger from '$lib/components/search/SearchTrigger.svelte';
import { ui } from '$lib/stores/ui.svelte.js';
import { theme, type BackgroundType } from '$lib/stores/theme.svelte.js';
interface Props {
user: { displayName: string; email: string; role: string; avatarUrl?: string | null } | null;
}
let { user }: Props = $props();
let showUserMenu = $state(false);
let showBgMenu = $state(false);
const bgOptions: { value: BackgroundType; label: string }[] = [
{ value: 'mesh', label: 'Mesh Gradient' },
{ value: 'particles', label: 'Particles' },
{ value: 'aurora', label: 'Aurora' },
{ value: 'none', label: 'None' }
];
function handleClickOutside(e: MouseEvent) {
const target = e.target as HTMLElement;
if (!target.closest('.user-menu-container')) {
showUserMenu = false;
}
if (!target.closest('.bg-menu-container')) {
showBgMenu = false;
}
}
</script>
<svelte:window onclick={handleClickOutside} />
<header
class="sticky top-0 z-20 flex h-14 items-center gap-3 border-b border-border bg-background/80 px-4 backdrop-blur-sm"
>
<!-- Mobile hamburger -->
{#if ui.isMobile}
<button
type="button"
onclick={() => ui.toggleSidebar()}
class="inline-flex items-center justify-center rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-foreground"
aria-label="Toggle sidebar"
>
<svg
class="h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="4" y1="6" x2="20" y2="6" />
<line x1="4" y1="12" x2="20" y2="12" />
<line x1="4" y1="18" x2="20" y2="18" />
</svg>
</button>
{/if}
<!-- Search -->
<div class="flex-1">
<SearchTrigger />
</div>
<!-- Background selector -->
<div class="bg-menu-container relative">
<button
type="button"
onclick={() => (showBgMenu = !showBgMenu)}
class="inline-flex items-center justify-center rounded-md p-2 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
title="Background effect"
aria-label="Change background effect"
>
<svg
class="h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7z" />
<circle cx="12" cy="12" r="3" />
</svg>
</button>
{#if showBgMenu}
<div
class="absolute right-0 top-full mt-1 w-44 rounded-md border border-border bg-popover p-1 shadow-lg"
>
{#each bgOptions as opt}
<button
type="button"
onclick={() => {
theme.setBackground(opt.value);
showBgMenu = false;
}}
class="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm transition-colors {theme.backgroundType === opt.value
? 'bg-accent text-accent-foreground'
: 'text-popover-foreground hover:bg-accent/50'}"
>
{#if theme.backgroundType === opt.value}
<svg
class="h-3 w-3"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="3"
>
<polyline points="20 6 9 17 4 12" />
</svg>
{:else}
<span class="h-3 w-3"></span>
{/if}
{opt.label}
</button>
{/each}
</div>
{/if}
</div>
<!-- Theme toggle -->
<ThemeToggle />
<!-- User menu -->
{#if user}
<div class="user-menu-container relative">
<button
type="button"
onclick={() => (showUserMenu = !showUserMenu)}
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
>
<span
class="flex h-7 w-7 items-center justify-center rounded-full bg-primary text-xs font-medium text-primary-foreground"
>
{user.displayName.charAt(0).toUpperCase()}
</span>
{#if !ui.isMobile}
<span class="max-w-[120px] truncate text-sm">{user.displayName}</span>
{/if}
</button>
{#if showUserMenu}
<div
class="absolute right-0 top-full mt-1 w-48 rounded-md border border-border bg-popover p-1 shadow-lg"
>
<div class="border-b border-border px-3 py-2">
<p class="text-sm font-medium text-popover-foreground">{user.displayName}</p>
<p class="truncate text-xs text-muted-foreground">{user.email}</p>
</div>
<form method="POST" action="/auth/logout">
<button
type="submit"
class="mt-1 flex w-full items-center gap-2 rounded-sm px-3 py-1.5 text-sm text-popover-foreground transition-colors hover:bg-accent"
>
<svg
class="h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
Sign Out
</button>
</form>
</div>
{/if}
</div>
{:else}
<a
href="/login"
class="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
Sign In
</a>
{/if}
</header>
@@ -0,0 +1,67 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import Sidebar from './Sidebar.svelte';
import Header from './Header.svelte';
import AmbientBackground from '$lib/components/background/AmbientBackground.svelte';
import SearchDialog from '$lib/components/search/SearchDialog.svelte';
import { ui } from '$lib/stores/ui.svelte.js';
interface BoardLink {
id: string;
name: string;
icon: string | null;
}
interface UserInfo {
displayName: string;
email: string;
role: string;
avatarUrl?: string | null;
}
interface Props {
user: UserInfo | null;
boards: BoardLink[];
children: Snippet;
}
let { user, boards, children }: Props = $props();
const isAdmin = $derived(user?.role === 'admin');
</script>
<!-- Ambient Background (fixed, behind everything) -->
<AmbientBackground />
<div class="relative z-10 flex h-screen overflow-hidden">
<!-- Mobile overlay -->
{#if ui.isMobile && !ui.sidebarHidden}
<button
type="button"
class="fixed inset-0 z-30 bg-black/50"
onclick={() => ui.closeMobileSidebar()}
aria-label="Close sidebar"
></button>
{/if}
<!-- Sidebar -->
{#if !ui.sidebarHidden || !ui.isMobile}
<div
class="shrink-0 {ui.isMobile ? 'fixed left-0 top-0 z-40 h-full' : 'relative'}"
>
<Sidebar {boards} {isAdmin} collapsed={ui.isMobile ? false : ui.sidebarCollapsed} />
</div>
{/if}
<!-- Main content area -->
<div class="flex min-w-0 flex-1 flex-col overflow-hidden">
<Header {user} />
<main class="flex-1 overflow-y-auto">
{@render children()}
</main>
</div>
</div>
<!-- Search Dialog (modal, z-50) -->
<SearchDialog />
+233
View File
@@ -0,0 +1,233 @@
<script lang="ts">
import { ui } from '$lib/stores/ui.svelte.js';
import { page } from '$app/stores';
interface BoardLink {
id: string;
name: string;
icon: string | null;
}
interface Props {
boards: BoardLink[];
isAdmin: boolean;
collapsed: boolean;
}
let { boards, isAdmin, collapsed }: Props = $props();
function isActive(path: string): boolean {
return $page.url.pathname.startsWith(path);
}
</script>
<aside
class="flex h-full flex-col border-r border-sidebar-border bg-sidebar transition-all duration-200"
class:w-64={!collapsed}
class:w-16={collapsed}
>
<!-- Brand -->
<div class="flex h-14 items-center border-b border-sidebar-border px-4">
{#if !collapsed}
<a href="/" class="flex items-center gap-2 text-sidebar-foreground">
<svg
class="h-6 w-6 text-sidebar-primary"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3" y="3" width="7" height="7" />
<rect x="14" y="3" width="7" height="7" />
<rect x="14" y="14" width="7" height="7" />
<rect x="3" y="14" width="7" height="7" />
</svg>
<span class="text-sm font-semibold">App Launcher</span>
</a>
{:else}
<a href="/" class="mx-auto text-sidebar-primary" title="App Launcher">
<svg
class="h-6 w-6"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3" y="3" width="7" height="7" />
<rect x="14" y="3" width="7" height="7" />
<rect x="14" y="14" width="7" height="7" />
<rect x="3" y="14" width="7" height="7" />
</svg>
</a>
{/if}
</div>
<!-- Navigation -->
<nav class="flex-1 overflow-y-auto px-2 py-3">
<!-- Main Links -->
<div class="mb-3">
{#if !collapsed}
<p class="mb-1 px-2 text-xs font-medium uppercase tracking-wider text-sidebar-foreground/50">
Navigation
</p>
{/if}
<a
href="/boards"
class="flex items-center gap-2 rounded-md px-2 py-2 text-sm transition-colors {isActive('/boards')
? 'bg-sidebar-accent text-sidebar-accent-foreground'
: 'text-sidebar-foreground hover:bg-sidebar-accent/50'}"
title={collapsed ? 'Boards' : undefined}
>
<svg
class="h-4 w-4 shrink-0"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<line x1="3" y1="9" x2="21" y2="9" />
<line x1="9" y1="21" x2="9" y2="9" />
</svg>
{#if !collapsed}<span>Boards</span>{/if}
</a>
<a
href="/apps"
class="flex items-center gap-2 rounded-md px-2 py-2 text-sm transition-colors {isActive('/apps')
? 'bg-sidebar-accent text-sidebar-accent-foreground'
: 'text-sidebar-foreground hover:bg-sidebar-accent/50'}"
title={collapsed ? 'Apps' : undefined}
>
<svg
class="h-4 w-4 shrink-0"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
<line x1="2" y1="12" x2="22" y2="12" />
<path
d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"
/>
</svg>
{#if !collapsed}<span>Apps</span>{/if}
</a>
</div>
<!-- Board List -->
{#if boards.length > 0}
<div class="mb-3">
{#if !collapsed}
<p
class="mb-1 px-2 text-xs font-medium uppercase tracking-wider text-sidebar-foreground/50"
>
Boards
</p>
{/if}
{#each boards as board (board.id)}
<a
href="/boards/{board.id}"
class="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors {isActive(`/boards/${board.id}`)
? 'bg-sidebar-accent text-sidebar-accent-foreground'
: 'text-sidebar-foreground hover:bg-sidebar-accent/50'}"
title={collapsed ? board.name : undefined}
onclick={() => ui.closeMobileSidebar()}
>
{#if board.icon}
<span class="shrink-0 text-base">{board.icon}</span>
{:else}
<span
class="flex h-5 w-5 shrink-0 items-center justify-center rounded bg-sidebar-accent text-[10px] font-medium text-sidebar-foreground"
>
{board.name.charAt(0).toUpperCase()}
</span>
{/if}
{#if !collapsed}
<span class="truncate">{board.name}</span>
{/if}
</a>
{/each}
</div>
{/if}
<!-- Admin -->
{#if isAdmin}
<div class="mt-auto border-t border-sidebar-border pt-3">
{#if !collapsed}
<p
class="mb-1 px-2 text-xs font-medium uppercase tracking-wider text-sidebar-foreground/50"
>
Admin
</p>
{/if}
<a
href="/admin/users"
class="flex items-center gap-2 rounded-md px-2 py-2 text-sm transition-colors {isActive('/admin')
? 'bg-sidebar-accent text-sidebar-accent-foreground'
: 'text-sidebar-foreground hover:bg-sidebar-accent/50'}"
title={collapsed ? 'Admin Panel' : undefined}
>
<svg
class="h-4 w-4 shrink-0"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M12.22 2h-.44a2 2 0 00-2 2v.18a2 2 0 01-1 1.73l-.43.25a2 2 0 01-2 0l-.15-.08a2 2 0 00-2.73.73l-.22.38a2 2 0 00.73 2.73l.15.1a2 2 0 011 1.72v.51a2 2 0 01-1 1.74l-.15.09a2 2 0 00-.73 2.73l.22.38a2 2 0 002.73.73l.15-.08a2 2 0 012 0l.43.25a2 2 0 011 1.73V20a2 2 0 002 2h.44a2 2 0 002-2v-.18a2 2 0 011-1.73l.43-.25a2 2 0 012 0l.15.08a2 2 0 002.73-.73l.22-.39a2 2 0 00-.73-2.73l-.15-.08a2 2 0 01-1-1.74v-.5a2 2 0 011-1.74l.15-.09a2 2 0 00.73-2.73l-.22-.38a2 2 0 00-2.73-.73l-.15.08a2 2 0 01-2 0l-.43-.25a2 2 0 01-1-1.73V4a2 2 0 00-2-2z"
/>
<circle cx="12" cy="12" r="3" />
</svg>
{#if !collapsed}<span>Admin Panel</span>{/if}
</a>
</div>
{/if}
</nav>
<!-- Collapse Toggle (desktop only) -->
{#if !ui.isMobile}
<div class="border-t border-sidebar-border p-2">
<button
type="button"
onclick={() => ui.toggleSidebar()}
class="flex w-full items-center justify-center rounded-md p-2 text-sidebar-foreground transition-colors hover:bg-sidebar-accent"
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
<svg
class="h-4 w-4 transition-transform duration-200"
class:rotate-180={collapsed}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
</div>
{/if}
</aside>
@@ -0,0 +1,41 @@
<script lang="ts">
import { theme } from '$lib/stores/theme.svelte.js';
const modeIcons: Record<string, { path: string; label: string }> = {
light: {
path: 'M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z',
label: 'Light'
},
dark: {
path: 'M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z',
label: 'Dark'
},
system: {
path: 'M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z',
label: 'System'
}
};
const currentIcon = $derived(modeIcons[theme.mode]);
</script>
<button
type="button"
onclick={() => theme.cycleMode()}
class="inline-flex items-center justify-center rounded-md p-2 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
title="Theme: {currentIcon.label}"
aria-label="Toggle theme (current: {currentIcon.label})"
>
<svg
class="h-5 w-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d={currentIcon.path} />
</svg>
</button>
@@ -0,0 +1,111 @@
<script lang="ts">
import { search } from '$lib/stores/search.svelte.js';
import SearchResult from './SearchResult.svelte';
let inputEl: HTMLInputElement;
const appResults = $derived(search.results.filter((r) => r.type === 'app'));
const boardResults = $derived(search.results.filter((r) => r.type === 'board'));
$effect(() => {
if (search.open && inputEl) {
// Focus input when dialog opens
requestAnimationFrame(() => inputEl?.focus());
}
});
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
search.close();
}
}
</script>
{#if search.open}
<!-- Backdrop -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="fixed inset-0 z-50 flex items-start justify-center bg-black/50 pt-[15vh] backdrop-blur-sm"
onclick={handleBackdropClick}
onkeydown={(e) => e.key === 'Escape' && search.close()}
>
<!-- Dialog -->
<div
class="w-full max-w-lg rounded-lg border border-border bg-popover shadow-2xl"
role="dialog"
aria-label="Search"
>
<!-- Input -->
<div class="flex items-center gap-2 border-b border-border px-4 py-3">
<svg
class="h-5 w-5 shrink-0 text-muted-foreground"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input
bind:this={inputEl}
bind:value={search.query}
type="text"
placeholder="Search apps and boards..."
class="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
/>
<kbd
class="hidden rounded border border-border bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground sm:inline"
>
ESC
</kbd>
</div>
<!-- Results -->
<div class="max-h-[50vh] overflow-y-auto p-2">
{#if search.loading}
<div class="flex items-center justify-center py-8">
<div
class="h-5 w-5 animate-spin rounded-full border-2 border-muted-foreground border-t-primary"
></div>
</div>
{:else if search.error}
<p class="py-6 text-center text-sm text-destructive">{search.error}</p>
{:else if search.query.length < 2}
<p class="py-6 text-center text-sm text-muted-foreground">
Type at least 2 characters to search
</p>
{:else if search.results.length === 0}
<p class="py-6 text-center text-sm text-muted-foreground">
No results for "{search.query}"
</p>
{:else}
{#if appResults.length > 0}
<div class="mb-2">
<p class="mb-1 px-3 text-xs font-medium uppercase tracking-wider text-muted-foreground">
Apps
</p>
{#each appResults as result (result.id)}
<SearchResult {result} onselect={() => search.close()} />
{/each}
</div>
{/if}
{#if boardResults.length > 0}
<div>
<p class="mb-1 px-3 text-xs font-medium uppercase tracking-wider text-muted-foreground">
Boards
</p>
{#each boardResults as result (result.id)}
<SearchResult {result} onselect={() => search.close()} />
{/each}
</div>
{/if}
{/if}
</div>
</div>
</div>
{/if}
@@ -0,0 +1,79 @@
<script lang="ts">
import type { SearchResultItem } from '$lib/stores/search.svelte.js';
interface Props {
result: SearchResultItem;
onselect: () => void;
}
let { result, onselect }: Props = $props();
const href = $derived(result.type === 'app' ? result.url : `/boards/${result.id}`);
const isExternal = $derived(result.type === 'app');
</script>
<a
{href}
target={isExternal ? '_blank' : undefined}
rel={isExternal ? 'noopener noreferrer' : undefined}
onclick={onselect}
class="flex items-center gap-3 rounded-md px-3 py-2.5 transition-colors hover:bg-accent"
>
<!-- Icon -->
<div
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-muted text-muted-foreground"
>
{#if result.icon}
<span class="text-lg">{result.icon}</span>
{:else if result.type === 'app'}
<svg
class="h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="12" cy="12" r="10" />
<line x1="2" y1="12" x2="22" y2="12" />
<path
d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"
/>
</svg>
{:else}
<svg
class="h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<line x1="3" y1="9" x2="21" y2="9" />
<line x1="9" y1="21" x2="9" y2="9" />
</svg>
{/if}
</div>
<!-- Content -->
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-foreground">{result.name}</p>
{#if result.description}
<p class="truncate text-xs text-muted-foreground">{result.description}</p>
{/if}
</div>
<!-- Type badge -->
<span
class="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-medium uppercase {result.type === 'app'
? 'bg-primary/10 text-primary'
: 'bg-accent text-accent-foreground'}"
>
{result.type}
</span>
</a>
@@ -0,0 +1,33 @@
<script lang="ts">
import { search } from '$lib/stores/search.svelte.js';
const isMac = $derived(
typeof navigator !== 'undefined' && navigator.platform?.toLowerCase().includes('mac')
);
</script>
<button
type="button"
onclick={() => search.toggle()}
class="flex w-full max-w-sm items-center gap-2 rounded-md border border-input bg-background/50 px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<svg
class="h-4 w-4 shrink-0"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<span class="flex-1 text-left">Search...</span>
<kbd
class="hidden rounded border border-border bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground sm:inline"
>
{isMac ? '⌘' : 'Ctrl'}K
</kbd>
</button>
+1 -1
View File
@@ -38,7 +38,7 @@
let expanded = $state(section.isExpandedByDefault);
</script>
<div class="rounded-lg border border-gray-700 bg-gray-800/30">
<div class="rounded-xl border border-border bg-card/30 shadow-sm">
<SectionHeader
title={section.title}
icon={section.icon}
@@ -12,10 +12,10 @@
<button
type="button"
onclick={onToggle}
class="flex w-full items-center gap-2 px-4 py-3 text-left transition-colors hover:bg-gray-700/30"
class="flex w-full items-center gap-2 rounded-t-xl px-4 py-3 text-left transition-colors hover:bg-accent/30"
>
<svg
class="h-4 w-4 shrink-0 text-gray-400 transition-transform duration-200"
class="h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200"
class:rotate-90={expanded}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
@@ -32,5 +32,5 @@
<span class="text-base">{icon}</span>
{/if}
<span class="font-medium text-white">{title}</span>
<span class="font-medium text-foreground">{title}</span>
</button>
@@ -0,0 +1,22 @@
<script lang="ts">
interface Props {
count?: number;
}
let { count = 3 }: Props = $props();
const items = $derived(Array.from({ length: count }, (_, i) => i));
</script>
{#each items as i (i)}
<div class="rounded-lg border border-border bg-card p-5">
<div class="flex items-start gap-3">
<div class="skeleton h-8 w-8 rounded-md"></div>
<div class="min-w-0 flex-1">
<div class="skeleton mb-2 h-5 w-1/2 rounded"></div>
<div class="skeleton mb-1 h-3 w-full rounded"></div>
<div class="skeleton mt-2 h-3 w-20 rounded"></div>
</div>
</div>
</div>
{/each}
@@ -0,0 +1,21 @@
<script lang="ts">
interface Props {
count?: number;
}
let { count = 1 }: Props = $props();
const items = $derived(Array.from({ length: count }, (_, i) => i));
</script>
{#each items as i (i)}
<div class="rounded-lg border border-border bg-card p-4">
<div class="mb-3 flex items-start justify-between">
<div class="skeleton h-10 w-10 rounded-lg"></div>
<div class="skeleton h-5 w-14 rounded-full"></div>
</div>
<div class="skeleton mb-2 h-4 w-3/4 rounded"></div>
<div class="skeleton h-3 w-full rounded"></div>
<div class="skeleton mt-1 h-3 w-1/2 rounded"></div>
</div>
{/each}
@@ -0,0 +1,32 @@
<script lang="ts">
interface Props {
count?: number;
widgetsPerSection?: number;
}
let { count = 2, widgetsPerSection = 4 }: Props = $props();
const sections = $derived(Array.from({ length: count }, (_, i) => i));
const widgets = $derived(Array.from({ length: widgetsPerSection }, (_, i) => i));
</script>
{#each sections as s (s)}
<div class="rounded-lg border border-border bg-card/50">
<!-- Section header skeleton -->
<div class="flex items-center gap-2 px-4 py-3">
<div class="skeleton h-4 w-4 rounded"></div>
<div class="skeleton h-4 w-32 rounded"></div>
</div>
<!-- Widget grid skeleton -->
<div class="grid grid-cols-2 gap-3 px-4 pb-4 sm:grid-cols-3 lg:grid-cols-4">
{#each widgets as w (w)}
<div class="flex flex-col items-center gap-2 rounded-lg border border-border bg-card p-4">
<div class="skeleton h-12 w-12 rounded-lg"></div>
<div class="skeleton h-3 w-16 rounded"></div>
<div class="skeleton h-4 w-12 rounded-full"></div>
</div>
{/each}
</div>
</div>
{/each}
+4 -4
View File
@@ -39,10 +39,10 @@
href={app.url}
target="_blank"
rel="noopener noreferrer"
class="group flex flex-col items-center gap-2 rounded-lg border border-gray-700 bg-gray-800/50 p-4 text-center transition-colors hover:border-indigo-500/50 hover:bg-gray-800"
class="card-hover group flex flex-col items-center gap-2 rounded-xl border border-border bg-card p-4 text-center transition-colors hover:border-primary/50"
>
<!-- Icon -->
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-gray-700 transition-colors group-hover:bg-gray-600">
<div class="flex h-12 w-12 items-center justify-center rounded-lg bg-muted transition-colors group-hover:bg-accent">
{#if app.iconType === 'emoji' && app.icon}
<span class="text-2xl">{app.icon}</span>
{:else if iconSrc}
@@ -52,14 +52,14 @@
class="h-8 w-8 object-contain"
/>
{:else}
<span class="text-lg font-bold text-gray-400">
<span class="text-lg font-bold text-muted-foreground">
{app.name.charAt(0).toUpperCase()}
</span>
{/if}
</div>
<!-- Name -->
<span class="text-sm font-medium text-white group-hover:text-indigo-300 transition-colors truncate w-full">
<span class="w-full truncate text-sm font-medium text-foreground transition-colors group-hover:text-primary">
{app.name}
</span>
+3 -3
View File
@@ -27,7 +27,7 @@
</script>
{#if widgets.length === 0}
<p class="text-sm text-gray-500">No widgets in this section.</p>
<p class="text-sm text-muted-foreground">No widgets in this section.</p>
{:else}
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{#each widgets as widget (widget.id)}
@@ -35,8 +35,8 @@
{#if widget.type === 'app' && widget.app}
<AppWidget app={widget.app} />
{:else}
<div class="flex h-full items-center justify-center rounded-lg border border-gray-700 bg-gray-800/50 p-4">
<span class="text-xs text-gray-500">{widget.type} widget</span>
<div class="flex h-full items-center justify-center rounded-xl border border-border bg-card p-4">
<span class="text-xs text-muted-foreground">{widget.type} widget</span>
</div>
{/if}
</WidgetContainer>
+122
View File
@@ -0,0 +1,122 @@
export interface SearchResultItem {
type: 'app' | 'board';
id: string;
name: string;
description: string | null;
url: string;
icon: string | null;
}
class SearchStore {
open = $state(false);
query = $state('');
results = $state<SearchResultItem[]>([]);
loading = $state(false);
error = $state<string | null>(null);
#debounceTimer: ReturnType<typeof setTimeout> | null = null;
constructor() {
if (typeof window !== 'undefined') {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
this.toggle();
}
if (e.key === 'Escape' && this.open) {
e.preventDefault();
this.close();
}
};
window.addEventListener('keydown', handleKeyDown);
}
$effect(() => {
const q = this.query;
if (q.length < 2) {
this.results = [];
this.error = null;
return;
}
this.#debouncedSearch(q);
});
}
#debouncedSearch(q: string) {
if (this.#debounceTimer) {
clearTimeout(this.#debounceTimer);
}
this.#debounceTimer = setTimeout(() => {
this.#performSearch(q);
}, 300);
}
async #performSearch(q: string) {
this.loading = true;
this.error = null;
try {
const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`);
if (!res.ok) {
this.error = 'Search failed';
this.results = [];
return;
}
const data = await res.json();
const items: SearchResultItem[] = [];
if (data.apps) {
for (const app of data.apps) {
items.push({
type: 'app',
id: app.id,
name: app.name,
description: app.description ?? null,
url: app.url,
icon: app.icon ?? null
});
}
}
if (data.boards) {
for (const board of data.boards) {
items.push({
type: 'board',
id: board.id,
name: board.name,
description: board.description ?? null,
url: `/boards/${board.id}`,
icon: board.icon ?? null
});
}
}
this.results = items;
} catch {
this.error = 'Search failed';
this.results = [];
} finally {
this.loading = false;
}
}
toggle() {
this.open = !this.open;
if (!this.open) {
this.query = '';
this.results = [];
this.error = null;
}
}
close() {
this.open = false;
this.query = '';
this.results = [];
this.error = null;
}
}
export const search = new SearchStore();
+120
View File
@@ -0,0 +1,120 @@
const THEME_STORAGE_KEY = 'wal-theme-mode';
const PRIMARY_HUE_KEY = 'wal-primary-hue';
const PRIMARY_SAT_KEY = 'wal-primary-sat';
const BG_TYPE_KEY = 'wal-bg-type';
export type ThemeMode = 'dark' | 'light' | 'system';
export type BackgroundType = 'mesh' | 'particles' | 'aurora' | 'none';
function getStoredValue<T>(key: string, fallback: T): T {
if (typeof window === 'undefined') return fallback;
try {
const stored = localStorage.getItem(key);
if (stored === null) return fallback;
return stored as unknown as T;
} catch {
return fallback;
}
}
function getStoredNumber(key: string, fallback: number): number {
if (typeof window === 'undefined') return fallback;
try {
const stored = localStorage.getItem(key);
if (stored === null) return fallback;
const parsed = Number(stored);
return Number.isNaN(parsed) ? fallback : parsed;
} catch {
return fallback;
}
}
class ThemeStore {
mode = $state<ThemeMode>('system');
primaryHue = $state(220);
primarySaturation = $state(70);
backgroundType = $state<BackgroundType>('mesh');
resolvedMode = $derived<'dark' | 'light'>(
this.mode === 'system' ? this.#systemPreference : this.mode
);
isDark = $derived(this.resolvedMode === 'dark');
#systemPreference: 'dark' | 'light' = 'dark';
constructor() {
if (typeof window !== 'undefined') {
this.mode = getStoredValue<ThemeMode>(THEME_STORAGE_KEY, 'system');
this.primaryHue = getStoredNumber(PRIMARY_HUE_KEY, 220);
this.primarySaturation = getStoredNumber(PRIMARY_SAT_KEY, 70);
this.backgroundType = getStoredValue<BackgroundType>(BG_TYPE_KEY, 'mesh');
const mql = window.matchMedia('(prefers-color-scheme: dark)');
this.#systemPreference = mql.matches ? 'dark' : 'light';
mql.addEventListener('change', (e) => {
this.#systemPreference = e.matches ? 'dark' : 'light';
});
}
$effect(() => {
if (typeof window === 'undefined') return;
localStorage.setItem(THEME_STORAGE_KEY, this.mode);
});
$effect(() => {
if (typeof window === 'undefined') return;
localStorage.setItem(PRIMARY_HUE_KEY, String(this.primaryHue));
});
$effect(() => {
if (typeof window === 'undefined') return;
localStorage.setItem(PRIMARY_SAT_KEY, String(this.primarySaturation));
});
$effect(() => {
if (typeof window === 'undefined') return;
localStorage.setItem(BG_TYPE_KEY, this.backgroundType);
});
$effect(() => {
if (typeof document === 'undefined') return;
const html = document.documentElement;
if (this.resolvedMode === 'dark') {
html.classList.add('dark');
html.classList.remove('light');
} else {
html.classList.remove('dark');
html.classList.add('light');
}
});
$effect(() => {
if (typeof document === 'undefined') return;
const html = document.documentElement;
html.style.setProperty('--primary-h', String(this.primaryHue));
html.style.setProperty('--primary-s', `${this.primarySaturation}%`);
});
}
cycleMode() {
const modes: ThemeMode[] = ['light', 'dark', 'system'];
const idx = modes.indexOf(this.mode);
this.mode = modes[(idx + 1) % modes.length];
}
setMode(mode: ThemeMode) {
this.mode = mode;
}
setBackground(bg: BackgroundType) {
this.backgroundType = bg;
}
setPrimaryColor(hue: number, saturation: number) {
this.primaryHue = Math.max(0, Math.min(360, hue));
this.primarySaturation = Math.max(0, Math.min(100, saturation));
}
}
export const theme = new ThemeStore();
+76
View File
@@ -0,0 +1,76 @@
const SIDEBAR_COLLAPSED_KEY = 'wal-sidebar-collapsed';
const SIDEBAR_HIDDEN_KEY = 'wal-sidebar-hidden';
function getStoredBool(key: string, fallback: boolean): boolean {
if (typeof window === 'undefined') return fallback;
try {
const stored = localStorage.getItem(key);
if (stored === null) return fallback;
return stored === 'true';
} catch {
return fallback;
}
}
class UiStore {
sidebarCollapsed = $state(false);
sidebarHidden = $state(false);
isMobile = $state(false);
sidebarVisible = $derived(!this.sidebarHidden);
constructor() {
if (typeof window !== 'undefined') {
this.sidebarCollapsed = getStoredBool(SIDEBAR_COLLAPSED_KEY, false);
this.sidebarHidden = getStoredBool(SIDEBAR_HIDDEN_KEY, false);
this.isMobile = window.innerWidth < 768;
const handleResize = () => {
const wasMobile = this.isMobile;
this.isMobile = window.innerWidth < 768;
if (this.isMobile && !wasMobile) {
this.sidebarHidden = true;
}
if (!this.isMobile && wasMobile) {
this.sidebarHidden = false;
}
};
window.addEventListener('resize', handleResize);
}
$effect(() => {
if (typeof window === 'undefined') return;
localStorage.setItem(SIDEBAR_COLLAPSED_KEY, String(this.sidebarCollapsed));
});
$effect(() => {
if (typeof window === 'undefined') return;
localStorage.setItem(SIDEBAR_HIDDEN_KEY, String(this.sidebarHidden));
});
}
toggleSidebar() {
if (this.isMobile) {
this.sidebarHidden = !this.sidebarHidden;
} else {
this.sidebarCollapsed = !this.sidebarCollapsed;
}
}
closeMobileSidebar() {
if (this.isMobile) {
this.sidebarHidden = true;
}
}
openMobileSidebar() {
if (this.isMobile) {
this.sidebarHidden = false;
}
}
}
export const ui = new UiStore();