mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-29 20:19:04 +01:00
feat(frontend): add v3 app shell with sidebar, rail and phone navigation
This commit is contained in:
@@ -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
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user