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
+12 -4
View File
@@ -97,10 +97,7 @@
"oidc_clients": "OIDC Clients",
"api_keys": "API Keys",
"application_configuration": "Application Configuration",
"settings": "Settings",
"update_pocket_id": "Update Pocket ID",
"sqlite_storage_warning": "We detected that your SQLite database is stored on a network-attached drive like NFS, SMB, or FUSE. This is unsupported and can lead to your database getting corrupted. {#link href=|https://pocket-id.org/docs/configuration/environment-variables#sqlite|}See docs.{/link}",
"powered_by": "Powered by",
"see_your_recent_account_activities": "See your account activities within the configured retention period.",
"time": "Time",
"event": "Event",
@@ -714,5 +711,16 @@
"custom_color_note_lightened": "Lightened in dark mode so buttons stand out from the background.",
"custom_color_note_edge": "Buttons get a thin border because the color is close to the background.",
"custom_color_note_near_status": "This color is close to the error, warning or success color, so buttons may read as a status.",
"custom": "Custom"
"custom": "Custom",
"account": "Account",
"activity": "Activity",
"claim_mappings": "Claim mappings",
"configuration": "Configuration",
"main": "Main",
"update_available": "Update available",
"collapse_sidebar": "Collapse sidebar",
"expand_sidebar": "Expand sidebar",
"appearance": "Appearance",
"account_menu": "Account menu",
"more": "More"
}
+8 -10
View File
@@ -94,12 +94,11 @@
--info-fg: #0a0a0a;
--info-soft: #f5f5f5;
--info-border: #e8e8e8;
--well-light: #f6f4f0;
--well-dark: #242220;
--well-light-fg: #57534d;
--well-dark-fg: #bab4aa;
--well-light: #f5f5f5;
--well-dark: #000000;
--well-light-fg: #5e5e5e;
--well-dark-fg: #a1a1a1;
--qr-bg: #ffffff;
--qr-fg: #121212;
--scrim: rgb(18 17 16 / 0.4);
--ring: #0a0a0a;
--ring-halo: rgb(10 10 10 / 0.24);
@@ -185,12 +184,11 @@
--info-fg: #ededed;
--info-soft: #0e0e0e;
--info-border: #1f1f1f;
--well-light: #f6f4f0;
--well-dark: #242220;
--well-light-fg: #57534d;
--well-dark-fg: #bab4aa;
--well-light: #f5f5f5;
--well-dark: #000000;
--well-light-fg: #5e5e5e;
--well-dark-fg: #a1a1a1;
--qr-bg: #ffffff;
--qr-fg: #121212;
--scrim: rgb(0 0 0 / 0.6);
--ring: #ededed;
--ring-halo: rgb(237 237 237 / 0.24);
@@ -1,50 +0,0 @@
<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 { m } from '#lib/paraglide/messages.js';
import WebAuthnService from '#lib/services/webauthn-service.ts';
import unsavedChangesStoreSvelte 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 { LucideLogOut, LucideUser } from '@lucide/svelte';
const webauthnService = new WebAuthnService();
async function logout() {
if (!unsavedChangesStoreSvelte.hasChanges) {
await webauthnService.logout();
}
// Navigate regardless of unsaved changes status
// If there are unsaved changes, the save changes bar will shake and navigation will be canceled
goto('/login');
}
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger aria-label={m.my_account()}
><Avatar.Root class="size-9">
<Avatar.Image src={cachedProfilePicture.getUrl($userStore!.id)} />
</Avatar.Root></DropdownMenu.Trigger
>
<DropdownMenu.Content class="min-w-40" align="end">
<DropdownMenu.Label class="font-normal">
<div class="flex flex-col space-y-1">
<p class="text-sm leading-none font-medium">
{$userStore?.displayName}
</p>
<p class="text-muted-foreground text-xs leading-none">{$userStore?.email}</p>
</div>
</DropdownMenu.Label>
<DropdownMenu.Separator />
<DropdownMenu.Group>
<DropdownMenu.Item onclick={() => goto('/settings/account')}
><LucideUser class="mr-2 size-4" /> {m.my_account()}</DropdownMenu.Item
>
<DropdownMenu.Item onclick={logout}
><LucideLogOut class="mr-2 size-4" /> {m.logout()}</DropdownMenu.Item
>
</DropdownMenu.Group>
</DropdownMenu.Content>
</DropdownMenu.Root>
@@ -1,54 +0,0 @@
<script lang="ts">
import { page } from '$app/state';
import { m } from '#lib/paraglide/messages.js';
import userStore from '#lib/stores/user-store.ts';
import Logo from '../logo.svelte';
import Separator from '../ui/separator/separator.svelte';
import HeaderAvatar from './header-avatar.svelte';
import ModeSwitcher from './mode-switcher.svelte';
const authUrls = [
/^\/interaction$/,
/^\/interaction\/error$/,
/^\/device$/,
/^\/login(?:\/.*)?$/,
/^\/logout$/,
/^\/signup(?:\/.*)?$/
];
let isAuthPage = $derived(
!page.error && authUrls.some((pattern) => pattern.test(page.url.pathname))
);
</script>
<div
class=" w-full {isAuthPage
? 'absolute top-0 z-10 mt-3 pr-2 lg:mt-8 lg:pr-3'
: 'bg-muted/40 dark:bg-background pt-3 '}"
>
<div
class="{!isAuthPage
? 'max-w-[1720px]'
: ''} mx-auto flex w-full items-center justify-between px-4 md:px-10"
>
<div class="flex h-16 items-center">
{#if !isAuthPage}
<a href="/" class="flex items-center transition-opacity hover:opacity-80">
<Logo class="size-8" />
<Separator orientation="vertical" class="mr-3 ml-2 h-5! bg-neutral-600" />
<h1 class="font-display text-2xl" data-testid="application-name">
{m.settings()}
</h1>
</a>
{/if}
</div>
<div class="flex items-center justify-between gap-4">
{#if !isAuthPage}
<ModeSwitcher />
{/if}
{#if $userStore?.id}
<HeaderAvatar />
{/if}
</div>
</div>
</div>
@@ -1,32 +0,0 @@
<script lang="ts">
import MoonIcon from '@lucide/svelte/icons/moon';
import SunIcon from '@lucide/svelte/icons/sun';
import { buttonVariants } from '#lib/components/ui/button/index.js';
import * as DropdownMenu from '#lib/components/ui/dropdown-menu/index.js';
import { m } from '#lib/paraglide/messages.js';
import { mode, resetMode, setMode } from 'mode-watcher';
const isDark = $derived(mode.current === 'dark');
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger class={buttonVariants({ variant: 'ghost', size: 'icon' })}>
<SunIcon
class="h-[1.2rem] w-[1.2rem] !transition-all {isDark
? 'scale-0 -rotate-90'
: 'scale-100 rotate-0'}"
/>
<MoonIcon
class="absolute h-[1.2rem] w-[1.2rem] !transition-all {isDark
? 'scale-100 rotate-0'
: 'scale-0 rotate-90'}"
/>
<span class="sr-only">Toggle theme</span>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={() => setMode('light')}>{m.light()}</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => setMode('dark')}>{m.dark()}</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => resetMode()}>{m.system()}</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
@@ -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}
-161
View File
@@ -1,161 +0,0 @@
<script lang="ts">
import { page } from '$app/state';
import { m } from '#lib/paraglide/messages.js';
import appConfigStore from '#lib/stores/application-configuration-store.ts';
import { cn } from '#lib/utils/style.ts';
import { LucideChevronDown, LucideExternalLink } from '@lucide/svelte';
import { PersistedState } from 'runed';
import { slide } from 'svelte/transition';
type NavItem = {
href?: string;
label: string;
children?: NavItem[];
};
let {
items = [] as NavItem[],
storageKey = 'sidebar-open:settings',
isAdmin = false,
isUpToDate = undefined
} = $props();
const openState = new PersistedState<Record<string, boolean>>(storageKey, {});
function groupId(item: NavItem, idx: number) {
return `${item.label}-${idx}`;
}
function isActive(href?: string) {
if (!href) return false;
return page.url.pathname.startsWith(href);
}
$effect(() => {
const state = openState.current;
items.forEach((item, idx) => {
if (!item.children?.length) return;
const id = groupId(item, idx);
if (state[id] === undefined) {
state[id] = item.children.some((c) => isActive(c.href));
}
});
});
function isOpen(id: string) {
return !!openState.current[id];
}
function toggle(id: string) {
openState.current[id] = !openState.current[id];
}
const activeClasses =
'text-primary bg-card rounded-md px-3 py-1.5 font-medium shadow-sm transition-all';
const inactiveClasses =
'hover:text-foreground hover:bg-muted/70 rounded-md px-3 py-1.5 transition-all hover:-translate-y-[2px] hover:shadow-sm';
const ROW_STAGGER = 50;
// Derive the offset (row index) for each top-level item,
// counting expanded children of previous items.
const layout = $derived(() => {
const offsets: number[] = [];
let total = 0;
items.forEach((it, idx) => {
offsets[idx] = total; // row index for this top-level item
total += 1; // this item itself
const id = groupId(it, idx);
if (it.children?.length && openState.current[id]) {
total += it.children.length; // rows for visible children
}
});
return { offsets, total };
});
const delayTop = (i: number) => `${layout().offsets[i] * ROW_STAGGER}ms`;
const delayChild = (i: number, j: number) => `${(layout().offsets[i] + 1 + j) * ROW_STAGGER}ms`;
const delayUpdateLink = () => `${layout().total * ROW_STAGGER}ms`;
</script>
<nav
class="text-muted-foreground grid gap-2 text-sm"
aria-label={m.settings()}
data-sveltekit-keepfocus
>
{#each items as item, i (groupId(item, i))}
{#if item.children?.length}
{@const id = groupId(item, i)}
<div class="group">
<button
type="button"
class={cn(
'hover:bg-muted/70 hover:text-foreground flex w-full items-center justify-between rounded-md px-3 py-1.5 text-left transition-all',
!$appConfigStore.disableAnimations && 'animate-fade-in'
)}
style={`animation-delay: ${delayTop(i)};`}
aria-expanded={isOpen(id)}
aria-controls={`submenu-${id}`}
onclick={() => toggle(id)}
>
{item.label}
<LucideChevronDown
class={cn('size-4 transition-transform', isOpen(id) ? 'rotate-180' : '')}
/>
</button>
{#if isOpen(id)}
<ul
id={`submenu-${id}`}
class="border-border/50 ml-2 border-l pl-2"
transition:slide|local={{ duration: 120 }}
>
{#each item.children as child, j (child.href ?? child.label)}
<li>
<a
href={child.href}
class={cn(
isActive(child.href) ? activeClasses : inactiveClasses,
'my-1 block',
!$appConfigStore.disableAnimations && 'animate-fade-in'
)}
style={`animation-delay: ${delayChild(i, j)};`}
>
{child.label}
</a>
</li>
{/each}
</ul>
{/if}
</div>
{:else}
<a
href={item.href}
class={cn(
isActive(item.href) ? activeClasses : inactiveClasses,
!$appConfigStore.disableAnimations && 'animate-fade-in'
)}
style={`animation-delay: ${delayTop(i)};`}
>
{item.label}
</a>
{/if}
{/each}
{#if isAdmin && isUpToDate === false}
<a
href="https://github.com/pocket-id/pocket-id/releases/latest"
target="_blank"
rel="noopener noreferrer"
class={cn(
inactiveClasses,
'flex items-center gap-2 text-orange-500 hover:text-orange-500/90',
!$appConfigStore.disableAnimations && 'animate-fade-in'
)}
style={`animation-delay: ${delayUpdateLink()};`}
>
{m.update_pocket_id()}
<LucideExternalLink class="my-auto inline-block size-3" />
</a>
{/if}
</nav>
+19 -9
View File
@@ -25,6 +25,9 @@ export type ColorTheme = {
accent: string;
tokens: Record<Mode, Record<string, string>>;
accentInputs: Record<Mode, AccentInputs>;
// Mono separates its sidebar with a hairline; Midnight paints a navy sidebar that remaps the neutrals inside it
sidebarEdge?: boolean;
sidebarVars?: Record<string, string>;
};
export const DEFAULT_COLOR_THEME = 'mono';
@@ -406,15 +409,22 @@ export function applyColorTheme(value: string) {
Object.entries(vars)
.map(([k, v]) => `--${k}: ${v};`)
.join(' ');
const css = (['light', 'dark'] as const)
.map((mode) => {
const vars = {
...theme.tokens[mode],
...primaryVars(deriveAccent(theme.accent, theme.accentInputs[mode]))
};
return `${mode === 'light' ? 'html:root' : 'html.dark'} { ${block(vars)} }`;
})
.join('\n');
const rules = (['light', 'dark'] as const).map((mode) => {
const vars = {
...theme.tokens[mode],
...primaryVars(deriveAccent(theme.accent, theme.accentInputs[mode]))
};
return `${mode === 'light' ? 'html:root' : 'html.dark'} { ${block(vars)} }`;
});
if (theme.sidebarEdge) {
rules.push('[data-slot="app-sidebar"] { box-shadow: inset -1px 0 0 var(--border); }');
}
if (theme.sidebarVars) {
rules.push(
`[data-slot="app-sidebar"], [data-slot="phone-top-bar"] { ${block(theme.sidebarVars)} color: var(--fg); }`
);
}
const css = rules.join('\n');
let el = document.getElementById('color-theme');
if (!el) {
+74 -31
View File
@@ -27,13 +27,14 @@ export const colorThemes: ColorTheme[] = [
'surface-raised': '#ffffff',
'fill-skeleton': '#eeeeee',
'border-control': '#8c8c8c',
'border-field': '#8c8c8c',
'on-solid': '#ffffff',
'info-solid': '#5e5e5e',
'info-fg': '#0a0a0a',
'info-soft': '#f5f5f5',
'info-border': '#e8e8e8',
'qr-fg': '#121212'
'well-light': '#f5f5f5',
'well-dark': '#000000',
'well-light-fg': '#5e5e5e',
'well-dark-fg': '#a1a1a1'
},
dark: {
sidebar: '#0a0a0a',
@@ -57,13 +58,14 @@ export const colorThemes: ColorTheme[] = [
'surface-raised': '#121212',
'fill-skeleton': '#1a1a1a',
'border-control': '#6b6b6b',
'border-field': '#6b6b6b',
'on-solid': '#0a0a0a',
'info-solid': '#a1a1a1',
'info-fg': '#ededed',
'info-soft': '#0e0e0e',
'info-border': '#1f1f1f',
'qr-fg': '#121212'
'well-light': '#f5f5f5',
'well-dark': '#000000',
'well-light-fg': '#5e5e5e',
'well-dark-fg': '#a1a1a1'
}
},
accentInputs: {
@@ -117,7 +119,8 @@ export const colorThemes: ColorTheme[] = [
success: 150
}
}
}
},
sidebarEdge: true
},
{
id: 'mist',
@@ -144,13 +147,14 @@ export const colorThemes: ColorTheme[] = [
'surface-raised': '#ffffff',
'fill-skeleton': '#eceef2',
'border-control': '#868d99',
'border-field': '#868d99',
'on-solid': '#ffffff',
'info-solid': '#545b67',
'info-fg': '#0f1218',
'info-soft': '#f7f8fa',
'info-border': '#e3e6eb',
'qr-fg': '#121212'
'well-light': '#f7f8fa',
'well-dark': '#1b1e23',
'well-light-fg': '#545b67',
'well-dark-fg': '#a3aab5'
},
dark: {
sidebar: '#15171b',
@@ -174,13 +178,14 @@ export const colorThemes: ColorTheme[] = [
'surface-raised': '#23272d',
'fill-skeleton': '#272b31',
'border-control': '#737b87',
'border-field': '#737b87',
'on-solid': '#15171b',
'info-solid': '#a3aab5',
'info-fg': '#eef0f3',
'info-soft': '#202328',
'info-border': '#2e333a',
'qr-fg': '#121212'
'well-light': '#f7f8fa',
'well-dark': '#1b1e23',
'well-light-fg': '#545b67',
'well-dark-fg': '#a3aab5'
}
},
accentInputs: {
@@ -261,13 +266,14 @@ export const colorThemes: ColorTheme[] = [
'surface-raised': '#ffffff',
'fill-skeleton': '#e8ede4',
'border-control': '#838c7e',
'border-field': '#838c7e',
'on-solid': '#ffffff',
'info-solid': '#515a4e',
'info-fg': '#121611',
'info-soft': '#f6f8f4',
'info-border': '#e0e6db',
'qr-fg': '#121212'
'well-light': '#f6f8f4',
'well-dark': '#1b1f1a',
'well-light-fg': '#515a4e',
'well-dark-fg': '#a5ad9e'
},
dark: {
sidebar: '#151814',
@@ -291,13 +297,14 @@ export const colorThemes: ColorTheme[] = [
'surface-raised': '#242922',
'fill-skeleton': '#272c25',
'border-control': '#747d6f',
'border-field': '#747d6f',
'on-solid': '#151814',
'info-solid': '#a5ad9e',
'info-fg': '#eef2eb',
'info-soft': '#20241e',
'info-border': '#2f362c',
'qr-fg': '#121212'
'well-light': '#f6f8f4',
'well-dark': '#1b1f1a',
'well-light-fg': '#515a4e',
'well-dark-fg': '#a5ad9e'
}
},
accentInputs: {
@@ -378,13 +385,23 @@ export const colorThemes: ColorTheme[] = [
'surface-raised': '#ffffff',
'fill-skeleton': '#ebedf2',
'border-control': '#858c9b',
'border-field': '#858c9b',
'on-solid': '#ffffff',
'info-solid': '#535a6b',
'info-fg': '#0e1220',
'info-soft': '#f6f7fa',
'info-border': '#e2e5ec',
'qr-fg': '#121212'
'well-light': '#f6f7fa',
'well-dark': '#161a25',
'well-light-fg': '#535a6b',
'well-dark-fg': '#a2aabb',
'sidebar-fg': '#d6dcea',
'sidebar-muted': '#97a2ba',
'sidebar-subtle': '#66718a',
'sidebar-hover': '#1e2540',
'sidebar-selected': '#28304e',
'sidebar-border': '#262e48',
'sidebar-field': '#1e2540',
'sidebar-edge': '#36405f',
'sidebar-ring': '#d6dcea'
},
dark: {
sidebar: '#0d111d',
@@ -408,13 +425,23 @@ export const colorThemes: ColorTheme[] = [
'surface-raised': '#1f2430',
'fill-skeleton': '#232836',
'border-control': '#727a8c',
'border-field': '#727a8c',
'on-solid': '#0d111d',
'info-solid': '#a2aabb',
'info-fg': '#edf0f6',
'info-soft': '#1b1f2a',
'info-border': '#2a3040',
'qr-fg': '#121212'
'well-light': '#f6f7fa',
'well-dark': '#161a25',
'well-light-fg': '#535a6b',
'well-dark-fg': '#a2aabb',
'sidebar-fg': '#d6dcea',
'sidebar-muted': '#97a2ba',
'sidebar-subtle': '#5f6a82',
'sidebar-hover': '#161c2e',
'sidebar-selected': '#212a43',
'sidebar-border': '#1b2236',
'sidebar-field': '#141a2b',
'sidebar-edge': '#2d3652',
'sidebar-ring': '#d6dcea'
}
},
accentInputs: {
@@ -468,6 +495,20 @@ export const colorThemes: ColorTheme[] = [
success: 150
}
}
},
sidebarVars: {
canvas: 'var(--sidebar)',
surface: 'var(--sidebar-field)',
'surface-raised': 'var(--sidebar-selected)',
fg: 'var(--sidebar-fg)',
'fg-muted': 'var(--sidebar-muted)',
'fg-subtle': 'var(--sidebar-subtle)',
'fill-hover': 'var(--sidebar-hover)',
'fill-selected': 'var(--sidebar-selected)',
'fill-muted': 'var(--sidebar-selected)',
border: 'var(--sidebar-border)',
'border-strong': 'var(--sidebar-edge)',
ring: 'var(--sidebar-ring)'
}
},
{
@@ -495,13 +536,14 @@ export const colorThemes: ColorTheme[] = [
'surface-raised': '#ffffff',
'fill-skeleton': '#ebe9f3',
'border-control': '#888498',
'border-field': '#888498',
'on-solid': '#ffffff',
'info-solid': '#56526a',
'info-fg': '#14121b',
'info-soft': '#f8f7fb',
'info-border': '#e5e2ee',
'qr-fg': '#121212'
'well-light': '#f8f7fb',
'well-dark': '#1c1a23',
'well-light-fg': '#56526a',
'well-dark-fg': '#aaa5ba'
},
dark: {
sidebar: '#16141c',
@@ -525,13 +567,14 @@ export const colorThemes: ColorTheme[] = [
'surface-raised': '#25222e',
'fill-skeleton': '#292632',
'border-control': '#787387',
'border-field': '#787387',
'on-solid': '#16141c',
'info-solid': '#aaa5ba',
'info-fg': '#f1eff6',
'info-soft': '#211e29',
'info-border': '#312d3c',
'qr-fg': '#121212'
'well-light': '#f8f7fb',
'well-dark': '#1c1a23',
'well-light-fg': '#56526a',
'well-dark-fg': '#aaa5ba'
}
},
accentInputs: {
+8 -2
View File
@@ -1,11 +1,12 @@
<script lang="ts">
import { beforeNavigate } from '$app/navigation';
import { page } from '$app/state';
import ConfirmDialog from '#lib/components/confirm-dialog/confirm-dialog.svelte';
import Error from '#lib/components/error.svelte';
import Header from '#lib/components/header/header.svelte';
import NavigationProgress from '#lib/components/navigation-progress.svelte';
import { Toaster } from '#lib/components/ui/sonner/index.ts';
import UnsavedChangesBar from '#lib/components/unsaved-changes-bar.svelte';
import unsavedChanges from '#lib/stores/unsaved-changes-store.svelte.ts';
import { m } from '#lib/paraglide/messages.js';
import { startPageTrace } from '#lib/utils/tracing-util.ts';
import { ModeWatcher } from 'mode-watcher';
@@ -27,12 +28,17 @@
} = $props();
const { appConfig } = data;
// Toasts sit above the phone bottom bar, and while the unsaved bar shows they rise above it too (its height plus 8px)
const barOpen = $derived(unsavedChanges.hasChanges || !!unsavedChanges.status);
const phoneClearance = $derived(
barOpen ? 136 : page.url.pathname.startsWith('/settings') ? 76 : 16
);
</script>
{#if !appConfig}
<Error message={m.critical_error_occurred_contact_administrator()} showButton={false} />
{:else}
<Header />
{@render children()}
{/if}
<Toaster
+52 -65
View File
@@ -2,15 +2,19 @@
import { onNavigate } from '$app/navigation';
import EmailVerificationStateBox from '#lib/components/email-verification-state-box.svelte';
import FormattedMessage from '#lib/components/formatted-message.svelte';
import Sidebar from '#lib/components/sidebar.svelte';
import AppSidebar from '#lib/components/shell/app-sidebar.svelte';
import { navGroups } from '#lib/components/shell/nav-items.ts';
import PhoneNav from '#lib/components/shell/phone-nav.svelte';
import * as Alert from '#lib/components/ui/alert/index.ts';
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 userStore from '#lib/stores/user-store.ts';
import { isEmailVerificationPending } from '#lib/utils/email-verification-util.ts';
import { cn } from '#lib/utils/style.ts';
import { LucideTriangleAlert } from '@lucide/svelte';
import { PersistedState } from 'runed';
import type { Snippet } from 'svelte';
import { fade, fly } from 'svelte/transition';
import type { LayoutData } from './$types';
let {
@@ -23,42 +27,40 @@
const { versionInformation, sqliteStorageWarning, user } = data;
type NavItem = {
href?: string;
label: string;
children?: NavItem[];
};
const isAdmin = $derived(!!($userStore?.isAdmin || user?.isAdmin));
const groups = $derived(
navGroups({ isAdmin, verificationPending: isEmailVerificationPending(user, $appConfigStore) })
);
const collapsed = new PersistedState('sidebar-collapsed', false);
// A user who still has to verify their email can only reach their account until they do
const items: NavItem[] = isEmailVerificationPending(user, $appConfigStore)
? [{ href: '/settings/account', label: m.my_account() }]
: [
{ href: '/settings/account', label: m.my_account() },
{ href: '/settings/apps', label: m.my_apps() },
{ href: '/settings/audit-log', label: m.audit_log() }
];
// List pages run gutter to gutter, the other pages keep the centered column
const listPage = $derived(
[
'/settings/admin/users',
'/settings/admin/user-groups',
'/settings/admin/oidc-clients',
'/settings/admin/apis',
'/settings/admin/api-keys',
'/settings/audit-log',
'/settings/audit-log/global'
].includes(page.route.id ?? '')
);
const adminChildren: NavItem[] = [
{ href: '/settings/admin/users', label: m.users() },
{ href: '/settings/admin/user-groups', label: m.user_groups() },
{ href: '/settings/admin/oidc-clients', label: m.oidc_clients() },
{ href: '/settings/admin/apis', label: m.apis() },
{ href: '/settings/admin/api-keys', label: m.api_keys() },
{
href: '/settings/admin/application-configuration',
label: m.application_configuration()
}
];
let mainEl: HTMLElement;
if (user?.isAdmin || $userStore?.isAdmin) {
items.push({ label: m.administration(), children: adminChildren });
}
// Navigating from the APIs or API keys pages, their tables' page updates included, re-checks their rows, so a first API or key has its row before you leave
// SvelteKit passes a from without a url when the app starts
afterNavigate(async ({ from }) => {
if (from?.url?.pathname.startsWith('/settings/admin/api'))
recheckedApiRows = await checkApiRows();
});
// Only the content region crossfades between pages; the shell never moves (docs/design-system.md, Page transitions)
onNavigate((navigation) => {
if (
navigation.shallow ||
!document.startViewTransition ||
$appConfigStore.disableAnimations ||
document.documentElement.classList.contains('no-motion') ||
window.matchMedia('(prefers-reduced-motion: reduce)').matches ||
!navigation.to?.url.pathname.startsWith('/settings/') ||
navigation.from?.url.pathname === navigation.to.url.pathname
@@ -76,28 +78,24 @@
});
</script>
<section>
<div
class="bg-muted/40 dark:bg-background flex min-h-[calc(100vh-86px)] w-full flex-col justify-between"
>
<main
in:fade={{ duration: 200 }}
class="mx-auto flex w-full max-w-[1720px] flex-col gap-x-8 gap-y-8 p-4 md:p-8 lg:flex-row"
>
<div class="w-full lg:w-[200px] lg:shrink-0 xl:w-[250px]">
<div in:fly={{ x: -15, duration: 200 }} class="sticky top-6">
<Sidebar
{items}
storageKey="sidebar-open:settings"
isAdmin={$userStore?.isAdmin || user?.isAdmin}
isUpToDate={versionInformation?.isUpToDate}
/>
</div>
</div>
<div class="settings-content flex w-full flex-col gap-4 overflow-hidden px-2 pb-2">
{#if sqliteStorageWarning && ($userStore?.isAdmin || user?.isAdmin)}
<Alert.Root variant="destructive">
<Tooltip.Provider>
<div class="bg-surface flex min-h-dvh">
<AppSidebar {groups} {versionInformation} {showUpdate} bind:collapsed={collapsed.current} />
<div class="flex min-w-0 flex-1 flex-col">
<PhoneNav
{groups}
version="Pocket ID {versionInformation.currentVersion}"
newestVersion={showUpdate ? versionInformation.newestVersion : null}
/>
<main
bind:this={mainEl}
class={cn(
'settings-content mx-auto flex w-full flex-col gap-4 px-4 pt-6 pb-[calc(var(--clear-end-phone)+env(safe-area-inset-bottom))] sm:px-8 sm:pt-10 sm:pb-20 xl:px-10',
!listPage && 'max-w-[calc(var(--col-content)+5rem)]'
)}
>
{#if sqliteStorageWarning && isAdmin}
<Alert.Root variant="warning">
<LucideTriangleAlert />
<Alert.Description>
<FormattedMessage message={m.sqlite_storage_warning} />
@@ -106,18 +104,7 @@
{/if}
<EmailVerificationStateBox />
{@render children()}
</div>
</main>
<div class="animate-fade-in flex flex-col items-center" style="animation-delay: 400ms;">
<p class="text-muted-foreground py-3 text-xs">
{m.powered_by()}
<a
class="text-foreground transition-all hover:underline"
href="https://github.com/pocket-id/pocket-id"
target="_blank">Pocket ID</a
>
({versionInformation.currentVersion})
</p>
</main>
</div>
</div>
</section>
</Tooltip.Provider>
+10 -4
View File
@@ -1,3 +1,4 @@
import { checkApiRows } from '#lib/components/shell/nav-items.ts';
import StorageService from '#lib/services/storage-service.ts';
import VersionService from '#lib/services/version-service.ts';
import WebAuthnService from '#lib/services/webauthn-service.ts';
@@ -5,17 +6,21 @@ import type { AppVersionInformation } from '#lib/types/application-configuration
import { redirect } from '@sveltejs/kit';
import type { LayoutLoad } from './$types';
export const load: LayoutLoad = async ({ url }) => {
export const load: LayoutLoad = async ({ url, parent, untrack }) => {
const versionService = new VersionService();
const storageService = new StorageService();
const webauthnService = new WebAuthnService();
const currentVersion = versionService.getCurrentVersion();
const [newestVersion, sqliteStorageWarning, passkeys] = await Promise.all([
const [newestVersion, sqliteStorageWarning, passkeys, apiRows] = await Promise.all([
versionService.getNewestVersion().catch(() => null),
storageService.getSqliteStorageWarning().catch(() => false),
webauthnService.listCredentials()
webauthnService.listCredentials(),
// A tracked parent() would rerun this load on every navigation, because the root load reads the pathname
untrack(() => parent()).then(({ user }) =>
user?.isAdmin ? checkApiRows() : { apis: false, apiKeys: false }
)
]);
// If newestVersion is empty, it means the check is disabled or failed.
@@ -39,6 +44,7 @@ export const load: LayoutLoad = async ({ url }) => {
return {
versionInformation,
sqliteStorageWarning,
passkeys
passkeys,
apiRows
};
};
+6 -6
View File
@@ -3,22 +3,22 @@ import { cleanupBackend } from '../utils/cleanup.util';
test.beforeEach(async () => await cleanupBackend());
test('settings sidebar has an accessible name', async ({ page }) => {
test('main navigation has an accessible name', async ({ page }) => {
await page.goto('/settings/account');
const nav = page.getByRole('navigation', { name: 'Settings' });
const nav = page.getByRole('navigation', { name: 'Main' }).first();
await expect(nav).toBeVisible();
});
test('keyboard focus stays on sidebar link after navigating', async ({ page }) => {
await page.goto('/settings/account');
const auditLog = page.getByRole('link', { name: 'Audit Log' });
await auditLog.focus();
const activity = page.getByRole('link', { name: 'Activity' }).first();
await activity.focus();
await page.keyboard.press('Enter');
await page.waitForURL('**/settings/audit-log');
await expect(auditLog).toBeFocused();
await expect(activity).toBeFocused();
});
test('unsaved changes block navigation until they are discarded', async ({ page }) => {
@@ -29,7 +29,7 @@ test('unsaved changes block navigation until they are discarded', async ({ page
await displayName.fill('Pending navigation');
await expect(page.getByText('You have unsaved changes', { exact: true })).toBeVisible();
const myApps = page.getByRole('link', { name: 'My Apps' });
const myApps = page.getByRole('link', { name: 'My Apps' }).first();
await myApps.click();
await expect(page).toHaveURL(/\/settings\/account$/);