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

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