Preserve originating views through detail navigation

This commit is contained in:
2026-10-05 19:02:18 -04:00
parent b926d46616
commit 056fbd7517
25 changed files with 189 additions and 48 deletions
+35
View File
@@ -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<string, string> = { '': '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'}`;
}
+6 -2
View File
@@ -1,4 +1,7 @@
<script lang="ts">
import { page } from '$app/stores';
import { contextHref } from '$lib/navigation-context.js';
import BackLink from '$ui/components/BackLink.svelte';
import { timeFormat } from '$ui/stores/time-zone.js';
import { onMount } from 'svelte';
import { pollVisible } from '$ui/poll.js';
@@ -285,13 +288,14 @@
<Breadcrumbs items={[{ label: crumbLabel, href: basePath }, { label: a.name }]} />
{/snippet}
</SectionHeader>
<BackLink fallback={basePath} />
{#if scopeComplete}<div class="scope-complete" role="status"><strong>Scope saved.</strong> {scopeComplete.composed ? `Intended configuration may change on ${scopeComplete.count} eligible servers.` : scopeComplete.detached ? `Inventory tracking detached on ${scopeComplete.detached} servers; live files remain.` : `${scopeComplete.count} servers are in the old or new scope.`} Maintenance detects resulting drift and rebuilds eligible servers when automation is enabled. <a href={scopeComplete.href}>View drift recovery →</a></div>{/if}
{#if latestVersion}<Card padding="lg"><div class="release-summary"><div><h2>Latest {artifactVersionLabel(latestVersion)}</h2><StatusBadge state={latestVersion.id === a.current_version_id ? 'promoted' : latestVersion.state} /><p>{latestVersion.notes || 'No version notes provided.'}</p></div>{#if runningBetaId !== null}<Button variant="primary" href={`/betas/${runningBetaId}`}>View active beta</Button>{:else if latestVersion.id !== a.current_version_id}<Button variant="primary" href={betaSelectionHref([{ scriptId: id, versionId: latestVersion.id }])}>Test latest version</Button>{:else}<span class="dim">Latest version is promoted</span>{/if}</div></Card>{/if}
{#if latestVersion}<Card padding="lg"><div class="release-summary"><div><h2>Latest {artifactVersionLabel(latestVersion)}</h2><StatusBadge state={latestVersion.id === a.current_version_id ? 'promoted' : latestVersion.state} /><p>{latestVersion.notes || 'No version notes provided.'}</p></div>{#if runningBetaId !== null}<Button variant="primary" href={contextHref(`/betas/${runningBetaId}`, $page.url)}>View active beta</Button>{:else if latestVersion.id !== a.current_version_id}<Button variant="primary" href={betaSelectionHref([{ scriptId: id, versionId: latestVersion.id }])}>Test latest version</Button>{:else}<span class="dim">Latest version is promoted</span>{/if}</div></Card>{/if}
{#if runningBetaId}
<div class="warn-banner">
Beta <a href="/betas/{runningBetaId}"><strong>#{runningBetaId}</strong></a> is running for this {family}.
Beta <a href={contextHref(`/betas/${runningBetaId}`, $page.url)}><strong>#{runningBetaId}</strong></a> is running for this {family}.
Rollback or promote it before making changes.
</div>
{/if}
+13
View File
@@ -0,0 +1,13 @@
<script lang="ts">
import { page } from '$app/stores';
import { returnLabel, safeReturnHref } from '$lib/navigation-context.js';
let { fallback }: { fallback: string } = $props();
const href = $derived(safeReturnHref($page.url.searchParams.get('returnTo'), fallback));
</script>
<a class="back-link" {href}>← {returnLabel(href)}</a>
<style>
.back-link { display: inline-block; align-self: start; width: fit-content; font-size: var(--text-sm); color: var(--accent-primary-hover); text-decoration: none; }
.back-link:hover { text-decoration: underline; }
</style>
+4 -2
View File
@@ -1,4 +1,6 @@
<script lang="ts">
import { page } from '$app/stores';
import { contextHref } from '$lib/navigation-context.js';
import { onMount } from 'svelte';
import Tabs from './primitives/Tabs.svelte';
import ListFooter from './primitives/ListFooter.svelte';
@@ -129,14 +131,14 @@
{#if filteredBaselines.length}
<section class="section"><div class="section-heading"><h2>Baselines <span>{filteredBaselines.length}</span></h2><p>One foundation per game</p></div>
<div class="baseline-grid">{#each filteredBaselines as artifact (artifact.id)}
<a class="baseline-card" href="/configs/{artifact.id}"><div class="card-top"><span class="game-label">{artifact.scope_value?.toUpperCase() ?? 'UNSCOPED'}</span><span class="open-arrow" aria-hidden="true">↗</span></div><h3>{artifact.name}</h3><div class="versions">{@render versionBadges(artifact)}</div><div class="card-footer"><span>{artifact.servers.length} matching server{artifact.servers.length === 1 ? '' : 's'}</span><span>{artifact.versionCount} version{artifact.versionCount === 1 ? '' : 's'}</span></div></a>
<a class="baseline-card" href={contextHref(`/configs/${artifact.id}`, $page.url)}><div class="card-top"><span class="game-label">{artifact.scope_value?.toUpperCase() ?? 'UNSCOPED'}</span><span class="open-arrow" aria-hidden="true">↗</span></div><h3>{artifact.name}</h3><div class="versions">{@render versionBadges(artifact)}</div><div class="card-footer"><span>{artifact.servers.length} matching server{artifact.servers.length === 1 ? '' : 's'}</span><span>{artifact.versionCount} version{artifact.versionCount === 1 ? '' : 's'}</span></div></a>
{/each}</div>
</section>
{/if}
{#if filteredOverlays.length}
<section class="section"><div class="section-heading"><h2>Overlays <span>{filteredOverlays.length}</span></h2><p>Small changes with a clear scope</p></div>
<div class="overlay-list">{#each filteredOverlays.slice(0,limit) as artifact (artifact.id)}
<a class="overlay-row" href="/configs/{artifact.id}"><div class="overlay-main"><span class="overlay-icon" aria-hidden="true">≋</span><div><h3>{artifact.name}</h3><span class="scope-label">{artifact.scope_type ?? 'Unscoped'}{artifact.scope_value ? ` · ${artifact.scope_type === 'map' ? mapLabel(artifact.scope_value) : artifact.scope_value.toUpperCase()}` : ''}</span></div></div><div class="versions">{@render versionBadges(artifact)}</div><span class="servers">{artifact.servers.length} server{artifact.servers.length === 1 ? '' : 's'}</span><span class="open-arrow" aria-hidden="true">↗</span></a>
<a class="overlay-row" href={contextHref(`/configs/${artifact.id}`, $page.url)}><div class="overlay-main"><span class="overlay-icon" aria-hidden="true">≋</span><div><h3>{artifact.name}</h3><span class="scope-label">{artifact.scope_type ?? 'Unscoped'}{artifact.scope_value ? ` · ${artifact.scope_type === 'map' ? mapLabel(artifact.scope_value) : artifact.scope_value.toUpperCase()}` : ''}</span></div></div><div class="versions">{@render versionBadges(artifact)}</div><span class="servers">{artifact.servers.length} server{artifact.servers.length === 1 ? '' : 's'}</span><span class="open-arrow" aria-hidden="true">↗</span></a>
{/each}</div>
</section>
{/if}
+11 -2
View File
@@ -1,4 +1,7 @@
<script lang="ts">
import { page } from '$app/stores';
import { contextHref } from '$lib/navigation-context.js';
import { goto } from '$app/navigation';
import { timeFormat } from '$ui/stores/time-zone.js';
import type { ServerMatch } from '$lib/match-history.js';
import { pollVisible } from '$ui/poll.js';
@@ -13,6 +16,12 @@
let matches = $state<ServerMatch[]>([]);
let total = $state(0);
let limit = $state(25);
$effect(() => { limit = Math.min(10000, Math.max(25, Number($page.url.searchParams.get('matches')) || 25)); });
function saveContext() {
const url = new URL($page.url);
if (limit > 25) url.searchParams.set('matches', String(limit)); else url.searchParams.delete('matches');
void goto(url, { replaceState: true, noScroll: true, keepFocus: true });
}
let loading = $state(true);
let error = $state('');
@@ -63,7 +72,7 @@
{#if server === undefined}<td><HistoricalServerIdentity name={match.server} deletedAt={match.server_deleted_at} href={`/servers/${encodeURIComponent(match.server)}?tab=matches`} /></td>{/if}
<td>
{#if match.status === 'completed'}
<a class="match-link" href={`/matches/${match.id}`} aria-label={`View ${match.map} match from ${$timeFormat.dateTime(match.startedAt)}`}>
<a class="match-link" href={contextHref(`/matches/${match.id}`, $page.url)} aria-label={`View ${match.map} match from ${$timeFormat.dateTime(match.startedAt)}`}>
{match.map}<small>View match →</small>
</a>
{:else}{match.map}{/if}
@@ -76,7 +85,7 @@
</tbody>
</table>
</div>
<ListFooter total={Math.min(total, 10000)} bind:limit label="matches" />
<ListFooter total={Math.min(total, 10000)} bind:limit onchange={saveContext} label="matches" />
{#if total > 10000}<p>Showing the latest 10,000 matches.</p>{/if}
{/if}
</Card>
+3 -1
View File
@@ -1,4 +1,6 @@
<script lang="ts">
import { page } from '$app/stores';
import { contextHref } from '$lib/navigation-context.js';
import RoundTelemetry from './RoundTelemetry.svelte';
import ServerIdentity from './ServerIdentity.svelte';
import type { RosterPlayer, Health } from '$ui/stores/servers.js';
@@ -93,7 +95,7 @@
</script>
<Card
href="/servers/{name}"
href={contextHref(`/servers/${encodeURIComponent(name)}`, $page.url)}
elevation="interactive"
padding="md"
class={restarting ? 'restarting' : health === 'offline' ? 'offline' : ''}
@@ -1,4 +1,6 @@
<script lang="ts">
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);
</script>
<nav class="crumbs" aria-label="Breadcrumb">
<ol>
{#each items as c, i (i + c.label)}
{#each trail as c, i (i + c.label)}
<li>
{#if c.href && i < items.length - 1}
{#if c.href && i < trail.length - 1}
<a href={c.href}>{c.label}</a>
{:else}
<span aria-current={i === items.length - 1 ? 'page' : undefined}>{c.label}</span>
<span aria-current={i === trail.length - 1 ? 'page' : undefined}>{c.label}</span>
{/if}
{#if i < items.length - 1}<span class="sep" aria-hidden="true">›</span>{/if}
{#if i < trail.length - 1}<span class="sep" aria-hidden="true">›</span>{/if}
</li>
{/each}
</ol>