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
All checks were successful
Deploy production / deploy (push) Successful in 1m13s
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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.'
|
||||
|
||||
@@ -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) => [
|
||||
|
||||
37
src/lib/server/games/active.ts
Normal file
37
src/lib/server/games/active.ts
Normal 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;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user