Implement user settings and dark mode toggle in layout component
- 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:
@@ -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,
|
||||
};
|
||||
@@ -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>
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||
return {
|
||||
user: locals.user ? structuredClone(locals.user) : null
|
||||
};
|
||||
};
|
||||
@@ -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()}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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' })
|
||||
}
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user