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:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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";
|
||||
|
||||
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: {
|
||||
variant: {
|
||||
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -1,9 +1,10 @@
|
||||
<script lang="ts">
|
||||
import './layout.css';
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
|
||||
import { ModeWatcher } from 'mode-watcher';
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<ModeWatcher />
|
||||
<svelte:head><link rel="icon" href={favicon} /></svelte:head>
|
||||
{@render children()}
|
||||
|
||||
Reference in New Issue
Block a user