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}