feat: add account menu to the sidebar footer
This commit is contained in:
@@ -1,45 +1,48 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Separator } from '$lib/components/ui/separator/index.js';
|
import { Separator } from '$lib/components/ui/separator/index.js';
|
||||||
|
|
||||||
import SettingsIcon from '@lucide/svelte/icons/settings';
|
import ChitaiMark from '$lib/components/icons/chitai-mark.svelte';
|
||||||
import UnjsDb0 from '$lib/components/icons/UnjsDb0.svelte';
|
|
||||||
|
|
||||||
import NavMain from './nav-main.svelte';
|
import NavMain from './nav-main.svelte';
|
||||||
|
import NavUser from './nav-user.svelte';
|
||||||
import LibrarySwitcher from './library-switcher.svelte';
|
import LibrarySwitcher from './library-switcher.svelte';
|
||||||
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
||||||
import type { ComponentProps } from 'svelte';
|
import type { ComponentProps } from 'svelte';
|
||||||
import { getLibraryState } from '$lib/state/library.svelte';
|
import { getLibraryState } from '$lib/state/library.svelte';
|
||||||
import { page } from '$app/state';
|
|
||||||
|
|
||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
collapsible = 'icon',
|
collapsible = 'icon',
|
||||||
|
user,
|
||||||
...restProps
|
...restProps
|
||||||
}: ComponentProps<typeof Sidebar.Root> = $props();
|
}: ComponentProps<typeof Sidebar.Root> & { user: { email: string } } = $props();
|
||||||
|
|
||||||
const libraryState = getLibraryState();
|
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({
|
const header = $derived({
|
||||||
title: 'chitai',
|
title: 'chitai',
|
||||||
icon: UnjsDb0,
|
|
||||||
url: `/library/${libraryState.activeLibrary!.id}`
|
url: `/library/${libraryState.activeLibrary!.id}`
|
||||||
});
|
});
|
||||||
|
|
||||||
const footer = $derived({
|
|
||||||
title: 'Settings',
|
|
||||||
url: '/settings',
|
|
||||||
icon: SettingsIcon,
|
|
||||||
isActive: page.url.pathname.startsWith('/settings')
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Sidebar.Root variant="floating" {collapsible} {...restProps} class="ml-1 py-3">
|
<Sidebar.Root variant="floating" {collapsible} {...restProps} class="ml-1 py-3">
|
||||||
<Sidebar.Header class="h-(--header-height)">
|
<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 })}
|
{#snippet child({ props })}
|
||||||
<a href={header.url} {...props}>
|
<a href={header.url} {...props}>
|
||||||
<header.icon class="mr-3 scale-175" />
|
<ChitaiMark class="mr-3 size-7!" />
|
||||||
<span class="text-xl font-semibold">{header.title}</span>
|
<span class="font-serif text-xl tracking-tight">{header.title}</span>
|
||||||
</a>
|
</a>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Sidebar.MenuButton>
|
</Sidebar.MenuButton>
|
||||||
@@ -50,15 +53,6 @@
|
|||||||
<NavMain />
|
<NavMain />
|
||||||
</Sidebar.Content>
|
</Sidebar.Content>
|
||||||
<Sidebar.Footer>
|
<Sidebar.Footer>
|
||||||
<Sidebar.MenuButton class="mb-2" isActive={footer.isActive}>
|
<NavUser {user} />
|
||||||
{#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>
|
|
||||||
</Sidebar.Footer>
|
</Sidebar.Footer>
|
||||||
</Sidebar.Root>
|
</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>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="avatar-badge"
|
||||||
|
class={cn(
|
||||||
|
"bg-primary text-primary-foreground ring-background absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-blend-color ring-2 select-none",
|
||||||
|
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
||||||
|
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
||||||
|
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</span>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<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(
|
||||||
|
"rounded-full bg-muted text-muted-foreground flex size-full items-center justify-center text-sm group-data-[size=sm]/avatar:text-xs",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="avatar-group-count"
|
||||||
|
class={cn(
|
||||||
|
"size-8 rounded-full bg-muted text-sm text-muted-foreground group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3 relative flex shrink-0 items-center justify-center ring-2 ring-background",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cn, type WithElementRef } from "$lib/utils.js";
|
||||||
|
import type { HTMLAttributes } from "svelte/elements";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
class: className,
|
||||||
|
children,
|
||||||
|
...restProps
|
||||||
|
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
bind:this={ref}
|
||||||
|
data-slot="avatar-group"
|
||||||
|
class={cn(
|
||||||
|
"cn-avatar-group group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
|
{@render children?.()}
|
||||||
|
</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.ImageProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
bind:ref
|
||||||
|
data-slot="avatar-image"
|
||||||
|
class={cn("rounded-full aspect-square size-full object-cover", className)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Avatar as AvatarPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils.js";
|
||||||
|
|
||||||
|
let {
|
||||||
|
ref = $bindable(null),
|
||||||
|
loadingStatus = $bindable("loading"),
|
||||||
|
size = "default",
|
||||||
|
class: className,
|
||||||
|
...restProps
|
||||||
|
}: AvatarPrimitive.RootProps & {
|
||||||
|
size?: "default" | "sm" | "lg";
|
||||||
|
} = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
bind:ref
|
||||||
|
bind:loadingStatus
|
||||||
|
data-slot="avatar"
|
||||||
|
data-size={size}
|
||||||
|
class={cn(
|
||||||
|
"size-8 rounded-full after:rounded-full data-[size=lg]:size-10 data-[size=sm]:size-6 group/avatar relative flex shrink-0 select-none after:absolute after:inset-0 after:border after:border-border after:mix-blend-darken dark:after:mix-blend-lighten",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...restProps}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import Badge from "./avatar-badge.svelte";
|
||||||
|
import Fallback from "./avatar-fallback.svelte";
|
||||||
|
import GroupCount from "./avatar-group-count.svelte";
|
||||||
|
import Group from "./avatar-group.svelte";
|
||||||
|
import Image from "./avatar-image.svelte";
|
||||||
|
import Root from "./avatar.svelte";
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Image,
|
||||||
|
Fallback,
|
||||||
|
Badge,
|
||||||
|
Group,
|
||||||
|
GroupCount,
|
||||||
|
//
|
||||||
|
Root as Avatar,
|
||||||
|
Image as AvatarImage,
|
||||||
|
Fallback as AvatarFallback,
|
||||||
|
Badge as AvatarBadge,
|
||||||
|
Group as AvatarGroup,
|
||||||
|
GroupCount as AvatarGroupCount,
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user