feat(frontend): add the command palette

This commit is contained in:
Elias Schneider
2026-10-27 23:32:53 +01:00
parent c3d7390be1
commit d6efcc4e95
17 changed files with 619 additions and 30 deletions
+14 -1
View File
@@ -1111,5 +1111,18 @@
"validation_max_items": "Choose at most {count}", "validation_max_items": "Choose at most {count}",
"validation_email": "Enter a valid email address", "validation_email": "Enter a valid email address",
"validation_url": "Enter a valid URL", "validation_url": "Enter a valid URL",
"validation_number": "Enter a number" "validation_number": "Enter a number",
"command_menu": "Command menu",
"search_or_jump_to": "Search or jump to…",
"palette_recent": "Recent",
"palette_actions": "Actions",
"palette_navigation": "Navigation",
"palette_settings": "Settings",
"palette_no_results": "No results for “{query}”",
"palette_searching": "Searching…",
"palette_search_failed": "Couldn’t search users and apps.",
"palette_hint_navigate": "to move",
"palette_hint_open": "to open",
"palette_hint_close": "to close",
"hide_advanced_settings": "Hide advanced settings"
} }
+33
View File
@@ -455,6 +455,39 @@ html.theme-switching *::after {
} }
} }
/* A link inside a sentence stands out by contrast and weight rather than a permanent underline, which appears only on hover */
@utility inline-link {
color: var(--fg);
font-weight: 500;
text-underline-offset: 3px;
text-decoration-line: underline;
text-decoration-thickness: 1px;
text-decoration-color: transparent;
transition: text-decoration-color var(--dur-fast) var(--ease-out);
&:hover {
text-decoration-color: color-mix(in srgb, currentColor 40%, transparent);
}
}
/* A key in a shortcut hint, in the palette footer and on its trigger */
@utility keycap {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding-inline: 4px;
border-radius: var(--radius-xs);
background: var(--fill-muted);
box-shadow: inset 0 0 0 1px var(--border);
font-family: var(--font-sans);
font-size: 11px;
line-height: 14px;
font-weight: 500;
color: var(--fg-muted);
}
/* Compositor-only motion; Tailwind 4 writes scale and translate as their own properties, so they are listed next to transform */ /* Compositor-only motion; Tailwind 4 writes scale and translate as their own properties, so they are listed next to transform */
@utility transition-motion { @utility transition-motion {
transition-property: opacity, transform, translate, scale; transition-property: opacity, transform, translate, scale;
@@ -4,10 +4,10 @@
import * as DropdownMenu from '#lib/components/ui/dropdown-menu/index.ts'; import * as DropdownMenu from '#lib/components/ui/dropdown-menu/index.ts';
import * as Tooltip from '#lib/components/ui/tooltip/index.ts'; import * as Tooltip from '#lib/components/ui/tooltip/index.ts';
import { m } from '#lib/paraglide/messages.js'; 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 userStore from '#lib/stores/user-store.ts';
import { cachedProfilePicture } from '#lib/utils/cached-image-util.ts'; import { cachedProfilePicture } from '#lib/utils/cached-image-util.ts';
import { setModeInstantly } from '#lib/utils/color-theme-util.ts';
import { signOut } from '#lib/utils/sign-out-util.ts';
import { cn } from '#lib/utils/style.ts'; import { cn } from '#lib/utils/style.ts';
import { IdCard, LogOut, SunMoon } from '@lucide/svelte'; import { IdCard, LogOut, SunMoon } from '@lucide/svelte';
import { userPrefersMode } from 'mode-watcher'; import { userPrefersMode } from 'mode-watcher';
@@ -21,19 +21,10 @@
version?: string; version?: string;
} = $props(); } = $props();
const webauthnService = new WebAuthnService();
let open = $state(false); let open = $state(false);
let jumping = $state(false);
const appearanceLabels = { light: m.light(), dark: m.dark(), system: m.system() }; 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> </script>
{#snippet appearanceOptions()} {#snippet appearanceOptions()}
@@ -4,9 +4,11 @@
import * as Tooltip from '#lib/components/ui/tooltip/index.ts'; import * as Tooltip from '#lib/components/ui/tooltip/index.ts';
import { m } from '#lib/paraglide/messages.js'; import { m } from '#lib/paraglide/messages.js';
import appConfigStore from '#lib/stores/application-configuration-store.ts'; import appConfigStore from '#lib/stores/application-configuration-store.ts';
import { commandPalette } from '#lib/stores/command-palette-store.svelte.ts';
import type { AppVersionInformation } from '#lib/types/application-configuration.type.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 { cn } from '#lib/utils/style.ts';
import { CircleChevronUp, PanelLeftClose, PanelLeftOpen } from '@lucide/svelte'; import { CircleChevronUp, PanelLeftClose, PanelLeftOpen, Search } from '@lucide/svelte';
import AccountMenu from './account-menu.svelte'; import AccountMenu from './account-menu.svelte';
import { activeHref, type NavGroup } from './nav-items.ts'; import { activeHref, type NavGroup } from './nav-items.ts';
@@ -84,6 +86,41 @@
</a> </a>
</div> </div>
<!-- A field-like trigger in the sidebar, a search icon in the rail -->
<Tooltip.Root>
<Tooltip.Trigger>
{#snippet child({ props })}
<button
{...props}
type="button"
aria-label={m.search_or_jump_to()}
aria-haspopup="dialog"
onclick={() => (commandPalette.open = true)}
class={cn(
'text-fg-muted focus-visible:outline-ring mt-3 flex h-9 shrink-0 items-center gap-2 rounded-sm outline-offset-[-2px] transition-colors focus-visible:outline-2',
collapsed
? 'hover:bg-fill-hover hover:text-fg w-9 justify-center'
: 'hover:bg-fill-hover hover:text-fg lg:bg-fill-muted lg:hover:bg-fill-hover w-9 justify-center lg:w-full lg:justify-start lg:px-3'
)}
>
<Search class="size-4 shrink-0" />
<span
class={cn(
'text-control flex-1 truncate text-left',
collapsed ? 'hidden' : 'hidden lg:inline'
)}>{m.search_or_jump_to()}</span
>
<span class={cn('gap-0.5', collapsed ? 'hidden' : 'hidden lg:flex')} aria-hidden="true">
<kbd class="keycap">{modKey.replace('+', '')}</kbd><kbd class="keycap">K</kbd>
</span>
</button>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content side="right" class={cn(!collapsed && 'lg:hidden')}>
{m.search_or_jump_to()}<span class="text-inverse-muted">{modKey}K</span>
</Tooltip.Content>
</Tooltip.Root>
<nav <nav
bind:this={navEl} bind:this={navEl}
aria-label={m.main()} aria-label={m.main()}
@@ -0,0 +1,447 @@
<script lang="ts">
import { goto } from '$app/navigation';
import OidcClientAvatar from '#lib/components/oidc-client-avatar.svelte';
import * as Avatar from '#lib/components/ui/avatar/index.ts';
import * as Command from '#lib/components/ui/command/index.ts';
import * as Dialog from '#lib/components/ui/dialog/index.ts';
import { m } from '#lib/paraglide/messages.js';
import ApisService from '#lib/services/apis-service.ts';
import OIDCService from '#lib/services/oidc-service.ts';
import UserGroupService from '#lib/services/user-group-service.ts';
import UserService from '#lib/services/user-service.ts';
import appConfigStore from '#lib/stores/application-configuration-store.ts';
import { commandPalette } from '#lib/stores/command-palette-store.svelte.ts';
import { cachedProfilePicture } from '#lib/utils/cached-image-util.ts';
import { encodeClientIdParam } from '#lib/utils/client-id-util.ts';
import { setModeInstantly } from '#lib/utils/color-theme-util.ts';
import { signOut } from '#lib/utils/sign-out-util.ts';
import {
ArrowUpRight,
LogOut,
Monitor,
Moon,
Plus,
Search,
Server,
Settings2,
SlidersHorizontal,
Sun,
UsersRound
} from '@lucide/svelte';
import { Command as CommandPrimitive } from 'bits-ui';
import { userPrefersMode } from 'mode-watcher';
import { PersistedState } from 'runed';
import type { Component } from 'svelte';
import type { Features } from '#lib/types/feature.type.ts';
import { featureSections } from '#lib/utils/feature-util.ts';
import type { NavGroup } from './nav-items.ts';
let {
groups,
features,
isAdmin
}: { groups: NavGroup[]; features: Features | null; isAdmin: boolean } = $props();
type Lead =
| { kind: 'icon'; icon: Component }
| { kind: 'user'; id: string; initials: string }
| { kind: 'client'; id: string; name: string; hasLogo: boolean };
type Item = {
key: string;
label: string;
context?: string;
mono?: boolean;
lead: Lead;
checked?: boolean;
external?: boolean;
// Search-only items stay out of the list until someone types
searchOnly?: boolean;
run: () => void;
};
type Recent = {
key: string;
label: string;
context?: string;
mono?: boolean;
lead?: Exclude<Lead, { kind: 'icon' }>;
href: string;
};
const userService = new UserService();
const groupService = new UserGroupService();
const oidcService = new OIDCService();
const apisService = new ApisService();
let query = $state('');
// The page crossfade snapshots the old page, so a palette still fading out would show under the new one
let jumping = $state(false);
const q = $derived(query.trim().toLowerCase());
const recent = new PersistedState<Recent[]>('palette-recent', []);
const icon = (i: Component): Lead => ({ kind: 'icon', icon: i });
const matches = (item: Item) =>
item.label.toLowerCase().includes(q) || !!item.context?.toLowerCase().includes(q);
function jump(href: string) {
jumping = true;
goto(href);
}
// Destinations remember themselves, so the empty palette starts with the last three
function link(
key: string,
label: string,
href: string,
lead: Lead,
context?: string,
mono?: boolean
): Item {
return {
key,
label,
context,
mono,
lead,
run: () => {
recent.current = [
{ key, label, context, mono, lead: lead.kind === 'icon' ? undefined : lead, href },
...recent.current.filter((r) => r.key !== key)
].slice(0, 3);
jump(href);
}
};
}
const navigation = $derived(
groups.flatMap((g, gi) =>
g.items.map((i) =>
link(i.href, i.label, i.href, icon(i.icon), gi === 0 ? undefined : g.label)
)
)
);
const configUrl = '/settings/admin/application-configuration';
const settings = $derived.by(() => {
if (!isAdmin) return [];
const category = (id: string, label: string) =>
link(`config:${id}`, label, `${configUrl}/${id}`, icon(SlidersHorizontal), m.configuration());
const setting = (label: string, id: string, categoryLabel: string): Item => ({
...link(`setting:${label}`, label, `${configUrl}/${id}`, icon(Settings2), categoryLabel),
searchOnly: true
});
const ldap = !!features?.ldap.enabled;
return [
category('general', m.general()),
category('features', m.config_tab_features()),
category('signup', m.config_tab_signup()),
category('passkeys', m.passkeys()),
category('email', m.email()),
...(ldap ? [category('ldap', m.ldap())] : []),
category('oidc', m.oidc()),
category('branding', m.config_tab_branding()),
setting(m.application_name(), 'general', m.general()),
setting(m.session_duration(), 'general', m.general()),
setting(m.color_theme(), 'general', m.general()),
setting(m.disable_animations(), 'general', m.general()),
setting(m.user_verification(), 'passkeys', m.passkeys()),
setting(m.email_verification(), 'email', m.email()),
...(ldap ? [setting(m.config_server_url(), 'ldap', m.ldap())] : []),
// A feature that is off is found on Features, where it can be turned on
...featureSections($appConfigStore.appName)
.flatMap((section) => section.rows)
.filter((row) => !features?.[row.id].enabled)
.map((row) => setting(row.title, 'features', m.config_tab_features())),
setting(m.logo(), 'branding', m.config_tab_branding()),
setting(m.config_signin_background(), 'branding', m.config_tab_branding())
];
});
const actions = $derived.by(() => {
const create = (label: string, path: string): Item => ({
key: `new:${path}`,
label,
lead: icon(Plus),
run: () => jump(`/settings/admin/${path}?new`)
});
const list: Item[] = isAdmin
? [
create(m.add_user(), 'users'),
create(m.add_group(), 'user-groups'),
create(m.add_oidc_client(), 'oidc-clients'),
...(features?.apis.enabled ? [create(m.add_api(), 'apis')] : []),
...(features?.apiKeys.enabled ? [create(m.create_api_key(), 'api-keys')] : [])
]
: [];
list.push({
key: 'sign-out',
label: m.sign_out(),
lead: icon(LogOut),
searchOnly: true,
run: signOut
});
const modes = [
['light', m.light(), Sun],
['dark', m.dark(), Moon],
['system', m.system(), Monitor]
] as const;
for (const [mode, label, modeIcon] of modes) {
list.push({
key: `mode:${mode}`,
label,
context: m.appearance(),
lead: icon(modeIcon),
checked: userPrefersMode.current === mode,
searchOnly: true,
run: () => setModeInstantly(mode)
});
}
return list;
});
// People and apps come from the server, so they load after the local results and never block them
let entities = $state<{ label: string; items: Item[] }[]>([]);
let searchStatus = $state<'idle' | 'searching' | 'done' | 'failed'>('idle');
let searchSeq = 0;
async function searchEntities(term: string) {
const seq = ++searchSeq;
const options = { search: term, pagination: { page: 1, limit: 5 } };
const spinner = setTimeout(() => seq === searchSeq && (searchStatus = 'searching'), 150);
try {
const [apps, users, userGroups, clients, apis] = await Promise.all([
oidcService.listOwnAccessibleClients(options),
isAdmin ? userService.list(options) : null,
isAdmin ? groupService.list(options) : null,
isAdmin ? oidcService.listClients(options) : null,
isAdmin ? apisService.list(options) : null
]);
if (seq !== searchSeq) return;
const found = [
{
label: m.my_apps(),
items: apps.data
.filter((a) => a.launchURL)
.map((a): Item => ({
key: `app:${a.id}`,
label: a.name,
context: a.launchURL!.replace(/^https?:\/\//, ''),
lead: { kind: 'client', id: a.id, name: a.name, hasLogo: a.hasLogo },
external: true,
run: () => window.open(a.launchURL, '_blank', 'noopener,noreferrer')
}))
},
{
label: m.users(),
items: (users?.data ?? []).map((u) =>
link(
`user:${u.id}`,
u.displayName,
`/settings/admin/users/${u.id}`,
{
kind: 'user',
id: u.id,
initials: (u.firstName?.[0] ?? '') + (u.lastName?.[0] ?? '')
},
u.email
)
)
},
{
label: m.groups(),
items: (userGroups?.data ?? []).map((g) =>
link(
`group:${g.id}`,
g.friendlyName,
`/settings/admin/user-groups/${g.id}`,
icon(UsersRound),
g.name
)
)
},
{
label: m.oidc_clients(),
items: (clients?.data ?? []).map((c) =>
link(
`client:${c.id}`,
c.name,
`/settings/admin/oidc-clients/${encodeClientIdParam(c.id)}`,
{ kind: 'client', id: c.id, name: c.name, hasLogo: c.hasLogo },
c.id,
true
)
)
},
{
label: m.apis(),
items: (apis?.data ?? []).map((a) =>
link(
`api:${a.id}`,
a.name,
`/settings/admin/apis/${a.id}`,
icon(Server),
a.resource,
true
)
)
}
];
entities = found.filter((g) => g.items.length);
searchStatus = 'done';
} catch {
if (seq === searchSeq) searchStatus = 'failed';
} finally {
clearTimeout(spinner);
}
}
$effect(() => {
const term = q;
entities = [];
searchStatus = 'idle';
// A search still running for the previous query must not fill in results for this one
searchSeq++;
if (!term) return;
const timer = setTimeout(() => searchEntities(term), 200);
return () => clearTimeout(timer);
});
// Icon components cannot be stored, so a recent entry's icon is found again from its key
function recentIcon(key: string): Component {
const kind = key.split(':')[0];
if (kind === 'group') return UsersRound;
if (kind === 'api') return Server;
if (kind === 'config') return SlidersHorizontal;
if (kind === 'setting') return Settings2;
const nav = navigation.find((n) => n.key === key);
return nav?.lead.kind === 'icon' ? nav.lead.icon : Search;
}
const sections = $derived.by(() => {
if (!q) {
const visible = (items: Item[]) => items.filter((i) => !i.searchOnly);
const recentItems = recent.current.map((r) =>
link(r.key, r.label, r.href, r.lead ?? icon(recentIcon(r.key)), r.context, r.mono)
);
return [
{ label: m.palette_recent(), items: recentItems },
{ label: m.palette_actions(), items: visible(actions) },
{ label: m.palette_navigation(), items: navigation },
{ label: m.palette_settings(), items: visible(settings) }
].filter((s) => s.items.length);
}
return [
{ label: m.palette_actions(), items: actions.filter(matches) },
{ label: m.palette_navigation(), items: navigation.filter(matches) },
{ label: m.palette_settings(), items: settings.filter(matches) },
...entities
].filter((s) => s.items.length);
});
function run(item: Item) {
commandPalette.open = false;
item.run();
}
function onKeydown(e: KeyboardEvent) {
if (e.key.toLowerCase() === 'k' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
commandPalette.open = !commandPalette.open;
}
}
$effect(() => {
if (commandPalette.open) jumping = false;
else query = '';
});
</script>
<svelte:window onkeydown={onKeydown} />
<Dialog.Root bind:open={commandPalette.open}>
<Dialog.Content
showCloseButton={false}
overlayClass={jumping ? 'data-closed:duration-0' : undefined}
class={[
'bg-popover/72 border-fg/6 dark:border-fg/10 [@media(prefers-reduced-transparency:reduce)]:bg-popover! top-[15%] flex h-[min(70dvh,600px)] max-w-(--palette-w) translate-y-0 flex-col gap-0 overflow-hidden rounded-lg p-0 backdrop-blur-[40px] backdrop-saturate-150 max-sm:inset-0 max-sm:h-dvh max-sm:max-h-none max-sm:w-full max-sm:max-w-none max-sm:translate-x-0 max-sm:rounded-none max-sm:border-0 max-sm:pb-[env(safe-area-inset-bottom)] max-sm:before:hidden [@media(prefers-reduced-transparency:reduce)]:backdrop-filter-none!',
jumping && 'data-closed:duration-0'
]}
>
<Dialog.Title class="sr-only">{m.command_menu()}</Dialog.Title>
<Command.Root shouldFilter={false} loop class="p-0">
<div
class="flex h-12 shrink-0 items-center gap-3 px-4 shadow-[inset_0_-1px_0_var(--border)] max-sm:h-[calc(52px+env(safe-area-inset-top))] max-sm:pt-[env(safe-area-inset-top)]"
>
<Search class="text-fg-muted size-4 shrink-0" />
<CommandPrimitive.Input
bind:value={query}
placeholder={m.search_or_jump_to()}
class="text-body-l placeholder:text-fg-muted h-full min-w-0 flex-1 bg-transparent outline-hidden max-sm:text-[16px]"
/>
<Dialog.Close class="text-fg-muted hover:text-fg text-control sm:hidden"
>{m.cancel()}</Dialog.Close
>
</div>
<Command.List class="max-h-none min-h-0 flex-1 p-2">
{#each sections as section (section.label)}
<Command.Group heading={section.label} class="py-0">
{#each section.items as item (item.key)}
<Command.Item
value={item.key}
onSelect={() => run(item)}
data-checked={item.checked}
class="text-body min-h-11 gap-3 px-2 sm:min-h-10"
>
{#if item.lead.kind === 'user'}
<Avatar.Root class="size-5">
<Avatar.Image src={cachedProfilePicture.getUrl(item.lead.id)} alt="" />
<Avatar.Fallback class="text-[9px]">{item.lead.initials}</Avatar.Fallback>
</Avatar.Root>
{:else if item.lead.kind === 'client'}
<OidcClientAvatar
id={item.lead.id}
name={item.lead.name}
hasLogo={item.lead.hasLogo}
size={20}
/>
{:else}
<span class="grid w-5 place-items-center"><item.lead.icon /></span>
{/if}
<span class="truncate">{item.label}</span>
{#if item.context}
<span
class={[
'text-fg-muted min-w-1/3 flex-1 truncate',
item.mono && 'text-mono-s font-mono'
]}>{item.context}</span
>
{/if}
{#if item.external}<ArrowUpRight class="ml-auto size-3" />{/if}
</Command.Item>
{/each}
</Command.Group>
{/each}
{#if searchStatus === 'searching'}
<p class="text-control text-fg-muted px-2 py-2">{m.palette_searching()}</p>
{:else if searchStatus === 'failed'}
<p class="text-control text-fg-muted px-2 py-2">{m.palette_search_failed()}</p>
{:else if searchStatus === 'done' && !sections.length}
<p class="text-control text-fg-muted px-2 py-2">
{m.palette_no_results({ query: query.trim() })}
</p>
{/if}
</Command.List>
<div
class="text-caption text-fg-muted flex h-9 shrink-0 items-center gap-4 px-4 shadow-[inset_0_1px_0_var(--border)] max-sm:hidden"
>
<span class="flex items-center gap-1.5"
><kbd class="keycap">↑</kbd><kbd class="keycap">↓</kbd>{m.palette_hint_navigate()}</span
>
<span class="flex items-center gap-1.5"
><kbd class="keycap">↵</kbd>{m.palette_hint_open()}</span
>
<span class="flex items-center gap-1.5"
><kbd class="keycap">esc</kbd>{m.palette_hint_close()}</span
>
</div>
</Command.Root>
</Dialog.Content>
</Dialog.Root>
@@ -5,10 +5,11 @@
import * as DropdownMenu from '#lib/components/ui/dropdown-menu/index.ts'; import * as DropdownMenu from '#lib/components/ui/dropdown-menu/index.ts';
import { m } from '#lib/paraglide/messages.js'; import { m } from '#lib/paraglide/messages.js';
import appConfigStore from '#lib/stores/application-configuration-store.ts'; import appConfigStore from '#lib/stores/application-configuration-store.ts';
import { commandPalette } from '#lib/stores/command-palette-store.svelte.ts';
import { cn } from '#lib/utils/style.ts'; import { cn } from '#lib/utils/style.ts';
import { Check, Ellipsis } from '@lucide/svelte'; import { Check, CircleChevronUp, Ellipsis, Search } from '@lucide/svelte';
import AccountMenu from './account-menu.svelte'; import AccountMenu from './account-menu.svelte';
import { activeHref, type NavGroup } from './nav-items.ts'; import { activeHref, type NavGroup, type NavItem } from './nav-items.ts';
let { let {
groups, groups,
@@ -43,7 +44,7 @@
<nav <nav
aria-label={m.main()} 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" class="bg-surface-raised border-border shadow-overlay fixed right-[80px] bottom-[calc(12px+env(safe-area-inset-bottom))] left-3 z-(--z-nav) flex h-14 items-stretch rounded-lg border p-1 [view-transition-name:phone-nav] sm:hidden"
> >
{#each accountGroup.items as item (item.href)} {#each accountGroup.items as item (item.href)}
{@render tab(item.href, item.label, item.icon, item.href === current)} {@render tab(item.href, item.label, item.icon, item.href === current)}
@@ -108,12 +109,17 @@
{/if} {/if}
</nav> </nav>
{#snippet tab( <button
href: string, type="button"
label: string, aria-label={m.search_or_jump_to()}
Icon: NavGroup['items'][number]['icon'], aria-haspopup="dialog"
active: boolean onclick={() => (commandPalette.open = true)}
)} class="bg-surface-raised border-border shadow-overlay text-fg focus-visible:outline-ring fixed right-3 bottom-[calc(12px+env(safe-area-inset-bottom))] z-(--z-nav) grid size-14 place-items-center rounded-lg border [view-transition-name:phone-search] focus-visible:outline-2 focus-visible:-outline-offset-2 sm:hidden"
>
<Search class="size-5" />
</button>
{#snippet tab(href: string, label: string, Icon: NavItem['icon'], active: boolean)}
<a <a
{href} {href}
aria-current={active ? 'page' : undefined} aria-current={active ? 'page' : undefined}
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { modKey } from '#lib/utils/keyboard-util.ts';
import { beforeNavigate } from '$app/navigation'; import { beforeNavigate } from '$app/navigation';
import { Button } from '#lib/components/ui/button/index.ts'; import { Button } from '#lib/components/ui/button/index.ts';
import * as Tooltip from '#lib/components/ui/tooltip/index.ts'; import * as Tooltip from '#lib/components/ui/tooltip/index.ts';
@@ -47,7 +48,6 @@
status?.message ?? status?.message ??
(blocked ? m.save_or_discard_before_leaving() : showingActions ? pendingText : '') (blocked ? m.save_or_discard_before_leaving() : showingActions ? pendingText : '')
); );
const modKey = navigator.userAgent.includes('Mac') ? '⌘' : 'Ctrl+';
// The status line keeps its last message while it slides away, so the bar does not shrink under it // The status line keeps its last message while it slides away, so the bar does not shrink under it
let shownStatus = $state<typeof status>(null); let shownStatus = $state<typeof status>(null);
@@ -0,0 +1,2 @@
// The palette is mounted once in the settings layout and opened from the sidebar, the phone bar and ⌘K
export const commandPalette = $state({ open: false });
@@ -0,0 +1,13 @@
import { afterNavigate, replaceState } from '$app/navigation';
// Links such as the command palette and the My apps empty state open a list's create sheet with ?new
// The parameter is dropped right away, so a reload or Back does not open the sheet again
export function openOnCreateParam(open: () => void) {
afterNavigate(({ to }) => {
if (!to?.url.searchParams.has('new')) return;
const url = new URL(to.url);
url.searchParams.delete('new');
replaceState(url, {});
open();
});
}
+1
View File
@@ -0,0 +1 @@
export const modKey = navigator.userAgent.includes('Mac') ? '⌘' : 'Ctrl+';
+14
View File
@@ -0,0 +1,14 @@
import { goto } from '$app/navigation';
import WebAuthnService from '#lib/services/webauthn-service.ts';
import unsavedChangesStore from '#lib/stores/unsaved-changes-store.svelte.ts';
const webauthnService = new WebAuthnService();
export async function signOut() {
if (!unsavedChangesStore.hasChanges) {
await webauthnService.logout();
}
// With unsaved changes the bar blocks this navigation and shakes instead
goto('/login');
}
+24 -2
View File
@@ -2,7 +2,8 @@
import { onNavigate } from '$app/navigation'; import { onNavigate } from '$app/navigation';
import FormattedMessage from '#lib/components/formatted-message.svelte'; import FormattedMessage from '#lib/components/formatted-message.svelte';
import AppSidebar from '#lib/components/shell/app-sidebar.svelte'; import AppSidebar from '#lib/components/shell/app-sidebar.svelte';
import { navGroups } from '#lib/components/shell/nav-items.ts'; import CommandPalette from '#lib/components/shell/command-palette.svelte';
import { checkApiRows, navGroups } from '#lib/components/shell/nav-items.ts';
import PhoneNav from '#lib/components/shell/phone-nav.svelte'; import PhoneNav from '#lib/components/shell/phone-nav.svelte';
import * as Alert from '#lib/components/ui/alert/index.ts'; import * as Alert from '#lib/components/ui/alert/index.ts';
import * as Tooltip from '#lib/components/ui/tooltip/index.ts'; import * as Tooltip from '#lib/components/ui/tooltip/index.ts';
@@ -27,8 +28,28 @@
const { versionInformation, sqliteStorageWarning, user } = data; const { versionInformation, sqliteStorageWarning, user } = data;
const isAdmin = $derived(!!($userStore?.isAdmin || user?.isAdmin)); 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( const groups = $derived(
navGroups({ isAdmin, verificationPending: isEmailVerificationPending(user, $appConfigStore) }) 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 collapsed = new PersistedState('sidebar-collapsed', false); const collapsed = new PersistedState('sidebar-collapsed', false);
@@ -108,4 +129,5 @@
</main> </main>
</div> </div>
</div> </div>
<CommandPalette groups={paletteGroups} isAdmin={isAdmin && !verificationPending} />
</Tooltip.Provider> </Tooltip.Provider>
@@ -17,6 +17,7 @@
import type { ApiKey } from '#lib/types/api-key.type.ts'; import type { ApiKey } from '#lib/types/api-key.type.ts';
import type { ListRequestOptions } from '#lib/types/list-request.type.ts'; import type { ListRequestOptions } from '#lib/types/list-request.type.ts';
import { axiosErrorToast } from '#lib/utils/error-util.ts'; import { axiosErrorToast } from '#lib/utils/error-util.ts';
import { openOnCreateParam } from '#lib/utils/create-param-util.ts';
import { LucideArrowUpRight, LucideBan, LucidePlus, LucideRefreshCcw } from '@lucide/svelte'; import { LucideArrowUpRight, LucideBan, LucidePlus, LucideRefreshCcw } from '@lucide/svelte';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import ApiKeyCreateSheet from './api-key-create-sheet.svelte'; import ApiKeyCreateSheet from './api-key-create-sheet.svelte';
@@ -30,8 +31,9 @@
let tableRef = $state<AdvancedTable<ApiKey>>(); let tableRef = $state<AdvancedTable<ApiKey>>();
let createOpen = $state(false); let createOpen = $state(false);
openOnCreateParam(() => (createOpen = true));
let renewing = $state<ApiKey | null>(null); let renewing = $state<ApiKey | null>(null);
let empty = $state(false); let empty = $state(data.empty);
const formatDate = (date: string) => const formatDate = (date: string) =>
new Date(date).toLocaleDateString(getLocale(), { dateStyle: 'medium' }); new Date(date).toLocaleDateString(getLocale(), { dateStyle: 'medium' });
@@ -14,6 +14,7 @@
} from '#lib/types/advanced-table.type.ts'; } from '#lib/types/advanced-table.type.ts';
import type { Api } from '#lib/types/api.type.ts'; import type { Api } from '#lib/types/api.type.ts';
import type { ListRequestOptions } from '#lib/types/list-request.type.ts'; import type { ListRequestOptions } from '#lib/types/list-request.type.ts';
import { openOnCreateParam } from '#lib/utils/create-param-util.ts';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { import {
LucideArrowRight, LucideArrowRight,
@@ -32,7 +33,8 @@
let tableRef = $state<AdvancedTable<Api>>(); let tableRef = $state<AdvancedTable<Api>>();
let createOpen = $state(false); let createOpen = $state(false);
let empty = $state(false); openOnCreateParam(() => (createOpen = true));
let empty = $state(data.empty);
const href = (api: Api) => `/settings/admin/apis/${api.id}`; const href = (api: Api) => `/settings/admin/apis/${api.id}`;
@@ -18,6 +18,7 @@
import type { ListRequestOptions } from '#lib/types/list-request.type.ts'; import type { ListRequestOptions } from '#lib/types/list-request.type.ts';
import type { OidcClientWithAllowedGroups } from '#lib/types/oidc.type.ts'; import type { OidcClientWithAllowedGroups } from '#lib/types/oidc.type.ts';
import { encodeClientIdParam } from '#lib/utils/client-id-util.ts'; import { encodeClientIdParam } from '#lib/utils/client-id-util.ts';
import { openOnCreateParam } from '#lib/utils/create-param-util.ts';
import { LucideArrowRight, LucideArrowUpRight, LucidePlus } from '@lucide/svelte'; import { LucideArrowRight, LucideArrowUpRight, LucidePlus } from '@lucide/svelte';
import OidcClientCreateSheet from './oidc-client-create-sheet.svelte'; import OidcClientCreateSheet from './oidc-client-create-sheet.svelte';
import { import {
@@ -33,7 +34,8 @@
const connectDocsUrl = 'https://pocket-id.org/docs/client-examples'; const connectDocsUrl = 'https://pocket-id.org/docs/client-examples';
let tableRef = $state<AdvancedTable<OidcClientWithAllowedGroups>>(); let tableRef = $state<AdvancedTable<OidcClientWithAllowedGroups>>();
let createOpen = $state(false); let createOpen = $state(false);
let empty = $state(false); openOnCreateParam(() => (createOpen = true));
let empty = $state(data.empty);
async function fetchClients(options: ListRequestOptions) { async function fetchClients(options: ListRequestOptions) {
const res = await oidcService.listClients(options); const res = await oidcService.listClients(options);
@@ -16,6 +16,7 @@
} from '#lib/types/advanced-table.type.ts'; } from '#lib/types/advanced-table.type.ts';
import type { ListRequestOptions } from '#lib/types/list-request.type.ts'; import type { ListRequestOptions } from '#lib/types/list-request.type.ts';
import type { UserGroupMinimal } from '#lib/types/user-group.type.ts'; import type { UserGroupMinimal } from '#lib/types/user-group.type.ts';
import { openOnCreateParam } from '#lib/utils/create-param-util.ts';
import { LucideArrowRight, LucidePlus, LucideTrash2 } from '@lucide/svelte'; import { LucideArrowRight, LucidePlus, LucideTrash2 } from '@lucide/svelte';
import { confirmDeleteGroup } from './user-group-actions'; import { confirmDeleteGroup } from './user-group-actions';
import UserGroupCreateSheet from './user-group-create-sheet.svelte'; import UserGroupCreateSheet from './user-group-create-sheet.svelte';
@@ -25,7 +26,8 @@
const userGroupService = new UserGroupService(); const userGroupService = new UserGroupService();
let tableRef = $state<AdvancedTable<UserGroupMinimal>>(); let tableRef = $state<AdvancedTable<UserGroupMinimal>>();
let createOpen = $state(false); let createOpen = $state(false);
let empty = $state(false); openOnCreateParam(() => (createOpen = true));
let empty = $state(data.empty);
const href = (group: UserGroupMinimal) => `/settings/admin/user-groups/${group.id}`; const href = (group: UserGroupMinimal) => `/settings/admin/user-groups/${group.id}`;
const groupSlug = (group: UserGroupMinimal) => const groupSlug = (group: UserGroupMinimal) =>
@@ -36,6 +36,7 @@
} from '@lucide/svelte'; } from '@lucide/svelte';
import { SvelteMap } from 'svelte/reactivity'; import { SvelteMap } from 'svelte/reactivity';
import OidcClientAvatar from '#lib/components/oidc-client-avatar.svelte'; import OidcClientAvatar from '#lib/components/oidc-client-avatar.svelte';
import { openOnCreateParam } from '#lib/utils/create-param-util.ts';
import PickerDialog, { groupsConfirmLabel } from '../user-groups/picker-dialog.svelte'; import PickerDialog, { groupsConfirmLabel } from '../user-groups/picker-dialog.svelte';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
import LoginCodeDialog from './login-code-dialog.svelte'; import LoginCodeDialog from './login-code-dialog.svelte';
@@ -59,6 +60,7 @@
let shownCount = $state(0); let shownCount = $state(0);
let selectedIds = $state<string[]>([]); let selectedIds = $state<string[]>([]);
let createOpen = $state(false); let createOpen = $state(false);
openOnCreateParam(() => (createOpen = true));
let tokensOpen = $state(false); let tokensOpen = $state(false);
let tokensStartWithCreate = $state(false); let tokensStartWithCreate = $state(false);
let groupPickerOpen = $state(false); let groupPickerOpen = $state(false);