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">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/stores';
|
||||||
import { Toaster } from 'svelte-sonner';
|
import { Toaster } from 'svelte-sonner';
|
||||||
import './app.css';
|
import './app.css';
|
||||||
import favicon from '$lib/assets/favicon.svg';
|
import favicon from '$lib/assets/favicon.svg';
|
||||||
import { initRealtime } from '$lib/realtime';
|
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 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(() => {
|
onMount(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
initRealtime().then((cleanup) => {
|
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()}
|
{@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