games: per-game pages, games.library table, games group in header/tiles/start
All checks were successful
Deploy production / deploy (push) Successful in 1m7s

This commit is contained in:
2026-10-25 15:20:45 +00:00
parent 037a525f91
commit d79b73ad83
19 changed files with 27712 additions and 38 deletions

View File

@@ -15,6 +15,8 @@
href: string;
external: boolean;
children?: readonly Item[];
icon?: string;
featured?: boolean;
};
let {
items,
@@ -29,6 +31,14 @@
} = $props();
const tracker = $derived(variant === 'tracker');
// Big groups (games) show their featured chips first and the rest behind a "more" chip.
let expanded = $state<Record<string, boolean>>({});
const visibleChildren = (group: Item) => {
const all = group.children ?? [];
return expanded[group.name] ? all : all.filter((c) => c.featured ?? true);
};
const hiddenCount = (group: Item) =>
(group.children?.length ?? 0) - visibleChildren(group).length;
</script>
{#snippet body(project: Item)}
@@ -79,16 +89,31 @@
{@render body(project)}
</a>
<ul class="relative z-10 mt-auto flex flex-wrap gap-1.5 pl-13">
{#each project.children as child (child.name)}
{#each visibleChildren(project) as child (child.name)}
<li>
<a
href={resolve(child.href as '/')}
class="chip flex items-center gap-1.5 py-1 pr-2.5 pl-1 text-xs font-medium"
title={child.subtitle}
><TrackerIcon name={child.name} class="chip-icon size-5" />{child.name}</a
><TrackerIcon
name={child.icon ?? child.name}
class="chip-icon size-5"
/>{child.name}</a
>
</li>
{/each}
{#if hiddenCount(project) > 0 || expanded[project.name]}
<li>
<button
type="button"
class="chip flex h-full cursor-pointer items-center px-2.5 py-1 text-xs font-medium text-[var(--site-muted)]"
aria-expanded={!!expanded[project.name]}
onclick={() =>
(expanded = { ...expanded, [project.name]: !expanded[project.name] })}
>{hiddenCount(project) > 0 ? `+${hiddenCount(project)} more` : 'less'}</button
>
</li>
{/if}
</ul>
</div>
{:else if project.external}

View File

@@ -184,16 +184,22 @@
// into the flyout (crossing a neighbouring tile) does not close it.
let flyout = $state<string | null>(null);
let flyoutSide = $state<'left' | 'right'>('right');
let flyoutUp = $state(false);
let flyoutTimer: ReturnType<typeof setTimeout> | undefined;
const FLYOUT_WIDTH = 240;
function openFlyout(name: string, el: HTMLElement) {
// Big groups (games) get a two-column flyout; it opens upwards when it would not fit below.
const WIDE_FLYOUT = 6;
function openFlyout(name: string, el: HTMLElement, count: number) {
clearTimeout(flyoutTimer);
if (flyout === name) return;
const wide = count > WIDE_FLYOUT;
const width = wide ? 432 : 240;
const height = (wide ? Math.ceil(count / 2) : count) * 46 + 20;
const rect = el.getBoundingClientRect();
const panel = el.closest('[data-menu-panel]')?.getBoundingClientRect() ?? rect;
const leftColumn = rect.left + rect.width / 2 < panel.left + panel.width / 2;
const roomLeft = rect.left - FLYOUT_WIDTH >= 8;
const roomRight = rect.right + FLYOUT_WIDTH <= window.innerWidth - 8;
const roomLeft = rect.left - width >= 8;
const roomRight = rect.right + width <= window.innerWidth - 8;
flyoutUp = rect.top + height > window.innerHeight - 8 && rect.bottom - height >= 8;
flyoutSide = leftColumn ? (roomLeft ? 'left' : 'right') : roomRight ? 'right' : 'left';
flyout = name;
}
@@ -308,12 +314,21 @@
aria-label={`${project.name} trackers`}
onpointerenter={(event) => {
if (event.pointerType !== 'touch')
openFlyout(project.name, event.currentTarget);
openFlyout(
project.name,
event.currentTarget,
project.children?.length ?? 0
);
}}
onpointerleave={(event) => {
if (event.pointerType !== 'touch') closeFlyoutSoon();
}}
onfocusin={(event) => openFlyout(project.name, event.currentTarget)}
onfocusin={(event) =>
openFlyout(
project.name,
event.currentTarget,
project.children?.length ?? 0
)}
onfocusout={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null))
flyout = null;
@@ -345,23 +360,30 @@
<!-- The padding facing the tile is a hover bridge into the panel. -->
<div
class={[
'absolute -top-2 w-60',
'absolute',
flyoutUp ? '-bottom-2' : '-top-2',
project.children.length > WIDE_FLYOUT ? 'w-[27rem]' : 'w-60',
flyoutSide === 'right' ? 'left-full pl-3' : 'right-full pr-3'
]}
>
<div
class="flyout grid gap-1 rounded-md border border-border bg-popover p-1.5 shadow-lg"
class={[
'flyout grid gap-1 rounded-md border border-border bg-popover p-1.5 shadow-lg',
project.children.length > WIDE_FLYOUT && 'grid-cols-2'
]}
class:from-left={flyoutSide === 'left'}
>
{#each project.children as child (child.name)}
<a
href={resolve(child.href as '/')}
class="tile flex items-center gap-2.5 p-1.5 text-sm"
class="tile flex min-w-0 items-center gap-2.5 p-1.5 text-sm"
title={child.subtitle}
aria-current={current(child.href) ? 'page' : undefined}
onkeydown={closeFlyoutOnEscape}
><TrackerIcon name={child.name} class="tile-icon size-8" /><span
class="font-medium">{child.name}</span
></a
><TrackerIcon
name={child.icon ?? child.name}
class="tile-icon size-8"
/><span class="truncate font-medium">{child.name}</span></a
>
{/each}
</div>
@@ -520,9 +542,10 @@
href={resolve(child.href as '/')}
class="tile flex min-w-0 items-center gap-2 p-1.5 text-sm"
onclick={() => (menuOpen = false)}
><TrackerIcon name={child.name} class="tile-icon size-6" /><span
class="truncate">{child.name}</span
></a
><TrackerIcon
name={child.icon ?? child.name}
class="tile-icon size-6"
/><span class="truncate">{child.name}</span></a
>
{/each}
</div>

View File

@@ -1,36 +1,49 @@
<script lang="ts">
// Small switcher shown above the cars/trains/planes/wildlife pages (via their +layout),
// to hop between the spotting pokedexes or back to /trackers/spotting.
// to hop between the spotting pokedexes or back to /trackers/spotting. Other tracker
// groups (games) pass their own items, home link and icon.
import type { Component } from 'svelte';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import Binoculars from 'lucide-svelte/icons/binoculars';
import TrackerIcon from '#lib/components/TrackerIcon.svelte';
import { spottingTrackers } from '#lib/projects.js';
type Item = { name: string; href: string; icon?: string; subtitle?: string };
let {
items = spottingTrackers,
home = '/trackers/spotting',
label = 'spotting',
homeIcon: HomeIcon = Binoculars,
class: className = 'relative mx-auto -mb-6 max-w-6xl px-4 pt-6 sm:-mb-8 sm:px-6 sm:pt-8'
}: {
items?: readonly Item[];
home?: string;
label?: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- lucide icon components
homeIcon?: Component<any>;
class?: string;
} = $props();
const active = $derived(
spottingTrackers.find(
(t) => page.url.pathname === t.href || page.url.pathname.startsWith(`${t.href}/`)
)?.name
items.find((t) => page.url.pathname === t.href || page.url.pathname.startsWith(`${t.href}/`))
?.name
);
</script>
<nav
aria-label="Spotting trackers"
class="relative mx-auto -mb-6 max-w-6xl px-4 pt-6 sm:-mb-8 sm:px-6 sm:pt-8"
>
<nav aria-label="{label} trackers" class={className}>
<div class="flex flex-wrap items-center gap-1.5">
<a
href={resolve('/trackers/spotting')}
class="pill flex items-center gap-1.5 px-2.5 py-1.5 text-sm"
><Binoculars size={14} aria-hidden="true" class="text-[var(--site-muted)]" />spotting</a
<a href={resolve(home as '/')} class="pill flex items-center gap-1.5 px-2.5 py-1.5 text-sm"
><HomeIcon size={14} aria-hidden="true" class="text-[var(--site-muted)]" />{label}</a
>
<span class="mx-1 h-4 w-px bg-border" aria-hidden="true"></span>
{#each spottingTrackers as t (t.name)}
{#each items as t (t.name)}
<a
href={resolve(t.href as '/')}
aria-current={active === t.name ? 'page' : undefined}
class="pill flex items-center gap-1.5 py-1 pr-2.5 pl-1 text-sm"
><TrackerIcon name={t.name} class="pill-icon size-6" />{t.name}</a
title={t.subtitle}
><TrackerIcon name={t.icon ?? t.name} class="pill-icon size-6" />{t.name}</a
>
{/each}
</div>

View File

@@ -15,9 +15,9 @@
>
<svg
viewBox="0 0 24 24"
class="size-[58%]"
fill="none"
stroke="currentColor"
class={icon.fill ? 'size-[54%]' : 'size-[58%]'}
fill={icon.fill ? 'currentColor' : 'none'}
stroke={icon.fill ? 'none' : 'currentColor'}
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"