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