feat: add app layout with sidebar, header, and theme support

- Add Sidebar component with navigation menu
- Add Header component with dark mode toggle
- Add ThemeSelector for primary color customization
- Add SidebarUser component for user profile display
- Add connection-status component
- Add avatar UI components
- Add theme context for primary color state
- Add ModeWatcher for dark mode support

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 13:58:07 -05:00
parent 3a34f8db8f
commit 68a5260dc6
13 changed files with 670 additions and 2 deletions
+105
View File
@@ -0,0 +1,105 @@
<script lang="ts">
import { cn } from '$lib/utils';
import { Button } from '$lib/components/ui/button';
import * as Sidebar from '$lib/components/ui/sidebar';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Avatar from '$lib/components/ui/avatar';
import { toggleMode } from "mode-watcher";
import ThemeSelector from './ThemeSelector.svelte';
// Icons
import Sun from '@lucide/svelte/icons/sun';
import Moon from '@lucide/svelte/icons/moon';
import User from '@lucide/svelte/icons/user';
import Settings from '@lucide/svelte/icons/settings';
import LogOut from '@lucide/svelte/icons/log-out';
import Monitor from '@lucide/svelte/icons/monitor';
let { user, class: className = '' }: { user?: any; class?: string } = $props();
// Dark mode state - you might want to use a proper theme store
let darkMode = $state(false);
// Theme options
const themes = [
{ label: 'Light', value: 'light', icon: Sun },
{ label: 'Dark', value: 'dark', icon: Moon },
{ label: 'System', value: 'system', icon: Monitor }
];
</script>
<header class={cn(
"flex h-14 items-center justify-between border-b bg-sidebar text-sidebar-foreground px-6 sticky top-0 z-50",
className
)}>
<div class="flex items-center gap-4">
<!-- Sidebar Trigger -->
<Sidebar.Trigger />
</div>
<div class="flex items-center gap-3">
<!-- Theme Selector -->
<ThemeSelector />
<!-- Dark Mode Toggle -->
<Button onclick={toggleMode} variant="ghost" size="icon">
<Sun
class="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 !transition-all dark:scale-0 dark:-rotate-90"
/>
<Moon
class="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 !transition-all dark:scale-100 dark:rotate-0"
/>
<span class="sr-only">Toggle theme</span>
</Button>
<!-- Account Avatar -->
{#if user}
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button variant="ghost" class="relative h-8 w-8 rounded-full" {...props}>
<Avatar.Root class="h-8 w-8">
<Avatar.Image src={user.avatar} alt={user.name || user.email} />
<Avatar.Fallback class="bg-gray-100 text-gray-600">
{user.name ? user.name.charAt(0).toUpperCase() : user.email?.charAt(0).toUpperCase() || 'U'}
</Avatar.Fallback>
</Avatar.Root>
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content class="w-56" align="end">
<DropdownMenu.Label class="font-normal">
<div class="flex flex-col space-y-1">
<p class="text-sm font-medium leading-none">
{user.name || 'User'}
</p>
<p class="text-xs leading-none text-muted-foreground">
{user.email}
</p>
</div>
</DropdownMenu.Label>
<DropdownMenu.Separator />
<DropdownMenu.Item>
<User class="mr-2 h-5 w-5" />
Profile
</DropdownMenu.Item>
<DropdownMenu.Item>
<Settings class="mr-2 h-5 w-5" />
Settings
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item>
<LogOut class="mr-2 h-5 w-5" />
Log out
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
{:else}
<!-- Guest user fallback -->
<Button variant="ghost" size="icon">
<User class="h-5 w-5" />
</Button>
{/if}
</div>
</header>
+76
View File
@@ -0,0 +1,76 @@
<script lang="ts">
import { House, Settings, Activity, CheckSquare, Bell, LayoutDashboard, Goal, NotebookPen } from "@lucide/svelte";
import * as Sidebar from "$lib/components/ui/sidebar/index";
import { page } from '$app/state'
import ConnectionStatus from "./connection-status.svelte";
let currentPath = $derived(page.url.pathname)
function isActivePage(url: string) {
return currentPath.startsWith(url)
}
// Menu items.
const items = [
{
title: "Home",
icon: House,
url: '/home'
},
{
title: "Habits",
icon: Activity,
url: '/habits'
},
{
title: "Notes",
icon: NotebookPen,
url: '/notes'
},
{
title: "Settings",
icon: Settings,
url: '/settings'
},
];
</script>
<Sidebar.Root>
<Sidebar.Header>
<Sidebar.Menu>
<Sidebar.MenuItem>
<div class="flex gap-3 pt-2 px-1 items-center">
<Goal class="h-6 w-6" />
<h1 class="text-lg font-semibold">MyApp</h1>
</div>
</Sidebar.MenuItem>
</Sidebar.Menu>
</Sidebar.Header>
<Sidebar.Content>
<Sidebar.Group>
<Sidebar.GroupLabel>Application</Sidebar.GroupLabel>
<Sidebar.GroupContent>
<Sidebar.Menu>
{#each items as item (item.title)}
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={isActivePage(item.url)}>
{#snippet child({ props })}
<a href={item.url} {...props}>
<item.icon class="h-5 w-5" />
<span class="text-base font-medium">{item.title}</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{/each}
</Sidebar.Menu>
</Sidebar.GroupContent>
</Sidebar.Group>
</Sidebar.Content>
<Sidebar.Footer>
<ConnectionStatus />
</Sidebar.Footer>
+124
View File
@@ -0,0 +1,124 @@
<script lang="ts">
import BadgeCheck from "@lucide/svelte/icons/badge-check";
import Bell from "@lucide/svelte/icons/bell";
import ChevronsUpDown from "@lucide/svelte/icons/chevrons-up-down";
import CreditCard from "@lucide/svelte/icons/credit-card";
import LogOut from "@lucide/svelte/icons/log-out";
import Sparkles from "@lucide/svelte/icons/sparkles";
import * as Avatar from "$lib/components/ui/avatar/index";
import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index";
import * as Sidebar from "$lib/components/ui/sidebar/index";
import { useSidebar } from "$lib/components/ui/sidebar/index";
import { logout } from "$lib/api/auth.remote";
let { user } = $props()
function getInitials(name: string) {
// Handle empty or null input
if (!name) return '';
// Remove extra spaces and split the name into parts
const nameParts = name.trim().split(/\s+/);
// If we have multiple parts, take first letter of first and last parts
if (nameParts.length > 1) {
const firstInitial = nameParts[0].charAt(0);
const lastInitial = nameParts[nameParts.length - 1].charAt(0);
return (firstInitial + lastInitial).toUpperCase();
}
// For single names, take first two letters, or just first letter if too short
const firstPart = nameParts[0];
if (firstPart.length > 1) {
return firstPart.slice(0, 2).toUpperCase();
}
return firstPart.toUpperCase();
}
let userInitials = $derived(getInitials(user.name))
const sidebar = useSidebar()
</script>
<Sidebar.Menu>
<Sidebar.MenuItem>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Sidebar.MenuButton
{...props}
size="lg"
class="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<Avatar.Root class="h-8 w-8 rounded-lg">
<Avatar.Image src={user?.avatar} alt={user.name} />
<Avatar.Fallback class="rounded-lg">{userInitials}</Avatar.Fallback>
</Avatar.Root>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-semibold">{user.name}</span>
<span class="truncate text-xs">{user.email}</span>
</div>
<ChevronsUpDown class="ml-auto size-4" />
</Sidebar.MenuButton>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content
class="w-[--bits-dropdown-menu-anchor-width] min-w-56 rounded-lg"
side={sidebar.isMobile ? "bottom" : "right"}
align="end"
sideOffset={4}
>
<DropdownMenu.Label class="p-0 font-normal">
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar.Root class="h-8 w-8 rounded-lg">
<Avatar.Image src={user.avatar} alt={user.name} />
<Avatar.Fallback class="rounded-lg">{userInitials}</Avatar.Fallback>
</Avatar.Root>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-semibold">{user.name}</span>
<span class="truncate text-xs">{user.email}</span>
</div>
</div>
</DropdownMenu.Label>
<DropdownMenu.Separator />
<DropdownMenu.Group>
<DropdownMenu.Item>
<Sparkles />
Upgrade to Pro
</DropdownMenu.Item>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Group>
<DropdownMenu.Item>
<BadgeCheck />
Account
</DropdownMenu.Item>
<DropdownMenu.Item>
<CreditCard />
Billing
</DropdownMenu.Item>
<DropdownMenu.Item>
<Bell />
Notifications
</DropdownMenu.Item>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<form {...logout}>
<button type="submit" class="flex items-center gap-2 w-full">
<DropdownMenu.Item class="w-full">
<LogOut />
Log out
</DropdownMenu.Item>
</button>
</form>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Sidebar.MenuItem>
</Sidebar.Menu>
+59
View File
@@ -0,0 +1,59 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import * as Popover from '$lib/components/ui/popover';
import { Palette, Check, RotateCcw } from '@lucide/svelte';
import { getThemeState, primaryColors } from '$lib/context/theme.svelte';
const themeState = getThemeState();
let showPopover = $state(false);
function selectColor(color: string) {
themeState.setPrimaryColor(color);
showPopover = false;
}
function resetToDefault() {
themeState.setPrimaryColor(null);
showPopover = false;
}
</script>
<Popover.Root bind:open={showPopover}>
<Popover.Trigger>
<Button variant="ghost" size="icon">
<Palette class="h-5 w-5" />
<span class="sr-only">Theme</span>
</Button>
</Popover.Trigger>
<Popover.Content class="w-48 p-3">
<div class="space-y-3">
<p class="text-sm font-medium text-muted-foreground">Primary Color</p>
<div class="grid grid-cols-3 gap-2">
{#each primaryColors as color}
<button
onclick={() => selectColor(color.value)}
class="relative h-8 w-full rounded-md border border-border transition-transform hover:scale-105"
style="background-color: {color.value}"
title={color.name}
>
{#if themeState.primaryColor === color.value}
<Check class="absolute inset-0 m-auto h-4 w-4 text-white drop-shadow-md" />
{/if}
</button>
{/each}
</div>
<Button
variant="ghost"
size="sm"
onclick={resetToDefault}
class="w-full gap-2"
>
<RotateCcw class="h-4 w-4" />
Reset to Default
</Button>
</div>
</Popover.Content>
</Popover.Root>
+119
View File
@@ -0,0 +1,119 @@
<script lang="ts">
import { useConnectionStatus } from '@triplit/svelte';
import { triplit } from '$lib/triplit/client';
import { cn } from '$lib/utils';
import { onMount } from 'svelte';
// Icons
import Wifi from '@lucide/svelte/icons/wifi';
import WifiOff from '@lucide/svelte/icons/wifi-off';
import Loader2 from '@lucide/svelte/icons/loader-2';
const connection = useConnectionStatus(triplit);
// Track overall connection attempt duration (including retries)
let initialConnectionAttemptTime = $state<number | null>(null);
let hasTimedOut = $state(false);
let timeoutId = $state<number | null>(null);
const CONNECTION_TIMEOUT = 15000; // 15 seconds for entire connection attempt
// Monitor connection status changes
$effect(() => {
if (connection.status === 'OPEN') {
// Successfully connected - reset everything
initialConnectionAttemptTime = null;
hasTimedOut = false;
if (timeoutId !== null) {
clearTimeout(timeoutId);
timeoutId = null;
}
} else if (connection.status === 'CONNECTING' || connection.status === 'CLOSED') {
// If this is the first connection attempt or we haven't timed out yet
if (initialConnectionAttemptTime === null && !hasTimedOut) {
initialConnectionAttemptTime = Date.now();
// Set timeout for the entire connection attempt (including retries)
timeoutId = setTimeout(() => {
hasTimedOut = true;
timeoutId = null;
}, CONNECTION_TIMEOUT);
}
}
});
// Map connection status to display info
const statusInfo = $derived(() => {
// If we've timed out after trying to connect, show connection failed
if (hasTimedOut) {
return {
text: 'Connection Failed',
indicatorClass: 'bg-red-500',
icon: WifiOff,
iconClass: 'text-red-600 dark:text-red-400'
};
}
// If we're in a connection attempt (including retries), show as connecting
if (initialConnectionAttemptTime !== null && (connection.status === 'CONNECTING' || connection.status === 'CLOSED')) {
return {
text: 'Connecting',
indicatorClass: 'bg-yellow-500',
icon: Loader2,
iconClass: 'text-yellow-600 dark:text-yellow-400 animate-spin'
};
}
switch (connection.status) {
case 'OPEN':
return {
text: 'Connected',
indicatorClass: 'bg-green-500',
icon: Wifi,
iconClass: 'text-green-600 dark:text-green-400'
};
case 'CLOSED':
default:
return {
text: 'Offline',
indicatorClass: 'bg-gray-500',
icon: WifiOff,
iconClass: 'text-gray-600 dark:text-gray-400'
};
}
});
</script>
<div class="flex items-center justify-center py-2 px-3">
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<!-- Status Indicator Light -->
<div class="relative flex items-center">
<div class={cn(
"h-2 w-2 rounded-full",
statusInfo().indicatorClass
)}>
</div>
<!-- Pulse animation for connecting state -->
{#if initialConnectionAttemptTime !== null && !hasTimedOut}
<div class="absolute h-2 w-2 rounded-full bg-yellow-500 animate-ping"></div>
{/if}
<!-- Pulse animation for connected state -->
{#if connection.status === 'OPEN'}
<div class="absolute h-2 w-2 rounded-full bg-green-500 animate-pulse"></div>
{/if}
</div>
<!-- Status Icon -->
{#snippet iconSnippet()}
{@const IconComponent = statusInfo().icon}
<IconComponent
class={cn("h-3 w-3", statusInfo().iconClass)}
/>
{/snippet}
{@render iconSnippet()}
<!-- Status Text -->
<span class="font-medium">
{statusInfo().text}
</span>
</div>
</div>
@@ -0,0 +1,17 @@
<script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AvatarPrimitive.FallbackProps = $props();
</script>
<AvatarPrimitive.Fallback
bind:ref
data-slot="avatar-fallback"
class={cn("bg-muted flex size-full items-center justify-center rounded-full", className)}
{...restProps}
/>
@@ -0,0 +1,17 @@
<script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
...restProps
}: AvatarPrimitive.ImageProps = $props();
</script>
<AvatarPrimitive.Image
bind:ref
data-slot="avatar-image"
class={cn("aspect-square size-full", className)}
{...restProps}
/>
@@ -0,0 +1,19 @@
<script lang="ts">
import { Avatar as AvatarPrimitive } from "bits-ui";
import { cn } from "$lib/utils.js";
let {
ref = $bindable(null),
loadingStatus = $bindable("loading"),
class: className,
...restProps
}: AvatarPrimitive.RootProps = $props();
</script>
<AvatarPrimitive.Root
bind:ref
bind:loadingStatus
data-slot="avatar"
class={cn("relative flex size-8 shrink-0 overflow-hidden rounded-full", className)}
{...restProps}
/>
+13
View File
@@ -0,0 +1,13 @@
import Root from "./avatar.svelte";
import Image from "./avatar-image.svelte";
import Fallback from "./avatar-fallback.svelte";
export {
Root,
Image,
Fallback,
//
Root as Avatar,
Image as AvatarImage,
Fallback as AvatarFallback,
};
@@ -2,7 +2,7 @@
import { tv, type VariantProps } from "tailwind-variants"; import { tv, type VariantProps } from "tailwind-variants";
export const sidebarMenuButtonVariants = tv({ export const sidebarMenuButtonVariants = tv({
base: "peer/menu-button ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-start text-sm outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pe-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:font-medium [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0", base: "peer/menu-button ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground flex w-full items-center gap-2 overflow-clip rounded-md p-2 text-start text-sm outline-hidden transition-[width,height,padding] group-has-data-[sidebar=menu-action]/menu-item:pe-8 group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:font-medium [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
variants: { variants: {
variant: { variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground", default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
+93
View File
@@ -0,0 +1,93 @@
import { getContext, setContext } from 'svelte';
import { browser } from '$app/environment';
import { parse, formatHex, oklch } from 'culori';
const THEME_STATE_KEY = Symbol('THEME_STATE');
// Predefined primary color options
export const primaryColors = [
{ name: 'Blue', value: 'oklch(0.6 0.2 260)' },
{ name: 'Emerald', value: 'oklch(0.6 0.2 160)' },
{ name: 'Rose', value: 'oklch(0.6 0.2 0)' },
{ name: 'Violet', value: 'oklch(0.6 0.2 290)' },
{ name: 'Orange', value: 'oklch(0.65 0.2 50)' },
{ name: 'Cyan', value: 'oklch(0.7 0.15 200)' }
] as const;
export class ThemeState {
primaryColor = $state<string | null>(null);
constructor() {
if (browser) {
const saved = localStorage.getItem('primary-color');
if (saved) {
this.primaryColor = saved;
this.applyPrimaryColor(saved);
}
}
}
setPrimaryColor(color: string | null) {
this.primaryColor = color;
if (browser) {
if (color) {
localStorage.setItem('primary-color', color);
this.applyPrimaryColor(color);
} else {
localStorage.removeItem('primary-color');
this.clearPrimaryColor();
}
}
}
private applyPrimaryColor(color: string) {
if (!browser) return;
document.documentElement.style.setProperty('--primary', color);
}
private clearPrimaryColor() {
if (!browser) return;
document.documentElement.style.removeProperty('--primary');
}
// Convert hex to OKLCH
hexToOklch(hex: string): string {
try {
const color = parse(hex);
if (color) {
const oklchColor = oklch(color);
if (oklchColor) {
const l = oklchColor.l?.toFixed(3) ?? '0';
const c = oklchColor.c?.toFixed(3) ?? '0';
const h = isNaN(oklchColor.h!) ? '0' : oklchColor.h!.toFixed(0);
return `oklch(${l} ${c} ${h})`;
}
}
} catch (e) {
console.warn('Failed to convert hex to OKLCH:', hex, e);
}
return 'oklch(0.6 0.2 260)';
}
// Convert OKLCH to hex
oklchToHex(oklchStr: string): string {
try {
const color = parse(oklchStr);
if (color) {
return formatHex(color) || '#3b82f6';
}
} catch (e) {
console.warn('Failed to parse OKLCH color:', oklchStr, e);
}
return '#3b82f6';
}
}
export function setThemeState(): ThemeState {
return setContext(THEME_STATE_KEY, new ThemeState());
}
export function getThemeState(): ThemeState {
return getContext(THEME_STATE_KEY);
}
+25
View File
@@ -0,0 +1,25 @@
<script lang="ts">
import * as Sidebar from "$lib/components/ui/sidebar/index";
import AppSidebar from "$lib/components/Sidebar.svelte";
import Header from "$lib/components/Header.svelte";
import { setThemeState } from "$lib/context/theme.svelte";
let { children, data } = $props();
// Initialize theme state context
setThemeState();
</script>
<Sidebar.Provider
style="--sidebar-width: calc(var(--spacing) * 72); --header-height: calc(var(--spacing) * 14);"
>
<AppSidebar />
<div class="flex flex-1 flex-col overflow-hidden">
<Header user={data?.user} />
<main class="flex-1 p-6 overflow-x-hidden overflow-y-auto bg-background">
{@render children?.()}
</main>
</div>
</Sidebar.Provider>
+2 -1
View File
@@ -1,9 +1,10 @@
<script lang="ts"> <script lang="ts">
import './layout.css'; import './layout.css';
import favicon from '$lib/assets/favicon.svg'; import favicon from '$lib/assets/favicon.svg';
import { ModeWatcher } from 'mode-watcher';
let { children } = $props(); let { children } = $props();
</script> </script>
<ModeWatcher />
<svelte:head><link rel="icon" href={favicon} /></svelte:head> <svelte:head><link rel="icon" href={favicon} /></svelte:head>
{@render children()} {@render children()}