feat(frontend): add v3 app shell with sidebar, rail and phone navigation

This commit is contained in:
Elias Schneider
2026-10-27 23:32:53 +01:00
parent 52aab275ba
commit bdb3578da5
16 changed files with 815 additions and 428 deletions
@@ -0,0 +1,153 @@
<script lang="ts">
import { goto } from '$app/navigation';
import * as Avatar from '#lib/components/ui/avatar/index.ts';
import * as DropdownMenu from '#lib/components/ui/dropdown-menu/index.ts';
import * as Tooltip from '#lib/components/ui/tooltip/index.ts';
import { m } from '#lib/paraglide/messages.js';
import WebAuthnService from '#lib/services/webauthn-service.ts';
import unsavedChangesStore from '#lib/stores/unsaved-changes-store.svelte.ts';
import userStore from '#lib/stores/user-store.ts';
import { cachedProfilePicture } from '#lib/utils/cached-image-util.ts';
import { cn } from '#lib/utils/style.ts';
import { IdCard, LogOut, SunMoon } from '@lucide/svelte';
import { userPrefersMode } from 'mode-watcher';
let {
variant,
version
}: {
// row: the sidebar's account row; rail: the avatar at the foot of the rail; avatar: the phone top bar
variant: 'row' | 'rail' | 'avatar';
version?: string;
} = $props();
const webauthnService = new WebAuthnService();
let open = $state(false);
const appearanceLabels = { light: m.light(), dark: m.dark(), system: m.system() };
async function signOut() {
if (!unsavedChangesStore.hasChanges) {
await webauthnService.logout();
}
// With unsaved changes the bar blocks this navigation and shakes instead
goto('/login');
}
</script>
{#snippet appearanceOptions()}
<DropdownMenu.RadioGroup
value={userPrefersMode.current}
onValueChange={(v) => setModeInstantly(v as 'light' | 'dark' | 'system')}
>
<DropdownMenu.RadioItem value="light">{m.light()}</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem value="dark">{m.dark()}</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem value="system">{m.system()}</DropdownMenu.RadioItem>
</DropdownMenu.RadioGroup>
{/snippet}
{#if $userStore}
<DropdownMenu.Root
bind:open
onOpenChange={(o) => {
if (o) jumping = false;
}}
>
<Tooltip.Root disabled={open || variant === 'avatar'}>
<Tooltip.Trigger>
{#snippet child({ props: tooltipProps })}
<DropdownMenu.Trigger {...tooltipProps}>
{#snippet child({ props })}
<button
{...props}
type="button"
aria-label={variant === 'row' ? undefined : m.account_menu()}
class={cn(
'focus-visible:outline-ring flex items-center rounded-sm outline-offset-[-2px] focus-visible:outline-2',
variant === 'row' &&
'text-fg hover:bg-fill-hover data-[state=open]:bg-fill-selected h-9 w-full gap-2.5 px-2.5 text-left text-sm transition-colors duration-(--dur-fast) active:scale-(--press-scale)',
variant === 'rail' &&
'hover:bg-fill-hover data-[state=open]:bg-fill-selected size-9 justify-center',
variant === 'avatar' && 'rounded-full'
)}
>
<Avatar.Root class={variant === 'avatar' ? 'size-8' : 'size-5'}>
<Avatar.Image src={cachedProfilePicture.getUrl($userStore.id)} alt="" />
<Avatar.Fallback>{$userStore.firstName?.[0]}</Avatar.Fallback>
</Avatar.Root>
{#if variant === 'row'}
<span class="min-w-0 truncate">{$userStore.displayName}</span>
{/if}
</button>
{/snippet}
</DropdownMenu.Trigger>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content side="right">
{$userStore.displayName}{#if version}<span class="text-inverse-muted">
· {version}</span
>{/if}
</Tooltip.Content>
</Tooltip.Root>
<DropdownMenu.Content
class="bg-popover w-60"
side={variant === 'avatar' ? 'bottom' : 'top'}
align={variant === 'avatar' ? 'end' : 'start'}
data-jumping={jumping || undefined}
>
<div class="flex items-center gap-3 px-2 py-2">
<Avatar.Root class="size-10">
<Avatar.Image src={cachedProfilePicture.getUrl($userStore.id)} alt="" />
<Avatar.Fallback>{$userStore.firstName?.[0]}</Avatar.Fallback>
</Avatar.Root>
<div class="flex min-w-0 flex-col">
<span class="truncate text-[13px] font-medium">{$userStore.displayName}</span>
<span class="text-fg-muted truncate text-xs">{$userStore.email}</span>
</div>
</div>
<DropdownMenu.Separator />
<DropdownMenu.Group>
<DropdownMenu.Item
onclick={() => {
jumping = true;
goto('/settings/account');
}}
>
<IdCard />
{m.my_account()}
</DropdownMenu.Item>
{#if variant !== 'avatar'}
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger>
<SunMoon />
<span class="flex flex-col">
{m.appearance()}
<span class="text-fg-muted text-xs"
>{appearanceLabels[userPrefersMode.current]}</span
>
</span>
</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent class="bg-popover">
{@render appearanceOptions()}
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
{/if}
</DropdownMenu.Group>
<!-- A phone has no room beside the menu for a submenu, so the options sit inline -->
{#if variant === 'avatar'}
<DropdownMenu.Separator />
<DropdownMenu.Group>
<DropdownMenu.GroupHeading>{m.appearance()}</DropdownMenu.GroupHeading>
{@render appearanceOptions()}
</DropdownMenu.Group>
{/if}
<DropdownMenu.Separator />
<DropdownMenu.Item onclick={signOut}>
<LogOut />
{m.sign_out()}
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
{/if}
@@ -0,0 +1,247 @@
<script lang="ts">
import { page } from '$app/state';
import Logo from '#lib/components/logo.svelte';
import * as Tooltip from '#lib/components/ui/tooltip/index.ts';
import { m } from '#lib/paraglide/messages.js';
import appConfigStore from '#lib/stores/application-configuration-store.ts';
import type { AppVersionInformation } from '#lib/types/application-configuration.type.ts';
import { cn } from '#lib/utils/style.ts';
import { CircleChevronUp, PanelLeftClose, PanelLeftOpen } from '@lucide/svelte';
import AccountMenu from './account-menu.svelte';
import { activeHref, type NavGroup } from './nav-items.ts';
let {
groups,
versionInformation,
showUpdate,
collapsed = $bindable()
}: {
groups: NavGroup[];
versionInformation: AppVersionInformation;
showUpdate: boolean;
collapsed: boolean;
} = $props();
const current = $derived(activeHref(groups, page.url.pathname));
const versionLabel = $derived(`Pocket ID ${versionInformation.currentVersion}`);
// One shared selected fill slides to the current row instead of every row painting its own
let navEl = $state<HTMLElement>();
let indicator = $state<{ top: number; left: number; width: number } | null>(null);
let indicatorGlides = $state(false);
let placedHref: string | undefined;
const placeIndicator = (nav: HTMLElement, href: string | undefined, glide: boolean) => {
const row = nav.querySelector<HTMLElement>(`a[href="${href}"]`);
indicator = row ? { top: row.offsetTop, left: row.offsetLeft, width: row.offsetWidth } : null;
indicatorGlides = glide;
placedHref = href;
return row;
};
// APIs and API keys rows join or leave without a page change and move the rows below them
const rowHrefs = $derived(groups.flatMap((g) => g.items.map((i) => i.href)).join());
// Only a change of page slides the fill
// On short windows the nav scrolls, and the rows' scroll margin brings the current one clear of the edge fade
$effect(() => {
void rowHrefs;
const nav = navEl;
const href = current;
if (!nav) return;
const row = placeIndicator(nav, href, placedHref !== undefined && href !== placedHref);
row?.scrollIntoView({ block: 'nearest' });
});
// Collapsing, expanding and crossing a rail breakpoint resize the nav and move its rows, so the fill jumps along with its row
$effect(() => {
const nav = navEl;
if (!nav) return;
const observer = new ResizeObserver(() => placeIndicator(nav, placedHref, false));
observer.observe(nav);
return () => observer.disconnect();
});
</script>
<aside
data-slot="app-sidebar"
class={cn(
'bg-sidebar sticky top-0 hidden h-dvh flex-col px-3 pt-4 pb-3 sm:flex',
collapsed
? 'w-(--rail-w) items-center px-2'
: 'w-(--rail-w) items-center px-2 lg:w-(--sidebar-w) lg:items-stretch lg:px-3'
)}
>
<div class={cn('flex h-[42px] shrink-0 items-center gap-2', !collapsed && 'lg:pl-2')}>
<a
href="/"
class="focus-visible:outline-ring flex min-w-0 items-center gap-2 rounded-sm focus-visible:outline-2 focus-visible:outline-offset-2"
aria-label={$appConfigStore.appName}
>
<Logo class="size-6 shrink-0" />
<span class={cn('text-heading truncate', collapsed ? 'hidden' : 'hidden lg:inline')}>
{$appConfigStore.appName}
</span>
</a>
</div>
<nav
bind:this={navEl}
aria-label={m.main()}
class="scroll-fade-y relative isolate -mx-3 mt-1 flex min-h-0 w-[calc(100%+1.5rem)] flex-1 flex-col overflow-x-hidden overflow-y-auto px-3 pb-4"
>
{#if indicator}
<span
aria-hidden="true"
class={cn(
'bg-fill-selected absolute -z-10 h-9 rounded-sm',
indicatorGlides && 'transition-transform duration-(--dur-base) ease-in-out'
)}
style="left: {indicator.left}px; width: {indicator.width}px; transform: translateY({indicator.top}px); top: 0"
></span>
{/if}
{#each groups as group, gi (group.label)}
<h2
class={cn(
'text-fg-muted px-4 pb-2 text-[13px] leading-[18px] font-semibold',
gi === 0 ? 'pt-3' : 'pt-5',
collapsed ? 'sr-only' : 'max-lg:sr-only'
)}
>
{group.label}
</h2>
{#if gi > 0}
<span
aria-hidden="true"
class={cn('bg-border mx-auto my-3 h-px w-6', !collapsed && 'lg:hidden')}
></span>
{/if}
<ul class={cn('flex flex-col gap-0.5', collapsed ? 'items-center' : 'max-lg:items-center')}>
{#each group.items as item (item.href)}
{@const active = item.href === current}
<li>
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<a
{...props}
href={item.href}
aria-current={active ? 'page' : undefined}
class={cn(
'group text-fg focus-visible:outline-ring relative flex h-9 items-center gap-3 rounded-sm text-sm font-medium outline-offset-[-2px] focus-visible:outline-2',
'before:bg-fill-hover before:absolute before:inset-0 before:-z-10 before:rounded-[inherit] before:opacity-0 before:transition-opacity before:duration-(--dur-fast) hover:before:opacity-100',
active && 'hover:before:opacity-0',
collapsed
? 'w-9 justify-center'
: 'w-9 justify-center lg:w-full lg:justify-start lg:pr-3 lg:pl-4'
)}
>
<item.icon
class={cn(
'size-[17px] shrink-0 transition-colors',
active ? 'text-fg' : 'text-fg-muted group-hover:text-fg'
)}
/>
<span
class={cn(
'min-w-0 flex-1',
collapsed ? 'sr-only' : 'max-lg:sr-only lg:inline-grid'
)}
>
<span
class={cn('col-start-1 row-start-1 truncate', active && 'opacity-0')}
aria-hidden={active}>{item.label}</span
>
<span
aria-hidden={!active}
class={cn(
'col-start-1 row-start-1 truncate font-semibold',
!active && 'invisible'
)}>{item.label}</span
>
</span>
</a>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content side="right" class={cn(!collapsed && 'lg:hidden')}
>{item.label}</Tooltip.Content
>
</Tooltip.Root>
</li>
{/each}
</ul>
{/each}
</nav>
<div class="flex w-full shrink-0 flex-col gap-0.5 pt-2 shadow-[inset_0_1px_0_var(--border)]">
{#if showUpdate}
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<a
{...props}
href="https://github.com/pocket-id/pocket-id/releases/latest"
target="_blank"
rel="noopener noreferrer"
class={cn(
'text-fg hover:bg-fill-hover focus-visible:outline-ring flex min-h-11 items-center gap-3 rounded-sm py-1.5 text-sm outline-offset-[-2px] transition-colors focus-visible:outline-2',
collapsed
? 'size-9 min-h-9 justify-center self-center'
: 'max-lg:size-9 max-lg:min-h-9 max-lg:justify-center max-lg:self-center lg:pr-2 lg:pl-4'
)}
>
<CircleChevronUp class="size-[17px] shrink-0" />
<span class={cn('flex min-w-0 flex-col', collapsed ? 'sr-only' : 'max-lg:sr-only')}>
{m.update_available()}
<span class="text-fg-muted truncate text-xs tabular-nums"
>Pocket ID {versionInformation.newestVersion}</span
>
</span>
</a>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content side="right" class={cn(!collapsed && 'lg:hidden')}>
{m.update_available()}<span class="text-inverse-muted tabular-nums"
>· Pocket ID {versionInformation.newestVersion}</span
>
</Tooltip.Content>
</Tooltip.Root>
{:else}
<p
class={cn(
'text-fg-muted px-4 text-xs leading-5 tabular-nums',
collapsed ? 'hidden' : 'max-lg:hidden'
)}
>
{versionLabel}
</p>
{/if}
<div class={cn('mt-1 flex items-center gap-1', collapsed ? 'flex-col' : 'max-lg:flex-col')}>
<div class={cn('min-w-0 flex-1', collapsed ? 'hidden' : 'max-lg:hidden')}>
<AccountMenu variant="row" version={versionLabel} />
</div>
<div class={cn(collapsed ? 'block' : 'lg:hidden')}>
<AccountMenu variant="rail" version={versionLabel} />
</div>
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<button
{...props}
type="button"
class="text-fg-muted hover:bg-fill-hover hover:text-fg focus-visible:outline-ring hidden size-9 shrink-0 items-center justify-center rounded-sm outline-offset-[-2px] focus-visible:outline-2 lg:flex"
aria-label={collapsed ? m.expand_sidebar() : m.collapse_sidebar()}
onclick={() => (collapsed = !collapsed)}
>
{#if collapsed}<PanelLeftOpen class="size-[17px]" />{:else}<PanelLeftClose
class="size-[17px]"
/>{/if}
</button>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content side="right">
{collapsed ? m.expand_sidebar() : m.collapse_sidebar()}
</Tooltip.Content>
</Tooltip.Root>
</div>
</div>
</aside>
@@ -0,0 +1,98 @@
import { m } from '#lib/paraglide/messages.js';
import {
Blocks,
Braces,
FileText,
History,
IdCard,
Key,
LayoutGrid,
Server,
SlidersHorizontal,
User,
UsersRound
} from '@lucide/svelte';
import type { Component } from 'svelte';
export type NavItem = { href: string; label: string; icon: Component };
export type NavGroup = { label: string; items: NavItem[] };
// The sidebar, the rail and the phone bar share these destinations
export function navGroups({
isAdmin,
verificationPending,
showApis,
showApiKeys
}: {
isAdmin: boolean;
verificationPending: boolean;
showApis: boolean;
showApiKeys: boolean;
}): NavGroup[] {
// A user who still has to verify their email can only reach their account until they do
if (verificationPending) {
return [
{
label: m.account(),
items: [{ href: '/settings/account', label: m.my_account(), icon: IdCard }]
}
];
}
const groups: NavGroup[] = [
{
label: m.account(),
items: [
{ href: '/settings/account', label: m.my_account(), icon: IdCard },
{ href: '/settings/apps', label: m.my_apps(), icon: LayoutGrid },
{ href: '/settings/audit-log', label: m.activity(), icon: History }
]
}
];
if (isAdmin) {
groups.push({
label: m.administration(),
items: [
{ href: '/settings/admin/users', label: m.users(), icon: User },
{ href: '/settings/admin/user-groups', label: m.groups(), icon: UsersRound },
{ href: '/settings/admin/oidc-clients', label: m.oidc_clients(), icon: Blocks },
{
href: '/settings/admin/oidc-claim-mapping-policies',
label: m.claim_mappings(),
icon: Braces
},
{ href: '/settings/admin/apis', label: m.apis(), icon: Server },
{ href: '/settings/admin/api-keys', label: m.api_keys(), icon: Key },
{ href: '/settings/audit-log/global', label: m.audit_log(), icon: FileText },
{
href: '/settings/admin/application-configuration',
label: m.configuration(),
icon: SlidersHorizontal
}
]
});
}
return groups;
}
// APIs and API keys join the sidebar once one exists (docs/design-system.md, PO11), and a failed check keeps the row
export async function checkApiRows() {
const first = { pagination: { page: 1, limit: 1 } };
const exists = (list: Promise<Paginated<unknown>>) =>
list.then((r) => r.data.length > 0).catch(() => true);
const [apis, apiKeys] = await Promise.all([
exists(new ApisService().list(first)),
exists(new ApiKeyService().list(first))
]);
return { apis, apiKeys };
}
// The most specific destination wins, so the global audit log is not also marked as Activity
export function activeHref(groups: NavGroup[], pathname: string) {
return groups
.flatMap((g) => g.items.map((i) => i.href))
.filter((href) => pathname === href || pathname.startsWith(href + '/'))
.sort((a, b) => b.length - a.length)[0];
}
@@ -0,0 +1,128 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import Logo from '#lib/components/logo.svelte';
import * as DropdownMenu from '#lib/components/ui/dropdown-menu/index.ts';
import { m } from '#lib/paraglide/messages.js';
import appConfigStore from '#lib/stores/application-configuration-store.ts';
import { cn } from '#lib/utils/style.ts';
import { Check, Ellipsis } from '@lucide/svelte';
import AccountMenu from './account-menu.svelte';
import { activeHref, type NavGroup } from './nav-items.ts';
let {
groups,
version,
newestVersion
}: { groups: NavGroup[]; version: string; newestVersion: string | null } = $props();
const current = $derived(activeHref(groups, page.url.pathname));
const [accountGroup, ...moreGroups] = $derived(groups);
const moreActive = $derived(moreGroups.some((g) => g.items.some((i) => i.href === current)));
let jumping = $state(false);
function jump(href: string) {
jumping = true;
goto(href);
}
</script>
<header
data-slot="phone-top-bar"
class="bg-sidebar sticky top-0 z-(--z-nav) flex h-[52px] items-center justify-between gap-3 px-4 [view-transition-name:phone-top-bar] sm:hidden"
>
<a
href="/"
class="focus-visible:outline-ring flex min-w-0 items-center gap-2 rounded-sm focus-visible:outline-2 focus-visible:outline-offset-2"
>
<Logo class="size-6 shrink-0" />
<span class="text-heading truncate">{$appConfigStore.appName}</span>
</a>
<AccountMenu variant="avatar" />
</header>
<nav
aria-label={m.main()}
class="bg-surface-raised border-border shadow-overlay fixed inset-x-3 bottom-[calc(12px+env(safe-area-inset-bottom))] z-(--z-nav) flex h-14 items-stretch rounded-lg border p-1 sm:hidden"
>
{#each accountGroup.items as item (item.href)}
{@render tab(item.href, item.label, item.icon, item.href === current)}
{/each}
{#if moreGroups.length}
<DropdownMenu.Root
onOpenChange={(open) => {
if (open) jumping = false;
}}
>
<DropdownMenu.Trigger
class={cn(
'focus-visible:outline-ring flex flex-1 flex-col items-center justify-center gap-0.5 rounded-md text-[11px] leading-[14px] focus-visible:outline-2 focus-visible:-outline-offset-2 max-[359px]:flex-auto',
moreActive ? 'bg-fill-selected text-fg font-medium' : 'text-fg-muted'
)}
>
<Ellipsis class="size-5" />
{m.more()}
</DropdownMenu.Trigger>
<DropdownMenu.Content side="top" align="end" class="w-64" data-jumping={jumping || undefined}>
{#if newestVersion}
<DropdownMenu.Group>
<DropdownMenu.Item>
{#snippet child({ props })}
<a
{...props}
href="https://github.com/pocket-id/pocket-id/releases/latest"
target="_blank"
rel="noopener noreferrer"
>
<CircleChevronUp />
<span class="flex flex-col">
{m.update_available()}
<span class="text-fg-muted text-xs tabular-nums">Pocket ID {newestVersion}</span
>
</span>
</a>
{/snippet}
</DropdownMenu.Item>
</DropdownMenu.Group>
{/if}
{#each moreGroups as group (group.label)}
<DropdownMenu.Group>
<DropdownMenu.GroupHeading>{group.label}</DropdownMenu.GroupHeading>
{#each group.items as item (item.href)}
<DropdownMenu.Item
onclick={() => jump(item.href)}
aria-current={item.href === current ? 'page' : undefined}
>
<item.icon />
<span class="flex-1">{item.label}</span>
{#if item.href === current}<Check />{/if}
</DropdownMenu.Item>
{/each}
</DropdownMenu.Group>
{/each}
{#if !newestVersion}
<p class="text-fg-muted px-1 py-1.5 text-xs tabular-nums">{version}</p>
{/if}
</DropdownMenu.Content>
</DropdownMenu.Root>
{/if}
</nav>
{#snippet tab(
href: string,
label: string,
Icon: NavGroup['items'][number]['icon'],
active: boolean
)}
<a
{href}
aria-current={active ? 'page' : undefined}
class={cn(
'focus-visible:outline-ring flex min-w-0 flex-1 flex-col items-center justify-center gap-0.5 rounded-md text-[11px] leading-[14px] transition-colors duration-(--dur-fast) focus-visible:outline-2 focus-visible:-outline-offset-2 max-[359px]:flex-auto',
active ? 'bg-fill-selected text-fg font-medium' : 'text-fg-muted'
)}
>
<Icon class="size-5" />
<span class="max-w-full truncate px-1">{label}</span>
</a>
{/snippet}