mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-29 20:19:04 +01:00
feat(frontend): animate sidebar icons on hover
This commit is contained in:
@@ -19,6 +19,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@inlang/paraglide-js-svelte": "^1.0.3",
|
||||
"@jis3r/icons": "^2.9.0",
|
||||
"@opentelemetry/api": "^1.9.1",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.223.0",
|
||||
"@opentelemetry/resources": "^2.8.0",
|
||||
|
||||
@@ -5,11 +5,15 @@
|
||||
import { m } from '#lib/paraglide/messages.js';
|
||||
import appConfigStore from '#lib/stores/application-configuration-store.ts';
|
||||
import { commandPalette } from '#lib/stores/command-palette-store.svelte.ts';
|
||||
import { pageTransition } from '#lib/stores/page-transition-store.svelte.ts';
|
||||
import type { AppVersionInformation } from '#lib/types/application-configuration.type.ts';
|
||||
import { modKey } from '#lib/utils/keyboard-util.ts';
|
||||
import { cn } from '#lib/utils/style.ts';
|
||||
import { CircleChevronUp, PanelLeftClose, PanelLeftOpen, Search } from '@lucide/svelte';
|
||||
import AnimatedPanelLeftClose from '@jis3r/icons/icons/panel-left-close';
|
||||
import AnimatedPanelLeftOpen from '@jis3r/icons/icons/panel-left-open';
|
||||
import { CircleChevronUp, Search } from '@lucide/svelte';
|
||||
import AccountMenu from './account-menu.svelte';
|
||||
import NavIcon from './nav-icon.svelte';
|
||||
import { activeHref, type NavGroup } from './nav-items.ts';
|
||||
|
||||
let {
|
||||
@@ -25,6 +29,36 @@
|
||||
} = $props();
|
||||
|
||||
const current = $derived(activeHref(groups, page.url.pathname));
|
||||
|
||||
// The control whose icon plays: hovering or keyboard focus starts it, a click or tap never does
|
||||
// Hover and keyboard focus are tracked apart, because a click moves focus to the new page and must not make a hovered icon play again
|
||||
let hovered = $state<string | null>(null);
|
||||
let focused = $state<string | null>(null);
|
||||
const playing = $derived(hovered ?? focused);
|
||||
const play = (key: string) => ({
|
||||
'data-icon-play': key,
|
||||
onmouseenter: () => (hovered = key),
|
||||
// A click starts a page transition, which takes the pointer away and back, so a leave during it is not real
|
||||
onmouseleave: () => {
|
||||
if (!pageTransition.running) hovered = null;
|
||||
},
|
||||
// The focusin pair leaves the tooltip's own onfocus and onblur in place
|
||||
onfocusin: (e: FocusEvent) => {
|
||||
if ((e.currentTarget as HTMLElement).matches(':focus-visible')) focused = key;
|
||||
},
|
||||
onfocusout: () => (focused = null)
|
||||
});
|
||||
|
||||
// A row the pointer left during a page transition stops playing on the next move, since the browser restores :hover only after the transition
|
||||
$effect(() => {
|
||||
if (pageTransition.running || !hovered) return;
|
||||
const key = hovered;
|
||||
const check = () => {
|
||||
if (!document.querySelector(`[data-icon-play="${key}"]`)?.matches(':hover')) hovered = null;
|
||||
};
|
||||
window.addEventListener('pointermove', check, { once: true });
|
||||
return () => window.removeEventListener('pointermove', check);
|
||||
});
|
||||
const versionLabel = $derived(`Pocket ID ${versionInformation.currentVersion}`);
|
||||
|
||||
// One shared selected fill slides to the current row instead of every row painting its own
|
||||
@@ -155,6 +189,10 @@
|
||||
<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}
|
||||
{@const iconClass = cn(
|
||||
'size-[17px] shrink-0 transition-colors',
|
||||
active ? 'text-fg' : 'text-fg-muted group-hover:text-fg'
|
||||
)}
|
||||
<li>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
@@ -163,8 +201,9 @@
|
||||
{...props}
|
||||
href={item.href}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
{...play(item.href)}
|
||||
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',
|
||||
'group text-fg focus-visible:outline-ring relative flex h-9 scroll-my-6 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
|
||||
@@ -172,12 +211,15 @@
|
||||
: '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'
|
||||
)}
|
||||
/>
|
||||
{#if item.animated}
|
||||
<NavIcon
|
||||
icon={item.animated}
|
||||
active={playing === item.href}
|
||||
class={iconClass}
|
||||
/>
|
||||
{:else}
|
||||
<item.icon class={iconClass} />
|
||||
{/if}
|
||||
<span
|
||||
class={cn(
|
||||
'min-w-0 flex-1',
|
||||
@@ -268,10 +310,13 @@
|
||||
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)}
|
||||
{...play('collapse')}
|
||||
>
|
||||
{#if collapsed}<PanelLeftOpen class="size-[17px]" />{:else}<PanelLeftClose
|
||||
class="size-[17px]"
|
||||
/>{/if}
|
||||
<NavIcon
|
||||
icon={collapsed ? AnimatedPanelLeftOpen : AnimatedPanelLeftClose}
|
||||
active={playing === 'collapse'}
|
||||
class="size-[17px]"
|
||||
/>
|
||||
</button>
|
||||
{/snippet}
|
||||
</Tooltip.Trigger>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '#lib/utils/style.ts';
|
||||
import type { IconProps } from '@jis3r/icons';
|
||||
import type { Component } from 'svelte';
|
||||
import { prefersReducedMotion } from 'svelte/motion';
|
||||
|
||||
let {
|
||||
icon: Icon,
|
||||
active,
|
||||
class: className
|
||||
}: { icon: Component<IconProps>; active: boolean; class?: string } = $props();
|
||||
|
||||
// Reduced motion and Disable animations keep the icon still; the class is read on every hover because the setting can change at runtime
|
||||
const animate = $derived(
|
||||
active &&
|
||||
!prefersReducedMotion.current &&
|
||||
!document.documentElement.classList.contains('no-motion')
|
||||
);
|
||||
</script>
|
||||
|
||||
<!-- The library names each icon with role="img" in English, which would leak into the row's accessible name, and plays on its own hover, which ignores reduced motion -->
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class={cn(
|
||||
'pointer-events-none inline-grid [&_svg_*]:[vector-effect:non-scaling-stroke]',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<Icon size={17} strokeWidth={1.5} {animate} />
|
||||
</span>
|
||||
@@ -1,4 +1,15 @@
|
||||
import { m } from '#lib/paraglide/messages.js';
|
||||
import type { Features } from '#lib/types/feature.type.ts';
|
||||
import AnimatedIdCard from '#lib/icons/id-card-animated.svelte';
|
||||
import AnimatedLayoutGrid from '#lib/icons/layout-grid-animated.svelte';
|
||||
import AnimatedHistory from '@jis3r/icons/icons/history';
|
||||
import AnimatedUser from '@jis3r/icons/icons/user';
|
||||
import AnimatedUsersRound from '@jis3r/icons/icons/users-round';
|
||||
import AnimatedBlocks from '@jis3r/icons/icons/blocks';
|
||||
import AnimatedKey from '@jis3r/icons/icons/key';
|
||||
import AnimatedFileText from '@jis3r/icons/icons/file-text';
|
||||
import AnimatedSlidersHorizontal from '@jis3r/icons/icons/sliders-horizontal';
|
||||
import type { IconProps } from '@jis3r/icons';
|
||||
import {
|
||||
Blocks,
|
||||
FileText,
|
||||
@@ -13,27 +24,38 @@ import {
|
||||
} from '@lucide/svelte';
|
||||
import type { Component } from 'svelte';
|
||||
|
||||
export type NavItem = { href: string; label: string; icon: Component };
|
||||
// animated is the sidebar's moving version of the icon; My account and My apps use #lib/icons because the library has no id-card and its layout-grid motion was too busy
|
||||
export type NavItem = {
|
||||
href: string;
|
||||
label: string;
|
||||
icon: Component;
|
||||
animated?: Component<IconProps>;
|
||||
};
|
||||
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
|
||||
features
|
||||
}: {
|
||||
isAdmin: boolean;
|
||||
verificationPending: boolean;
|
||||
showApis: boolean;
|
||||
showApiKeys: boolean;
|
||||
features: Features | null;
|
||||
}): 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 }]
|
||||
items: [
|
||||
{
|
||||
href: '/settings/account',
|
||||
label: m.my_account(),
|
||||
icon: IdCard,
|
||||
animated: AnimatedIdCard
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -42,9 +64,24 @@ export function navGroups({
|
||||
{
|
||||
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 }
|
||||
{
|
||||
href: '/settings/account',
|
||||
label: m.my_account(),
|
||||
icon: IdCard,
|
||||
animated: AnimatedIdCard
|
||||
},
|
||||
{
|
||||
href: '/settings/apps',
|
||||
label: m.my_apps(),
|
||||
icon: LayoutGrid,
|
||||
animated: AnimatedLayoutGrid
|
||||
},
|
||||
{
|
||||
href: '/settings/audit-log',
|
||||
label: m.activity(),
|
||||
icon: History,
|
||||
animated: AnimatedHistory
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
@@ -53,16 +90,43 @@ export function navGroups({
|
||||
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/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/users', label: m.users(), icon: User, animated: AnimatedUser },
|
||||
{
|
||||
href: '/settings/admin/user-groups',
|
||||
label: m.groups(),
|
||||
icon: UsersRound,
|
||||
animated: AnimatedUsersRound
|
||||
},
|
||||
{
|
||||
href: '/settings/admin/oidc-clients',
|
||||
label: m.oidc_clients(),
|
||||
icon: Blocks,
|
||||
animated: AnimatedBlocks
|
||||
},
|
||||
...(features?.apis.enabled
|
||||
? [{ href: '/settings/admin/apis', label: m.apis(), icon: Server }]
|
||||
: []),
|
||||
...(features?.apiKeys.enabled
|
||||
? [
|
||||
{
|
||||
href: '/settings/admin/api-keys',
|
||||
label: m.api_keys(),
|
||||
icon: Key,
|
||||
animated: AnimatedKey
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
href: '/settings/audit-log/global',
|
||||
label: m.audit_log(),
|
||||
icon: FileText,
|
||||
animated: AnimatedFileText
|
||||
},
|
||||
{
|
||||
href: '/settings/admin/application-configuration',
|
||||
label: m.configuration(),
|
||||
icon: SlidersHorizontal
|
||||
icon: SlidersHorizontal,
|
||||
animated: AnimatedSlidersHorizontal
|
||||
}
|
||||
]
|
||||
});
|
||||
@@ -71,18 +135,6 @@ export function navGroups({
|
||||
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
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<script lang="ts">
|
||||
import type { IconProps } from '@jis3r/icons';
|
||||
|
||||
let { size = 24, strokeWidth = 2, animate = false }: IconProps = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width={strokeWidth}
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class:animate
|
||||
>
|
||||
<path d="M16 10h2" />
|
||||
<path d="M16 14h2" />
|
||||
<g class="avatar">
|
||||
<path d="M6.17 15a3 3 0 0 1 5.66 0" />
|
||||
<circle cx="9" cy="11" r="2" />
|
||||
</g>
|
||||
<rect x="2" y="5" width="20" height="14" rx="2" />
|
||||
</svg>
|
||||
|
||||
<style>
|
||||
svg {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.animate .avatar {
|
||||
animation: nod 0.45s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
@keyframes nod {
|
||||
45% {
|
||||
transform: translateY(-1.5px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import type { IconProps } from '@jis3r/icons';
|
||||
|
||||
let { size = 24, strokeWidth = 2, animate = false }: IconProps = $props();
|
||||
</script>
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width={strokeWidth}
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class:animate
|
||||
>
|
||||
<rect width="7" height="7" x="3" y="3" rx="1" />
|
||||
<rect width="7" height="7" x="14" y="3" rx="1" class="lifted" />
|
||||
<rect width="7" height="7" x="14" y="14" rx="1" />
|
||||
<rect width="7" height="7" x="3" y="14" rx="1" />
|
||||
</svg>
|
||||
|
||||
<style>
|
||||
svg {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.animate .lifted {
|
||||
animation: lift 0.45s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
@keyframes lift {
|
||||
45% {
|
||||
transform: translate(2px, -2px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,2 @@
|
||||
// True while a settings page crossfade runs; the browser takes the pointer off the page for it, so hover events in that window are not real moves
|
||||
export const pageTransition = $state({ running: false });
|
||||
@@ -1,4 +0,0 @@
|
||||
import { PersistedState } from 'runed';
|
||||
|
||||
// Per-admin preference that opens every Advanced disclosure; it lives in this browser until it moves to the user's settings
|
||||
export const showAdvanced = new PersistedState('show-advanced-settings', false);
|
||||
@@ -1,14 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { onNavigate } from '$app/navigation';
|
||||
import FormattedMessage from '#lib/components/formatted-message.svelte';
|
||||
import { page } from '$app/state';
|
||||
import DocsLink from '#lib/components/docs-link.svelte';
|
||||
import AppSidebar from '#lib/components/shell/app-sidebar.svelte';
|
||||
import CommandPalette from '#lib/components/shell/command-palette.svelte';
|
||||
import { checkApiRows, navGroups } from '#lib/components/shell/nav-items.ts';
|
||||
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 { pageTransition } from '#lib/stores/page-transition-store.svelte.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';
|
||||
@@ -30,27 +32,7 @@
|
||||
const isAdmin = $derived(!!($userStore?.isAdmin || user?.isAdmin));
|
||||
const verificationPending = $derived(isEmailVerificationPending(user, $appConfigStore));
|
||||
const showUpdate = $derived(isAdmin && versionInformation.isUpToDate === false);
|
||||
// A writable $derived(data.apiRows) would lose a re-check on the next navigation, because the root load re-runs every time and replaces data
|
||||
let recheckedApiRows = $state<typeof data.apiRows>();
|
||||
const apiRows = $derived(recheckedApiRows ?? data.apiRows);
|
||||
// The page you are on keeps its row, and the palette always lists both (docs/design-system.md, PO11)
|
||||
const groups = $derived(
|
||||
navGroups({
|
||||
isAdmin,
|
||||
verificationPending,
|
||||
showApis:
|
||||
apiRows.apis ||
|
||||
showAdvanced.current ||
|
||||
page.url.pathname.startsWith('/settings/admin/apis'),
|
||||
showApiKeys:
|
||||
apiRows.apiKeys ||
|
||||
showAdvanced.current ||
|
||||
page.url.pathname.startsWith('/settings/admin/api-keys')
|
||||
})
|
||||
);
|
||||
const paletteGroups = $derived(
|
||||
navGroups({ isAdmin, verificationPending, showApis: true, showApiKeys: true })
|
||||
);
|
||||
const groups = $derived(navGroups({ isAdmin, verificationPending, features: data.features }));
|
||||
const collapsed = new PersistedState('sidebar-collapsed', false);
|
||||
|
||||
// List pages run gutter to gutter, the other pages keep the centered column
|
||||
@@ -68,14 +50,7 @@
|
||||
|
||||
let mainEl: HTMLElement;
|
||||
|
||||
// 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)
|
||||
// Only the content region crossfades between pages, so the shell never moves
|
||||
onNavigate((navigation) => {
|
||||
if (
|
||||
navigation.shallow ||
|
||||
@@ -83,17 +58,25 @@
|
||||
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
|
||||
navigation.from?.url.pathname === navigation.to.url.pathname ||
|
||||
// Configuration tabs are routes, but switching them swaps only the tab content
|
||||
(navigation.to.route.id === '/settings/admin/application-configuration/[category]' &&
|
||||
navigation.from?.route.id === navigation.to.route.id)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// onNavigate runs after the load functions resolved, so the transition never delays the new page
|
||||
return new Promise((resolve) => {
|
||||
document.startViewTransition(async () => {
|
||||
pageTransition.running = true;
|
||||
// The outgoing column fades out where it was instead of at the new page's scroll position
|
||||
mainEl.style.viewTransitionName = 'settings-content-old';
|
||||
const transition = document.startViewTransition(async () => {
|
||||
mainEl.style.viewTransitionName = '';
|
||||
resolve();
|
||||
await navigation.complete;
|
||||
});
|
||||
transition.finished.finally(() => (pageTransition.running = false));
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -118,7 +101,10 @@
|
||||
<Alert.Root variant="warning">
|
||||
<LucideTriangleAlert />
|
||||
<Alert.Description>
|
||||
<FormattedMessage message={m.sqlite_storage_warning} />
|
||||
{m.sqlite_network_storage_warning()}
|
||||
<DocsLink
|
||||
href="https://pocket-id.org/docs/configuration/environment-variables#sqlite"
|
||||
/>
|
||||
</Alert.Description>
|
||||
</Alert.Root>
|
||||
{/if}
|
||||
@@ -129,5 +115,5 @@
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<CommandPalette groups={paletteGroups} isAdmin={isAdmin && !verificationPending} />
|
||||
<CommandPalette {groups} features={data.features} isAdmin={isAdmin && !verificationPending} />
|
||||
</Tooltip.Provider>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { checkApiRows } from '#lib/components/shell/nav-items.ts';
|
||||
import FeatureService from '#lib/services/feature-service.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';
|
||||
@@ -6,21 +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, parent, untrack }) => {
|
||||
export const load: LayoutLoad = async ({ url, parent, untrack, depends }) => {
|
||||
depends('app:features');
|
||||
|
||||
const versionService = new VersionService();
|
||||
const storageService = new StorageService();
|
||||
const webauthnService = new WebAuthnService();
|
||||
|
||||
const currentVersion = versionService.getCurrentVersion();
|
||||
|
||||
const [newestVersion, sqliteStorageWarning, passkeys, apiRows] = await Promise.all([
|
||||
const [newestVersion, sqliteStorageWarning, passkeys, features] = await Promise.all([
|
||||
versionService.getNewestVersion().catch(() => null),
|
||||
storageService.getSqliteStorageWarning().catch(() => false),
|
||||
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 }
|
||||
)
|
||||
untrack(() => parent()).then(({ user }) => (user?.isAdmin ? new FeatureService().list() : null))
|
||||
]);
|
||||
|
||||
// If newestVersion is empty, it means the check is disabled or failed.
|
||||
@@ -45,6 +45,6 @@ export const load: LayoutLoad = async ({ url, parent, untrack }) => {
|
||||
versionInformation,
|
||||
sqliteStorageWarning,
|
||||
passkeys,
|
||||
apiRows
|
||||
features
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user