Preserve originating views through detail navigation
This commit is contained in:
@@ -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'}`;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user