Implement user settings and dark mode toggle in layout component
CI / build (push) Has been skipped
CI / deploy (push) Successful in 50s

- Added functionality to toggle dark mode based on user preferences, with state management and API integration for saving settings.
- Introduced a user avatar display with initials fallback and a dropdown menu for logout and dark mode options.
- Enhanced layout structure to conditionally render the header based on user authentication status.
This commit is contained in:
eewing
2026-02-17 10:31:57 -06:00
parent 3c4712b5ee
commit 069f38a313
10 changed files with 233 additions and 1 deletions
@@ -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,
};
+7
View File
@@ -0,0 +1,7 @@
import Root from "./switch.svelte";
export {
Root,
//
Root as Switch,
};
@@ -0,0 +1,29 @@
<script lang="ts">
import { Switch as SwitchPrimitive } from "bits-ui";
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
let {
ref = $bindable(null),
class: className,
checked = $bindable(false),
...restProps
}: WithoutChildrenOrChild<SwitchPrimitive.RootProps> = $props();
</script>
<SwitchPrimitive.Root
bind:ref
bind:checked
data-slot="switch"
class={cn(
"data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 peer inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...restProps}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
class={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
+7
View File
@@ -0,0 +1,7 @@
import type { LayoutServerLoad } from './$types';
export const load: LayoutServerLoad = async ({ locals }) => {
return {
user: locals.user ? structuredClone(locals.user) : null
};
};
+89 -1
View File
@@ -1,13 +1,56 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { Toaster } from 'svelte-sonner';
import './app.css';
import favicon from '$lib/assets/favicon.svg';
import { initRealtime } from '$lib/realtime';
import * as Avatar from '$lib/components/ui/avatar/index.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import { Switch } from '$lib/components/ui/switch/index.js';
import LogOutIcon from '@lucide/svelte/icons/log-out';
import MoonIcon from '@lucide/svelte/icons/moon';
let { children } = $props();
let { data, children } = $props();
let cleanupRealtime: (() => void) | undefined;
let darkmode = $state<boolean>((data.user as { darkmode?: boolean } | null)?.darkmode ?? false);
$effect(() => {
const root = typeof document !== 'undefined' ? document.documentElement : null;
if (root) root.classList.toggle('dark', darkmode);
});
async function toggleDarkmode(checked: boolean) {
darkmode = checked;
try {
await fetch('/api/user/settings', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ darkmode: checked })
});
} catch {
darkmode = !checked;
}
}
function getInitials(user: { name?: string; email?: string } | null): string {
if (!user) return '?';
if (user.name && typeof user.name === 'string') {
const parts = user.name.trim().split(/\s+/);
if (parts.length >= 2) return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
if (parts[0]) return parts[0].slice(0, 2).toUpperCase();
}
const email = user.email && typeof user.email === 'string' ? user.email : '';
const local = email.split('@')[0] ?? '';
if (local.length >= 2) return local.slice(0, 2).toUpperCase();
return local ? local[0].toUpperCase() : '?';
}
const showHeader = $derived(!!data.user && $page.url.pathname !== '/login');
const user = $derived(data.user);
onMount(() => {
let cancelled = false;
initRealtime().then((cleanup) => {
@@ -34,4 +77,49 @@
}}
/>
{#if showHeader}
<header class="border-border flex h-14 shrink-0 items-center justify-end gap-2 border-b px-4 md:px-6">
<DropdownMenu.Root>
<DropdownMenu.Trigger
class="rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring"
asChild
>
<button type="button" class="rounded-full ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
<Avatar.Root class="size-9">
<Avatar.Image
src={(user as { avatar?: string })?.avatar}
alt={(user as { name?: string })?.name ?? 'User'}
/>
<Avatar.Fallback class="bg-primary text-primary-foreground text-sm font-medium">
{getInitials(user as { name?: string; email?: string })}
</Avatar.Fallback>
</Avatar.Root>
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end" class="w-56">
<div class="flex items-center justify-between gap-2 px-2 py-1.5 text-sm">
<span class="text-muted-foreground flex items-center gap-2">
<MoonIcon class="size-4" />
Dark mode
</span>
<Switch
checked={darkmode}
onCheckedChange={toggleDarkmode}
/>
</div>
<DropdownMenu.Separator />
<DropdownMenu.Item
onclick={async () => {
await fetch('/logout', { method: 'POST', credentials: 'same-origin' });
goto('/login');
}}
>
<LogOutIcon class="size-4" />
Log out
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</header>
{/if}
{@render children()}
+24
View File
@@ -0,0 +1,24 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const PATCH: RequestHandler = async ({ locals, request }) => {
if (!locals.user) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
let body: { darkmode?: boolean };
try {
body = await request.json();
} catch {
return json({ error: 'Invalid JSON' }, { status: 400 });
}
if (typeof body.darkmode !== 'boolean') {
return json({ error: 'darkmode must be a boolean' }, { status: 400 });
}
try {
await locals.pb.collection('users').update(locals.user.id, { darkmode: body.darkmode });
return json({ ok: true, darkmode: body.darkmode });
} catch (e) {
console.error('Failed to update user settings:', e);
return json({ error: 'Failed to update settings' }, { status: 500 });
}
};
+11
View File
@@ -0,0 +1,11 @@
import { redirect } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ locals }) => {
locals.pb.authStore.clear();
return redirect(303, '/login', {
headers: {
'set-cookie': locals.pb.authStore.exportToCookie({ httpOnly: false, sameSite: 'Lax' })
}
});
};