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

@@ -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>