games: per-game pages, games.library table, games group in header/tiles/start
All checks were successful
Deploy production / deploy (push) Successful in 1m7s
All checks were successful
Deploy production / deploy (push) Successful in 1m7s
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user