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
+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
};
};