mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-10-29 12:09:05 +01:00
feat(frontend): add the command palette
This commit is contained in:
@@ -1111,5 +1111,18 @@
|
||||
"validation_max_items": "Choose at most {count}",
|
||||
"validation_email": "Enter a valid email address",
|
||||
"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"
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
@utility transition-motion {
|
||||
transition-property: opacity, transform, translate, scale;
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
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 { 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 { IdCard, LogOut, SunMoon } from '@lucide/svelte';
|
||||
import { userPrefersMode } from 'mode-watcher';
|
||||
@@ -21,19 +21,10 @@
|
||||
version?: string;
|
||||
} = $props();
|
||||
|
||||
const webauthnService = new WebAuthnService();
|
||||
let open = $state(false);
|
||||
let jumping = $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()}
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
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 { commandPalette } from '#lib/stores/command-palette-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 } from '@lucide/svelte';
|
||||
import { CircleChevronUp, PanelLeftClose, PanelLeftOpen, Search } from '@lucide/svelte';
|
||||
import AccountMenu from './account-menu.svelte';
|
||||
import { activeHref, type NavGroup } from './nav-items.ts';
|
||||
|
||||
@@ -84,6 +86,41 @@
|
||||
</a>
|
||||
</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
|
||||
bind:this={navEl}
|
||||
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 { 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 { 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 { activeHref, type NavGroup } from './nav-items.ts';
|
||||
import { activeHref, type NavGroup, type NavItem } from './nav-items.ts';
|
||||
|
||||
let {
|
||||
groups,
|
||||
@@ -43,7 +44,7 @@
|
||||
|
||||
<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"
|
||||
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)}
|
||||
{@render tab(item.href, item.label, item.icon, item.href === current)}
|
||||
@@ -108,12 +109,17 @@
|
||||
{/if}
|
||||
</nav>
|
||||
|
||||
{#snippet tab(
|
||||
href: string,
|
||||
label: string,
|
||||
Icon: NavGroup['items'][number]['icon'],
|
||||
active: boolean
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={m.search_or_jump_to()}
|
||||
aria-haspopup="dialog"
|
||||
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
|
||||
{href}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { modKey } from '#lib/utils/keyboard-util.ts';
|
||||
import { beforeNavigate } from '$app/navigation';
|
||||
import { Button } from '#lib/components/ui/button/index.ts';
|
||||
import * as Tooltip from '#lib/components/ui/tooltip/index.ts';
|
||||
@@ -47,7 +48,6 @@
|
||||
status?.message ??
|
||||
(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
|
||||
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();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const modKey = navigator.userAgent.includes('Mac') ? '⌘' : 'Ctrl+';
|
||||
@@ -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');
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
import { onNavigate } from '$app/navigation';
|
||||
import FormattedMessage from '#lib/components/formatted-message.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 * as Alert from '#lib/components/ui/alert/index.ts';
|
||||
import * as Tooltip from '#lib/components/ui/tooltip/index.ts';
|
||||
@@ -27,8 +28,28 @@
|
||||
const { versionInformation, sqliteStorageWarning, user } = data;
|
||||
|
||||
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: 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);
|
||||
|
||||
@@ -108,4 +129,5 @@
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<CommandPalette groups={paletteGroups} isAdmin={isAdmin && !verificationPending} />
|
||||
</Tooltip.Provider>
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import type { ApiKey } from '#lib/types/api-key.type.ts';
|
||||
import type { ListRequestOptions } from '#lib/types/list-request.type.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 { toast } from 'svelte-sonner';
|
||||
import ApiKeyCreateSheet from './api-key-create-sheet.svelte';
|
||||
@@ -30,8 +31,9 @@
|
||||
|
||||
let tableRef = $state<AdvancedTable<ApiKey>>();
|
||||
let createOpen = $state(false);
|
||||
openOnCreateParam(() => (createOpen = true));
|
||||
let renewing = $state<ApiKey | null>(null);
|
||||
let empty = $state(false);
|
||||
let empty = $state(data.empty);
|
||||
|
||||
const formatDate = (date: string) =>
|
||||
new Date(date).toLocaleDateString(getLocale(), { dateStyle: 'medium' });
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
} from '#lib/types/advanced-table.type.ts';
|
||||
import type { Api } from '#lib/types/api.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 {
|
||||
LucideArrowRight,
|
||||
@@ -32,7 +33,8 @@
|
||||
|
||||
let tableRef = $state<AdvancedTable<Api>>();
|
||||
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}`;
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
import type { ListRequestOptions } from '#lib/types/list-request.type.ts';
|
||||
import type { OidcClientWithAllowedGroups } from '#lib/types/oidc.type.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 OidcClientCreateSheet from './oidc-client-create-sheet.svelte';
|
||||
import {
|
||||
@@ -33,7 +34,8 @@
|
||||
const connectDocsUrl = 'https://pocket-id.org/docs/client-examples';
|
||||
let tableRef = $state<AdvancedTable<OidcClientWithAllowedGroups>>();
|
||||
let createOpen = $state(false);
|
||||
let empty = $state(false);
|
||||
openOnCreateParam(() => (createOpen = true));
|
||||
let empty = $state(data.empty);
|
||||
|
||||
async function fetchClients(options: ListRequestOptions) {
|
||||
const res = await oidcService.listClients(options);
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
} from '#lib/types/advanced-table.type.ts';
|
||||
import type { ListRequestOptions } from '#lib/types/list-request.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 { confirmDeleteGroup } from './user-group-actions';
|
||||
import UserGroupCreateSheet from './user-group-create-sheet.svelte';
|
||||
@@ -25,7 +26,8 @@
|
||||
const userGroupService = new UserGroupService();
|
||||
let tableRef = $state<AdvancedTable<UserGroupMinimal>>();
|
||||
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 groupSlug = (group: UserGroupMinimal) =>
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
} from '@lucide/svelte';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
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 type { PageProps } from './$types';
|
||||
import LoginCodeDialog from './login-code-dialog.svelte';
|
||||
@@ -59,6 +60,7 @@
|
||||
let shownCount = $state(0);
|
||||
let selectedIds = $state<string[]>([]);
|
||||
let createOpen = $state(false);
|
||||
openOnCreateParam(() => (createOpen = true));
|
||||
let tokensOpen = $state(false);
|
||||
let tokensStartWithCreate = $state(false);
|
||||
let groupPickerOpen = $state(false);
|
||||
|
||||
Reference in New Issue
Block a user