From 86ef3f743bed4193ecd76ef125b58a1d0a04006c Mon Sep 17 00:00:00 2001 From: jarek Date: Sun, 9 Aug 2026 10:31:27 +0200 Subject: [PATCH] configurable default homepage --- src/lib/components/NavigationSelector.svelte | 197 ++++++++++++++++++ src/lib/components/app-sidebar.svelte | 10 +- src/lib/server/nav-preferences-core.ts | 39 ++++ src/lib/server/nav-preferences.ts | 62 ++++++ src/lib/utils/landing.ts | 21 ++ src/routes/+page.svelte | 17 +- src/routes/+page.ts | 20 ++ src/routes/api/settings/navigation/+server.ts | 49 +++++ src/routes/profile/+page.svelte | 18 +- src/routes/settings/general/GeneralTab.svelte | 15 +- 10 files changed, 440 insertions(+), 8 deletions(-) create mode 100644 src/lib/components/NavigationSelector.svelte create mode 100644 src/lib/server/nav-preferences-core.ts create mode 100644 src/lib/server/nav-preferences.ts create mode 100644 src/lib/utils/landing.ts create mode 100644 src/routes/+page.ts create mode 100644 src/routes/api/settings/navigation/+server.ts diff --git a/src/lib/components/NavigationSelector.svelte b/src/lib/components/NavigationSelector.svelte new file mode 100644 index 0000000..f73cb11 --- /dev/null +++ b/src/lib/components/NavigationSelector.svelte @@ -0,0 +1,197 @@ + + + +
+ + +
+ +
+ { if (v) pickHome(v); }}> + +
+ {#if isUser && landingPage === null} + Global default ({pageLabel(globalDefaults.landingPage ?? 'dashboard')}) + {:else} + {@const m = pageOf(landingPage)} + + {m.label} + {/if} +
+
+ + {#if isUser} + Use global default + {/if} + {#each PAGES as page} + +
+ + {page.label} +
+
+ {/each} +
+
+
+

Where the app opens (on the last-used environment).

+
+ + +
+ + { if (v) pickClick(v); }}> + +
+ {#if isUser && envClickPageV === null} + Global default ({pageLabel(globalDefaults.envClickPage ?? 'containers')}) + {:else} + {@const m = pageOf(envClickPageV ?? 'containers')} + + {m.label} + {/if} +
+
+ + {#if isUser} + Use global default + {/if} + {#each CLICK_PAGES as page} + +
+ + {page.label} +
+
+ {/each} +
+
+

Where a tile click goes.

+
+
diff --git a/src/lib/components/app-sidebar.svelte b/src/lib/components/app-sidebar.svelte index 29f2e10..4915c8b 100644 --- a/src/lib/components/app-sidebar.svelte +++ b/src/lib/components/app-sidebar.svelte @@ -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 @@
- + Dockhand Logo {#if $licenseStore.isEnterprise} diff --git a/src/lib/server/nav-preferences-core.ts b/src/lib/server/nav-preferences-core.ts new file mode 100644 index 0000000..46d47db --- /dev/null +++ b/src/lib/server/nav-preferences-core.ts @@ -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 { + const out: Partial = {}; + 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; +} diff --git a/src/lib/server/nav-preferences.ts b/src/lib/server/nav-preferences.ts new file mode 100644 index 0000000..af478d3 --- /dev/null +++ b/src/lib/server/nav-preferences.ts @@ -0,0 +1,62 @@ +// Navigation preferences (landing page + env-click target), stored as plain settings keys. +// GLOBAL = bare key; PER-USER = user::. 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 { + 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 { + return readNav((k) => k); +} +export async function setGlobalNavPreferences(prefs: Partial): Promise { + const ups: Promise[] = []; + 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 { + return readNav((k) => `user:${userId}:${k}`); +} +export async function setUserNavPreferences(userId: number, prefs: Partial): Promise { + const ups: Promise[] = []; + 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 { + 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' + }; +} diff --git a/src/lib/utils/landing.ts b/src/lib/utils/landing.ts new file mode 100644 index 0000000..7b61742 --- /dev/null +++ b/src/lib/utils/landing.ts @@ -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}`; +} diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 1881f4a..63e04ae 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -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([]); @@ -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(); diff --git a/src/routes/+page.ts b/src/routes/+page.ts new file mode 100644 index 0000000..ef59b52 --- /dev/null +++ b/src/routes/+page.ts @@ -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 {}; +}; diff --git a/src/routes/api/settings/navigation/+server.ts b/src/routes/api/settings/navigation/+server.ts new file mode 100644 index 0000000..73c829e --- /dev/null +++ b/src/routes/api/settings/navigation/+server.ts @@ -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; + 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() }); +}; diff --git a/src/routes/profile/+page.svelte b/src/routes/profile/+page.svelte index 4d59c87..9ae7d90 100644 --- a/src/routes/profile/+page.svelte +++ b/src/routes/profile/+page.svelte @@ -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 @@ + + + + + + Navigation + + Your landing page and environment-click target. Leave on "Use global default" to follow the workspace setting. + + + + + +
{/if} diff --git a/src/routes/settings/general/GeneralTab.svelte b/src/routes/settings/general/GeneralTab.svelte index 0127972..b4f7933 100644 --- a/src/routes/settings/general/GeneralTab.svelte +++ b/src/routes/settings/general/GeneralTab.svelte @@ -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: + + + + + Navigation + + + + + + +