configurable default homepage

This commit is contained in:
jarek committed 2026-08-09 10:31:27 +02:00
1 parent 1cec7d0814
commit 86ef3f743b
10 files changed
+440 -8

No files matched your search

@@ -0,0 +1,197 @@
<script lang="ts">
// Navigation preferences (default env + start page + env-click target). Saved to
// /api/settings/navigation under the given scope ('global' from Settings, 'user' from Profile).
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { toast } from 'svelte-sonner';
import * as Select from '$lib/components/ui/select';
import { Label } from '$lib/components/ui/label';
import { licenseStore } from '$lib/stores/license';
import { authStore, hasAnyAccess } from '$lib/stores/auth';
import { LayoutDashboard, Box, ScrollText, Layers, Images, HardDrive, Network,
Terminal, LibraryBig, Download, Activity, Archive, Timer, ClipboardList } from 'lucide-svelte';
let { scope = 'global' as 'global' | 'user' } = $props();
const isUser = $derived(scope === 'user');
// Every landing/env-click page, MIRRORING the sidebar (same routes, icons, permission /
// gate / enterprise conditions) so the dropdown only offers pages the user can actually
// reach. `needsEnv` = the page is env-scoped (shown with the chosen env in the hint and
// eligible for env-click). `permission` / `gate` / `enterpriseOnly` mirror canSeeMenuItem.
const ALL_PAGES = [
{ value: 'dashboard', label: 'Dashboard', Icon: LayoutDashboard, needsEnv: false, permission: 'always' },
{ value: 'containers', label: 'Containers', Icon: Box, needsEnv: true, permission: 'containers' },
{ value: 'logs', label: 'Logs', Icon: ScrollText, needsEnv: true, permission: 'containers' },
{ value: 'terminal', label: 'Shell', Icon: Terminal, needsEnv: true, permission: 'containers' },
{ value: 'stacks', label: 'Compose stacks', Icon: Layers, needsEnv: true, permission: 'stacks' },
{ value: 'images', label: 'Images', Icon: Images, needsEnv: true, permission: 'images' },
{ value: 'volumes', label: 'Volumes', Icon: HardDrive, needsEnv: true, permission: 'volumes' },
{ value: 'networks', label: 'Networks', Icon: Network, needsEnv: true, permission: 'networks' },
{ value: 'templates', label: 'Templates', Icon: LibraryBig, needsEnv: false, permission: 'templates' },
{ value: 'registry', label: 'Registry', Icon: Download, needsEnv: false, permission: 'registries' },
{ value: 'activity', label: 'Activity', Icon: Activity, needsEnv: false, permission: 'activity' },
{ value: 'backups', label: 'Backups', Icon: Archive, needsEnv: false, permission: 'backups', gate: 'backups' },
{ value: 'schedules', label: 'Schedules', Icon: Timer, needsEnv: false, permission: 'schedules' },
{ value: 'audit', label: 'Audit log', Icon: ClipboardList, needsEnv: false, permission: 'audit_logs', enterpriseOnly: true }
] as const;
// Visibility mirrors app-sidebar's canSeeMenuItem: hide the Backups beta unless the gate is
// on, enterprise-only pages without an enterprise license, and (enterprise + auth on, non-
// admin) pages the user has no permission for. Free/auth-off shows everything non-enterprise.
function canSee(item: (typeof ALL_PAGES)[number]): boolean {
const p = item as { permission?: string; gate?: string; enterpriseOnly?: boolean };
if (p.gate === 'backups' && !$page.data?.backupsEnabled) return false;
if (p.enterpriseOnly && !$licenseStore.isEnterprise) return false;
if (!$licenseStore.isEnterprise || !$authStore.authEnabled) return true;
if ($authStore.user?.isAdmin) return true;
if (!p.permission || p.permission === 'always') return true;
return $hasAnyAccess(p.permission as any);
}
const PAGES = $derived(ALL_PAGES.filter(canSee));
// Env-click: only env-scoped pages, and never Dashboard (you already clicked an env).
const CLICK_PAGES = $derived(PAGES.filter((p) => p.needsEnv));
// In the 'user' scope, a null field means "inherit the global default". We surface that as a
// pickable sentinel option so the user can go back to inheriting.
const INHERIT = '__inherit__';
// State. Values: a page string, or null (=inherit in user scope). In GLOBAL scope null is
// never stored — an unset field defaults to 'dashboard' (landing) / 'containers' (env-click).
let landingPage = $state<string | null>(null);
let envClickPageV = $state<string | null>(null);
let loaded = $state(false);
// The global values, shown as the "(default: X)" hint in user scope.
let globalDefaults = $state<{ landingPage: string | null; envClickPage: string | null }>({ landingPage: null, envClickPage: null });
const pageOf = (v: string | null) => PAGES.find((p) => p.value === v) ?? PAGES[0];
// Resolve a stored field to the value the Select should show. In user scope an unset
// field (null) stays inherit; in global scope it falls back to the given default.
const resolve = (v: string | null, globalDefault: string) => v ?? (isUser ? INHERIT : globalDefault);
// Home defaults to Dashboard; env-click defaults to a concrete page (containers).
const homeSel = $derived(resolve(landingPage, 'dashboard'));
const clickSel = $derived(resolve(envClickPageV, 'containers'));
// Label helper for the trigger + the (default: X) hint.
const pageLabel = (v: string | null): string => v ? pageOf(v).label : 'Dashboard';
onMount(async () => {
try {
const res = await fetch('/api/settings/navigation');
if (res.ok) {
const data = await res.json();
globalDefaults = { landingPage: data.global?.landingPage ?? null, envClickPage: data.global?.envClickPage ?? null };
const p = isUser ? (data.user ?? {}) : (data.global ?? {});
landingPage = p.landingPage ?? null;
envClickPageV = p.envClickPage ?? null;
}
} catch { /* keep defaults */ }
loaded = true;
});
function pickHome(v: string) {
landingPage = v === INHERIT ? null : v;
persist();
}
function pickClick(v: string) {
envClickPageV = v === INHERIT ? null : v;
persist();
}
let saveTimer: ReturnType<typeof setTimeout> | null = null;
async function save() {
try {
const res = await fetch(`/api/settings/navigation?scope=${scope}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
// '' clears (inherit in user scope). A page value sets it.
landingPage: landingPage ?? '',
envClickPage: envClickPageV ?? ''
})
});
if (!res.ok) toast.error('Failed to save navigation preference');
} catch {
toast.error('Failed to save navigation preference');
}
}
function persist() {
if (!loaded) return;
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(save, 250);
}
</script>
<!-- Two groups side by side: "Open the app on" (which page + which env) and "Environment
click". Wraps to a column on narrow screens. -->
<div class="flex flex-col sm:flex-row sm:items-start gap-6">
<!-- 1. Open the app on: the landing PAGE. The environment is not forced - the app opens on
the last-used one. -->
<div class="space-y-1.5 min-w-0 flex-1">
<Label>Open the app on</Label>
<div class="flex items-center gap-2.5">
<Select.Root type="single" value={homeSel} onValueChange={(v) => { if (v) pickHome(v); }}>
<Select.Trigger class="flex-1 min-w-0">
<div class="flex items-center gap-2">
{#if isUser && landingPage === null}
<span class="text-muted-foreground truncate">Global default ({pageLabel(globalDefaults.landingPage ?? 'dashboard')})</span>
{:else}
{@const m = pageOf(landingPage)}
<m.Icon class="w-4 h-4 text-muted-foreground" />
<span class="truncate">{m.label}</span>
{/if}
</div>
</Select.Trigger>
<Select.Content>
{#if isUser}
<Select.Item value={INHERIT}><span class="text-muted-foreground">Use global default</span></Select.Item>
{/if}
{#each PAGES as page}
<Select.Item value={page.value}>
<div class="flex items-center gap-2">
<page.Icon class="w-4 h-4 text-muted-foreground" />
<span>{page.label}</span>
</div>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<p class="text-xs text-muted-foreground">Where the app opens (on the last-used environment).</p>
</div>
<!-- 3. Dashboard env-click target. Clicking an environment is an intentional "show me THIS
view of this env", so it's always a concrete page (default: containers). -->
<div class="space-y-1.5 min-w-0 flex-1">
<Label>Environment click</Label>
<Select.Root type="single" value={clickSel} onValueChange={(v) => { if (v) pickClick(v); }}>
<Select.Trigger class="w-full">
<div class="flex items-center gap-2">
{#if isUser && envClickPageV === null}
<span class="text-muted-foreground">Global default ({pageLabel(globalDefaults.envClickPage ?? 'containers')})</span>
{:else}
{@const m = pageOf(envClickPageV ?? 'containers')}
<m.Icon class="w-4 h-4 text-muted-foreground" />
<span>{m.label}</span>
{/if}
</div>
</Select.Trigger>
<Select.Content>
{#if isUser}
<Select.Item value={INHERIT}><span class="text-muted-foreground">Use global default</span></Select.Item>
{/if}
{#each CLICK_PAGES as page}
<Select.Item value={page.value}>
<div class="flex items-center gap-2">
<page.Icon class="w-4 h-4 text-muted-foreground" />
<span>{page.label}</span>
</div>
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<p class="text-xs text-muted-foreground">Where a tile click goes.</p>
</div>
</div>
+6 -4
View File
@@ -71,8 +71,10 @@
const sidebar = useSidebar();
function isActive(path: string): boolean {
if (path === '/') return currentPath === '/';
return currentPath === path || currentPath.startsWith(`${path}/`);
// The Dashboard link carries a ?home marker; compare on the path only.
const p = path.split('?')[0];
if (p === '/') return currentPath === '/';
return currentPath === p || currentPath.startsWith(`${p}/`);
}
async function handleLogout() {
@@ -121,7 +123,7 @@
}
const menuItems: readonly MenuItem[] = [
{ href: '/', Icon: LayoutDashboard, label: 'Dashboard', permission: 'always' },
{ href: '/?home', Icon: LayoutDashboard, label: 'Dashboard', permission: 'always' },
{ href: '/containers', Icon: Box, label: 'Containers', permission: 'containers' },
{ href: '/logs', Icon: ScrollText, label: 'Logs', permission: 'containers' },
{ href: '/terminal', Icon: Terminal, label: 'Shell', permission: 'containers' },
@@ -241,7 +243,7 @@
<Sidebar.Header class="overflow-visible flex items-center justify-center p-0">
<!-- Expanded state: logo + collapse button -->
<div class="relative flex items-center justify-center w-full group-data-[state=collapsed]:hidden">
<a href="/" class="flex justify-center relative">
<a href="/?home" class="flex justify-center relative">
<img src="/logo-light.webp" alt="Dockhand Logo" class="h-[52px] w-auto object-contain mt-2 mb-1 dark:hidden" style="filter: drop-shadow(1px 1px 2px rgba(0,0,0,0.3)) drop-shadow(-1px -1px 1px rgba(255,255,255,0.9));" />
<img src="/logo-dark.webp" alt="Dockhand Logo" class="h-[52px] w-auto object-contain mt-2 mb-1 hidden dark:block" style="filter: drop-shadow(2px 2px 3px rgba(0,0,0,0.6)) drop-shadow(-1px -1px 1px rgba(255,255,255,0.2));" />
{#if $licenseStore.isEnterprise}
+39
View File
@@ -0,0 +1,39 @@
/**
* nav-preferences-core.ts — the PURE parts of the navigation preferences: the page allowlist
* and the incoming-patch validation. No DB import, so it is unit-testable directly (importing
* nav-preferences.ts pulls in db/drizzle -> better-sqlite3, which bun's test runner can't load).
* nav-preferences.ts re-exports these so callers have one import site.
*/
export interface NavPreferences {
landingPage: string | null; // 'dashboard' | 'containers' | ... | null (=inherit/unset)
envClickPage: string | null; // where a Dashboard env-tile click goes
}
// The single list of pages the landing/env-click dropdowns can pick — the sidebar routes.
// ONE source of truth: the API validation allowlist and the NavigationSelector UI both use
// this, so a new page can't drift between "offered in the UI" and "accepted on save".
export const PAGE_SLUGS = [
'dashboard', 'containers', 'logs', 'terminal', 'stacks', 'images', 'volumes', 'networks',
'templates', 'registry', 'activity', 'backups', 'schedules', 'audit'
] as const;
/** Coerce + validate an incoming nav-prefs patch (pure). A field set to '' / null clears it
* (inherit/none). Throws on an invalid page/env so the API returns 400. */
export function parseNavPatch(data: any): Partial<NavPreferences> {
const out: Partial<NavPreferences> = {};
if (data.landingPage !== undefined) {
const p = data.landingPage;
if (p === '' || p === null) out.landingPage = null;
else if ((PAGE_SLUGS as readonly string[]).includes(p)) out.landingPage = p;
else throw new Error('Invalid landingPage');
}
if (data.envClickPage !== undefined) {
const p = data.envClickPage;
// env-click is always a concrete page, never dashboard (you already clicked an env).
if (p === '' || p === null) out.envClickPage = null;
else if ((PAGE_SLUGS as readonly string[]).includes(p) && p !== 'dashboard') out.envClickPage = p;
else throw new Error('Invalid envClickPage');
}
return out;
}
+62
View File
@@ -0,0 +1,62 @@
// Navigation preferences (landing page + env-click target), stored as plain settings keys.
// GLOBAL = bare key; PER-USER = user:<id>:<key>. A per-user value of '' / null means "use the
// global default". Kept out of the (already huge) db.ts - it only leans on the generic settings
// get/set helpers.
import { getSetting, setSetting, setUserSetting } from './db.js';
// The type + patch validation live in a db-free core module so they stay unit-testable
// (importing this file pulls in db/drizzle -> better-sqlite3). Re-exported so the API route
// gets the type, parseNavPatch, and the db-backed getters/setters from one import site.
import { type NavPreferences, parseNavPatch } from './nav-preferences-core.js';
export { type NavPreferences, parseNavPatch };
async function readNav(scopeKey: (k: string) => string): Promise<NavPreferences> {
const [page, click] = await Promise.all([
getSetting(scopeKey('landing_page')),
getSetting(scopeKey('env_click_page'))
]);
const s = (v: any) => (typeof v === 'string' && v.trim() !== '' ? v : null);
return {
landingPage: s(page),
envClickPage: s(click)
};
}
/** Global navigation defaults (whole instance). */
export async function getGlobalNavPreferences(): Promise<NavPreferences> {
return readNav((k) => k);
}
export async function setGlobalNavPreferences(prefs: Partial<NavPreferences>): Promise<void> {
const ups: Promise<void>[] = [];
if (prefs.landingPage !== undefined) ups.push(setSetting('landing_page', prefs.landingPage ?? ''));
if (prefs.envClickPage !== undefined) ups.push(setSetting('env_click_page', prefs.envClickPage ?? ''));
await Promise.all(ups);
}
/** Per-user overrides. A null field inherits the global default. */
export async function getUserNavPreferences(userId: number): Promise<NavPreferences> {
return readNav((k) => `user:${userId}:${k}`);
}
export async function setUserNavPreferences(userId: number, prefs: Partial<NavPreferences>): Promise<void> {
const ups: Promise<void>[] = [];
if (prefs.landingPage !== undefined) ups.push(setUserSetting(userId, 'landing_page', prefs.landingPage ?? ''));
if (prefs.envClickPage !== undefined) ups.push(setUserSetting(userId, 'env_click_page', prefs.envClickPage ?? ''));
await Promise.all(ups);
}
/** Effective navigation prefs for a request: per-user value wins, else global, else the default.
* An unset landing page resolves to 'dashboard'. `userId` null (auth off / no user) uses global
* only. NOTE: consumed by the client landing redirect + dashboard env-click. The NavigationSelector
* UI reads the raw `global`/`user` views (where null still means inherit/unset), never this. */
export async function getEffectiveNavPreferences(userId: number | null): Promise<NavPreferences> {
const global = await getGlobalNavPreferences();
const user = userId == null ? null : await getUserNavPreferences(userId);
const merged = user == null ? global : {
landingPage: user.landingPage ?? global.landingPage,
envClickPage: user.envClickPage ?? global.envClickPage
};
return {
landingPage: merged.landingPage ?? 'dashboard',
// Env-click is always a concrete view (default: containers).
envClickPage: merged.envClickPage ?? 'containers'
};
}
+21
View File
@@ -0,0 +1,21 @@
// Custom-homepage landing decision (pure). SSR is off, so `/`'s client-side load (+page.ts)
// decides whether opening the app should redirect to the user's landing page. Kept pure so the
// redirect rule lives in one testable place.
export interface NavEffective {
landingPage?: string | null;
envClickPage?: string | null;
}
/**
* Where to send the user when opening `/`, or null to stay on the dashboard. Redirects
* whenever the landing page is a real page other than the dashboard, UNLESS the URL carries the
* `?home` marker (the sidebar's Dashboard link sets it, so clicking Dashboard is not bounced
* away). The environment is not forced here - it stays whatever the store already holds.
*/
export function landingRedirectTarget(nav: NavEffective | null | undefined, hasHomeMarker: boolean): string | null {
if (hasHomeMarker) return null;
const page = nav?.landingPage;
if (!page || page === 'dashboard') return null;
return `/${page}`;
}
+15 -2
View File
@@ -61,6 +61,9 @@
let locked = $state(false);
let viewMode = $state<'grid' | 'list'>('grid');
// Where a tile click navigates (nav preference; default containers). Loaded in onMount.
let envClickPage = $state('containers');
// List view filter state
let listSearchQuery = $state('');
let listConnectionFilter = $state<string[]>([]);
@@ -658,12 +661,12 @@
return tiles.find(t => t.id === id);
}
// Handle tile click - select environment and navigate to containers
// Handle tile click - select environment and navigate to the env-click target page
function handleTileClick(envId: number) {
const tile = getTileById(envId);
if (tile?.stats) {
currentEnvironment.set({ id: envId, name: tile.stats.name });
goto('/containers');
goto(`/${envClickPage}`);
}
}
@@ -939,6 +942,16 @@
// Chrome 77+ Page Lifecycle API - fires when frozen tab is resumed
document.addEventListener('resume', handleVisibilityChange);
// Env-click target for the tiles. The landing redirect itself lives in +page.ts (runs before
// this component mounts), so reaching here means we're staying on the dashboard.
try {
const res = await fetch('/api/settings/navigation');
if (res.ok) {
const nav = (await res.json())?.effective;
if (nav?.envClickPage) envClickPage = nav.envClickPage;
}
} catch { /* default env-click */ }
// Load label filter and custom label colors
loadLabelFilter();
labelColorOverrides.load();
+20
View File
@@ -0,0 +1,20 @@
import { redirect } from '@sveltejs/kit';
import { landingRedirectTarget } from '$lib/utils/landing';
// SSR is off, so this load runs on the client before the dashboard mounts. Opening `/` redirects
// to the configured landing page here - no dashboard flash, no placeholder. The sidebar's Dashboard
// link carries `?home` to suppress the redirect (see landingRedirectTarget).
export const load = async ({ url, fetch }) => {
if (url.searchParams.has('home')) return {};
try {
const res = await fetch('/api/settings/navigation');
if (!res.ok) return {};
const nav = (await res.json())?.effective;
const target = landingRedirectTarget(nav, false);
if (target) throw redirect(307, target);
} catch (e) {
// Re-throw the redirect; swallow only a failed nav fetch (stay on the dashboard).
if (e && typeof e === 'object' && 'status' in e && 'location' in e) throw e;
}
return {};
};
@@ -0,0 +1,49 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from '@sveltejs/kit';
import {
getGlobalNavPreferences, setGlobalNavPreferences,
getUserNavPreferences, setUserNavPreferences,
getEffectiveNavPreferences,
parseNavPatch,
type NavPreferences
} from '$lib/server/nav-preferences';
import { validateSession, isAuthEnabled } from '$lib/server/auth';
import { authorize } from '$lib/server/authorize';
// GET /api/settings/navigation — returns effective prefs (for redirect/env-click), plus the raw
// global + per-user values so the UI can show what's inherited. Auth off => global only.
export const GET: RequestHandler = async ({ cookies }) => {
const authEnabled = await isAuthEnabled();
const user = authEnabled ? await validateSession(cookies) : null;
const global = await getGlobalNavPreferences();
const userPrefs = user ? await getUserNavPreferences(user.id) : null;
const effective = await getEffectiveNavPreferences(user?.id ?? null);
return json({ effective, global, user: userPrefs });
};
// PUT /api/settings/navigation?scope=global|user
export const PUT: RequestHandler = async ({ request, url, cookies }) => {
const scope = url.searchParams.get('scope') ?? 'global';
let patch: Partial<NavPreferences>;
try {
patch = parseNavPatch(await request.json());
} catch (e) {
return json({ error: e instanceof Error ? e.message : 'Invalid request' }, { status: 400 });
}
if (scope === 'user') {
if (!(await isAuthEnabled())) return json({ error: 'Authentication is not enabled' }, { status: 400 });
const user = await validateSession(cookies);
if (!user) return json({ error: 'Not authenticated' }, { status: 401 });
await setUserNavPreferences(user.id, patch);
return json({ success: true, user: await getUserNavPreferences(user.id) });
}
// global scope — needs settings-edit permission (mirrors /api/settings/general POST)
const auth = await authorize(cookies);
if (auth.authEnabled && !(await auth.can('settings', 'edit'))) {
return json({ error: 'Permission denied' }, { status: 403 });
}
await setGlobalNavPreferences(patch);
return json({ success: true, global: await getGlobalNavPreferences() });
};
+17 -1
View File
@@ -24,7 +24,8 @@
Trash2,
TriangleAlert,
Palette,
Plus
Plus,
Compass
} from 'lucide-svelte';
import { authStore } from '$lib/stores/auth';
import { formatDateTime } from '$lib/stores/settings';
@@ -38,6 +39,7 @@
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
import * as Table from '$lib/components/ui/table';
import ThemeSelector from '$lib/components/ThemeSelector.svelte';
import NavigationSelector from '$lib/components/NavigationSelector.svelte';
import AnimateIconsToggle from '$lib/components/AnimateIconsToggle.svelte';
import ColoredActionsToggle from '$lib/components/ColoredActionsToggle.svelte';
import { themeStore } from '$lib/stores/theme';
@@ -732,6 +734,20 @@
</Card.Content>
</Card.Root>
<!-- Navigation card (per-user overrides) -->
<Card.Root>
<Card.Header>
<Card.Title class="flex items-center gap-2">
<Compass class="w-5 h-5" />
Navigation
</Card.Title>
<Card.Description>Your landing page and environment-click target. Leave on "Use global default" to follow the workspace setting.</Card.Description>
</Card.Header>
<Card.Content>
<NavigationSelector scope="user" />
</Card.Content>
</Card.Root>
</div>
</div>
{/if}
+14 -1
View File
@@ -8,12 +8,13 @@
import { TogglePill, ToggleSwitch } from '$lib/components/ui/toggle-pill';
import CronEditor from '$lib/components/cron-editor.svelte';
import TimezoneSelector from '$lib/components/TimezoneSelector.svelte';
import { Eye, Bell, Database, Calendar, ShieldCheck, FileText, AlertTriangle, HelpCircle, Globe, Activity, Clock, Info, Save, RotateCcw, LayoutDashboard, Tags, Archive, ChevronRight, ChevronDown } from 'lucide-svelte';
import { Eye, Bell, Database, Calendar, ShieldCheck, FileText, AlertTriangle, HelpCircle, Globe, Activity, Clock, Info, Save, RotateCcw, LayoutDashboard, Tags, Archive, ChevronRight, ChevronDown, Compass } from 'lucide-svelte';
import CodeEditor from '$lib/components/CodeEditor.svelte';
import { appSettings, type DateFormat, type DownloadFormat, type EventCollectionMode, type LabelFilterMode } from '$lib/stores/settings';
import { canAccess, authStore } from '$lib/stores/auth';
import { toast } from 'svelte-sonner';
import ThemeSelector from '$lib/components/ThemeSelector.svelte';
import NavigationSelector from '$lib/components/NavigationSelector.svelte';
import AnimateIconsToggle from '$lib/components/AnimateIconsToggle.svelte';
import ColoredActionsToggle from '$lib/components/ColoredActionsToggle.svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
@@ -523,6 +524,18 @@ services:
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title class="text-sm font-medium flex items-center gap-2">
<Compass class="w-4 h-4" />
Navigation
</Card.Title>
</Card.Header>
<Card.Content>
<NavigationSelector />
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title class="text-sm font-medium flex items-center gap-2">