From 056fbd75174a86ddd3226b2bf10fb637849969b0 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Mon, 5 Oct 2026 19:02:18 -0400 Subject: [PATCH] Preserve originating views through detail navigation --- src/lib/navigation-context.ts | 35 +++++++++++++++++++ src/lib/ui/components/ArtifactDetail.svelte | 8 +++-- src/lib/ui/components/BackLink.svelte | 13 +++++++ src/lib/ui/components/ConfigLibrary.svelte | 6 ++-- src/lib/ui/components/MatchHistory.svelte | 13 +++++-- src/lib/ui/components/ServerCard.svelte | 4 ++- .../components/primitives/Breadcrumbs.svelte | 12 ++++--- src/routes/+page.svelte | 7 ++-- src/routes/betas/+page.svelte | 25 ++++++++++--- src/routes/betas/[id]/+page.svelte | 15 ++++---- src/routes/campaigns/+page.svelte | 3 +- src/routes/campaigns/[id]/+page.svelte | 7 ++-- src/routes/capacity/+page.svelte | 3 +- src/routes/collections/+page.svelte | 3 +- .../collections/launches/[id]/+page.svelte | 9 +++-- src/routes/hosts/+page.svelte | 3 +- .../leaderboard/players/[guid]/+page.svelte | 8 +++-- src/routes/maintenance/+page.svelte | 3 +- src/routes/maintenance/[id]/+page.svelte | 9 +++-- src/routes/matches/[id]/+page.svelte | 9 +++-- src/routes/plugins/+page.svelte | 4 ++- src/routes/provisioning/[id]/+page.svelte | 5 +-- src/routes/scripts/+page.svelte | 8 +++-- src/routes/servers/[name]/+page.svelte | 2 ++ tests/unit/navigation-context.test.ts | 23 ++++++++++++ 25 files changed, 189 insertions(+), 48 deletions(-) create mode 100644 src/lib/navigation-context.ts create mode 100644 src/lib/ui/components/BackLink.svelte create mode 100644 tests/unit/navigation-context.test.ts diff --git a/src/lib/navigation-context.ts b/src/lib/navigation-context.ts new file mode 100644 index 0000000..17bd6ad --- /dev/null +++ b/src/lib/navigation-context.ts @@ -0,0 +1,35 @@ +const origin = 'https://electric-cherry.invalid'; +const pagePath = /^\/(?:$|operations|account|capacity|metrics|server-keys|settings(?:\/users)?|servers\/[^/]+|hosts(?:\/[^/]+)?|collections(?:\/launches\/[^/]+)?|(?:scripts|configs|plugins|betas|campaigns|maintenance|provisioning|matches)(?:\/[^/]+)?|game-files|leaderboard(?:\/players\/[^/]+(?:\/[^/]+)?)?)$/; + +/** Only return to application pages, never an external URL or a mutation/API endpoint. */ +export function safeReturnHref(value: string | null | undefined, fallback: string): string { + if (!value || value.length > 12000 || !value.startsWith('/') || value.startsWith('//') || /[\\\x00-\x1f\x7f]/.test(value)) return fallback; + try { + const url = new URL(value, origin); + if (url.origin !== origin || !pagePath.test(url.pathname)) return fallback; + return url.pathname + url.search + url.hash; + } catch { return fallback; } +} + +export function contextHref(destination: string, source: URL | string): string { + const current = source instanceof URL ? source.pathname + source.search + source.hash : source; + const url = new URL(destination, origin); + const returnTo = safeReturnHref(current, '/'); + url.searchParams.set('returnTo', returnTo); + return url.pathname + url.search + url.hash; +} + +export function returnLabel(href: string): string { + const url = new URL(href, origin); + const path = url.pathname; + if (path.startsWith('/servers/')) return 'Back to server'; + if (path.startsWith('/leaderboard/players/')) return 'Back to player'; + if (path.startsWith('/collections/launches/')) return 'Back to collection launch'; + if (path === '/collections' && url.searchParams.get('view') === 'active') return 'Back to active launches'; + if (path === '/collections' && url.searchParams.get('view') === 'history') return 'Back to launch history'; + if (path.startsWith('/matches/')) return 'Back to match'; + if (path === '/hosts/operations') return 'Back to host operation history'; + if (path.startsWith('/hosts/')) return 'Back to host'; + const names: Record = { '': 'Fleet', operations: 'Operations', collections: 'Collections', hosts: 'Hosts', capacity: 'Capacity', scripts: 'Scripts', configs: 'Configs', plugins: 'Plugins', 'game-files': 'Game Files', betas: 'Betas', campaigns: 'Campaigns', maintenance: 'Maintenance', provisioning: 'Provisioning', leaderboard: 'Leaderboard', matches: 'Match History', metrics: 'Metrics' }; + return `Back to ${names[path.split('/')[1]] ?? 'previous page'}`; +} diff --git a/src/lib/ui/components/ArtifactDetail.svelte b/src/lib/ui/components/ArtifactDetail.svelte index b5445f3..dd1732c 100644 --- a/src/lib/ui/components/ArtifactDetail.svelte +++ b/src/lib/ui/components/ArtifactDetail.svelte @@ -1,4 +1,7 @@ + +← {returnLabel(href)} + + diff --git a/src/lib/ui/components/ConfigLibrary.svelte b/src/lib/ui/components/ConfigLibrary.svelte index 7faee00..b5de921 100644 --- a/src/lib/ui/components/ConfigLibrary.svelte +++ b/src/lib/ui/components/ConfigLibrary.svelte @@ -1,4 +1,6 @@ + import { page } from '$app/stores'; + import { navigationLocation } from '$lib/navigation.js'; interface Crumb { label: string; href?: string | null; @@ -9,18 +11,20 @@ } let { items }: Props = $props(); + const group = $derived(navigationLocation($page.url.pathname, $page.data.user?.role ?? 'viewer')?.group); + const trail = $derived(group && group.label !== items[0]?.label ? [{ label: group.label }, ...items] : items);