games: active vs non-active games (games.library.active, owner toggles on the dashboard and each game ⋯ menu; nothing deleted). Header flyout and mobile menu list every game split into active / non-active; /trackers and home tiles show active chips and open both sections on hover; /start shows active with a non-active toggle. Games dashboard moved from /projects/game-tracker to /trackers/games (dashboard of active games only; old URL and /credits 301); deep trackers stay put
All checks were successful
Deploy production / deploy (push) Successful in 1m13s

This commit is contained in:
2026-10-25 20:23:15 +00:00
parent 60370d4572
commit 3ad13e5828
31 changed files with 31471 additions and 565 deletions

View File

@@ -18,6 +18,7 @@
children?: readonly Item[];
icon?: string;
featured?: boolean;
inactive?: boolean;
locked?: boolean;
};
let {
@@ -33,18 +34,28 @@
} = $props();
const tracker = $derived(variant === 'tracker');
// Big groups (games) show their featured chips first and the rest behind a "more" chip.
// Big groups (games) show their featured chips (for games: the active ones, my pick) and the
// rest behind a "more" chip. Hovering the tile (or the chip) opens all of them; games then
// split into "active" and "non-active", like the header's Trackers dropdown.
let expanded = $state<Record<string, boolean>>({});
let hovered = $state<string | null>(null);
const open = (group: Item) => !!expanded[group.name] || hovered === group.name;
const visibleChildren = (group: Item) => {
const all = group.children ?? [];
return expanded[group.name]
? all
: all.some((c) => c.featured)
? all.filter((c) => c.featured)
: all;
return open(group) ? all : all.some((c) => c.featured) ? all.filter((c) => c.featured) : all;
};
const hiddenCount = (group: Item) =>
(group.children?.length ?? 0) - visibleChildren(group).length;
const sections = (group: Item) => {
const shown = visibleChildren(group);
return open(group) && shown.some((c) => c.inactive)
? [
{ label: 'active', items: shown.filter((c) => !c.inactive) },
{ label: 'non-active', items: shown.filter((c) => c.inactive) }
]
: [{ label: '', items: shown }];
};
const hoverable = (group: Item) => (group.children ?? []).some((c) => c.featured);
</script>
{#snippet body(project: Item)}
@@ -81,7 +92,14 @@
<ul class="grid gap-3 sm:grid-cols-2" class:wide>
{#each items as project (project.name)}
<li>
<li
onpointerenter={(event) => {
if (event.pointerType !== 'touch' && hoverable(project)) hovered = project.name;
}}
onpointerleave={() => {
if (hovered === project.name) hovered = null;
}}
>
<Card.Root class="catalog-card relative h-full py-0 transition-colors hover:bg-muted/50"
><Card.Content class="h-full p-0">
{#if project.children}
@@ -95,20 +113,30 @@
{@render body(project)}
</a>
<ul class="relative z-10 mt-auto flex flex-wrap gap-1.5 pl-13">
{#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.icon ?? child.name}
class="chip-icon size-5"
/>{child.name}</a
>
</li>
{#each sections(project) as section (section.label)}
{#if section.label}
<li class="chip-label">
{section.label}<span>{section.items.length}</span>
</li>
{/if}
{#each section.items 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',
child.inactive && 'inactive'
]}
title={child.subtitle}
><TrackerIcon
name={child.icon ?? child.name}
class="chip-icon size-5"
/>{child.name}</a
>
</li>
{/each}
{/each}
{#if hiddenCount(project) > 0 || expanded[project.name]}
{#if hiddenCount(project) > 0 || (expanded[project.name] && hovered !== project.name)}
<li>
<button
type="button"
@@ -206,6 +234,33 @@
border-color: color-mix(in oklab, var(--foreground) 35%, transparent);
transform: translateY(-1px);
}
.chip-label {
flex-basis: 100%;
display: flex;
align-items: baseline;
gap: 0.4rem;
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--site-muted);
}
.chip-label span {
letter-spacing: 0;
opacity: 0.7;
font-variant-numeric: tabular-nums;
}
.chip-label:not(:first-child) {
margin-top: 0.35rem;
}
.chip.inactive :global(.chip-icon) {
filter: grayscale(0.6);
opacity: 0.75;
}
.chip.inactive:hover :global(.chip-icon) {
filter: none;
opacity: 1;
}
.chip:focus-visible {
outline: 2px solid var(--foreground);
outline-offset: 2px;

View File

@@ -38,6 +38,7 @@
user: { name: string } | null;
isOwner: boolean;
version: string;
inactiveGames?: string[];
};
let { data, children }: { data: ChromeData; children: Snippet } = $props();
const showFooter = $derived(!hidesFooter(page.url.pathname));
@@ -59,10 +60,12 @@
href: string;
external: boolean;
children?: readonly MenuItem[];
icon?: string;
inactive?: boolean;
};
const menus: Record<string, { label: string; items: readonly MenuItem[] }> = $derived({
'/projects': { label: 'projects', items: projects },
'/trackers': { label: 'trackers', items: visibleTrackers(data.isOwner) }
'/trackers': { label: 'trackers', items: visibleTrackers(data.isOwner, data.inactiveGames) }
});
const socialLinks = [
{ icon: SiMail, label: 'email', href: 'mailto:stan@stanrunge.dev' },
@@ -197,7 +200,7 @@
if (flyout === name) return;
const columns = flyoutColumns(count);
const width = columns === 3 ? 640 : columns === 2 ? 432 : 240;
const height = Math.min(Math.ceil(count / columns) * 46 + 20, window.innerHeight - 80);
const height = Math.min(Math.ceil(count / columns) * 46 + 80, window.innerHeight - 80);
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;
@@ -207,6 +210,15 @@
flyoutSide = leftColumn ? (roomLeft ? 'left' : 'right') : roomRight ? 'right' : 'left';
flyout = name;
}
// Games split into what I play now and the rest (games.library.active); other groups have
// one unlabelled section.
const childSections = (items: readonly MenuItem[]) =>
items.some((c) => c.inactive)
? [
{ label: 'active', items: items.filter((c) => !c.inactive) },
{ label: 'non-active', items: items.filter((c) => c.inactive) }
]
: [{ label: '', items }];
function closeFlyoutOnEscape(event: KeyboardEvent) {
if (event.key !== 'Escape' || !flyout) return;
event.stopPropagation();
@@ -385,18 +397,28 @@
]}
class:from-left={flyoutSide === 'left'}
>
{#each project.children as child (child.name)}
<a
href={resolve(child.href as '/')}
class="tile flex min-w-0 items-center gap-2.5 p-1.5 text-sm"
title={`${child.name}: ${child.subtitle}`}
aria-current={current(child.href) ? 'page' : undefined}
onkeydown={closeFlyoutOnEscape}
><TrackerIcon
name={child.icon ?? child.name}
class="tile-icon size-8"
/><span class="truncate font-medium">{child.name}</span></a
>
{#each childSections(project.children) as section (section.label)}
{#if section.label}
<p class="flyout-label col-span-full">
{section.label}<span>{section.items.length}</span>
</p>
{/if}
{#each section.items as child (child.name)}
<a
href={resolve(child.href as '/')}
class={[
'tile flex min-w-0 items-center gap-2.5 p-1.5 text-sm',
child.inactive && 'inactive'
]}
title={`${child.name}: ${child.subtitle}`}
aria-current={current(child.href) ? 'page' : undefined}
onkeydown={closeFlyoutOnEscape}
><TrackerIcon
name={child.icon ?? child.name}
class="tile-icon size-8"
/><span class="truncate font-medium">{child.name}</span></a
>
{/each}
{/each}
</div>
</div>
@@ -551,16 +573,26 @@
<div class="col-span-2 mt-1">
<span class="mb-1 block text-xs text-muted-foreground">{group.name}</span>
<div class="ml-1 grid grid-cols-2 gap-1.5 border-l border-border pl-2">
{#each group.children ?? [] as child (child.name)}
<a
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.icon ?? child.name}
class="tile-icon size-6"
/><span class="truncate">{child.name}</span></a
>
{#each childSections(group.children ?? []) as section (section.label)}
{#if section.label}
<p class="flyout-label col-span-full">
{section.label}<span>{section.items.length}</span>
</p>
{/if}
{#each section.items as child (child.name)}
<a
href={resolve(child.href as '/')}
class={[
'tile flex min-w-0 items-center gap-2 p-1.5 text-sm',
child.inactive && 'inactive'
]}
onclick={() => (menuOpen = false)}
><TrackerIcon
name={child.icon ?? child.name}
class="tile-icon size-6"
/><span class="truncate">{child.name}</span></a
>
{/each}
{/each}
</div>
</div>
@@ -617,7 +649,7 @@
{#if showFooter}
<footer
class="mt-16 border-t border-[var(--site-border)]"
class:tracker-footer={page.route.id === '/projects/game-tracker'}
class:tracker-footer={page.route.id === '/trackers/games'}
class:cod-footer={page.route.id === '/projects/game-tracker/call-of-duty/[slug]'}
>
<div class="mx-auto max-w-6xl space-y-5 px-4 py-8 text-[var(--site-muted)] sm:px-6">
@@ -699,6 +731,36 @@
background: color-mix(in oklab, var(--foreground) 9%, transparent);
border-color: color-mix(in oklab, var(--foreground) 35%, transparent);
}
.flyout-label {
display: flex;
align-items: baseline;
gap: 0.4rem;
padding: 0.35rem 0.4rem 0.1rem;
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--muted-foreground);
}
.flyout-label span {
font-variant-numeric: tabular-nums;
letter-spacing: 0;
opacity: 0.7;
}
.flyout-label:not(:first-child) {
margin-top: 0.35rem;
border-top: 1px solid var(--border);
padding-top: 0.55rem;
}
.tile.inactive :global(.tile-icon) {
filter: grayscale(0.6);
opacity: 0.75;
}
.tile.inactive:hover :global(.tile-icon),
.tile.inactive:focus-visible :global(.tile-icon) {
filter: none;
opacity: 1;
}
.flyout {
animation: flyout-in 140ms cubic-bezier(0.2, 0.8, 0.2, 1);
--flyout-from: -4px;

View File

@@ -12,7 +12,7 @@ export type GameLogo = {
bleed?: boolean;
/** Wide wordmark (Pokémon logos): use more of the tile so it stays readable. */
wide?: boolean;
/** Where the asset came from (shown on /projects/game-tracker/credits). */
/** Where the asset came from (shown on /trackers/games/credits). */
credit: string;
source: string;
};

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { visibleTrackers } from './projects';
import { activeGames, pokemonEntryFor, sortedGames, visibleTrackers } from './projects';
describe('trackers', () => {
it('lists tasks, money, reading first for the owner', () => {
@@ -28,4 +28,28 @@ describe('trackers', () => {
expect(owner.find((t) => t.name === 'money')?.locked).toBe(true);
expect(owner.find((t) => t.name === 'reading')?.locked).toBeUndefined();
});
it('splits games into active first and non-active (flagged) after', () => {
const off = ['lol', 'black-ops', 'pokemon-x-y'];
const games = visibleTrackers(true, off).find((t) => t.name === 'games')!.children!;
const inactive = games.filter((g) => g.inactive).map((g) => g.href);
expect(inactive).toEqual([
'/trackers/games/lol',
'/trackers/games/black-ops',
'/trackers/games/pokemon-x-y'
]);
expect(games.slice(-3).every((g) => g.inactive)).toBe(true);
expect(games.filter((g) => !g.inactive).every((g) => g.featured)).toBe(true);
expect(activeGames(off)).toHaveLength(sortedGames(off).length - 3);
expect(
visibleTrackers(true)
.find((t) => t.name === 'games')!
.children!.some((g) => g.inactive)
).toBe(false);
});
it('maps Pokémon journal games to their generation entry', () => {
expect(pokemonEntryFor('platinum')).toBe('pokemon-diamond-pearl-platinum');
expect(pokemonEntryFor('soulsilver')).toBe('pokemon-heartgold-soulsilver');
expect(pokemonEntryFor('the-teal-mask-violet')).toBe('pokemon-scarlet-violet');
expect(pokemonEntryFor('champions')).toBeUndefined();
});
});

View File

@@ -43,6 +43,8 @@ type Tracker = {
icon?: string;
/** Shown on group tiles before the "more" chip (main and actively played games). */
featured?: boolean;
/** A game I don't play now (games.library.active = false): listed under "non-active". */
inactive?: boolean;
/** Owner-only (needs login): every listing shows a small lock after the name. */
locked?: boolean;
};
@@ -82,6 +84,9 @@ export const spottingTrackers: readonly Tracker[] = [
// 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).
// Which games are active (the ones I play now) is my pick in games.library.active, toggled on
// /trackers/games; sortedGames() puts them first, makes them the tile chips and flags the
// rest `inactive`, so the header, /start and the tiles show "active" and "non-active" parts.
// Call of Duty is one entry per title and Pokémon one per generation (remakes such as
// HeartGold & SoulSilver and spin-offs separate); full names, no abbreviations. The old
// /trackers/games/bo2 and /trackers/games/pokemon URLs redirect (see [slug]/+page.server.ts).
@@ -185,6 +190,66 @@ export const gameTrackers: readonly Tracker[] = [
game('pokemon-legends-z-a', 'pokémon legends: z-a', 'generation 9, Nintendo Switch')
];
/** games.library slug of a game tracker entry (`/trackers/games/<slug>`). */
export const gameSlug = (t: { href: string }) => t.href.replace('/trackers/games/', '');
/** Every game, active ones first (games.library.active, my pick), non-active ones flagged
* `inactive`; `inactiveGames` comes from the root layout data. */
export function sortedGames(inactiveGames: readonly string[] = []): readonly Tracker[] {
if (!inactiveGames.length) return gameTrackers;
const off = new Set(inactiveGames);
const flagged = gameTrackers.map((g) =>
off.has(gameSlug(g)) ? { ...g, inactive: true, featured: false } : { ...g, featured: true }
);
return [...flagged.filter((g) => !g.inactive), ...flagged.filter((g) => g.inactive)];
}
/** Only the games I play now. */
export const activeGames = (inactiveGames: readonly string[] = []) =>
sortedGames(inactiveGames).filter((g) => !g.inactive);
// Which games.library entry each Pokémon journal game (ref.pokemon_games.slug) belongs to,
// so hiding a generation also hides its games on the hub. Unlisted games always show.
const POKEMON_ENTRY_GAMES: Record<string, readonly string[]> = {
'pokemon-red-blue-yellow': ['red', 'blue', 'yellow', 'red-japan', 'blue-japan', 'green-japan'],
'pokemon-gold-silver-crystal': ['gold', 'silver', 'crystal'],
'pokemon-ruby-sapphire-emerald': ['ruby', 'sapphire', 'emerald'],
'pokemon-colosseum': ['colosseum'],
'pokemon-xd': ['xd'],
'pokemon-firered-leafgreen': ['firered', 'leafgreen'],
'pokemon-diamond-pearl-platinum': ['diamond', 'pearl', 'platinum'],
'pokemon-heartgold-soulsilver': ['heartgold', 'soulsilver'],
'pokemon-black-white': ['black', 'white', 'black-2', 'white-2'],
'pokemon-x-y': ['x', 'y'],
'pokemon-omega-ruby-alpha-sapphire': ['omega-ruby', 'alpha-sapphire'],
'pokemon-sun-moon': ['sun', 'moon', 'ultra-sun', 'ultra-moon'],
'pokemon-lets-go': ['lets-go-pikachu', 'lets-go-eevee'],
'pokemon-sword-shield': [
'sword',
'shield',
'the-isle-of-armor-sword',
'the-isle-of-armor-shield',
'the-crown-tundra-sword',
'the-crown-tundra-shield'
],
'pokemon-brilliant-diamond-shining-pearl': ['brilliant-diamond', 'shining-pearl'],
'pokemon-legends-arceus': ['legends-arceus'],
'pokemon-scarlet-violet': [
'scarlet',
'violet',
'the-teal-mask-scarlet',
'the-teal-mask-violet',
'the-indigo-disk-scarlet',
'the-indigo-disk-violet'
],
'pokemon-legends-z-a': ['legends-za', 'mega-dimension']
};
const POKEMON_GAME_ENTRY = new Map(
Object.entries(POKEMON_ENTRY_GAMES).flatMap(([entry, games]) => games.map((g) => [g, entry]))
);
/** games.library entry for a Pokémon journal game slug, if it has one. */
export const pokemonEntryFor = (slug: string | null | undefined) =>
slug ? POKEMON_GAME_ENTRY.get(slug) : undefined;
// 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
// header, home page and /trackers only to the logged-in owner (layout data `isOwner`), never
@@ -273,7 +338,7 @@ const allTrackers: readonly (Tracker & { private: boolean })[] = [
{
name: 'games',
subtitle: 'every pokemon in every game, and call of duty camo mastery',
href: '/projects/game-tracker',
href: '/trackers/games',
external: false,
children: gameTrackers,
private: false
@@ -323,6 +388,14 @@ export const trackers: readonly Tracker[] = allTrackers.filter((t) => !t.private
export const privateTrackers: readonly Tracker[] = allTrackers.filter((t) => t.private).map(strip);
const ownerTrackers: readonly Tracker[] = allTrackers.map(strip);
export function visibleTrackers(isOwner: boolean): readonly Tracker[] {
return isOwner ? ownerTrackers : trackers;
/** Trackers for the header, home, /trackers, /start and error pages; with `inactiveGames` (root
* layout data) the games group lists active games first and flags the rest `inactive`. */
export function visibleTrackers(
isOwner: boolean,
inactiveGames: readonly string[] = []
): readonly Tracker[] {
const list = isOwner ? ownerTrackers : trackers;
if (!inactiveGames.length) return list;
const games = sortedGames(inactiveGames);
return list.map((t) => (t.children === gameTrackers ? { ...t, children: games } : t));
}

View File

@@ -76,7 +76,7 @@ const publicPages: Record<string, { title: string; description: string }> = {
'Stan Runge’s PC setup: his PC, desk and devices on one screen with a time slider back to 2021, the planned next build and mouse sensitivities.'
},
'/lists': { title: 'Lists', description: 'Lists collected and maintained by Stan Runge.' },
'/projects/game-tracker': {
'/trackers/games': {
title: 'Game Tracker',
description:
'Pokémon adventures and collections, Call of Duty ranks and unlocks, and osu! scores and training.'

View File

@@ -1,11 +1,12 @@
import { sql } from 'drizzle-orm';
import { check, integer, text } from 'drizzle-orm/pg-core';
import { boolean, 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.
// (static, so navigation needs no query); keep the slugs in sync with these rows. Only the
// `active` flag is dynamic: the root layout loads the non-active slugs (#lib/server/games/active).
// 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;
@@ -36,6 +37,9 @@ export const gameLibrary = gamesSchema.table(
.default(sql`'{}'::text[]`),
notes: text('notes'),
sortOrder: integer('sort_order').notNull().default(100),
/** Games I play now (my pick, toggled on /trackers/games). Non-active games keep their
* data and page; navigation lists them in a separate "non-active" section. */
active: boolean('active').notNull().default(true),
...timestamps
},
(t) => [

View File

@@ -0,0 +1,37 @@
import { asc, eq } from 'drizzle-orm';
import { db } from '#lib/server/db/index.js';
import { gameLibrary } from '#lib/server/db/schema/index.js';
// Games I don't play now (games.library.active = false). The root layout loads these slugs on
// every request so the header, home, /trackers, /start and the /trackers/games dashboard can
// split active from non-active, so keep a short in-memory cache; setGameActive clears it.
const TTL_MS = 60_000;
let cache: { at: number; slugs: string[] } | undefined;
export async function loadInactiveGames(): Promise<string[]> {
if (cache && Date.now() - cache.at < TTL_MS) return cache.slugs;
try {
const rows = await db
.select({ slug: gameLibrary.slug })
.from(gameLibrary)
.where(eq(gameLibrary.active, false))
.orderBy(asc(gameLibrary.sortOrder));
cache = { at: Date.now(), slugs: rows.map((r) => r.slug) };
return cache.slugs;
} catch (err) {
// Navigation must never break on this: treat every game as active if the query fails.
console.error('games: could not load the non-active games', err);
return cache?.slugs ?? [];
}
}
/** Mark one game active or non-active; false when the slug is not in games.library. */
export async function setGameActive(slug: string, active: boolean): Promise<boolean> {
const rows = await db
.update(gameLibrary)
.set({ active, updatedAt: new Date() })
.where(eq(gameLibrary.slug, slug))
.returning({ slug: gameLibrary.slug });
cache = undefined;
return rows.length > 0;
}

View File

@@ -38,7 +38,7 @@ export function getTitle(path: string, environment: SiteEnvironment = null): str
'/projects/derby': 'Derby Lens',
'/projects/cs-prices': 'CS Prices',
'/projects/energy': 'Energy',
'/projects/game-tracker': 'Game Tracker',
'/trackers/games': 'Game Tracker',
'/projects/language-learner': 'Language Learner',
'/projects/spotify-beatmap-finder': 'osu! map finder',
'/projects/progress': 'Progress',

View File

@@ -1,10 +1,11 @@
import { isAdmin } from '#lib/server/authorization.js';
import { isOwner } from '#lib/server/owner.js';
import { loadInactiveGames } from '#lib/server/games/active.js';
import { dev } from '$app/env';
import { siteEnvironment } from '#lib/title.js';
import type { LayoutServerLoad } from './$types';
const environment = siteEnvironment(dev, process.env.ORIGIN);
export const load: LayoutServerLoad = (event) => ({
export const load: LayoutServerLoad = async (event) => ({
user: event.locals.user
? { id: event.locals.user.id, name: event.locals.user.name, image: event.locals.user.image }
: null,
@@ -12,5 +13,7 @@ export const load: LayoutServerLoad = (event) => ({
isOwner: isOwner(event),
editable: isAdmin(event),
version: process.env.APP_VERSION || 'dev',
environment
environment,
/** Games I don't play now: navigation lists them under "non-active" (#lib/projects). */
inactiveGames: await loadInactiveGames()
});

View File

@@ -138,7 +138,7 @@
>
</h2>
<CatalogGrid
items={visibleTrackers(data.isOwner)}
items={visibleTrackers(data.isOwner, data.inactiveGames)}
variant="tracker"
subtitleClass="mt-1 block text-base text-[var(--site-muted)]"
/>

View File

@@ -1,172 +1,6 @@
import { asc, eq } from 'drizzle-orm';
import { db } from '#lib/server/db/index.js';
import {
trackerGames,
trackerItems,
trackerProgress,
trackerWeaponRanks,
trackerRanks,
trackerChallenges,
trackerChallengeProgress,
osuEntries
} from '#lib/server/db/schema/index.js';
import { loadPokemonProgress } from '#lib/server/tracker-progress.js';
import { challengeProgress, combinedProgress, sumProgress } from '#lib/game-tracker/progress.js';
import { gameRanksProgress, hasReachedMaxLevel } from '#lib/game-tracker/rank-progress.js';
import { summarizeGame } from '#lib/game-tracker/mastery.js';
import { chronologicalPokemon, pokemonRelease } from '#lib/game-tracker/game-art.js';
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
async function pokemonSummary(userId: string | undefined) {
const progress = await loadPokemonProgress(userId);
return {
...progress,
games: chronologicalPokemon(progress.configured ? progress.games : []).map((g) => ({
...g,
releaseYear: pokemonRelease(g.slug).slice(0, 4)
}))
};
}
async function callOfDutySummary(userId: string | undefined) {
const [gamesList, items, progress, ranks, playerRanks, challenges, challengeRows] =
await Promise.all([
db
.select()
.from(trackerGames)
.where(eq(trackerGames.franchise, 'call-of-duty'))
.orderBy(asc(trackerGames.releaseYear), asc(trackerGames.sortOrder)),
db
.select({
id: trackerItems.id,
gameId: trackerItems.gameId,
kind: trackerItems.kind,
name: trackerItems.name,
category: trackerItems.category,
postLaunch: trackerItems.postLaunch
})
.from(trackerItems),
userId
? db
.select({ itemId: trackerProgress.itemId, milestone: trackerProgress.milestone })
.from(trackerProgress)
.where(eq(trackerProgress.userId, userId))
: Promise.resolve([]),
userId
? db
.select({
itemId: trackerWeaponRanks.itemId,
prestige: trackerWeaponRanks.prestige,
level: trackerWeaponRanks.level
})
.from(trackerWeaponRanks)
.where(eq(trackerWeaponRanks.userId, userId))
: Promise.resolve([]),
userId
? db
.select({
gameId: trackerRanks.gameId,
level: trackerRanks.level,
prestige: trackerRanks.prestige
})
.from(trackerRanks)
.where(eq(trackerRanks.userId, userId))
: Promise.resolve([]),
db
.select({
id: trackerChallenges.id,
gameId: trackerChallenges.gameId,
target: trackerChallenges.target
})
.from(trackerChallenges),
userId
? db
.select({
challengeId: trackerChallengeProgress.challengeId,
current: trackerChallengeProgress.current,
done: trackerChallengeProgress.done
})
.from(trackerChallengeProgress)
.where(eq(trackerChallengeProgress.userId, userId))
: []
]);
const done = new Set(progress.map((row) => `${row.itemId}:${row.milestone}`));
for (const rank of ranks) if (rank.prestige > 0) done.add(`${rank.itemId}:maxLevel`);
const byGame = Map.groupBy(items, (item) => item.gameId);
return gamesList.map((game) => {
const list = byGame.get(game.id) ?? [];
const weapons = list.filter((i) => i.kind === 'weapon');
const rankMap = new Map(ranks.map((r) => [r.itemId, r]));
for (const weapon of weapons)
if (hasReachedMaxLevel(game.rules, weapon, rankMap.get(weapon.id)))
done.add(`${weapon.id}:maxLevel`);
const summary = summarizeGame(
game.rules,
list.filter((item) => item.kind === 'weapon'),
list.filter((item) => item.kind === 'achievement').map((item) => item.id),
(id, milestone) => done.has(`${id}:${milestone}`)
);
const weaponProgress = sumProgress(summary.milestones);
const challengesProgress = challengeProgress(
challenges.filter((c) => c.gameId === game.id),
challengeRows
);
const ranksProgress = gameRanksProgress(
game.rules,
weapons,
rankMap,
playerRanks.find((r) => r.gameId === game.id)
);
return {
overall: combinedProgress([
weaponProgress,
ranksProgress,
summary.achievements,
challengesProgress
]),
weaponProgress,
challengesProgress,
slug: game.slug,
name: game.shortName,
releaseYear: game.releaseYear,
achievements: summary.achievements,
milestones: summary.milestones.map(({ key, label, value, total }) => ({
key,
label,
value,
total
})),
tiers: summary.tiers.map(({ key, label, scope, value, total, achieved }) => ({
key,
label,
scope,
value,
total,
achieved
}))
};
});
}
export const load: PageServerLoad = async ({ locals }) => {
const [pokemon, callOfDuty, osu] = await Promise.all([
pokemonSummary(locals.user?.id),
callOfDutySummary(locals.user?.id),
locals.user
? db
.select({ kind: osuEntries.kind, achieved: osuEntries.achieved })
.from(osuEntries)
.where(eq(osuEntries.userId, locals.user.id))
: []
]);
return {
pokemon,
callOfDuty,
codOverall: combinedProgress(callOfDuty.map((g) => g.overall)),
osuGoals: {
value: osu.filter((e) => e.kind === 'goal' && e.achieved).length,
total: osu.filter((e) => e.kind === 'goal').length
}
};
};
// The games dashboard moved to /trackers/games (the deep trackers, such as /pokemon, /osu and
// /call-of-duty/<title>, stay under /projects/game-tracker).
export const load: PageServerLoad = ({ url }) => redirect(301, `/trackers/games${url.search}`);

View File

@@ -1,222 +1 @@
<script lang="ts">
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 ProgressMeter from '#lib/game-tracker/ProgressMeter.svelte';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
</script>
<div class="tracker-library mx-auto flex w-full max-w-[1600px] flex-col px-4 py-5 sm:px-6">
<div class="mb-5 flex flex-wrap items-end justify-between gap-2">
<div>
<p class="text-xs tracking-[.18em] text-muted-foreground uppercase">Your next session</p>
<h1 class="mt-1 text-3xl font-semibold tracking-tight">Game tracker</h1>
</div>
{#if !data.user}<a
class="text-sm text-muted-foreground underline"
href={resolve('login?returnTo=/projects/game-tracker')}>Sign in to save progress</a
>{:else}<a
class="text-xs text-muted-foreground underline"
href={resolve('projects/game-tracker/credits')}>Artwork credits</a
>{/if}
</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
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"
>
<ProgressMeter
label="National collection"
progress={data.pokemon.caught}
detail={`${data.pokemon.caught.value} unique species caught`}
hint="Unique species caught across the Pokémon games in your rules, out of the full species catalog."
/>
<ProgressMeter
label="Species seen at least once"
progress={data.pokemon.seen}
color="sky"
detail={`${data.pokemon.seen.value} species discovered`}
/>
<ProgressMeter
label="Pokémon games completed"
progress={data.pokemon.completed}
color="amber"
hint="Games marked story complete, or with all default story milestones checked."
/>
<ProgressMeter
label="Call of Duty overall"
progress={data.codOverall}
color="violet"
detail="Mastery · achievements · challenges"
/>
</div>
<div class="series-grid grid min-h-0 flex-1 gap-4">
<section
class="series-column flex min-h-0 min-w-0 flex-col rounded-2xl border bg-card p-3"
aria-labelledby="pokemon-series"
>
<a
href={resolve('projects/game-tracker/pokemon')}
class="series-heading mb-3 flex items-center gap-3 rounded-xl bg-amber-500/10 p-3 transition-colors hover:bg-amber-500/20"
>
<GameLogo slug="pokemon" name="Pokémon" class="h-12 w-24" />
<div class="min-w-0 flex-1">
<h2 id="pokemon-series" class="font-semibold">Pokémon</h2>
<p class="text-xs text-muted-foreground">Open your adventure journal →</p>
<p class="mt-1 text-xs text-muted-foreground">
{data.pokemon.caught.value} / {data.pokemon.caught.total} species
</p>
</div>
</a>
<div class="mb-3 space-y-2 rounded-lg bg-muted/20 p-2">
<ProgressMeter
label="Across every adventure"
progress={data.pokemon.overall}
color="amber"
compact
hint="Equal weight per game; collection and story stages each contribute."
/>
<ProgressMeter
label="Every game’s collection"
progress={data.pokemon.collection}
compact
hint="Each confirmed species/game pairing counts once, including trades and events."
/>
</div>
{#if data.pokemon.games.length}
<div class="collection-grid grid grid-cols-2 gap-2">
{#each data.pokemon.games as game (game.id)}
<a
class="game-row flex min-h-0 min-w-0 items-center gap-2 rounded-lg border px-2 py-1.5 transition-colors hover:border-amber-500/50 hover:bg-amber-500/5"
href={resolve(`projects/game-tracker/pokemon?game=${game.slug ?? game.id}`)}
title={`${game.name} · ${game.releaseYear}`}
>
<GameLogo
slug={`pokemon/${game.slug}`}
name={game.name}
class="game-cover h-10 w-10"
/>
<div class="min-w-0 flex-1">
<p class="game-name text-xs leading-tight font-medium">{game.name}</p>
<p class="game-year mt-0.5 text-[10px] text-muted-foreground">
{game.releaseYear}{game.completed ? ' · ✓ story' : ''}
</p>
<ProgressMeter
label="Game progress"
progress={game.overall}
color="amber"
compact
/>
</div>
</a>
{/each}
</div>
{:else}<a
class="rounded-xl border border-dashed p-5 text-sm text-muted-foreground hover:text-foreground"
href={resolve('projects/game-tracker/pokemon')}
>Choose your games in “set rules” to build your collection here.</a
>{/if}
</section>
<section
class="series-column flex min-h-0 min-w-0 flex-col rounded-2xl border bg-card p-3"
aria-labelledby="cod-series"
>
<div class="series-heading mb-3 flex items-center gap-3 rounded-xl bg-zinc-500/10 p-3">
<GameLogo slug="call-of-duty" name="Call of Duty" class="h-12 w-24" />
<div>
<h2 id="cod-series" class="font-semibold">Call of Duty</h2>
<p class="text-xs text-muted-foreground">
{data.callOfDuty.length} campaigns of progress
</p>
</div>
</div>
<div class="mb-3 px-1">
<ProgressMeter label="COD completion" progress={data.codOverall} color="violet" compact />
</div>
<div class="cod-rows grid min-h-0 gap-1.5">
{#each data.callOfDuty as game (game.slug)}
<a
href={resolve(`projects/game-tracker/call-of-duty/${game.slug}`)}
class="game-row flex min-h-0 items-center gap-3 rounded-lg border p-2 transition-colors hover:border-primary/40 hover:bg-muted/50"
>
<GameLogo slug={`cod/${game.slug}`} name={game.name} class="h-11 w-24" />
<div class="min-w-0 flex-1">
<p class="text-sm leading-tight font-medium">{game.name}</p>
<p class="mt-1 text-[11px] text-muted-foreground">
{game.releaseYear}<span class="ml-2"
>{game.achievements.value}/{game.achievements.total} achievements</span
>
</p>
<ProgressMeter
label={`${game.name} progress`}
progress={game.overall}
color="violet"
compact
/>
</div>
</a>
{/each}
</div>
</section>
<section
class="series-column flex min-w-0 flex-col rounded-2xl border bg-card p-3"
aria-labelledby="osu-series"
>
<div class="series-heading mb-3 flex items-center gap-3 rounded-xl bg-pink-500/10 p-3">
<GameLogo slug="osu" name="osu!" class="h-12 w-12" />
<div>
<h2 id="osu-series" class="font-semibold">osu!</h2>
<p class="text-xs text-muted-foreground">One more attempt</p>
</div>
</div>
<a
href={resolve('projects/game-tracker/osu')}
class="rounded-xl border p-4 transition-colors hover:border-pink-500/50 hover:bg-pink-500/5"
><div class="flex items-center gap-3">
<GameLogo slug="osu" name="osu!" class="h-14 w-14" />
<div>
<p class="font-semibold">osu!</p>
<p class="text-xs text-muted-foreground">2007</p>
</div>
</div>
<p class="mt-4 text-sm">Your training room →</p>
<p class="mt-1 text-xs leading-relaxed text-muted-foreground">
Scores, dream FCs, stamina and speed. Build your next personal best.
</p>
<div class="mt-4">
<ProgressMeter label="osu! goals achieved" progress={data.osuGoals} color="pink" />
</div></a
>
</section>
</div>
</div>
<style>
.series-grid {
grid-template-columns: minmax(0, 1.5fr) minmax(0, 1.2fr) minmax(0, 0.8fr);
align-items: start;
}
.game-row {
min-height: 64px;
}
@media (max-width: 1100px) {
.series-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 639px) {
.series-grid {
grid-template-columns: minmax(0, 1fr);
}
}
</style>
<!-- Redirects to /trackers/games (see +page.server.ts). -->

View File

@@ -239,7 +239,7 @@
<div class="cod-companion">
<div class="companion-header">
<a href={resolve('projects/game-tracker')} class="back-link">← game tracker</a>
<a href={resolve('/trackers/games')} class="back-link">← game tracker</a>
<GameLogo
slug={`cod/${data.game.slug}`}
name={data.game.shortName}

View File

@@ -1,17 +1,5 @@
import weaponArt from '../../../../../data/cod-art.json';
import rankArt from '../../../../../data/cod-rank-art.json';
import images from '../../../../../data/game-images.json';
import maps from '../../../../../data/pokemon-map-art.json';
export function load() {
const sources = new Map<string, { source: string; credit: string; count: number }>();
for (const image of [...images, ...weaponArt, ...rankArt]) {
const existing = sources.get(image.source);
if (existing) existing.count++;
else sources.set(image.source, { source: image.source, credit: image.credit, count: 1 });
}
return {
sources: [...sources.values()],
maps: Object.values(maps).map(({ name, source, credit }) => ({ name, source, credit })),
total: images.length + weaponArt.length + rankArt.length
};
}
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
// Moved with the games dashboard.
export const load: PageServerLoad = () => redirect(301, '/trackers/games/credits');

View File

@@ -1,61 +1 @@
<script lang="ts">
import { resolve } from '$app/paths';
import TrackerIcon from '#lib/components/TrackerIcon.svelte';
import { gameLogos } from '#lib/game-logos.js';
import { gameTrackers } from '#lib/projects.js';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
</script>
<!-- Download manifest links point to reviewed external image sources. -->
<!-- eslint-disable svelte/no-navigation-without-resolve -->
<div class="mx-auto max-w-4xl px-4 py-10 sm:px-6">
<a href={resolve('projects/game-tracker')} class="text-sm text-muted-foreground hover:underline"
>← Game tracker</a
>
<h1 class="mt-6 text-3xl font-semibold">Artwork credits</h1>
<p class="mt-3 text-muted-foreground">
Game covers, logos, sprites, maps and screenshots belong to their respective owners. The tracker
stores WebP copies for consistent, reliable display. Map imagery is shared across games in the
same region; later-region landmark positions are approximate.
</p>
<h2 class="mt-8 text-xl font-semibold">Game logos</h2>
<ul class="mt-4 grid gap-3 sm:grid-cols-2">
{#each gameTrackers as game (game.href)}
{@const logo = gameLogos[game.icon ?? game.name]}
{#if logo}
<li class="flex items-center gap-3">
<TrackerIcon name={game.icon ?? game.name} class="size-8" />
<div class="min-w-0">
<p class="text-sm font-medium">{game.name}</p>
<a href={logo.source} target="_blank" rel="noreferrer" class="text-xs underline"
>{logo.credit}</a
>
</div>
</li>
{/if}
{/each}
</ul>
<h2 class="mt-8 text-xl font-semibold">Regional maps</h2>
<ul class="mt-4 space-y-3">
{#each data.maps as map (map.name)}<li>
<a href={map.source} target="_blank" rel="noreferrer" class="font-medium underline"
>{map.name}</a
>
<p class="text-xs text-muted-foreground">{map.credit}</p>
</li>{/each}
</ul>
<h2 class="mt-8 text-xl font-semibold">Covers, sprites and other artwork</h2>
<p class="mt-2 text-sm text-muted-foreground">
{data.total.toLocaleString()} downloaded image records. Box art is archived by Libretro and Serebii;
Pokémon sprites and official artwork are archived by PokéAPI. COD covers and achievement icons come
from Steam.
</p>
<ul class="mt-4 space-y-3">
{#each data.sources as item (item.source)}<li>
<a href={item.source} target="_blank" rel="noreferrer" class="text-sm underline"
>{item.credit}</a
><span class="ml-2 text-xs text-muted-foreground">{item.count} images</span>
</li>{/each}
</ul>
</div>
<!-- Redirects to /trackers/games/credits (see +page.server.ts). -->

View File

@@ -71,9 +71,8 @@
</script>
<div class="mx-auto max-w-6xl px-3 py-4 sm:px-5">
<a
href={resolve('projects/game-tracker')}
class="text-sm text-muted-foreground hover:text-foreground">← game tracker</a
<a href={resolve('/trackers/games')} class="text-sm text-muted-foreground hover:text-foreground"
>← game tracker</a
>
<div class="mt-3 flex flex-wrap items-center gap-3">
<GameLogo slug="osu" name="osu!" class="h-12 w-12 rounded-full bg-pink-500/10 text-pink-500" />

View File

@@ -48,9 +48,8 @@
class="h-12 w-12"
/>{/if}
<div>
<a
href={resolve('projects/game-tracker')}
class="text-xs text-muted-foreground hover:underline">← Game tracker</a
<a href={resolve('/trackers/games')} class="text-xs text-muted-foreground hover:underline"
>← Game tracker</a
>
<h1 class="text-xl font-semibold">
Pokémon journal <span class="text-sm font-normal text-muted-foreground"

View File

@@ -28,7 +28,7 @@ const staticPaths = [
'/trackers/wildlife/groups',
'/trackers/music',
'/trackers/pc',
'/projects/game-tracker',
'/trackers/games',
...gameTrackers.map((g) => g.href),
'/projects/game-tracker/pokemon',
'/projects/game-tracker/osu',

View File

@@ -41,7 +41,7 @@
// Same list, order and icons as the header Trackers dropdown (owner-only ones only when
// the header shows them). Spotting is a group tile with one-click chips per sub-tracker.
const trackers = $derived(visibleTrackers(data.isOwner));
const trackers = $derived(visibleTrackers(data.isOwner, data.inactiveGames));
// Search-only shortcuts that aren't trackers of their own.
const extraHits = [
{ name: 'Weigh-in', href: '/trackers/health#body' },
@@ -249,6 +249,20 @@
? (t.children ?? []).filter((c) => c.featured)
: (t.children ?? []);
const hiddenChildren = (t: Group) => (t.children?.length ?? 0) - tileChildren(t).length;
// Games split like the header's Trackers dropdown: "active" (my pick, always shown) and
// "non-active" (behind its own toggle, shown while filtering).
type Child = NonNullable<Group['children']>[number];
const splitGroup = (t: Group) => (t.children ?? []).some((c) => c.inactive);
const chipSections = (t: Group): { label: string; items: readonly Child[] }[] => {
const all = t.children ?? [];
if (!splitGroup(t)) return [{ label: '', items: tileChildren(t) }];
const off = all.filter((c) => c.inactive);
return [
{ label: 'active', items: all.filter((c) => !c.inactive) },
{ label: 'non-active', items: filtering || expandedGroups[t.name] ? off : [] }
];
};
const inactiveCount = (t: Group) => (t.children ?? []).filter((c) => c.inactive).length;
// The links filter (f) also narrows the trackers; a group stays if it or a chip matches.
const shownTrackers = $derived.by(() => {
const q = linkFilter.trim().toLowerCase();
@@ -571,12 +585,35 @@
<span class="tname">{t.name}</span>{#if t.locked}<PrivateLock tight />{/if}
</a>
<span class="tchips">
{#each tileChildren(t) as c (c.name)}
<a class="tchip" href={c.href} title={c.subtitle}
><TrackerIcon name={c.icon ?? c.name} class="t-icon size-5" />{c.name}</a
>
{#each chipSections(t) as section (section.label)}
{#if section.label === 'non-active' && !filtering}
<button
type="button"
class="tlabel toggle"
aria-expanded={!!expandedGroups[t.name]}
onclick={() =>
(expandedGroups = {
...expandedGroups,
[t.name]: !expandedGroups[t.name]
})}
>non-active <span>{inactiveCount(t)}</span>{expandedGroups[t.name]
? ' ▾'
: ' ▸'}</button
>
{:else if section.label && section.items.length}
<span class="tlabel">{section.label} <span>{section.items.length}</span></span
>
{/if}
{#each section.items as c (c.name)}
<a
class={['tchip', c.inactive && 'inactive']}
href={c.href}
title={c.subtitle}
><TrackerIcon name={c.icon ?? c.name} class="t-icon size-5" />{c.name}</a
>
{/each}
{/each}
{#if !filtering && (hiddenChildren(t) > 0 || expandedGroups[t.name])}
{#if !filtering && !splitGroup(t) && (hiddenChildren(t) > 0 || expandedGroups[t.name])}
<button
type="button"
class="tchip more"
@@ -1670,6 +1707,38 @@
.stretched:hover {
text-decoration: none;
}
.tlabel {
flex-basis: 100%;
display: flex;
align-items: baseline;
gap: 0.35rem;
font-size: 0.62rem;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--site-muted);
background: none;
border: 0;
padding: 0;
text-align: left;
}
.tlabel span {
letter-spacing: 0;
opacity: 0.7;
}
.tlabel:not(:first-child) {
margin-top: 0.3rem;
}
button.tlabel {
cursor: pointer;
}
button.tlabel:hover {
color: inherit;
}
.tchip.inactive :global(.t-icon) {
filter: grayscale(0.6);
opacity: 0.75;
}
.tchips {
position: relative;
z-index: 1;

View File

@@ -10,5 +10,5 @@
<p class="mb-10 text-[var(--site-muted)]">
Personal data I keep track of: games, money, houses, books, prices, animals and lists.
</p>
<CatalogGrid items={visibleTrackers(data.isOwner)} variant="tracker" wide />
<CatalogGrid items={visibleTrackers(data.isOwner, data.inactiveGames)} variant="tracker" wide />
</div>

View File

@@ -1,15 +1,20 @@
<script lang="ts">
import Gamepad2 from 'lucide-svelte/icons/gamepad-2';
import { page } from '$app/state';
import SpottingSwitcher from '#lib/components/SpottingSwitcher.svelte';
import { gameTrackers } from '#lib/projects.js';
import { activeGames, sortedGames } from '#lib/projects.js';
let { children } = $props();
// The game chips above every /trackers/games page: the games I play now (games.library.active),
// plus the game whose page this is when it is non-active.
let { data, children } = $props();
const items = $derived.by(() => {
const active = activeGames(data.inactiveGames);
const current = sortedGames(data.inactiveGames).find(
(g) => g.inactive && page.url.pathname === g.href
);
return current ? [...active, current] : active;
});
</script>
<SpottingSwitcher
items={gameTrackers}
home="/projects/game-tracker"
label="games"
homeIcon={Gamepad2}
/>
<SpottingSwitcher {items} home="/trackers/games" label="games" homeIcon={Gamepad2} />
{@render children()}

View File

@@ -1,5 +1,220 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
import { fail } from '@sveltejs/kit';
import { asc, eq } from 'drizzle-orm';
import { db } from '#lib/server/db/index.js';
import {
trackerGames,
trackerItems,
trackerProgress,
trackerWeaponRanks,
trackerRanks,
trackerChallenges,
trackerChallengeProgress,
osuEntries,
gameLibrary
} from '#lib/server/db/schema/index.js';
import { isOwner, requireOwner } from '#lib/server/owner.js';
import { loadInactiveGames, setGameActive } from '#lib/server/games/active.js';
import { pokemonEntryFor } from '#lib/projects.js';
import { loadPokemonProgress } from '#lib/server/tracker-progress.js';
import { challengeProgress, combinedProgress, sumProgress } from '#lib/game-tracker/progress.js';
import { gameRanksProgress, hasReachedMaxLevel } from '#lib/game-tracker/rank-progress.js';
import { summarizeGame } from '#lib/game-tracker/mastery.js';
import { chronologicalPokemon, pokemonRelease } from '#lib/game-tracker/game-art.js';
import type { Actions, 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');
// The games dashboard (moved here from /projects/game-tracker): only the games I play now
// (games.library.active, my pick). The owner switches games between active and non-active at
// the bottom; non-active games keep all their data and their pages.
async function pokemonSummary(userId: string | undefined) {
const progress = await loadPokemonProgress(userId);
return {
...progress,
games: chronologicalPokemon(progress.configured ? progress.games : []).map((g) => ({
...g,
releaseYear: pokemonRelease(g.slug).slice(0, 4)
}))
};
}
async function callOfDutySummary(userId: string | undefined) {
const [gamesList, items, progress, ranks, playerRanks, challenges, challengeRows] =
await Promise.all([
db
.select()
.from(trackerGames)
.where(eq(trackerGames.franchise, 'call-of-duty'))
.orderBy(asc(trackerGames.releaseYear), asc(trackerGames.sortOrder)),
db
.select({
id: trackerItems.id,
gameId: trackerItems.gameId,
kind: trackerItems.kind,
name: trackerItems.name,
category: trackerItems.category,
postLaunch: trackerItems.postLaunch
})
.from(trackerItems),
userId
? db
.select({ itemId: trackerProgress.itemId, milestone: trackerProgress.milestone })
.from(trackerProgress)
.where(eq(trackerProgress.userId, userId))
: Promise.resolve([]),
userId
? db
.select({
itemId: trackerWeaponRanks.itemId,
prestige: trackerWeaponRanks.prestige,
level: trackerWeaponRanks.level
})
.from(trackerWeaponRanks)
.where(eq(trackerWeaponRanks.userId, userId))
: Promise.resolve([]),
userId
? db
.select({
gameId: trackerRanks.gameId,
level: trackerRanks.level,
prestige: trackerRanks.prestige
})
.from(trackerRanks)
.where(eq(trackerRanks.userId, userId))
: Promise.resolve([]),
db
.select({
id: trackerChallenges.id,
gameId: trackerChallenges.gameId,
target: trackerChallenges.target
})
.from(trackerChallenges),
userId
? db
.select({
challengeId: trackerChallengeProgress.challengeId,
current: trackerChallengeProgress.current,
done: trackerChallengeProgress.done
})
.from(trackerChallengeProgress)
.where(eq(trackerChallengeProgress.userId, userId))
: []
]);
const done = new Set(progress.map((row) => `${row.itemId}:${row.milestone}`));
for (const rank of ranks) if (rank.prestige > 0) done.add(`${rank.itemId}:maxLevel`);
const byGame = Map.groupBy(items, (item) => item.gameId);
return gamesList.map((game) => {
const list = byGame.get(game.id) ?? [];
const weapons = list.filter((i) => i.kind === 'weapon');
const rankMap = new Map(ranks.map((r) => [r.itemId, r]));
for (const weapon of weapons)
if (hasReachedMaxLevel(game.rules, weapon, rankMap.get(weapon.id)))
done.add(`${weapon.id}:maxLevel`);
const summary = summarizeGame(
game.rules,
list.filter((item) => item.kind === 'weapon'),
list.filter((item) => item.kind === 'achievement').map((item) => item.id),
(id, milestone) => done.has(`${id}:${milestone}`)
);
const weaponProgress = sumProgress(summary.milestones);
const challengesProgress = challengeProgress(
challenges.filter((c) => c.gameId === game.id),
challengeRows
);
const ranksProgress = gameRanksProgress(
game.rules,
weapons,
rankMap,
playerRanks.find((r) => r.gameId === game.id)
);
return {
overall: combinedProgress([
weaponProgress,
ranksProgress,
summary.achievements,
challengesProgress
]),
weaponProgress,
challengesProgress,
slug: game.slug,
name: game.shortName,
releaseYear: game.releaseYear,
achievements: summary.achievements,
milestones: summary.milestones.map(({ key, label, value, total }) => ({
key,
label,
value,
total
})),
tiers: summary.tiers.map(({ key, label, scope, value, total, achieved }) => ({
key,
label,
scope,
value,
total,
achieved
}))
};
});
}
export const load: PageServerLoad = async (event) => {
const { locals } = event;
const owner = isOwner(event);
const [pokemon, callOfDuty, osu, inactiveGames, library] = await Promise.all([
pokemonSummary(locals.user?.id),
callOfDutySummary(locals.user?.id),
locals.user
? db
.select({ kind: osuEntries.kind, achieved: osuEntries.achieved })
.from(osuEntries)
.where(eq(osuEntries.userId, locals.user.id))
: [],
loadInactiveGames(),
owner
? db
.select({
slug: gameLibrary.slug,
name: gameLibrary.name,
active: gameLibrary.active
})
.from(gameLibrary)
.orderBy(asc(gameLibrary.sortOrder))
: []
]);
const off = new Set(inactiveGames);
// Pokémon journal games belong to a generation entry (e.g. Platinum to Diamond, Pearl &
// Platinum); games without one always show.
const activePokemon = pokemon.games.filter((g) => {
const entry = pokemonEntryFor(g.slug);
return !entry || !off.has(entry);
});
return {
pokemon: { ...pokemon, games: activePokemon },
callOfDuty: callOfDuty.filter((g) => !off.has(g.slug)),
codOverall: combinedProgress(callOfDuty.map((g) => g.overall)),
showOsu: !off.has('osu'),
showPokemon: library.length
? library.some((g) => g.slug.startsWith('pokemon-') && g.active)
: true,
osuGoals: {
value: osu.filter((e) => e.kind === 'goal' && e.achieved).length,
total: osu.filter((e) => e.kind === 'goal').length
},
/** Owner only: every game with its active flag, for the active/non-active switches. */
library
};
};
export const actions: Actions = {
/** Owner only: move one game between active and non-active (nothing is deleted). */
setActive: async (event) => {
requireOwner(event);
const form = await event.request.formData();
const slug = String(form.get('slug') ?? '');
const active = form.get('active') === 'true';
if (!/^[a-z0-9-]+$/.test(slug)) return fail(400, { message: 'Unknown game' });
if (!(await setGameActive(slug, active))) return fail(404, { message: 'Unknown game' });
return { slug, active };
}
};

View File

@@ -1 +1,384 @@
<!-- Redirects to /projects/game-tracker (see +page.server.ts). -->
<script lang="ts">
import { resolve } from '$app/paths';
import { enhance } from '$app/forms';
import Power from 'lucide-svelte/icons/power';
import PowerOff from 'lucide-svelte/icons/power-off';
import TrackerIcon from '#lib/components/TrackerIcon.svelte';
import { gameTrackers, gameSlug } from '#lib/projects.js';
import GameLogo from '#lib/game-tracker/GameLogo.svelte';
import ProgressMeter from '#lib/game-tracker/ProgressMeter.svelte';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
// Owner: every game, split into what I play now and the rest; one click switches a game.
const nav = new Map(gameTrackers.map((g) => [gameSlug(g), g]));
const activeList = $derived(data.library.filter((g) => g.active));
const inactiveList = $derived(data.library.filter((g) => !g.active));
let busy = $state<string | null>(null);
let showInactive = $state(true);
</script>
{#snippet switchRow(game: { slug: string; name: string; active: boolean })}
{@const item = nav.get(game.slug)}
<li>
<form
method="POST"
action="?/setActive"
use:enhance={() => {
busy = game.slug;
return async ({ update }) => {
await update({ reset: false });
busy = null;
};
}}
class={['switch-row', !game.active && 'off']}
>
<input type="hidden" name="slug" value={game.slug} />
<input type="hidden" name="active" value={game.active ? 'false' : 'true'} />
<a href={resolve(`/trackers/games/${game.slug}`)} class="switch-name" title={game.name}
><TrackerIcon name={item?.icon ?? `game-${game.slug}`} class="switch-icon size-7" /><span
class="truncate">{item?.name ?? game.name}</span
></a
>
<button
type="submit"
class="switch-btn"
disabled={busy === game.slug}
aria-label={game.active ? `Make ${game.name} non-active` : `Make ${game.name} active`}
title={game.active ? 'Make non-active' : 'Make active'}
>{#if game.active}<PowerOff size={14} aria-hidden="true" />{:else}<Power
size={14}
aria-hidden="true"
/>{/if}<span>{game.active ? 'non-active' : 'activate'}</span></button
>
</form>
</li>
{/snippet}
<div class="tracker-library mx-auto flex w-full max-w-[1600px] flex-col px-4 py-5 sm:px-6">
<div class="mb-5 flex flex-wrap items-end justify-between gap-2">
<div>
<p class="text-xs tracking-[.18em] text-muted-foreground uppercase">Your next session</p>
<h1 class="mt-1 text-3xl font-semibold tracking-tight">Game tracker</h1>
</div>
{#if !data.user}<a
class="text-sm text-muted-foreground underline"
href={resolve('/login?returnTo=/trackers/games')}>Sign in to save progress</a
>{:else}<a
class="text-xs text-muted-foreground underline"
href={resolve('/trackers/games/credits')}>Artwork credits</a
>{/if}
</div>
<div
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"
>
<ProgressMeter
label="National collection"
progress={data.pokemon.caught}
detail={`${data.pokemon.caught.value} unique species caught`}
hint="Unique species caught across the Pokémon games in your rules, out of the full species catalog."
/>
<ProgressMeter
label="Species seen at least once"
progress={data.pokemon.seen}
color="sky"
detail={`${data.pokemon.seen.value} species discovered`}
/>
<ProgressMeter
label="Pokémon games completed"
progress={data.pokemon.completed}
color="amber"
hint="Games marked story complete, or with all default story milestones checked."
/>
<ProgressMeter
label="Call of Duty overall"
progress={data.codOverall}
color="violet"
detail="Mastery · achievements · challenges"
/>
</div>
<div class="series-grid grid min-h-0 flex-1 gap-4">
{#if data.showPokemon}<section
class="series-column flex min-h-0 min-w-0 flex-col rounded-2xl border bg-card p-3"
aria-labelledby="pokemon-series"
>
<a
href={resolve('projects/game-tracker/pokemon')}
class="series-heading mb-3 flex items-center gap-3 rounded-xl bg-amber-500/10 p-3 transition-colors hover:bg-amber-500/20"
>
<GameLogo slug="pokemon" name="Pokémon" class="h-12 w-24" />
<div class="min-w-0 flex-1">
<h2 id="pokemon-series" class="font-semibold">Pokémon</h2>
<p class="text-xs text-muted-foreground">Open your adventure journal →</p>
<p class="mt-1 text-xs text-muted-foreground">
{data.pokemon.caught.value} / {data.pokemon.caught.total} species
</p>
</div>
</a>
<div class="mb-3 space-y-2 rounded-lg bg-muted/20 p-2">
<ProgressMeter
label="Across every adventure"
progress={data.pokemon.overall}
color="amber"
compact
hint="Equal weight per game; collection and story stages each contribute."
/>
<ProgressMeter
label="Every game’s collection"
progress={data.pokemon.collection}
compact
hint="Each confirmed species/game pairing counts once, including trades and events."
/>
</div>
{#if data.pokemon.games.length}
<div class="collection-grid grid grid-cols-2 gap-2">
{#each data.pokemon.games as game (game.id)}
<a
class="game-row flex min-h-0 min-w-0 items-center gap-2 rounded-lg border px-2 py-1.5 transition-colors hover:border-amber-500/50 hover:bg-amber-500/5"
href={resolve(`projects/game-tracker/pokemon?game=${game.slug ?? game.id}`)}
title={`${game.name} · ${game.releaseYear}`}
>
<GameLogo
slug={`pokemon/${game.slug}`}
name={game.name}
class="game-cover h-10 w-10"
/>
<div class="min-w-0 flex-1">
<p class="game-name text-xs leading-tight font-medium">{game.name}</p>
<p class="game-year mt-0.5 text-[10px] text-muted-foreground">
{game.releaseYear}{game.completed ? ' · ✓ story' : ''}
</p>
<ProgressMeter
label="Game progress"
progress={game.overall}
color="amber"
compact
/>
</div>
</a>
{/each}
</div>
{:else}<a
class="rounded-xl border border-dashed p-5 text-sm text-muted-foreground hover:text-foreground"
href={resolve('projects/game-tracker/pokemon')}
>Choose your games in “set rules” to build your collection here.</a
>{/if}
</section>
{/if}
{#if data.callOfDuty.length}<section
class="series-column flex min-h-0 min-w-0 flex-col rounded-2xl border bg-card p-3"
aria-labelledby="cod-series"
>
<div class="series-heading mb-3 flex items-center gap-3 rounded-xl bg-zinc-500/10 p-3">
<GameLogo slug="call-of-duty" name="Call of Duty" class="h-12 w-24" />
<div>
<h2 id="cod-series" class="font-semibold">Call of Duty</h2>
<p class="text-xs text-muted-foreground">
{data.callOfDuty.length} campaigns of progress
</p>
</div>
</div>
<div class="mb-3 px-1">
<ProgressMeter label="COD completion" progress={data.codOverall} color="violet" compact />
</div>
<div class="cod-rows grid min-h-0 gap-1.5">
{#each data.callOfDuty as game (game.slug)}
<a
href={resolve(`projects/game-tracker/call-of-duty/${game.slug}`)}
class="game-row flex min-h-0 items-center gap-3 rounded-lg border p-2 transition-colors hover:border-primary/40 hover:bg-muted/50"
>
<GameLogo slug={`cod/${game.slug}`} name={game.name} class="h-11 w-24" />
<div class="min-w-0 flex-1">
<p class="text-sm leading-tight font-medium">{game.name}</p>
<p class="mt-1 text-[11px] text-muted-foreground">
{game.releaseYear}<span class="ml-2"
>{game.achievements.value}/{game.achievements.total} achievements</span
>
</p>
<ProgressMeter
label={`${game.name} progress`}
progress={game.overall}
color="violet"
compact
/>
</div>
</a>
{/each}
</div>
</section>
{/if}
{#if data.showOsu}<section
class="series-column flex min-w-0 flex-col rounded-2xl border bg-card p-3"
aria-labelledby="osu-series"
>
<div class="series-heading mb-3 flex items-center gap-3 rounded-xl bg-pink-500/10 p-3">
<GameLogo slug="osu" name="osu!" class="h-12 w-12" />
<div>
<h2 id="osu-series" class="font-semibold">osu!</h2>
<p class="text-xs text-muted-foreground">One more attempt</p>
</div>
</div>
<a
href={resolve('projects/game-tracker/osu')}
class="rounded-xl border p-4 transition-colors hover:border-pink-500/50 hover:bg-pink-500/5"
><div class="flex items-center gap-3">
<GameLogo slug="osu" name="osu!" class="h-14 w-14" />
<div>
<p class="font-semibold">osu!</p>
<p class="text-xs text-muted-foreground">2007</p>
</div>
</div>
<p class="mt-4 text-sm">Your training room →</p>
<p class="mt-1 text-xs leading-relaxed text-muted-foreground">
Scores, dream FCs, stamina and speed. Build your next personal best.
</p>
<div class="mt-4">
<ProgressMeter label="osu! goals achieved" progress={data.osuGoals} color="pink" />
</div></a
>
</section>{/if}
</div>
{#if data.library.length}
<!-- Owner only: which games I play now. Non-active games keep their data and pages; the
header and the tiles list them under "non-active". -->
<section
id="manage"
class="manage mt-6 grid gap-3 lg:grid-cols-2"
aria-label="Active and non-active games"
>
<div class="manage-col rounded-2xl border bg-card p-3">
<h2 class="manage-title">active <span>{activeList.length}</span></h2>
<p class="manage-hint">Shown here and first everywhere games are listed.</p>
<ul class="switch-list">
{#each activeList as game (game.slug)}{@render switchRow(game)}{/each}
</ul>
</div>
<div class="manage-col rounded-2xl border bg-card p-3">
<button
type="button"
class="manage-title w-full text-left"
aria-expanded={showInactive}
onclick={() => (showInactive = !showInactive)}
>non-active <span>{inactiveList.length}</span><span class="ml-auto"
>{showInactive ? '▾' : '▸'}</span
></button
>
<p class="manage-hint">Hidden from this dashboard; data and pages stay.</p>
{#if showInactive}
<ul class="switch-list">
{#each inactiveList as game (game.slug)}{@render switchRow(game)}{/each}
</ul>
{/if}
</div>
</section>
{/if}
</div>
<style>
.manage-title {
display: flex;
align-items: baseline;
gap: 0.45rem;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.manage-title span {
color: var(--muted-foreground);
letter-spacing: 0;
font-variant-numeric: tabular-nums;
}
.manage-hint {
margin: 0.15rem 0 0.6rem;
font-size: 0.75rem;
color: var(--muted-foreground);
}
.switch-list {
display: grid;
gap: 0.35rem;
grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
}
.switch-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.3rem 0.35rem 0.3rem 0.3rem;
border: 1px solid color-mix(in oklab, var(--foreground) 9%, transparent);
border-radius: 0.7rem;
background: color-mix(in oklab, var(--foreground) 2%, transparent);
transition:
border-color 160ms ease,
background-color 160ms ease,
transform 160ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.switch-row:hover {
border-color: color-mix(in oklab, var(--foreground) 22%, transparent);
background: color-mix(in oklab, var(--foreground) 5%, transparent);
transform: translateY(-1px);
}
.switch-name {
display: flex;
min-width: 0;
flex: 1;
align-items: center;
gap: 0.55rem;
font-size: 0.85rem;
font-weight: 500;
}
.switch-row.off :global(.switch-icon) {
filter: grayscale(0.6);
opacity: 0.75;
}
.switch-row.off:hover :global(.switch-icon) {
filter: none;
opacity: 1;
}
.switch-btn {
display: inline-flex;
flex: none;
align-items: center;
gap: 0.3rem;
border: 1px solid color-mix(in oklab, var(--foreground) 14%, transparent);
border-radius: 999px;
padding: 0.2rem 0.6rem;
font-size: 0.72rem;
color: var(--muted-foreground);
cursor: pointer;
transition:
color 160ms ease,
background-color 160ms ease,
border-color 160ms ease;
}
.switch-btn:hover,
.switch-btn:focus-visible {
color: var(--foreground);
border-color: color-mix(in oklab, var(--foreground) 40%, transparent);
background: color-mix(in oklab, var(--foreground) 8%, transparent);
}
.switch-row.off .switch-btn {
color: var(--foreground);
}
.switch-btn:disabled {
opacity: 0.5;
cursor: progress;
}
.series-grid {
grid-template-columns: minmax(0, 1.5fr) minmax(0, 1.2fr) minmax(0, 0.8fr);
align-items: start;
}
.game-row {
min-height: 64px;
}
@media (max-width: 1100px) {
.series-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 639px) {
.series-grid {
grid-template-columns: minmax(0, 1fr);
}
}
</style>

View File

@@ -1,10 +1,11 @@
import { error, redirect } from '@sveltejs/kit';
import { error, fail, redirect } 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 { isOwner } from '#lib/server/owner.js';
import { isOwner, requireOwner } from '#lib/server/owner.js';
import { setGameActive } from '#lib/server/games/active.js';
import { isAutoTracked, loadHistory } from '#lib/server/games/history.js';
import type { PageServerLoad } from './$types';
import type { Actions, 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. CS2, PUBG and R6 also get their automatic
@@ -47,8 +48,10 @@ export const load: PageServerLoad = async (event) => {
currentRank: game.currentRank,
peakRank: game.peakRank,
rankLabel: game.rankLabel,
notes: game.notes
notes: game.notes,
active: game.active
},
owner: isOwner(event),
sensitivity: sensitivity ?? null,
history: isAutoTracked(game.slug) ? await loadHistory(game.slug, isOwner(event)) : null,
seo: {
@@ -57,3 +60,15 @@ export const load: PageServerLoad = async (event) => {
}
};
};
export const actions: Actions = {
/** Owner only: move this game between active and non-active (nothing is deleted). */
setActive: async (event) => {
requireOwner(event);
const form = await event.request.formData();
const active = form.get('active') === 'true';
if (!(await setGameActive(event.params.slug, active)))
return fail(404, { message: 'Unknown game' });
return { active };
}
};

View File

@@ -1,5 +1,11 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { enhance } from '$app/forms';
import { goto } from '$app/navigation';
import Ellipsis from 'lucide-svelte/icons/ellipsis';
import Power from 'lucide-svelte/icons/power';
import PowerOff from 'lucide-svelte/icons/power-off';
import * as DropdownMenu from '#lib/components/ui/dropdown-menu/index.js';
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
import ArrowRight from 'lucide-svelte/icons/arrow-right';
import TrackerIcon from '#lib/components/TrackerIcon.svelte';
@@ -19,6 +25,7 @@
// "~#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}`;
let activeForm = $state<HTMLFormElement>();
const hasStats = $derived(
game.currentRank != null || game.peakRank != null || !!data.sensitivity
);
@@ -26,15 +33,43 @@
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
<a
href={resolve('/projects/game-tracker')}
href={resolve('/trackers/games')}
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}
{#if data.owner}
<!-- Owner ⋯ menu: switch this game between active and non-active. -->
<form
method="POST"
action="?/setActive"
use:enhance={() =>
async ({ update }) =>
update({ reset: false })}
bind:this={activeForm}
hidden
>
<input type="hidden" name="active" value={game.active ? 'false' : 'true'} />
</form>
<DropdownMenu.Root>
<DropdownMenu.Trigger class="game-actions ml-auto" aria-label="Actions for {game.name}"
><Ellipsis size={18} aria-hidden="true" /></DropdownMenu.Trigger
>
<DropdownMenu.Content align="end" class="min-w-48">
<DropdownMenu.Item onSelect={() => activeForm?.requestSubmit()}>
{#if game.active}<PowerOff /> make non-active{:else}<Power /> make active{/if}
</DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => goto(`${resolve('/trackers/games')}#manage`)}
>all active and non-active games</DropdownMenu.Item
>
</DropdownMenu.Content>
</DropdownMenu.Root>
{/if}
</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.active}<span class="badge inactive">non-active</span>{/if}
{#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)]"
@@ -111,6 +146,26 @@
color: var(--background);
border-color: var(--foreground);
}
.badge.inactive {
border-style: dashed;
color: var(--site-muted);
}
:global(.game-actions) {
display: inline-grid;
place-items: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 999px;
color: var(--site-muted);
transition:
color 160ms ease,
background-color 160ms ease;
}
:global(.game-actions:hover),
:global(.game-actions:focus-visible) {
color: var(--foreground);
background: color-mix(in oklab, var(--foreground) 8%, transparent);
}
.status-active {
border-color: color-mix(in oklab, var(--foreground) 40%, transparent);
}

View File

@@ -0,0 +1,17 @@
import weaponArt from '../../../../../data/cod-art.json';
import rankArt from '../../../../../data/cod-rank-art.json';
import images from '../../../../../data/game-images.json';
import maps from '../../../../../data/pokemon-map-art.json';
export function load() {
const sources = new Map<string, { source: string; credit: string; count: number }>();
for (const image of [...images, ...weaponArt, ...rankArt]) {
const existing = sources.get(image.source);
if (existing) existing.count++;
else sources.set(image.source, { source: image.source, credit: image.credit, count: 1 });
}
return {
sources: [...sources.values()],
maps: Object.values(maps).map(({ name, source, credit }) => ({ name, source, credit })),
total: images.length + weaponArt.length + rankArt.length
};
}

View File

@@ -0,0 +1,61 @@
<script lang="ts">
import { resolve } from '$app/paths';
import TrackerIcon from '#lib/components/TrackerIcon.svelte';
import { gameLogos } from '#lib/game-logos.js';
import { gameTrackers } from '#lib/projects.js';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
</script>
<!-- Download manifest links point to reviewed external image sources. -->
<!-- eslint-disable svelte/no-navigation-without-resolve -->
<div class="mx-auto max-w-4xl px-4 py-10 sm:px-6">
<a href={resolve('/trackers/games')} class="text-sm text-muted-foreground hover:underline"
>← Game tracker</a
>
<h1 class="mt-6 text-3xl font-semibold">Artwork credits</h1>
<p class="mt-3 text-muted-foreground">
Game covers, logos, sprites, maps and screenshots belong to their respective owners. The tracker
stores WebP copies for consistent, reliable display. Map imagery is shared across games in the
same region; later-region landmark positions are approximate.
</p>
<h2 class="mt-8 text-xl font-semibold">Game logos</h2>
<ul class="mt-4 grid gap-3 sm:grid-cols-2">
{#each gameTrackers as game (game.href)}
{@const logo = gameLogos[game.icon ?? game.name]}
{#if logo}
<li class="flex items-center gap-3">
<TrackerIcon name={game.icon ?? game.name} class="size-8" />
<div class="min-w-0">
<p class="text-sm font-medium">{game.name}</p>
<a href={logo.source} target="_blank" rel="noreferrer" class="text-xs underline"
>{logo.credit}</a
>
</div>
</li>
{/if}
{/each}
</ul>
<h2 class="mt-8 text-xl font-semibold">Regional maps</h2>
<ul class="mt-4 space-y-3">
{#each data.maps as map (map.name)}<li>
<a href={map.source} target="_blank" rel="noreferrer" class="font-medium underline"
>{map.name}</a
>
<p class="text-xs text-muted-foreground">{map.credit}</p>
</li>{/each}
</ul>
<h2 class="mt-8 text-xl font-semibold">Covers, sprites and other artwork</h2>
<p class="mt-2 text-sm text-muted-foreground">
{data.total.toLocaleString()} downloaded image records. Box art is archived by Libretro and Serebii;
Pokémon sprites and official artwork are archived by PokéAPI. COD covers and achievement icons come
from Steam.
</p>
<ul class="mt-4 space-y-3">
{#each data.sources as item (item.source)}<li>
<a href={item.source} target="_blank" rel="noreferrer" class="text-sm underline"
>{item.credit}</a
><span class="ml-2 text-xs text-muted-foreground">{item.count} images</span>
</li>{/each}
</ul>
</div>