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