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

@@ -0,0 +1,54 @@
CREATE TABLE "games"."library" (
"id" serial PRIMARY KEY,
"slug" text NOT NULL UNIQUE,
"name" text NOT NULL,
"short_name" text NOT NULL,
"kind" text DEFAULT 'video' NOT NULL,
"status" text DEFAULT 'unknown' NOT NULL,
"platform" text,
"tracker_href" text,
"current_rank" integer,
"peak_rank" integer,
"rank_label" text,
"aliases" text[] DEFAULT '{}'::text[] NOT NULL,
"notes" text,
"sort_order" integer DEFAULT 100 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "library_status_check" CHECK ("status" IN ('main','active','occasional','unknown')),
CONSTRAINT "library_kind_check" CHECK ("kind" IN ('video','puzzle')),
CONSTRAINT "library_rank_check" CHECK (("current_rank" IS NULL OR "current_rank" > 0) AND ("peak_rank" IS NULL OR "peak_rank" > 0))
);
--> statement-breakpoint
-- Hand-written: grants (same pattern as the other app tables) and the initial games.
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
GRANT SELECT, INSERT, UPDATE, DELETE ON games.library TO personal_app;
GRANT USAGE, SELECT ON SEQUENCE games.library_id_seq TO personal_app;
END IF;
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ro') THEN
GRANT SELECT ON games.library TO personal_ro;
END IF;
END $$;
--> statement-breakpoint
-- Ranks only where Stan gave them (osu!: ~#40k now, peak ~#11k); platform null = not known.
INSERT INTO games.library (slug, name, short_name, kind, status, platform, tracker_href, current_rank, peak_rank, rank_label, aliases, sort_order) VALUES
('osu', 'osu!', 'osu!', 'video', 'main', 'PC', '/projects/game-tracker/osu', 40000, 11000, 'osu! global rank, approximate', '{}', 10),
('cs2', 'Counter-Strike 2', 'CS2', 'video', 'active', 'PC', NULL, NULL, NULL, NULL, '{CS:GO,"Counter-Strike: Global Offensive"}', 20),
('pubg', 'PUBG: Battlegrounds', 'PUBG', 'video', 'active', NULL, NULL, NULL, NULL, NULL, '{PUBG,"PlayerUnknown''s Battlegrounds"}', 30),
('r6', 'Tom Clancy''s Rainbow Six Siege', 'R6 Siege', 'video', 'active', NULL, NULL, NULL, NULL, NULL, '{"Rainbow Six Siege",R6}', 40),
('tarkov', 'Escape from Tarkov', 'Tarkov', 'video', 'active', 'PC', NULL, NULL, NULL, NULL, '{EFT}', 50),
('bo2', 'Call of Duty: Black Ops II', 'Black Ops II', 'video', 'active', NULL, '/projects/game-tracker/call-of-duty/black-ops-2', NULL, NULL, NULL, '{"Call of Duty",BO2,"Black Ops 2"}', 60),
('valorant', 'Valorant', 'Valorant', 'video', 'occasional', NULL, NULL, NULL, NULL, NULL, '{}', 70),
('lol', 'League of Legends', 'League', 'video', 'occasional', 'PC', NULL, NULL, NULL, NULL, '{LoL}', 80),
('overwatch', 'Overwatch 2', 'Overwatch 2', 'video', 'occasional', NULL, NULL, NULL, NULL, NULL, '{Overwatch}', 90),
('minecraft', 'Minecraft', 'Minecraft', 'video', 'occasional', NULL, NULL, NULL, NULL, NULL, '{}', 100),
('factorio', 'Factorio', 'Factorio', 'video', 'occasional', NULL, NULL, NULL, NULL, NULL, '{}', 110),
('ksp', 'Kerbal Space Program', 'KSP', 'video', 'occasional', NULL, NULL, NULL, NULL, NULL, '{}', 120),
('smash', 'Super Smash Bros.', 'Smash Bros', 'video', 'occasional', 'Nintendo', NULL, NULL, NULL, NULL, '{"Super Smash Bros"}', 130),
('cubing', 'Speedcubing', 'speedcubing', 'puzzle', 'occasional', 'physical', NULL, NULL, NULL, NULL, '{"Rubik''s Cube"}', 140),
('pokemon', 'Pokémon', 'Pokémon', 'video', 'unknown', NULL, '/projects/game-tracker/pokemon', NULL, NULL, NULL, '{}', 150),
('sot', 'Sea of Thieves', 'Sea of Thieves', 'video', 'unknown', NULL, NULL, NULL, NULL, NULL, '{}', 160)
ON CONFLICT (slug) DO NOTHING;

File diff suppressed because it is too large Load Diff

View File

@@ -15,6 +15,8 @@
href: string; href: string;
external: boolean; external: boolean;
children?: readonly Item[]; children?: readonly Item[];
icon?: string;
featured?: boolean;
}; };
let { let {
items, items,
@@ -29,6 +31,14 @@
} = $props(); } = $props();
const tracker = $derived(variant === 'tracker'); 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> </script>
{#snippet body(project: Item)} {#snippet body(project: Item)}
@@ -79,16 +89,31 @@
{@render body(project)} {@render body(project)}
</a> </a>
<ul class="relative z-10 mt-auto flex flex-wrap gap-1.5 pl-13"> <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> <li>
<a <a
href={resolve(child.href as '/')} href={resolve(child.href as '/')}
class="chip flex items-center gap-1.5 py-1 pr-2.5 pl-1 text-xs font-medium" class="chip flex items-center gap-1.5 py-1 pr-2.5 pl-1 text-xs font-medium"
title={child.subtitle} 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> </li>
{/each} {/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> </ul>
</div> </div>
{:else if project.external} {:else if project.external}

View File

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

View File

@@ -1,36 +1,49 @@
<script lang="ts"> <script lang="ts">
// Small switcher shown above the cars/trains/planes/wildlife pages (via their +layout), // 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 { resolve } from '$app/paths';
import { page } from '$app/state'; import { page } from '$app/state';
import Binoculars from 'lucide-svelte/icons/binoculars'; import Binoculars from 'lucide-svelte/icons/binoculars';
import TrackerIcon from '#lib/components/TrackerIcon.svelte'; import TrackerIcon from '#lib/components/TrackerIcon.svelte';
import { spottingTrackers } from '#lib/projects.js'; 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( const active = $derived(
spottingTrackers.find( items.find((t) => page.url.pathname === t.href || page.url.pathname.startsWith(`${t.href}/`))
(t) => page.url.pathname === t.href || page.url.pathname.startsWith(`${t.href}/`) ?.name
)?.name
); );
</script> </script>
<nav <nav aria-label="{label} trackers" class={className}>
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"
>
<div class="flex flex-wrap items-center gap-1.5"> <div class="flex flex-wrap items-center gap-1.5">
<a <a href={resolve(home as '/')} class="pill flex items-center gap-1.5 px-2.5 py-1.5 text-sm"
href={resolve('/trackers/spotting')} ><HomeIcon size={14} aria-hidden="true" class="text-[var(--site-muted)]" />{label}</a
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
> >
<span class="mx-1 h-4 w-px bg-border" aria-hidden="true"></span> <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 <a
href={resolve(t.href as '/')} href={resolve(t.href as '/')}
aria-current={active === t.name ? 'page' : undefined} aria-current={active === t.name ? 'page' : undefined}
class="pill flex items-center gap-1.5 py-1 pr-2.5 pl-1 text-sm" 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} {/each}
</div> </div>

View File

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

View File

@@ -39,6 +39,10 @@ type Tracker = {
external: boolean; external: boolean;
/** Sub-trackers linked directly from this item (header flyout, tile chips). */ /** Sub-trackers linked directly from this item (header flyout, tile chips). */
children?: readonly Tracker[]; children?: readonly Tracker[];
/** Icon key in #lib/tracker-icons when it differs from `name` (games). */
icon?: string;
/** Shown on group tiles before the "more" chip (main and actively played games). */
featured?: boolean;
}; };
// The spotting trackers: pokedexes of things I can spot in the wild. Grouped under one // The spotting trackers: pokedexes of things I can spot in the wild. Grouped under one
@@ -72,6 +76,37 @@ export const spottingTrackers: readonly Tracker[] = [
} }
]; ];
// Games I play (plus speedcubing), grouped under "games" like spotting: every game has its
// own page at /trackers/games/<slug>, one click away from the header flyout and the chips.
// `featured` games (my main game and the ones I actively play) show first on tiles; the
// rest sit behind a "more" chip. Data per game lives in games.library (same slugs).
const game = (slug: string, name: string, subtitle: string, featured = false): Tracker => ({
name,
subtitle,
href: `/trackers/games/${slug}`,
external: false,
icon: `game-${slug}`,
...(featured ? { featured } : {})
});
export const gameTrackers: readonly Tracker[] = [
game('osu', 'osu!', 'rhythm game, my main focus right now', true),
game('cs2', 'cs2', 'Counter-Strike 2 (CS:GO)', true),
game('pubg', 'pubg', 'PUBG: Battlegrounds', true),
game('r6', 'r6 siege', 'Tom Clancy’s Rainbow Six Siege', true),
game('tarkov', 'tarkov', 'Escape from Tarkov', true),
game('bo2', 'bo2', 'Call of Duty: Black Ops II', true),
game('valorant', 'valorant', 'Riot’s tactical shooter', false),
game('lol', 'league', 'League of Legends', false),
game('overwatch', 'overwatch 2', 'Blizzard’s hero shooter', false),
game('minecraft', 'minecraft', 'blocks, mining and building', false),
game('factorio', 'factorio', 'factory building and automation', false),
game('ksp', 'ksp', 'Kerbal Space Program', false),
game('smash', 'smash bros', 'Super Smash Bros.', false),
game('cubing', 'speedcubing', 'Rubik’s cube solving, not a video game', false),
game('pokemon', 'pokémon', 'catching them all across the games', false),
game('sot', 'sea of thieves', 'pirate sandbox', false)
];
// Trackers, in display order. Names are short single nouns. Most keep their existing // Trackers, in display order. Names are short single nouns. Most keep their existing
// /projects/... URLs (no moves, so no redirects needed). `private` trackers are shown in the // /projects/... URLs (no moves, so no redirects needed). `private` trackers are shown in the
// header, home page and /trackers only to the logged-in owner (layout data `isOwner`), never // header, home page and /trackers only to the logged-in owner (layout data `isOwner`), never
@@ -153,6 +188,7 @@ const allTrackers: readonly (Tracker & { private: boolean })[] = [
subtitle: 'every pokemon in every game, and call of duty camo mastery', subtitle: 'every pokemon in every game, and call of duty camo mastery',
href: '/projects/game-tracker', href: '/projects/game-tracker',
external: false, external: false,
children: gameTrackers,
private: false private: false
}, },
{ {

View File

@@ -0,0 +1,51 @@
import { sql } from 'drizzle-orm';
import { check, integer, text } from 'drizzle-orm/pg-core';
import { gamesSchema } from './schemas';
import { id, timestamps } from './helpers';
// Every game I play (plus speedcubing): one row per game, shown at /trackers/games/<slug>.
// The header, home, /trackers and /start chips come from `gameTrackers` in #lib/projects
// (static, so navigation needs no query); keep the slugs in sync with these rows.
// Deep trackers (osu! journal, Call of Duty mastery, Pokémon catches) stay where they are
// and are linked through `tracker_href`.
export const GAME_STATUSES = ['main', 'active', 'occasional', 'unknown'] as const;
export const GAME_KINDS = ['video', 'puzzle'] as const;
export const gameLibrary = gamesSchema.table(
'library',
{
id: id(),
slug: text('slug').notNull().unique(),
name: text('name').notNull(),
shortName: text('short_name').notNull(),
kind: text('kind', { enum: GAME_KINDS }).notNull().default('video'),
status: text('status', { enum: GAME_STATUSES }).notNull().default('unknown'),
/** Where I play it (PC, Nintendo, physical…); null when not known yet. */
platform: text('platform'),
/** Internal path of a deeper tracker for this game, if there is one. */
trackerHref: text('tracker_href'),
/** Rank as a position (#40 000 is 40000); only values I gave. */
currentRank: integer('current_rank'),
peakRank: integer('peak_rank'),
/** What the rank numbers mean, e.g. "osu! standard global rank". */
rankLabel: text('rank_label'),
/** Names this game goes by elsewhere (hardware.sensitivities.game, old titles). */
aliases: text('aliases')
.array()
.notNull()
.default(sql`'{}'::text[]`),
notes: text('notes'),
sortOrder: integer('sort_order').notNull().default(100),
...timestamps
},
(t) => [
check('library_status_check', sql`${t.status} IN ('main','active','occasional','unknown')`),
check('library_kind_check', sql`${t.kind} IN ('video','puzzle')`),
check(
'library_rank_check',
sql`(${t.currentRank} IS NULL OR ${t.currentRank} > 0) AND (${t.peakRank} IS NULL OR ${t.peakRank} > 0)`
)
]
);
export type GameLibraryRow = typeof gameLibrary.$inferSelect;

View File

@@ -5,6 +5,7 @@ export * from './blog';
export * from './progress'; export * from './progress';
export * from './catch-em-all'; export * from './catch-em-all';
export * from './game-tracker'; export * from './game-tracker';
export * from './game-library';
export * from './tracker-journal'; export * from './tracker-journal';
export * from './animaldex'; export * from './animaldex';
export * from './lists'; // meta registry; the lists themselves are runtime tables export * from './lists'; // meta registry; the lists themselves are runtime tables

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { trackerIcons } from './tracker-icons'; import { trackerIcons } from './tracker-icons';
import { spottingTrackers, visibleTrackers } from './projects'; import { gameTrackers, spottingTrackers, visibleTrackers } from './projects';
describe('tracker icons', () => { describe('tracker icons', () => {
it('gives every tracker its own colour', () => { it('gives every tracker its own colour', () => {
@@ -10,6 +10,19 @@ describe('tracker icons', () => {
expect(new Set(colours).size).toBe(colours.length); expect(new Set(colours).size).toBe(colours.length);
}); });
it('gives every game its own icon and colour', () => {
const keys = gameTrackers.map((g) => g.icon ?? g.name);
for (const key of keys) expect(trackerIcons[key], key).toBeDefined();
expect(new Set(keys.map((k) => trackerIcons[k].to)).size).toBe(keys.length);
});
it('links every game to its own page under /trackers/games', () => {
const slugs = gameTrackers.map((g) => g.href.replace('/trackers/games/', ''));
for (const slug of slugs) expect(slug).toMatch(/^[a-z0-9-]+$/);
expect(new Set(slugs).size).toBe(slugs.length);
expect(gameTrackers[0].name).toBe('osu!');
});
it('keeps tasks, money and reading first', () => { it('keeps tasks, money and reading first', () => {
expect( expect(
visibleTrackers(true) visibleTrackers(true)

View File

@@ -12,6 +12,8 @@ export type TrackerIcon = {
to: string; to: string;
/** Icon colour; white unless the tile is light (yellow, lime). */ /** Icon colour; white unless the tile is light (yellow, lime). */
ink: string; ink: string;
/** Filled glyph (brand marks from simple-icons) instead of stroked outline paths. */
fill?: boolean;
paths: string[]; paths: string[];
}; };
@@ -163,6 +165,157 @@ export const trackerIcons: Record<string, TrackerIcon> = {
to: '#475569', to: '#475569',
ink: WHITE, ink: WHITE,
paths: ['M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01'] paths: ['M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01']
},
// Games (keys are each game's `icon` in gameTrackers): brand marks where simple-icons has
// a clean glyph, otherwise a lucide-style outline that hints at the game.
'game-osu': {
from: '#ff79b0',
to: '#e6338a',
ink: WHITE,
fill: true,
paths: [
'M7.698 10.362a1.94 1.94 0 0 0-.7-.516q-.421-.189-.988-.189c-.567 0-.704.063-.982.189s-.51.298-.692.516q-.273.328-.413.777q-.139.448-.139.96q0 .511.14.952q.139.44.412.767q.274.329.692.512t.982.184c.565 0 .707-.062.988-.184q.422-.184.7-.512q.279-.327.413-.767q.135-.44.135-.952a3.3 3.3 0 0 0-.135-.96a2.1 2.1 0 0 0-.413-.777m-.965 2.81q-.22.372-.723.372q-.494 0-.713-.372q-.22-.373-.22-1.073c0-.7.073-.824.22-1.073q.22-.372.713-.372q.503 0 .723.372q.22.373.22 1.073t-.22 1.073m11.89-.83l-.09-4.39a4.5 4.5 0 0 1 .69-.054q.351 0 .701.054l-.09 4.39q-.315.053-.601.053a3.5 3.5 0 0 1-.61-.054m1.319 1.4q0 .332-.054.664a4 4 0 0 1-.655.054a4 4 0 0 1-.664-.054a4 4 0 0 1-.054-.655q0-.323.054-.665a4 4 0 0 1 .655-.054q.323 0 .664.054q.054.341.054.656m-3.223-4.03q.315 0 .638.053v4.461q-.288.099-.759.193a5.3 5.3 0 0 1-1.863.023a1.7 1.7 0 0 1-.74-.305q-.32-.234-.507-.683q-.189-.449-.189-1.193V9.765a4 4 0 0 1 .638-.054q.313 0 .637.054v2.46q0 .367.058.606a.9.9 0 0 0 .18.377a.66.66 0 0 0 .3.197q.18.058.422.058q.332 0 .557-.062V9.765a4 4 0 0 1 .628-.054m-4.362 2.683q.08.225.08.548a1.4 1.4 0 0 1-.542 1.117q-.265.212-.642.333q-.378.12-.853.12a5 5 0 0 1-.395-.013a3 3 0 0 1-.346-.045a4 4 0 0 1-.327-.076a4 4 0 0 1-.35-.116a2.6 2.6 0 0 1 .085-.49a3 3 0 0 1 .175-.48q.296.117.561.175q.265.06.552.059q.126 0 .274-.023a1 1 0 0 0 .274-.08a.7.7 0 0 0 .21-.153a.35.35 0 0 0 .086-.247q0-.216-.13-.31a1.3 1.3 0 0 0-.364-.166l-.556-.162q-.503-.143-.786-.426q-.282-.283-.283-.848q0-.682.49-1.068q.489-.386 1.332-.386q.35 0 .692.062q.341.063.691.189a2.5 2.5 0 0 1-.09.485a2.3 2.3 0 0 1-.17.44a4 4 0 0 0-.476-.158a2.2 2.2 0 0 0-.548-.067q-.305 0-.476.094a.32.32 0 0 0-.17.301q0 .197.121.278t.346.153l.511.153q.252.072.454.175t.345.255t.225.377M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12c6.628 0 12-5.373 12-12S18.628 0 12 0m0 22.8C6.035 22.8 1.2 17.965 1.2 12S6.035 1.2 12 1.2S22.8 6.035 22.8 12S17.965 22.8 12 22.8'
]
},
'game-cs2': {
from: '#fcd34d',
to: '#d97706',
ink: WHITE,
fill: true,
paths: [
'M21.71 3.235a.02.02 0 0 1-.022-.022c.002-.081.004-.37.005-.424c0-.129-.143-.183-.212-.083l-.229.333a.02.02 0 0 1-.02.01h-6.55a.047.047 0 0 1-.048-.046l-.013-.177a.048.048 0 0 1 .056-.048l.335.032a.06.06 0 0 0 .063-.045l.244-.989a.05.05 0 0 0-.03-.054l-.227-.085a.04.04 0 0 1-.026-.03c-.041-.171-.377-1.323-1.993-1.58c-.787-.125-1.302.21-1.577.478a1.6 1.6 0 0 0-.302.41l-.097.212a2 2 0 0 0-.046.234l.051.982a.11.11 0 0 0 .043.085l.354.153l-.196.325a.055.055 0 0 1-.053.04s-.417.01-.622.02c-.386.015-1.245.485-1.878 1.838l-.724 1.55a.07.07 0 0 1-.068.04l-.578.001c-.035 0-.073.028-.088.06L6.364 9a.11.11 0 0 0 .017.108l.627.392a.06.06 0 0 1 .02.058l-.328.967a.2.2 0 0 1-.023.062l-.435.382a.1.1 0 0 0-.035.06l-.598 1.53a.06.06 0 0 1-.06.045l-.336.002a.163.163 0 0 0-.162.149l-.201 2.288l-.016.121l-.158.908a.13.13 0 0 1-.034.055l-.558.427a4.8 4.8 0 0 0-.767 1.001l-1.86 3.924a.8.8 0 0 0-.078.322l.132.235c.002.084-.032.456-.07.53l-.624 1.09a.1.1 0 0 0-.003.085l.03.07l.094.187L2.829 24c.118.011.247-.14.251-.3l.103-1.297l-.027-.195l3.606-4.232c.095-.114.222-.317.286-.45l1.719-3.79a.17.17 0 0 1 .1-.088l.109-.035a.17.17 0 0 1 .183.053c.15.181.504.781.676 1.032c.143.208.85 1.23 1.158 1.567c.086.093.349.198.466.27a.083.083 0 0 1 .03.112l-1.03 1.808l-.455 2.136a1 1 0 0 0-.036.152l-.412 1.483c.003.188-.14.286-.153.507l-.15 1.084a.06.06 0 0 0 .059.061l2.544.014q.142-.001.286-.006l.075-.007c.124-.016.563-.076.75-.15a.6.6 0 0 0 .227-.13c.185-.194.2-.278.203-.398a.3.3 0 0 0-.028-.105a.12.12 0 0 0-.06-.047l-1.18-.356a.37.37 0 0 1-.19-.134l-.317-.47a.09.09 0 0 1 .018-.097l.618-.609a.2.2 0 0 0 .048-.072l1.904-4.488c.089-.285.059-.605 0-.944c-.044-.25-.686-1.326-.854-1.624l-1.286-2.251c-.079-.138-.19-.133-.228-.276l-.073-1.118a.04.04 0 0 1 .036-.05l.33-.028a.1.1 0 0 0 .075-.048l1.147-2.155a.1.1 0 0 0-.002-.094l-.235-.29a.09.09 0 0 1-.001-.088l.352-.38a.054.054 0 0 1 .073-.02l.934.526a.4.4 0 0 0 .186.05c.26-.001.686-.154.908-.29a.4.4 0 0 0 .139-.148l.458-1.07c.006-.014.027-.012.03.003l.127.595a.064.064 0 0 0 .079.05l1.35-.3a.066.066 0 0 0 .05-.078l-.319-1.344a.07.07 0 0 1 .01-.054l.13-.203a.3.3 0 0 0 .037-.082l.159-.725a.04.04 0 0 1 .04-.032l3.732.005a.09.09 0 0 0 .093-.093v-.634a.02.02 0 0 1 .022-.021h1.439a.047.047 0 0 0 .046-.047V3.28a.047.047 0 0 0-.046-.047h-1.44z'
]
},
'game-pubg': {
from: '#fde68a',
to: '#b45309',
ink: '#422006',
paths: [
'M15.4 15.63a7.875 6 135 1 1 6.23-6.23 4.5 3.43 135 0 0-6.23 6.23',
'm8.29 12.71-2.6 2.6a2.5 2.5 0 1 0-1.65 4.65A2.5 2.5 0 1 0 8.7 18.3l2.59-2.59'
]
},
'game-r6': {
from: '#94a3b8',
to: '#1e293b',
ink: WHITE,
paths: [
'M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z',
'M14 8.5h-2a2 2 0 0 0-2 2v3.5a2 2 0 0 0 4 0v-.5a2 2 0 0 0-4 0'
]
},
'game-tarkov': {
from: '#bdb76b',
to: '#4d4a26',
ink: WHITE,
paths: [
'M4 10a4 4 0 0 1 4-4h8a4 4 0 0 1 4 4v10a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z',
'M8 10h8',
'M8 18h8',
'M8 22v-6a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v6',
'M9 6V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2'
]
},
'game-bo2': {
from: '#fb923c',
to: '#9a3412',
ink: WHITE,
paths: [c(12, 12, 9), 'M21 12h-4M7 12H3M12 7V3M12 21v-4', c(12, 12, 1)]
},
'game-valorant': {
from: '#ff6b77',
to: '#9f1239',
ink: WHITE,
fill: true,
paths: [
'M23.792 2.152a.25.25 0 0 0-.098.083q-5.077 6.345-10.15 12.69c-.107.093-.025.288.119.265c2.439.003 4.877 0 7.316.001a.66.66 0 0 0 .552-.25l2.324-2.903a.72.72 0 0 0 .144-.49c-.002-3.077 0-6.153-.003-9.23c.016-.11-.1-.206-.204-.167zM.077 2.166c-.077.038-.074.132-.076.205q.002 4.612.001 9.225a.68.68 0 0 0 .158.463l7.64 9.55c.12.152.308.25.505.247c2.455 0 4.91.003 7.365 0c.142.02.222-.174.116-.265C10.661 15.176 5.526 8.766.4 2.35c-.08-.094-.174-.272-.322-.184z'
]
},
'game-lol': {
from: '#e2c27a',
to: '#0e4a5c',
ink: WHITE,
fill: true,
paths: [
'm1.912 0l1.212 2.474v19.053L1.912 24h14.73l1.337-4.682H8.33V0ZM12 1.516c-.913 0-1.798.112-2.648.312v1.74A9.7 9.7 0 0 1 12 3.2c5.267 0 9.536 4.184 9.536 9.348a9.2 9.2 0 0 1-2.3 6.086l-.273.954l-.602 2.112c2.952-1.993 4.89-5.335 4.89-9.122C23.25 6.468 18.213 1.516 12 1.516m0 2.673c-.924 0-1.814.148-2.648.414v13.713h8.817a8.25 8.25 0 0 0 2.36-5.768c0-4.617-3.818-8.359-8.529-8.359M2.104 7.312A10.86 10.86 0 0 0 .75 12.576c0 1.906.492 3.7 1.355 5.266z'
]
},
'game-overwatch': {
from: '#fdba74',
to: '#f97316',
ink: '#431407',
paths: [c(12, 12, 9), 'm8 7.5 4 7 4-7', 'M7 15.5h10']
},
'game-minecraft': {
from: '#86efac',
to: '#166534',
ink: WHITE,
paths: [
'm14 13-8.381 8.38a1 1 0 0 1-3.001-3L11 9.999',
'M15.973 4.027A13 13 0 0 0 5.902 2.373c-1.398.342-1.092 2.158.277 2.601a19.9 19.9 0 0 1 5.822 3.024',
'M16.001 11.999a19.9 19.9 0 0 1 3.024 5.824c.444 1.369 2.26 1.676 2.603.278A13 13 0 0 0 20 8.069',
'M18.352 3.352a1.205 1.205 0 0 0-1.704 0l-5.296 5.296a1.205 1.205 0 0 0 0 1.704l2.296 2.296a1.205 1.205 0 0 0 1.704 0l5.296-5.296a1.205 1.205 0 0 0 0-1.704z'
]
},
'game-factorio': {
from: '#f0b16a',
to: '#7c4a1e',
ink: WHITE,
paths: [
'M11 10.27 7 3.34',
'm11 13.73-4 6.93',
'M12 22v-2',
'M12 2v2',
'M14 12h8',
'm17 20.66-1-1.73',
'm17 3.34-1 1.73',
'M2 12h2',
'm20.66 17-1.73-1',
'm20.66 7-1.73 1',
'm3.34 17 1.73-1',
'm3.34 7 1.73 1',
c(12, 12, 2),
c(12, 12, 8)
]
},
'game-ksp': {
from: '#93c5fd',
to: '#1e3a8a',
ink: WHITE,
paths: [
'M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5',
'M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09',
'M9 12a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.4 22.4 0 0 1-4 2z',
'M9 12H4s.55-3.03 2-4c1.62-1.08 5 .05 5 .05'
]
},
'game-smash': {
from: '#f87171',
to: '#991b1b',
ink: WHITE,
paths: [c(12, 12, 9), 'M9.5 3.4v17.2', 'M3 14.5h18']
},
'game-cubing': {
from: '#4ade80',
to: '#1d4ed8',
ink: WHITE,
paths: ['M4 4h16v16H4z', 'M9.33 4v16M14.67 4v16', 'M4 9.33h16M4 14.67h16']
},
'game-pokemon': {
from: '#fb7185',
to: '#c81e4a',
ink: WHITE,
paths: [c(12, 12, 9), 'M3 12h6M15 12h6', c(12, 12, 3)]
},
'game-sot': {
from: '#5eead4',
to: '#0f766e',
ink: WHITE,
paths: [
'M10 2v15',
'M7 22a4 4 0 0 1-4-4 1 1 0 0 1 1-1h16a1 1 0 0 1 1 1 4 4 0 0 1-4 4z',
'M9.159 2.46a1 1 0 0 1 1.521-.193l9.977 8.98A1 1 0 0 1 20 13H4a1 1 0 0 1-.824-1.567z'
]
} }
}; };

View File

@@ -1,5 +1,8 @@
<script lang="ts"> <script lang="ts">
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import Gamepad2 from 'lucide-svelte/icons/gamepad-2';
import SpottingSwitcher from '#lib/components/SpottingSwitcher.svelte';
import { gameTrackers } from '#lib/projects.js';
import GameLogo from '#lib/game-tracker/GameLogo.svelte'; import GameLogo from '#lib/game-tracker/GameLogo.svelte';
import ProgressMeter from '#lib/game-tracker/ProgressMeter.svelte'; import ProgressMeter from '#lib/game-tracker/ProgressMeter.svelte';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
@@ -20,6 +23,14 @@
href={resolve('projects/game-tracker/credits')}>Artwork credits</a href={resolve('projects/game-tracker/credits')}>Artwork credits</a
>{/if} >{/if}
</div> </div>
<!-- Every game I play: one page each (same chips as the header flyout and tiles). -->
<SpottingSwitcher
items={gameTrackers}
home="/projects/game-tracker"
label="games"
homeIcon={Gamepad2}
class="mb-4"
/>
<div <div
class="mb-4 grid grid-cols-2 gap-3 rounded-xl border bg-card p-3 lg:grid-cols-4" class="mb-4 grid grid-cols-2 gap-3 rounded-xl border bg-card p-3 lg:grid-cols-4"
aria-label="Your completion dashboard" aria-label="Your completion dashboard"

View File

@@ -4,6 +4,7 @@ import { blogArticles, bookWorks } from '#lib/server/db/schema/index.js';
import { SITE_ORIGIN } from '#lib/seo.js'; import { SITE_ORIGIN } from '#lib/seo.js';
import { races } from '#lib/derby/races.js'; import { races } from '#lib/derby/races.js';
import { COURSES } from '#lib/language-learner/courses/index.js'; import { COURSES } from '#lib/language-learner/courses/index.js';
import { gameTrackers } from '#lib/projects.js';
import { and, eq, isNull, ne } from 'drizzle-orm'; import { and, eq, isNull, ne } from 'drizzle-orm';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
@@ -28,6 +29,7 @@ const staticPaths = [
'/trackers/music', '/trackers/music',
'/trackers/pc', '/trackers/pc',
'/projects/game-tracker', '/projects/game-tracker',
...gameTrackers.map((g) => g.href),
'/projects/game-tracker/pokemon', '/projects/game-tracker/pokemon',
'/projects/game-tracker/osu', '/projects/game-tracker/osu',
'/projects/game-tracker/pokemon/locations', '/projects/game-tracker/pokemon/locations',

View File

@@ -63,6 +63,14 @@
}); });
const filtering = $derived(linkFilter.trim() !== ''); const filtering = $derived(linkFilter.trim() !== '');
// Big groups (games): featured chips first, the rest behind "+N more" (all while filtering).
let expandedGroups = $state<Record<string, boolean>>({});
type Group = (typeof trackers)[number];
const tileChildren = (t: Group) =>
filtering || expandedGroups[t.name]
? (t.children ?? [])
: (t.children ?? []).filter((c) => c.featured ?? true);
const hiddenChildren = (t: Group) => (t.children?.length ?? 0) - tileChildren(t).length;
// The links filter (f) also narrows the trackers; a group stays if it or a chip matches. // The links filter (f) also narrows the trackers; a group stays if it or a chip matches.
const shownTrackers = $derived.by(() => { const shownTrackers = $derived.by(() => {
const q = linkFilter.trim().toLowerCase(); const q = linkFilter.trim().toLowerCase();
@@ -291,7 +299,7 @@
</h2> </h2>
<ul class="tgrid"> <ul class="tgrid">
{#each shownTrackers as t (t.name)} {#each shownTrackers as t (t.name)}
<li class={{ group: !!t.children }}> <li class={{ group: !!t.children, wide: (t.children?.length ?? 0) > 6 }}>
{#if t.children} {#if t.children}
<!-- Group tile: stretched main link, chips layered above it (no nested <a>). --> <!-- Group tile: stretched main link, chips layered above it (no nested <a>). -->
<div class="ttile"> <div class="ttile">
@@ -300,11 +308,21 @@
<span class="tname">{t.name}</span> <span class="tname">{t.name}</span>
</a> </a>
<span class="tchips"> <span class="tchips">
{#each t.children as c (c.name)} {#each tileChildren(t) as c (c.name)}
<a class="tchip" href={c.href} title={c.subtitle} <a class="tchip" href={c.href} title={c.subtitle}
><TrackerIcon name={c.name} class="t-icon size-5" />{c.name}</a ><TrackerIcon name={c.icon ?? c.name} class="t-icon size-5" />{c.name}</a
> >
{/each} {/each}
{#if !filtering && (hiddenChildren(t) > 0 || expandedGroups[t.name])}
<button
type="button"
class="tchip more"
aria-expanded={!!expandedGroups[t.name]}
onclick={() =>
(expandedGroups = { ...expandedGroups, [t.name]: !expandedGroups[t.name] })}
>{hiddenChildren(t) > 0 ? `+${hiddenChildren(t)} more` : 'less'}</button
>
{/if}
</span> </span>
</div> </div>
{:else} {:else}
@@ -1039,11 +1057,15 @@
.tgrid { .tgrid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
grid-auto-flow: row dense;
gap: 6px; gap: 6px;
} }
.tgrid > li.group { .tgrid > li.group {
grid-column: span 4; grid-column: span 4;
} }
.tgrid > li.group.wide {
grid-column: span 6;
}
.ttile { .ttile {
position: relative; position: relative;
display: flex; display: flex;
@@ -1122,6 +1144,17 @@
border-color 160ms ease, border-color 160ms ease,
background-color 160ms ease; background-color 160ms ease;
} }
button.tchip {
padding: 2px 9px;
color: var(--site-muted);
cursor: pointer;
font: inherit;
font-size: 12px;
font-weight: 500;
}
button.tchip:hover {
color: var(--site-text);
}
.tchip :global(.t-icon) { .tchip :global(.t-icon) {
border-radius: 999px; border-radius: 999px;
} }
@@ -1145,6 +1178,14 @@
transform: none !important; transform: none !important;
} }
} }
@media (max-width: 1000px) {
.tgrid > li.group.wide {
grid-column: 1 / -1;
}
div.ttile {
flex-wrap: wrap;
}
}
@media (max-width: 760px) { @media (max-width: 760px) {
.tgrid > li.group { .tgrid > li.group {
grid-column: 1 / -1; grid-column: 1 / -1;

View File

@@ -0,0 +1,15 @@
<script lang="ts">
import Gamepad2 from 'lucide-svelte/icons/gamepad-2';
import SpottingSwitcher from '#lib/components/SpottingSwitcher.svelte';
import { gameTrackers } from '#lib/projects.js';
let { children } = $props();
</script>
<SpottingSwitcher
items={gameTrackers}
home="/projects/game-tracker"
label="games"
homeIcon={Gamepad2}
/>
{@render children()}

View File

@@ -0,0 +1,5 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
// The games group's landing page is the game tracker; each game lives at /trackers/games/<slug>.
export const load: PageServerLoad = () => redirect(307, '/projects/game-tracker');

View File

@@ -0,0 +1 @@
<!-- Redirects to /projects/game-tracker (see +page.server.ts). -->

View File

@@ -0,0 +1,45 @@
import { error } from '@sveltejs/kit';
import { eq, inArray } from 'drizzle-orm';
import { db } from '#lib/server/db/index.js';
import { gameLibrary, hardwareSensitivities } from '#lib/server/db/schema/index.js';
import type { PageServerLoad } from './$types';
// One game from games.library, plus its mouse sensitivity (hardware.sensitivities, matched by
// name, short name or alias) when there is one.
export const load: PageServerLoad = async ({ params }) => {
const [game] = await db.select().from(gameLibrary).where(eq(gameLibrary.slug, params.slug));
if (!game) error(404, 'Not Found');
const names = [game.name, game.shortName, ...game.aliases];
const [sensitivity] = await db
.select({
game: hardwareSensitivities.game,
cmPer360: hardwareSensitivities.cmPer360,
inGameSens: hardwareSensitivities.inGameSens,
yaw: hardwareSensitivities.yaw,
fov: hardwareSensitivities.fov,
notes: hardwareSensitivities.notes
})
.from(hardwareSensitivities)
.where(inArray(hardwareSensitivities.game, names))
.limit(1);
return {
game: {
slug: game.slug,
name: game.name,
shortName: game.shortName,
kind: game.kind,
status: game.status,
platform: game.platform,
trackerHref: game.trackerHref,
currentRank: game.currentRank,
peakRank: game.peakRank,
rankLabel: game.rankLabel,
notes: game.notes
},
sensitivity: sensitivity ?? null,
seo: {
title: `${game.name} · Games`,
description: `${game.name} on Stan Runge’s game tracker${game.status === 'main' ? ': my main game right now' : ''}.`
}
};
};

View File

@@ -0,0 +1,172 @@
<script lang="ts">
import { resolve } from '$app/paths';
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
import ArrowRight from 'lucide-svelte/icons/arrow-right';
import TrackerIcon from '#lib/components/TrackerIcon.svelte';
import { gameTrackers } from '#lib/projects.js';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
const game = $derived(data.game);
const nav = $derived(gameTrackers.find((g) => g.href === `/trackers/games/${game.slug}`));
const status: Record<string, string> = {
main: 'main game',
active: 'actively playing',
occasional: 'occasionally',
unknown: 'not sure yet'
};
// "~#40k": ranks are rough positions I gave, so round them.
const rank = (n: number) =>
n >= 1000 ? `~#${Math.round(n / 1000).toLocaleString('en-GB')}k` : `#${n}`;
const hasStats = $derived(
game.currentRank != null || game.peakRank != null || !!data.sensitivity
);
</script>
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
<a
href={resolve('/projects/game-tracker')}
class="mb-6 inline-flex items-center gap-1 text-sm text-[var(--site-muted)] hover:text-foreground"
><ArrowLeft size={14} aria-hidden="true" /> games</a
>
<h1 class="mb-3 flex items-center gap-3 text-4xl font-semibold tracking-tight sm:text-5xl">
<TrackerIcon name={nav?.icon ?? `game-${game.slug}`} class="size-10 sm:size-12" />{game.name}
</h1>
<div class="mb-10 flex flex-wrap items-center gap-1.5 text-sm">
<span class={['badge', `status-${game.status}`]}>{status[game.status]}</span>
{#if game.platform}<span class="badge">{game.platform}</span>{/if}
{#if game.kind === 'puzzle'}<span class="badge">not a video game</span>{/if}
{#if nav?.subtitle && nav.subtitle !== game.name}<span class="ml-1 text-[var(--site-muted)]"
>{nav.subtitle}</span
>{/if}
</div>
{#if hasStats}
<ul class="mb-8 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{#if game.currentRank != null}
<li class="stat">
<span class="label">current rank</span>
<span class="value">{rank(game.currentRank)}</span>
{#if game.rankLabel}<span class="hint">{game.rankLabel}</span>{/if}
</li>
{/if}
{#if game.peakRank != null}
<li class="stat">
<span class="label">peak rank</span>
<span class="value">{rank(game.peakRank)}</span>
{#if game.rankLabel}<span class="hint">{game.rankLabel}</span>{/if}
</li>
{/if}
{#if data.sensitivity}
{@const s = data.sensitivity}
<li class="stat">
<span class="label">sensitivity</span>
<span class="value"
>{s.cmPer360 != null
? `${s.cmPer360} cm/360°`
: s.inGameSens != null
? s.inGameSens
: 'not set yet'}</span
>
<a class="hint underline" href={resolve('/trackers/pc')}>on the PC tracker</a>
</li>
{/if}
</ul>
{/if}
{#if game.notes}
<p class="mb-8 max-w-2xl whitespace-pre-line text-[var(--site-muted)]">{game.notes}</p>
{/if}
{#if game.trackerHref}
<a href={resolve(game.trackerHref as '/')} class="deep inline-flex items-center gap-3 p-4">
<TrackerIcon name={nav?.icon ?? `game-${game.slug}`} class="size-8" />
<span>
<span class="block font-medium">open the full {game.shortName} tracker</span>
<span class="block text-sm text-[var(--site-muted)]">progress, goals and history</span>
</span>
<ArrowRight size={18} aria-hidden="true" class="ml-4 text-[var(--site-muted)]" />
</a>
{:else if !hasStats && !game.notes}
<p class="empty rounded-xl p-6 text-[var(--site-muted)]">
Nothing tracked for {game.shortName} yet. Stats, ranks and notes show up here once I add them.
</p>
{/if}
</div>
<style>
.badge {
border: 1px solid color-mix(in oklab, var(--foreground) 12%, transparent);
border-radius: 999px;
padding: 0.15rem 0.65rem;
font-weight: 500;
}
.status-main {
background: var(--foreground);
color: var(--background);
border-color: var(--foreground);
}
.status-active {
border-color: color-mix(in oklab, var(--foreground) 40%, transparent);
}
.status-unknown,
.status-occasional {
color: var(--site-muted);
}
.stat {
display: flex;
flex-direction: column;
gap: 0.15rem;
padding: 1rem 1.1rem;
border: 1px solid color-mix(in oklab, var(--foreground) 9%, transparent);
border-radius: 0.9rem;
background: color-mix(in oklab, var(--foreground) 2%, transparent);
}
.stat .label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--site-muted);
}
.stat .value {
font-size: 1.6rem;
font-weight: 600;
letter-spacing: -0.01em;
font-variant-numeric: tabular-nums;
}
.stat .hint {
font-size: 0.8rem;
color: var(--site-muted);
}
.deep,
.empty {
border: 1px solid color-mix(in oklab, var(--foreground) 9%, transparent);
border-radius: 0.9rem;
background: color-mix(in oklab, var(--foreground) 2%, transparent);
}
.deep {
outline: none;
transition:
transform 160ms cubic-bezier(0.2, 0.8, 0.2, 1),
background-color 160ms ease,
border-color 160ms ease,
box-shadow 160ms ease;
}
.deep:hover {
background: color-mix(in oklab, var(--foreground) 6%, transparent);
border-color: color-mix(in oklab, var(--foreground) 22%, transparent);
transform: translateY(-2px);
box-shadow: 0 12px 28px -16px rgb(0 0 0 / 0.55);
}
.deep:focus-visible {
outline: 2px solid var(--foreground);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
.deep,
.deep:hover {
transition: none;
transform: none;
}
}
</style>