feat: add account menu to the sidebar footer

This commit is contained in:
2026-08-11 20:08:53 -04:00
parent cb070336c6
commit adddcfeeed
9 changed files with 270 additions and 25 deletions
@@ -1,45 +1,48 @@
<script lang="ts">
import { Separator } from '$lib/components/ui/separator/index.js';
import SettingsIcon from '@lucide/svelte/icons/settings';
import UnjsDb0 from '$lib/components/icons/UnjsDb0.svelte';
import ChitaiMark from '$lib/components/icons/chitai-mark.svelte';
import NavMain from './nav-main.svelte';
import NavUser from './nav-user.svelte';
import LibrarySwitcher from './library-switcher.svelte';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import type { ComponentProps } from 'svelte';
import { getLibraryState } from '$lib/state/library.svelte';
import { page } from '$app/state';
let {
ref = $bindable(null),
collapsible = 'icon',
user,
...restProps
}: ComponentProps<typeof Sidebar.Root> = $props();
}: ComponentProps<typeof Sidebar.Root> & { user: { email: string } } = $props();
const libraryState = getLibraryState();
// Keep components out of these objects. `<header.icon />` is a *dynamic*
// component: every time the $derived object is recomputed — on hydration
// when LibraryState picks up previousLibraryId, and on every navigation for
// the footer — Svelte re-evaluates the expression and can remount the SVG,
// which reads as a flash and shifts layout. Referencing the component
// directly in the markup keeps it static.
const header = $derived({
title: 'chitai',
icon: UnjsDb0,
url: `/library/${libraryState.activeLibrary!.id}`
});
const footer = $derived({
title: 'Settings',
url: '/settings',
icon: SettingsIcon,
isActive: page.url.pathname.startsWith('/settings')
});
</script>
<Sidebar.Root variant="floating" {collapsible} {...restProps} class="ml-1 py-3">
<Sidebar.Header class="h-(--header-height)">
<Sidebar.MenuButton>
<!--
The mark is 28px where the nav icons are 16px, and both start at the same
8px padding — so its centre sits 6px right of theirs. -ml-1.5 is that 6px,
which lines the logo up with the nav in both expanded and collapsed states.
-->
<Sidebar.MenuButton class="mt-1 -ml-1.5">
{#snippet child({ props })}
<a href={header.url} {...props}>
<header.icon class="mr-3 scale-175" />
<span class="text-xl font-semibold">{header.title}</span>
<ChitaiMark class="mr-3 size-7!" />
<span class="font-serif text-xl tracking-tight">{header.title}</span>
</a>
{/snippet}
</Sidebar.MenuButton>
@@ -50,15 +53,6 @@
<NavMain />
</Sidebar.Content>
<Sidebar.Footer>
<Sidebar.MenuButton class="mb-2" isActive={footer.isActive}>
{#snippet child({ props })}
<a href={footer.url} {...props}>
{#if footer.icon}
<footer.icon class="scale-125" />
{/if}
<span class="text-md pl-2">{footer.title}</span>
</a>
{/snippet}
</Sidebar.MenuButton>
<NavUser {user} />
</Sidebar.Footer>
</Sidebar.Root>
@@ -0,0 +1,94 @@
<script lang="ts">
import * as Avatar from '$lib/components/ui/avatar/index.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import { useSidebar } from '$lib/components/ui/sidebar/index.js';
import { logout } from '$lib/api';
import { goto } from '$app/navigation';
import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down';
import SettingsIcon from '@lucide/svelte/icons/settings';
import PaletteIcon from '@lucide/svelte/icons/palette';
import LogOutIcon from '@lucide/svelte/icons/log-out';
let { user }: { user: { email: string } } = $props();
const sidebar = useSidebar();
// No display name on the account yet, so the local part of the address
// stands in for one and its first two letters make the avatar.
const handle = $derived(user?.email?.split('@')[0] ?? 'Account');
const initials = $derived(handle.slice(0, 2).toUpperCase());
</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="size-8 rounded-lg">
<Avatar.Fallback class="rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
{initials}
</Avatar.Fallback>
</Avatar.Root>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-semibold">{handle}</span>
<span class="truncate font-mono text-[11px] text-muted-foreground">{user?.email}</span>
</div>
<ChevronsUpDownIcon 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="size-8 rounded-lg">
<Avatar.Fallback class="rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
{initials}
</Avatar.Fallback>
</Avatar.Root>
<div class="grid flex-1 text-left text-sm leading-tight">
<span class="truncate font-semibold">{handle}</span>
<span class="truncate font-mono text-[11px] text-muted-foreground">{user?.email}</span>
</div>
</div>
</DropdownMenu.Label>
<DropdownMenu.Separator />
<DropdownMenu.Group>
<DropdownMenu.Item onSelect={() => goto('/settings/account')}>
<SettingsIcon class="size-4" />
Settings
</DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => goto('/settings/appearance')}>
<PaletteIcon class="size-4" />
Appearance
</DropdownMenu.Item>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={async () => {
await logout();
await goto('/login');
}}
>
<LogOutIcon class="size-4" />
Log out
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Sidebar.MenuItem>
</Sidebar.Menu>