games: per-game pages, games.library table, games group in header/tiles/start
All checks were successful
Deploy production / deploy (push) Successful in 1m7s
All checks were successful
Deploy production / deploy (push) Successful in 1m7s
This commit is contained in:
54
drizzle/20261025151833_games_library/migration.sql
Normal file
54
drizzle/20261025151833_games_library/migration.sql
Normal file
@@ -0,0 +1,54 @@
|
||||
CREATE TABLE "games"."library" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"slug" text NOT NULL UNIQUE,
|
||||
"name" text NOT NULL,
|
||||
"short_name" text NOT NULL,
|
||||
"kind" text DEFAULT 'video' NOT NULL,
|
||||
"status" text DEFAULT 'unknown' NOT NULL,
|
||||
"platform" text,
|
||||
"tracker_href" text,
|
||||
"current_rank" integer,
|
||||
"peak_rank" integer,
|
||||
"rank_label" text,
|
||||
"aliases" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||
"notes" text,
|
||||
"sort_order" integer DEFAULT 100 NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "library_status_check" CHECK ("status" IN ('main','active','occasional','unknown')),
|
||||
CONSTRAINT "library_kind_check" CHECK ("kind" IN ('video','puzzle')),
|
||||
CONSTRAINT "library_rank_check" CHECK (("current_rank" IS NULL OR "current_rank" > 0) AND ("peak_rank" IS NULL OR "peak_rank" > 0))
|
||||
);
|
||||
|
||||
--> statement-breakpoint
|
||||
-- Hand-written: grants (same pattern as the other app tables) and the initial games.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_app') THEN
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON games.library TO personal_app;
|
||||
GRANT USAGE, SELECT ON SEQUENCE games.library_id_seq TO personal_app;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'personal_ro') THEN
|
||||
GRANT SELECT ON games.library TO personal_ro;
|
||||
END IF;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
-- Ranks only where Stan gave them (osu!: ~#40k now, peak ~#11k); platform null = not known.
|
||||
INSERT INTO games.library (slug, name, short_name, kind, status, platform, tracker_href, current_rank, peak_rank, rank_label, aliases, sort_order) VALUES
|
||||
('osu', 'osu!', 'osu!', 'video', 'main', 'PC', '/projects/game-tracker/osu', 40000, 11000, 'osu! global rank, approximate', '{}', 10),
|
||||
('cs2', 'Counter-Strike 2', 'CS2', 'video', 'active', 'PC', NULL, NULL, NULL, NULL, '{CS:GO,"Counter-Strike: Global Offensive"}', 20),
|
||||
('pubg', 'PUBG: Battlegrounds', 'PUBG', 'video', 'active', NULL, NULL, NULL, NULL, NULL, '{PUBG,"PlayerUnknown''s Battlegrounds"}', 30),
|
||||
('r6', 'Tom Clancy''s Rainbow Six Siege', 'R6 Siege', 'video', 'active', NULL, NULL, NULL, NULL, NULL, '{"Rainbow Six Siege",R6}', 40),
|
||||
('tarkov', 'Escape from Tarkov', 'Tarkov', 'video', 'active', 'PC', NULL, NULL, NULL, NULL, '{EFT}', 50),
|
||||
('bo2', 'Call of Duty: Black Ops II', 'Black Ops II', 'video', 'active', NULL, '/projects/game-tracker/call-of-duty/black-ops-2', NULL, NULL, NULL, '{"Call of Duty",BO2,"Black Ops 2"}', 60),
|
||||
('valorant', 'Valorant', 'Valorant', 'video', 'occasional', NULL, NULL, NULL, NULL, NULL, '{}', 70),
|
||||
('lol', 'League of Legends', 'League', 'video', 'occasional', 'PC', NULL, NULL, NULL, NULL, '{LoL}', 80),
|
||||
('overwatch', 'Overwatch 2', 'Overwatch 2', 'video', 'occasional', NULL, NULL, NULL, NULL, NULL, '{Overwatch}', 90),
|
||||
('minecraft', 'Minecraft', 'Minecraft', 'video', 'occasional', NULL, NULL, NULL, NULL, NULL, '{}', 100),
|
||||
('factorio', 'Factorio', 'Factorio', 'video', 'occasional', NULL, NULL, NULL, NULL, NULL, '{}', 110),
|
||||
('ksp', 'Kerbal Space Program', 'KSP', 'video', 'occasional', NULL, NULL, NULL, NULL, NULL, '{}', 120),
|
||||
('smash', 'Super Smash Bros.', 'Smash Bros', 'video', 'occasional', 'Nintendo', NULL, NULL, NULL, NULL, '{"Super Smash Bros"}', 130),
|
||||
('cubing', 'Speedcubing', 'speedcubing', 'puzzle', 'occasional', 'physical', NULL, NULL, NULL, NULL, '{"Rubik''s Cube"}', 140),
|
||||
('pokemon', 'Pokémon', 'Pokémon', 'video', 'unknown', NULL, '/projects/game-tracker/pokemon', NULL, NULL, NULL, '{}', 150),
|
||||
('sot', 'Sea of Thieves', 'Sea of Thieves', 'video', 'unknown', NULL, NULL, NULL, NULL, NULL, '{}', 160)
|
||||
ON CONFLICT (slug) DO NOTHING;
|
||||
27013
drizzle/20261025151833_games_library/snapshot.json
Normal file
27013
drizzle/20261025151833_games_library/snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,8 @@
|
||||
href: string;
|
||||
external: boolean;
|
||||
children?: readonly Item[];
|
||||
icon?: string;
|
||||
featured?: boolean;
|
||||
};
|
||||
let {
|
||||
items,
|
||||
@@ -29,6 +31,14 @@
|
||||
} = $props();
|
||||
|
||||
const tracker = $derived(variant === 'tracker');
|
||||
// Big groups (games) show their featured chips first and the rest behind a "more" chip.
|
||||
let expanded = $state<Record<string, boolean>>({});
|
||||
const visibleChildren = (group: Item) => {
|
||||
const all = group.children ?? [];
|
||||
return expanded[group.name] ? all : all.filter((c) => c.featured ?? true);
|
||||
};
|
||||
const hiddenCount = (group: Item) =>
|
||||
(group.children?.length ?? 0) - visibleChildren(group).length;
|
||||
</script>
|
||||
|
||||
{#snippet body(project: Item)}
|
||||
@@ -79,16 +89,31 @@
|
||||
{@render body(project)}
|
||||
</a>
|
||||
<ul class="relative z-10 mt-auto flex flex-wrap gap-1.5 pl-13">
|
||||
{#each project.children as child (child.name)}
|
||||
{#each visibleChildren(project) as child (child.name)}
|
||||
<li>
|
||||
<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.name} class="chip-icon size-5" />{child.name}</a
|
||||
><TrackerIcon
|
||||
name={child.icon ?? child.name}
|
||||
class="chip-icon size-5"
|
||||
/>{child.name}</a
|
||||
>
|
||||
</li>
|
||||
{/each}
|
||||
{#if hiddenCount(project) > 0 || expanded[project.name]}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="chip flex h-full cursor-pointer items-center px-2.5 py-1 text-xs font-medium text-[var(--site-muted)]"
|
||||
aria-expanded={!!expanded[project.name]}
|
||||
onclick={() =>
|
||||
(expanded = { ...expanded, [project.name]: !expanded[project.name] })}
|
||||
>{hiddenCount(project) > 0 ? `+${hiddenCount(project)} more` : 'less'}</button
|
||||
>
|
||||
</li>
|
||||
{/if}
|
||||
</ul>
|
||||
</div>
|
||||
{:else if project.external}
|
||||
|
||||
@@ -184,16 +184,22 @@
|
||||
// into the flyout (crossing a neighbouring tile) does not close it.
|
||||
let flyout = $state<string | null>(null);
|
||||
let flyoutSide = $state<'left' | 'right'>('right');
|
||||
let flyoutUp = $state(false);
|
||||
let flyoutTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const FLYOUT_WIDTH = 240;
|
||||
function openFlyout(name: string, el: HTMLElement) {
|
||||
// Big groups (games) get a two-column flyout; it opens upwards when it would not fit below.
|
||||
const WIDE_FLYOUT = 6;
|
||||
function openFlyout(name: string, el: HTMLElement, count: number) {
|
||||
clearTimeout(flyoutTimer);
|
||||
if (flyout === name) return;
|
||||
const wide = count > WIDE_FLYOUT;
|
||||
const width = wide ? 432 : 240;
|
||||
const height = (wide ? Math.ceil(count / 2) : count) * 46 + 20;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const panel = el.closest('[data-menu-panel]')?.getBoundingClientRect() ?? rect;
|
||||
const leftColumn = rect.left + rect.width / 2 < panel.left + panel.width / 2;
|
||||
const roomLeft = rect.left - FLYOUT_WIDTH >= 8;
|
||||
const roomRight = rect.right + FLYOUT_WIDTH <= window.innerWidth - 8;
|
||||
const roomLeft = rect.left - width >= 8;
|
||||
const roomRight = rect.right + width <= window.innerWidth - 8;
|
||||
flyoutUp = rect.top + height > window.innerHeight - 8 && rect.bottom - height >= 8;
|
||||
flyoutSide = leftColumn ? (roomLeft ? 'left' : 'right') : roomRight ? 'right' : 'left';
|
||||
flyout = name;
|
||||
}
|
||||
@@ -308,12 +314,21 @@
|
||||
aria-label={`${project.name} trackers`}
|
||||
onpointerenter={(event) => {
|
||||
if (event.pointerType !== 'touch')
|
||||
openFlyout(project.name, event.currentTarget);
|
||||
openFlyout(
|
||||
project.name,
|
||||
event.currentTarget,
|
||||
project.children?.length ?? 0
|
||||
);
|
||||
}}
|
||||
onpointerleave={(event) => {
|
||||
if (event.pointerType !== 'touch') closeFlyoutSoon();
|
||||
}}
|
||||
onfocusin={(event) => openFlyout(project.name, event.currentTarget)}
|
||||
onfocusin={(event) =>
|
||||
openFlyout(
|
||||
project.name,
|
||||
event.currentTarget,
|
||||
project.children?.length ?? 0
|
||||
)}
|
||||
onfocusout={(event) => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget as Node | null))
|
||||
flyout = null;
|
||||
@@ -345,23 +360,30 @@
|
||||
<!-- The padding facing the tile is a hover bridge into the panel. -->
|
||||
<div
|
||||
class={[
|
||||
'absolute -top-2 w-60',
|
||||
'absolute',
|
||||
flyoutUp ? '-bottom-2' : '-top-2',
|
||||
project.children.length > WIDE_FLYOUT ? 'w-[27rem]' : 'w-60',
|
||||
flyoutSide === 'right' ? 'left-full pl-3' : 'right-full pr-3'
|
||||
]}
|
||||
>
|
||||
<div
|
||||
class="flyout grid gap-1 rounded-md border border-border bg-popover p-1.5 shadow-lg"
|
||||
class={[
|
||||
'flyout grid gap-1 rounded-md border border-border bg-popover p-1.5 shadow-lg',
|
||||
project.children.length > WIDE_FLYOUT && 'grid-cols-2'
|
||||
]}
|
||||
class:from-left={flyoutSide === 'left'}
|
||||
>
|
||||
{#each project.children as child (child.name)}
|
||||
<a
|
||||
href={resolve(child.href as '/')}
|
||||
class="tile flex items-center gap-2.5 p-1.5 text-sm"
|
||||
class="tile flex min-w-0 items-center gap-2.5 p-1.5 text-sm"
|
||||
title={child.subtitle}
|
||||
aria-current={current(child.href) ? 'page' : undefined}
|
||||
onkeydown={closeFlyoutOnEscape}
|
||||
><TrackerIcon name={child.name} class="tile-icon size-8" /><span
|
||||
class="font-medium">{child.name}</span
|
||||
></a
|
||||
><TrackerIcon
|
||||
name={child.icon ?? child.name}
|
||||
class="tile-icon size-8"
|
||||
/><span class="truncate font-medium">{child.name}</span></a
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -520,9 +542,10 @@
|
||||
href={resolve(child.href as '/')}
|
||||
class="tile flex min-w-0 items-center gap-2 p-1.5 text-sm"
|
||||
onclick={() => (menuOpen = false)}
|
||||
><TrackerIcon name={child.name} class="tile-icon size-6" /><span
|
||||
class="truncate">{child.name}</span
|
||||
></a
|
||||
><TrackerIcon
|
||||
name={child.icon ?? child.name}
|
||||
class="tile-icon size-6"
|
||||
/><span class="truncate">{child.name}</span></a
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -1,36 +1,49 @@
|
||||
<script lang="ts">
|
||||
// Small switcher shown above the cars/trains/planes/wildlife pages (via their +layout),
|
||||
// to hop between the spotting pokedexes or back to /trackers/spotting.
|
||||
// to hop between the spotting pokedexes or back to /trackers/spotting. Other tracker
|
||||
// groups (games) pass their own items, home link and icon.
|
||||
import type { Component } from 'svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
import Binoculars from 'lucide-svelte/icons/binoculars';
|
||||
import TrackerIcon from '#lib/components/TrackerIcon.svelte';
|
||||
import { spottingTrackers } from '#lib/projects.js';
|
||||
|
||||
type Item = { name: string; href: string; icon?: string; subtitle?: string };
|
||||
let {
|
||||
items = spottingTrackers,
|
||||
home = '/trackers/spotting',
|
||||
label = 'spotting',
|
||||
homeIcon: HomeIcon = Binoculars,
|
||||
class: className = 'relative mx-auto -mb-6 max-w-6xl px-4 pt-6 sm:-mb-8 sm:px-6 sm:pt-8'
|
||||
}: {
|
||||
items?: readonly Item[];
|
||||
home?: string;
|
||||
label?: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- lucide icon components
|
||||
homeIcon?: Component<any>;
|
||||
class?: string;
|
||||
} = $props();
|
||||
|
||||
const active = $derived(
|
||||
spottingTrackers.find(
|
||||
(t) => page.url.pathname === t.href || page.url.pathname.startsWith(`${t.href}/`)
|
||||
)?.name
|
||||
items.find((t) => page.url.pathname === t.href || page.url.pathname.startsWith(`${t.href}/`))
|
||||
?.name
|
||||
);
|
||||
</script>
|
||||
|
||||
<nav
|
||||
aria-label="Spotting trackers"
|
||||
class="relative mx-auto -mb-6 max-w-6xl px-4 pt-6 sm:-mb-8 sm:px-6 sm:pt-8"
|
||||
>
|
||||
<nav aria-label="{label} trackers" class={className}>
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<a
|
||||
href={resolve('/trackers/spotting')}
|
||||
class="pill flex items-center gap-1.5 px-2.5 py-1.5 text-sm"
|
||||
><Binoculars size={14} aria-hidden="true" class="text-[var(--site-muted)]" />spotting</a
|
||||
<a href={resolve(home as '/')} class="pill flex items-center gap-1.5 px-2.5 py-1.5 text-sm"
|
||||
><HomeIcon size={14} aria-hidden="true" class="text-[var(--site-muted)]" />{label}</a
|
||||
>
|
||||
<span class="mx-1 h-4 w-px bg-border" aria-hidden="true"></span>
|
||||
{#each spottingTrackers as t (t.name)}
|
||||
{#each items as t (t.name)}
|
||||
<a
|
||||
href={resolve(t.href as '/')}
|
||||
aria-current={active === t.name ? 'page' : undefined}
|
||||
class="pill flex items-center gap-1.5 py-1 pr-2.5 pl-1 text-sm"
|
||||
><TrackerIcon name={t.name} class="pill-icon size-6" />{t.name}</a
|
||||
title={t.subtitle}
|
||||
><TrackerIcon name={t.icon ?? t.name} class="pill-icon size-6" />{t.name}</a
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
class="size-[58%]"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
class={icon.fill ? 'size-[54%]' : 'size-[58%]'}
|
||||
fill={icon.fill ? 'currentColor' : 'none'}
|
||||
stroke={icon.fill ? 'none' : 'currentColor'}
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
|
||||
@@ -39,6 +39,10 @@ type Tracker = {
|
||||
external: boolean;
|
||||
/** Sub-trackers linked directly from this item (header flyout, tile chips). */
|
||||
children?: readonly Tracker[];
|
||||
/** Icon key in #lib/tracker-icons when it differs from `name` (games). */
|
||||
icon?: string;
|
||||
/** Shown on group tiles before the "more" chip (main and actively played games). */
|
||||
featured?: boolean;
|
||||
};
|
||||
|
||||
// The spotting trackers: pokedexes of things I can spot in the wild. Grouped under one
|
||||
@@ -72,6 +76,37 @@ export const spottingTrackers: readonly Tracker[] = [
|
||||
}
|
||||
];
|
||||
|
||||
// Games I play (plus speedcubing), grouped under "games" like spotting: every game has its
|
||||
// own page at /trackers/games/<slug>, one click away from the header flyout and the chips.
|
||||
// `featured` games (my main game and the ones I actively play) show first on tiles; the
|
||||
// rest sit behind a "more" chip. Data per game lives in games.library (same slugs).
|
||||
const game = (slug: string, name: string, subtitle: string, featured = false): Tracker => ({
|
||||
name,
|
||||
subtitle,
|
||||
href: `/trackers/games/${slug}`,
|
||||
external: false,
|
||||
icon: `game-${slug}`,
|
||||
...(featured ? { featured } : {})
|
||||
});
|
||||
export const gameTrackers: readonly Tracker[] = [
|
||||
game('osu', 'osu!', 'rhythm game, my main focus right now', true),
|
||||
game('cs2', 'cs2', 'Counter-Strike 2 (CS:GO)', true),
|
||||
game('pubg', 'pubg', 'PUBG: Battlegrounds', true),
|
||||
game('r6', 'r6 siege', 'Tom Clancy’s Rainbow Six Siege', true),
|
||||
game('tarkov', 'tarkov', 'Escape from Tarkov', true),
|
||||
game('bo2', 'bo2', 'Call of Duty: Black Ops II', true),
|
||||
game('valorant', 'valorant', 'Riot’s tactical shooter', false),
|
||||
game('lol', 'league', 'League of Legends', false),
|
||||
game('overwatch', 'overwatch 2', 'Blizzard’s hero shooter', false),
|
||||
game('minecraft', 'minecraft', 'blocks, mining and building', false),
|
||||
game('factorio', 'factorio', 'factory building and automation', false),
|
||||
game('ksp', 'ksp', 'Kerbal Space Program', false),
|
||||
game('smash', 'smash bros', 'Super Smash Bros.', false),
|
||||
game('cubing', 'speedcubing', 'Rubik’s cube solving, not a video game', false),
|
||||
game('pokemon', 'pokémon', 'catching them all across the games', false),
|
||||
game('sot', 'sea of thieves', 'pirate sandbox', false)
|
||||
];
|
||||
|
||||
// Trackers, in display order. Names are short single nouns. Most keep their existing
|
||||
// /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
|
||||
@@ -153,6 +188,7 @@ const allTrackers: readonly (Tracker & { private: boolean })[] = [
|
||||
subtitle: 'every pokemon in every game, and call of duty camo mastery',
|
||||
href: '/projects/game-tracker',
|
||||
external: false,
|
||||
children: gameTrackers,
|
||||
private: false
|
||||
},
|
||||
{
|
||||
|
||||
51
src/lib/server/db/schema/game-library.ts
Normal file
51
src/lib/server/db/schema/game-library.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { check, integer, text } from 'drizzle-orm/pg-core';
|
||||
import { gamesSchema } from './schemas';
|
||||
import { id, timestamps } from './helpers';
|
||||
|
||||
// Every game I play (plus speedcubing): one row per game, shown at /trackers/games/<slug>.
|
||||
// The header, home, /trackers and /start chips come from `gameTrackers` in #lib/projects
|
||||
// (static, so navigation needs no query); keep the slugs in sync with these rows.
|
||||
// Deep trackers (osu! journal, Call of Duty mastery, Pokémon catches) stay where they are
|
||||
// and are linked through `tracker_href`.
|
||||
export const GAME_STATUSES = ['main', 'active', 'occasional', 'unknown'] as const;
|
||||
export const GAME_KINDS = ['video', 'puzzle'] as const;
|
||||
|
||||
export const gameLibrary = gamesSchema.table(
|
||||
'library',
|
||||
{
|
||||
id: id(),
|
||||
slug: text('slug').notNull().unique(),
|
||||
name: text('name').notNull(),
|
||||
shortName: text('short_name').notNull(),
|
||||
kind: text('kind', { enum: GAME_KINDS }).notNull().default('video'),
|
||||
status: text('status', { enum: GAME_STATUSES }).notNull().default('unknown'),
|
||||
/** Where I play it (PC, Nintendo, physical…); null when not known yet. */
|
||||
platform: text('platform'),
|
||||
/** Internal path of a deeper tracker for this game, if there is one. */
|
||||
trackerHref: text('tracker_href'),
|
||||
/** Rank as a position (#40 000 is 40000); only values I gave. */
|
||||
currentRank: integer('current_rank'),
|
||||
peakRank: integer('peak_rank'),
|
||||
/** What the rank numbers mean, e.g. "osu! standard global rank". */
|
||||
rankLabel: text('rank_label'),
|
||||
/** Names this game goes by elsewhere (hardware.sensitivities.game, old titles). */
|
||||
aliases: text('aliases')
|
||||
.array()
|
||||
.notNull()
|
||||
.default(sql`'{}'::text[]`),
|
||||
notes: text('notes'),
|
||||
sortOrder: integer('sort_order').notNull().default(100),
|
||||
...timestamps
|
||||
},
|
||||
(t) => [
|
||||
check('library_status_check', sql`${t.status} IN ('main','active','occasional','unknown')`),
|
||||
check('library_kind_check', sql`${t.kind} IN ('video','puzzle')`),
|
||||
check(
|
||||
'library_rank_check',
|
||||
sql`(${t.currentRank} IS NULL OR ${t.currentRank} > 0) AND (${t.peakRank} IS NULL OR ${t.peakRank} > 0)`
|
||||
)
|
||||
]
|
||||
);
|
||||
|
||||
export type GameLibraryRow = typeof gameLibrary.$inferSelect;
|
||||
@@ -5,6 +5,7 @@ export * from './blog';
|
||||
export * from './progress';
|
||||
export * from './catch-em-all';
|
||||
export * from './game-tracker';
|
||||
export * from './game-library';
|
||||
export * from './tracker-journal';
|
||||
export * from './animaldex';
|
||||
export * from './lists'; // meta registry; the lists themselves are runtime tables
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { trackerIcons } from './tracker-icons';
|
||||
import { spottingTrackers, visibleTrackers } from './projects';
|
||||
import { gameTrackers, spottingTrackers, visibleTrackers } from './projects';
|
||||
|
||||
describe('tracker icons', () => {
|
||||
it('gives every tracker its own colour', () => {
|
||||
@@ -10,6 +10,19 @@ describe('tracker icons', () => {
|
||||
expect(new Set(colours).size).toBe(colours.length);
|
||||
});
|
||||
|
||||
it('gives every game its own icon and colour', () => {
|
||||
const keys = gameTrackers.map((g) => g.icon ?? g.name);
|
||||
for (const key of keys) expect(trackerIcons[key], key).toBeDefined();
|
||||
expect(new Set(keys.map((k) => trackerIcons[k].to)).size).toBe(keys.length);
|
||||
});
|
||||
|
||||
it('links every game to its own page under /trackers/games', () => {
|
||||
const slugs = gameTrackers.map((g) => g.href.replace('/trackers/games/', ''));
|
||||
for (const slug of slugs) expect(slug).toMatch(/^[a-z0-9-]+$/);
|
||||
expect(new Set(slugs).size).toBe(slugs.length);
|
||||
expect(gameTrackers[0].name).toBe('osu!');
|
||||
});
|
||||
|
||||
it('keeps tasks, money and reading first', () => {
|
||||
expect(
|
||||
visibleTrackers(true)
|
||||
|
||||
@@ -12,6 +12,8 @@ export type TrackerIcon = {
|
||||
to: string;
|
||||
/** Icon colour; white unless the tile is light (yellow, lime). */
|
||||
ink: string;
|
||||
/** Filled glyph (brand marks from simple-icons) instead of stroked outline paths. */
|
||||
fill?: boolean;
|
||||
paths: string[];
|
||||
};
|
||||
|
||||
@@ -163,6 +165,157 @@ export const trackerIcons: Record<string, TrackerIcon> = {
|
||||
to: '#475569',
|
||||
ink: WHITE,
|
||||
paths: ['M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01']
|
||||
},
|
||||
// Games (keys are each game's `icon` in gameTrackers): brand marks where simple-icons has
|
||||
// a clean glyph, otherwise a lucide-style outline that hints at the game.
|
||||
'game-osu': {
|
||||
from: '#ff79b0',
|
||||
to: '#e6338a',
|
||||
ink: WHITE,
|
||||
fill: true,
|
||||
paths: [
|
||||
'M7.698 10.362a1.94 1.94 0 0 0-.7-.516q-.421-.189-.988-.189c-.567 0-.704.063-.982.189s-.51.298-.692.516q-.273.328-.413.777q-.139.448-.139.96q0 .511.14.952q.139.44.412.767q.274.329.692.512t.982.184c.565 0 .707-.062.988-.184q.422-.184.7-.512q.279-.327.413-.767q.135-.44.135-.952a3.3 3.3 0 0 0-.135-.96a2.1 2.1 0 0 0-.413-.777m-.965 2.81q-.22.372-.723.372q-.494 0-.713-.372q-.22-.373-.22-1.073c0-.7.073-.824.22-1.073q.22-.372.713-.372q.503 0 .723.372q.22.373.22 1.073t-.22 1.073m11.89-.83l-.09-4.39a4.5 4.5 0 0 1 .69-.054q.351 0 .701.054l-.09 4.39q-.315.053-.601.053a3.5 3.5 0 0 1-.61-.054m1.319 1.4q0 .332-.054.664a4 4 0 0 1-.655.054a4 4 0 0 1-.664-.054a4 4 0 0 1-.054-.655q0-.323.054-.665a4 4 0 0 1 .655-.054q.323 0 .664.054q.054.341.054.656m-3.223-4.03q.315 0 .638.053v4.461q-.288.099-.759.193a5.3 5.3 0 0 1-1.863.023a1.7 1.7 0 0 1-.74-.305q-.32-.234-.507-.683q-.189-.449-.189-1.193V9.765a4 4 0 0 1 .638-.054q.313 0 .637.054v2.46q0 .367.058.606a.9.9 0 0 0 .18.377a.66.66 0 0 0 .3.197q.18.058.422.058q.332 0 .557-.062V9.765a4 4 0 0 1 .628-.054m-4.362 2.683q.08.225.08.548a1.4 1.4 0 0 1-.542 1.117q-.265.212-.642.333q-.378.12-.853.12a5 5 0 0 1-.395-.013a3 3 0 0 1-.346-.045a4 4 0 0 1-.327-.076a4 4 0 0 1-.35-.116a2.6 2.6 0 0 1 .085-.49a3 3 0 0 1 .175-.48q.296.117.561.175q.265.06.552.059q.126 0 .274-.023a1 1 0 0 0 .274-.08a.7.7 0 0 0 .21-.153a.35.35 0 0 0 .086-.247q0-.216-.13-.31a1.3 1.3 0 0 0-.364-.166l-.556-.162q-.503-.143-.786-.426q-.282-.283-.283-.848q0-.682.49-1.068q.489-.386 1.332-.386q.35 0 .692.062q.341.063.691.189a2.5 2.5 0 0 1-.09.485a2.3 2.3 0 0 1-.17.44a4 4 0 0 0-.476-.158a2.2 2.2 0 0 0-.548-.067q-.305 0-.476.094a.32.32 0 0 0-.17.301q0 .197.121.278t.346.153l.511.153q.252.072.454.175t.345.255t.225.377M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12c6.628 0 12-5.373 12-12S18.628 0 12 0m0 22.8C6.035 22.8 1.2 17.965 1.2 12S6.035 1.2 12 1.2S22.8 6.035 22.8 12S17.965 22.8 12 22.8'
|
||||
]
|
||||
},
|
||||
'game-cs2': {
|
||||
from: '#fcd34d',
|
||||
to: '#d97706',
|
||||
ink: WHITE,
|
||||
fill: true,
|
||||
paths: [
|
||||
'M21.71 3.235a.02.02 0 0 1-.022-.022c.002-.081.004-.37.005-.424c0-.129-.143-.183-.212-.083l-.229.333a.02.02 0 0 1-.02.01h-6.55a.047.047 0 0 1-.048-.046l-.013-.177a.048.048 0 0 1 .056-.048l.335.032a.06.06 0 0 0 .063-.045l.244-.989a.05.05 0 0 0-.03-.054l-.227-.085a.04.04 0 0 1-.026-.03c-.041-.171-.377-1.323-1.993-1.58c-.787-.125-1.302.21-1.577.478a1.6 1.6 0 0 0-.302.41l-.097.212a2 2 0 0 0-.046.234l.051.982a.11.11 0 0 0 .043.085l.354.153l-.196.325a.055.055 0 0 1-.053.04s-.417.01-.622.02c-.386.015-1.245.485-1.878 1.838l-.724 1.55a.07.07 0 0 1-.068.04l-.578.001c-.035 0-.073.028-.088.06L6.364 9a.11.11 0 0 0 .017.108l.627.392a.06.06 0 0 1 .02.058l-.328.967a.2.2 0 0 1-.023.062l-.435.382a.1.1 0 0 0-.035.06l-.598 1.53a.06.06 0 0 1-.06.045l-.336.002a.163.163 0 0 0-.162.149l-.201 2.288l-.016.121l-.158.908a.13.13 0 0 1-.034.055l-.558.427a4.8 4.8 0 0 0-.767 1.001l-1.86 3.924a.8.8 0 0 0-.078.322l.132.235c.002.084-.032.456-.07.53l-.624 1.09a.1.1 0 0 0-.003.085l.03.07l.094.187L2.829 24c.118.011.247-.14.251-.3l.103-1.297l-.027-.195l3.606-4.232c.095-.114.222-.317.286-.45l1.719-3.79a.17.17 0 0 1 .1-.088l.109-.035a.17.17 0 0 1 .183.053c.15.181.504.781.676 1.032c.143.208.85 1.23 1.158 1.567c.086.093.349.198.466.27a.083.083 0 0 1 .03.112l-1.03 1.808l-.455 2.136a1 1 0 0 0-.036.152l-.412 1.483c.003.188-.14.286-.153.507l-.15 1.084a.06.06 0 0 0 .059.061l2.544.014q.142-.001.286-.006l.075-.007c.124-.016.563-.076.75-.15a.6.6 0 0 0 .227-.13c.185-.194.2-.278.203-.398a.3.3 0 0 0-.028-.105a.12.12 0 0 0-.06-.047l-1.18-.356a.37.37 0 0 1-.19-.134l-.317-.47a.09.09 0 0 1 .018-.097l.618-.609a.2.2 0 0 0 .048-.072l1.904-4.488c.089-.285.059-.605 0-.944c-.044-.25-.686-1.326-.854-1.624l-1.286-2.251c-.079-.138-.19-.133-.228-.276l-.073-1.118a.04.04 0 0 1 .036-.05l.33-.028a.1.1 0 0 0 .075-.048l1.147-2.155a.1.1 0 0 0-.002-.094l-.235-.29a.09.09 0 0 1-.001-.088l.352-.38a.054.054 0 0 1 .073-.02l.934.526a.4.4 0 0 0 .186.05c.26-.001.686-.154.908-.29a.4.4 0 0 0 .139-.148l.458-1.07c.006-.014.027-.012.03.003l.127.595a.064.064 0 0 0 .079.05l1.35-.3a.066.066 0 0 0 .05-.078l-.319-1.344a.07.07 0 0 1 .01-.054l.13-.203a.3.3 0 0 0 .037-.082l.159-.725a.04.04 0 0 1 .04-.032l3.732.005a.09.09 0 0 0 .093-.093v-.634a.02.02 0 0 1 .022-.021h1.439a.047.047 0 0 0 .046-.047V3.28a.047.047 0 0 0-.046-.047h-1.44z'
|
||||
]
|
||||
},
|
||||
'game-pubg': {
|
||||
from: '#fde68a',
|
||||
to: '#b45309',
|
||||
ink: '#422006',
|
||||
paths: [
|
||||
'M15.4 15.63a7.875 6 135 1 1 6.23-6.23 4.5 3.43 135 0 0-6.23 6.23',
|
||||
'm8.29 12.71-2.6 2.6a2.5 2.5 0 1 0-1.65 4.65A2.5 2.5 0 1 0 8.7 18.3l2.59-2.59'
|
||||
]
|
||||
},
|
||||
'game-r6': {
|
||||
from: '#94a3b8',
|
||||
to: '#1e293b',
|
||||
ink: WHITE,
|
||||
paths: [
|
||||
'M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z',
|
||||
'M14 8.5h-2a2 2 0 0 0-2 2v3.5a2 2 0 0 0 4 0v-.5a2 2 0 0 0-4 0'
|
||||
]
|
||||
},
|
||||
'game-tarkov': {
|
||||
from: '#bdb76b',
|
||||
to: '#4d4a26',
|
||||
ink: WHITE,
|
||||
paths: [
|
||||
'M4 10a4 4 0 0 1 4-4h8a4 4 0 0 1 4 4v10a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z',
|
||||
'M8 10h8',
|
||||
'M8 18h8',
|
||||
'M8 22v-6a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v6',
|
||||
'M9 6V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2'
|
||||
]
|
||||
},
|
||||
'game-bo2': {
|
||||
from: '#fb923c',
|
||||
to: '#9a3412',
|
||||
ink: WHITE,
|
||||
paths: [c(12, 12, 9), 'M21 12h-4M7 12H3M12 7V3M12 21v-4', c(12, 12, 1)]
|
||||
},
|
||||
'game-valorant': {
|
||||
from: '#ff6b77',
|
||||
to: '#9f1239',
|
||||
ink: WHITE,
|
||||
fill: true,
|
||||
paths: [
|
||||
'M23.792 2.152a.25.25 0 0 0-.098.083q-5.077 6.345-10.15 12.69c-.107.093-.025.288.119.265c2.439.003 4.877 0 7.316.001a.66.66 0 0 0 .552-.25l2.324-2.903a.72.72 0 0 0 .144-.49c-.002-3.077 0-6.153-.003-9.23c.016-.11-.1-.206-.204-.167zM.077 2.166c-.077.038-.074.132-.076.205q.002 4.612.001 9.225a.68.68 0 0 0 .158.463l7.64 9.55c.12.152.308.25.505.247c2.455 0 4.91.003 7.365 0c.142.02.222-.174.116-.265C10.661 15.176 5.526 8.766.4 2.35c-.08-.094-.174-.272-.322-.184z'
|
||||
]
|
||||
},
|
||||
'game-lol': {
|
||||
from: '#e2c27a',
|
||||
to: '#0e4a5c',
|
||||
ink: WHITE,
|
||||
fill: true,
|
||||
paths: [
|
||||
'm1.912 0l1.212 2.474v19.053L1.912 24h14.73l1.337-4.682H8.33V0ZM12 1.516c-.913 0-1.798.112-2.648.312v1.74A9.7 9.7 0 0 1 12 3.2c5.267 0 9.536 4.184 9.536 9.348a9.2 9.2 0 0 1-2.3 6.086l-.273.954l-.602 2.112c2.952-1.993 4.89-5.335 4.89-9.122C23.25 6.468 18.213 1.516 12 1.516m0 2.673c-.924 0-1.814.148-2.648.414v13.713h8.817a8.25 8.25 0 0 0 2.36-5.768c0-4.617-3.818-8.359-8.529-8.359M2.104 7.312A10.86 10.86 0 0 0 .75 12.576c0 1.906.492 3.7 1.355 5.266z'
|
||||
]
|
||||
},
|
||||
'game-overwatch': {
|
||||
from: '#fdba74',
|
||||
to: '#f97316',
|
||||
ink: '#431407',
|
||||
paths: [c(12, 12, 9), 'm8 7.5 4 7 4-7', 'M7 15.5h10']
|
||||
},
|
||||
'game-minecraft': {
|
||||
from: '#86efac',
|
||||
to: '#166534',
|
||||
ink: WHITE,
|
||||
paths: [
|
||||
'm14 13-8.381 8.38a1 1 0 0 1-3.001-3L11 9.999',
|
||||
'M15.973 4.027A13 13 0 0 0 5.902 2.373c-1.398.342-1.092 2.158.277 2.601a19.9 19.9 0 0 1 5.822 3.024',
|
||||
'M16.001 11.999a19.9 19.9 0 0 1 3.024 5.824c.444 1.369 2.26 1.676 2.603.278A13 13 0 0 0 20 8.069',
|
||||
'M18.352 3.352a1.205 1.205 0 0 0-1.704 0l-5.296 5.296a1.205 1.205 0 0 0 0 1.704l2.296 2.296a1.205 1.205 0 0 0 1.704 0l5.296-5.296a1.205 1.205 0 0 0 0-1.704z'
|
||||
]
|
||||
},
|
||||
'game-factorio': {
|
||||
from: '#f0b16a',
|
||||
to: '#7c4a1e',
|
||||
ink: WHITE,
|
||||
paths: [
|
||||
'M11 10.27 7 3.34',
|
||||
'm11 13.73-4 6.93',
|
||||
'M12 22v-2',
|
||||
'M12 2v2',
|
||||
'M14 12h8',
|
||||
'm17 20.66-1-1.73',
|
||||
'm17 3.34-1 1.73',
|
||||
'M2 12h2',
|
||||
'm20.66 17-1.73-1',
|
||||
'm20.66 7-1.73 1',
|
||||
'm3.34 17 1.73-1',
|
||||
'm3.34 7 1.73 1',
|
||||
c(12, 12, 2),
|
||||
c(12, 12, 8)
|
||||
]
|
||||
},
|
||||
'game-ksp': {
|
||||
from: '#93c5fd',
|
||||
to: '#1e3a8a',
|
||||
ink: WHITE,
|
||||
paths: [
|
||||
'M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5',
|
||||
'M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09',
|
||||
'M9 12a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.4 22.4 0 0 1-4 2z',
|
||||
'M9 12H4s.55-3.03 2-4c1.62-1.08 5 .05 5 .05'
|
||||
]
|
||||
},
|
||||
'game-smash': {
|
||||
from: '#f87171',
|
||||
to: '#991b1b',
|
||||
ink: WHITE,
|
||||
paths: [c(12, 12, 9), 'M9.5 3.4v17.2', 'M3 14.5h18']
|
||||
},
|
||||
'game-cubing': {
|
||||
from: '#4ade80',
|
||||
to: '#1d4ed8',
|
||||
ink: WHITE,
|
||||
paths: ['M4 4h16v16H4z', 'M9.33 4v16M14.67 4v16', 'M4 9.33h16M4 14.67h16']
|
||||
},
|
||||
'game-pokemon': {
|
||||
from: '#fb7185',
|
||||
to: '#c81e4a',
|
||||
ink: WHITE,
|
||||
paths: [c(12, 12, 9), 'M3 12h6M15 12h6', c(12, 12, 3)]
|
||||
},
|
||||
'game-sot': {
|
||||
from: '#5eead4',
|
||||
to: '#0f766e',
|
||||
ink: WHITE,
|
||||
paths: [
|
||||
'M10 2v15',
|
||||
'M7 22a4 4 0 0 1-4-4 1 1 0 0 1 1-1h16a1 1 0 0 1 1 1 4 4 0 0 1-4 4z',
|
||||
'M9.159 2.46a1 1 0 0 1 1.521-.193l9.977 8.98A1 1 0 0 1 20 13H4a1 1 0 0 1-.824-1.567z'
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<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';
|
||||
@@ -20,6 +23,14 @@
|
||||
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"
|
||||
|
||||
@@ -4,6 +4,7 @@ import { blogArticles, bookWorks } from '#lib/server/db/schema/index.js';
|
||||
import { SITE_ORIGIN } from '#lib/seo.js';
|
||||
import { races } from '#lib/derby/races.js';
|
||||
import { COURSES } from '#lib/language-learner/courses/index.js';
|
||||
import { gameTrackers } from '#lib/projects.js';
|
||||
import { and, eq, isNull, ne } from 'drizzle-orm';
|
||||
import type { RequestHandler } from './$types';
|
||||
|
||||
@@ -28,6 +29,7 @@ const staticPaths = [
|
||||
'/trackers/music',
|
||||
'/trackers/pc',
|
||||
'/projects/game-tracker',
|
||||
...gameTrackers.map((g) => g.href),
|
||||
'/projects/game-tracker/pokemon',
|
||||
'/projects/game-tracker/osu',
|
||||
'/projects/game-tracker/pokemon/locations',
|
||||
|
||||
@@ -63,6 +63,14 @@
|
||||
});
|
||||
|
||||
const filtering = $derived(linkFilter.trim() !== '');
|
||||
// Big groups (games): featured chips first, the rest behind "+N more" (all while filtering).
|
||||
let expandedGroups = $state<Record<string, boolean>>({});
|
||||
type Group = (typeof trackers)[number];
|
||||
const tileChildren = (t: Group) =>
|
||||
filtering || expandedGroups[t.name]
|
||||
? (t.children ?? [])
|
||||
: (t.children ?? []).filter((c) => c.featured ?? true);
|
||||
const hiddenChildren = (t: Group) => (t.children?.length ?? 0) - tileChildren(t).length;
|
||||
// The links filter (f) also narrows the trackers; a group stays if it or a chip matches.
|
||||
const shownTrackers = $derived.by(() => {
|
||||
const q = linkFilter.trim().toLowerCase();
|
||||
@@ -291,7 +299,7 @@
|
||||
</h2>
|
||||
<ul class="tgrid">
|
||||
{#each shownTrackers as t (t.name)}
|
||||
<li class={{ group: !!t.children }}>
|
||||
<li class={{ group: !!t.children, wide: (t.children?.length ?? 0) > 6 }}>
|
||||
{#if t.children}
|
||||
<!-- Group tile: stretched main link, chips layered above it (no nested <a>). -->
|
||||
<div class="ttile">
|
||||
@@ -300,11 +308,21 @@
|
||||
<span class="tname">{t.name}</span>
|
||||
</a>
|
||||
<span class="tchips">
|
||||
{#each t.children as c (c.name)}
|
||||
{#each tileChildren(t) as c (c.name)}
|
||||
<a class="tchip" href={c.href} title={c.subtitle}
|
||||
><TrackerIcon name={c.name} class="t-icon size-5" />{c.name}</a
|
||||
><TrackerIcon name={c.icon ?? c.name} class="t-icon size-5" />{c.name}</a
|
||||
>
|
||||
{/each}
|
||||
{#if !filtering && (hiddenChildren(t) > 0 || expandedGroups[t.name])}
|
||||
<button
|
||||
type="button"
|
||||
class="tchip more"
|
||||
aria-expanded={!!expandedGroups[t.name]}
|
||||
onclick={() =>
|
||||
(expandedGroups = { ...expandedGroups, [t.name]: !expandedGroups[t.name] })}
|
||||
>{hiddenChildren(t) > 0 ? `+${hiddenChildren(t)} more` : 'less'}</button
|
||||
>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{:else}
|
||||
@@ -1039,11 +1057,15 @@
|
||||
.tgrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
grid-auto-flow: row dense;
|
||||
gap: 6px;
|
||||
}
|
||||
.tgrid > li.group {
|
||||
grid-column: span 4;
|
||||
}
|
||||
.tgrid > li.group.wide {
|
||||
grid-column: span 6;
|
||||
}
|
||||
.ttile {
|
||||
position: relative;
|
||||
display: flex;
|
||||
@@ -1122,6 +1144,17 @@
|
||||
border-color 160ms ease,
|
||||
background-color 160ms ease;
|
||||
}
|
||||
button.tchip {
|
||||
padding: 2px 9px;
|
||||
color: var(--site-muted);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
button.tchip:hover {
|
||||
color: var(--site-text);
|
||||
}
|
||||
.tchip :global(.t-icon) {
|
||||
border-radius: 999px;
|
||||
}
|
||||
@@ -1145,6 +1178,14 @@
|
||||
transform: none !important;
|
||||
}
|
||||
}
|
||||
@media (max-width: 1000px) {
|
||||
.tgrid > li.group.wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
div.ttile {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.tgrid > li.group {
|
||||
grid-column: 1 / -1;
|
||||
|
||||
15
src/routes/trackers/games/+layout.svelte
Normal file
15
src/routes/trackers/games/+layout.svelte
Normal file
@@ -0,0 +1,15 @@
|
||||
<script lang="ts">
|
||||
import Gamepad2 from 'lucide-svelte/icons/gamepad-2';
|
||||
import SpottingSwitcher from '#lib/components/SpottingSwitcher.svelte';
|
||||
import { gameTrackers } from '#lib/projects.js';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<SpottingSwitcher
|
||||
items={gameTrackers}
|
||||
home="/projects/game-tracker"
|
||||
label="games"
|
||||
homeIcon={Gamepad2}
|
||||
/>
|
||||
{@render children()}
|
||||
5
src/routes/trackers/games/+page.server.ts
Normal file
5
src/routes/trackers/games/+page.server.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
// The games group's landing page is the game tracker; each game lives at /trackers/games/<slug>.
|
||||
export const load: PageServerLoad = () => redirect(307, '/projects/game-tracker');
|
||||
1
src/routes/trackers/games/+page.svelte
Normal file
1
src/routes/trackers/games/+page.svelte
Normal file
@@ -0,0 +1 @@
|
||||
<!-- Redirects to /projects/game-tracker (see +page.server.ts). -->
|
||||
45
src/routes/trackers/games/[slug]/+page.server.ts
Normal file
45
src/routes/trackers/games/[slug]/+page.server.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { eq, inArray } from 'drizzle-orm';
|
||||
import { db } from '#lib/server/db/index.js';
|
||||
import { gameLibrary, hardwareSensitivities } from '#lib/server/db/schema/index.js';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
// One game from games.library, plus its mouse sensitivity (hardware.sensitivities, matched by
|
||||
// name, short name or alias) when there is one.
|
||||
export const load: PageServerLoad = async ({ params }) => {
|
||||
const [game] = await db.select().from(gameLibrary).where(eq(gameLibrary.slug, params.slug));
|
||||
if (!game) error(404, 'Not Found');
|
||||
const names = [game.name, game.shortName, ...game.aliases];
|
||||
const [sensitivity] = await db
|
||||
.select({
|
||||
game: hardwareSensitivities.game,
|
||||
cmPer360: hardwareSensitivities.cmPer360,
|
||||
inGameSens: hardwareSensitivities.inGameSens,
|
||||
yaw: hardwareSensitivities.yaw,
|
||||
fov: hardwareSensitivities.fov,
|
||||
notes: hardwareSensitivities.notes
|
||||
})
|
||||
.from(hardwareSensitivities)
|
||||
.where(inArray(hardwareSensitivities.game, names))
|
||||
.limit(1);
|
||||
return {
|
||||
game: {
|
||||
slug: game.slug,
|
||||
name: game.name,
|
||||
shortName: game.shortName,
|
||||
kind: game.kind,
|
||||
status: game.status,
|
||||
platform: game.platform,
|
||||
trackerHref: game.trackerHref,
|
||||
currentRank: game.currentRank,
|
||||
peakRank: game.peakRank,
|
||||
rankLabel: game.rankLabel,
|
||||
notes: game.notes
|
||||
},
|
||||
sensitivity: sensitivity ?? null,
|
||||
seo: {
|
||||
title: `${game.name} · Games`,
|
||||
description: `${game.name} on Stan Runge’s game tracker${game.status === 'main' ? ': my main game right now' : ''}.`
|
||||
}
|
||||
};
|
||||
};
|
||||
172
src/routes/trackers/games/[slug]/+page.svelte
Normal file
172
src/routes/trackers/games/[slug]/+page.svelte
Normal file
@@ -0,0 +1,172 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
||||
import ArrowRight from 'lucide-svelte/icons/arrow-right';
|
||||
import TrackerIcon from '#lib/components/TrackerIcon.svelte';
|
||||
import { gameTrackers } from '#lib/projects.js';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
const game = $derived(data.game);
|
||||
const nav = $derived(gameTrackers.find((g) => g.href === `/trackers/games/${game.slug}`));
|
||||
const status: Record<string, string> = {
|
||||
main: 'main game',
|
||||
active: 'actively playing',
|
||||
occasional: 'occasionally',
|
||||
unknown: 'not sure yet'
|
||||
};
|
||||
// "~#40k": ranks are rough positions I gave, so round them.
|
||||
const rank = (n: number) =>
|
||||
n >= 1000 ? `~#${Math.round(n / 1000).toLocaleString('en-GB')}k` : `#${n}`;
|
||||
const hasStats = $derived(
|
||||
game.currentRank != null || game.peakRank != null || !!data.sensitivity
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
|
||||
<a
|
||||
href={resolve('/projects/game-tracker')}
|
||||
class="mb-6 inline-flex items-center gap-1 text-sm text-[var(--site-muted)] hover:text-foreground"
|
||||
><ArrowLeft size={14} aria-hidden="true" /> games</a
|
||||
>
|
||||
<h1 class="mb-3 flex items-center gap-3 text-4xl font-semibold tracking-tight sm:text-5xl">
|
||||
<TrackerIcon name={nav?.icon ?? `game-${game.slug}`} class="size-10 sm:size-12" />{game.name}
|
||||
</h1>
|
||||
<div class="mb-10 flex flex-wrap items-center gap-1.5 text-sm">
|
||||
<span class={['badge', `status-${game.status}`]}>{status[game.status]}</span>
|
||||
{#if game.platform}<span class="badge">{game.platform}</span>{/if}
|
||||
{#if game.kind === 'puzzle'}<span class="badge">not a video game</span>{/if}
|
||||
{#if nav?.subtitle && nav.subtitle !== game.name}<span class="ml-1 text-[var(--site-muted)]"
|
||||
>{nav.subtitle}</span
|
||||
>{/if}
|
||||
</div>
|
||||
|
||||
{#if hasStats}
|
||||
<ul class="mb-8 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{#if game.currentRank != null}
|
||||
<li class="stat">
|
||||
<span class="label">current rank</span>
|
||||
<span class="value">{rank(game.currentRank)}</span>
|
||||
{#if game.rankLabel}<span class="hint">{game.rankLabel}</span>{/if}
|
||||
</li>
|
||||
{/if}
|
||||
{#if game.peakRank != null}
|
||||
<li class="stat">
|
||||
<span class="label">peak rank</span>
|
||||
<span class="value">{rank(game.peakRank)}</span>
|
||||
{#if game.rankLabel}<span class="hint">{game.rankLabel}</span>{/if}
|
||||
</li>
|
||||
{/if}
|
||||
{#if data.sensitivity}
|
||||
{@const s = data.sensitivity}
|
||||
<li class="stat">
|
||||
<span class="label">sensitivity</span>
|
||||
<span class="value"
|
||||
>{s.cmPer360 != null
|
||||
? `${s.cmPer360} cm/360°`
|
||||
: s.inGameSens != null
|
||||
? s.inGameSens
|
||||
: 'not set yet'}</span
|
||||
>
|
||||
<a class="hint underline" href={resolve('/trackers/pc')}>on the PC tracker</a>
|
||||
</li>
|
||||
{/if}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
{#if game.notes}
|
||||
<p class="mb-8 max-w-2xl whitespace-pre-line text-[var(--site-muted)]">{game.notes}</p>
|
||||
{/if}
|
||||
|
||||
{#if game.trackerHref}
|
||||
<a href={resolve(game.trackerHref as '/')} class="deep inline-flex items-center gap-3 p-4">
|
||||
<TrackerIcon name={nav?.icon ?? `game-${game.slug}`} class="size-8" />
|
||||
<span>
|
||||
<span class="block font-medium">open the full {game.shortName} tracker</span>
|
||||
<span class="block text-sm text-[var(--site-muted)]">progress, goals and history</span>
|
||||
</span>
|
||||
<ArrowRight size={18} aria-hidden="true" class="ml-4 text-[var(--site-muted)]" />
|
||||
</a>
|
||||
{:else if !hasStats && !game.notes}
|
||||
<p class="empty rounded-xl p-6 text-[var(--site-muted)]">
|
||||
Nothing tracked for {game.shortName} yet. Stats, ranks and notes show up here once I add them.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.badge {
|
||||
border: 1px solid color-mix(in oklab, var(--foreground) 12%, transparent);
|
||||
border-radius: 999px;
|
||||
padding: 0.15rem 0.65rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.status-main {
|
||||
background: var(--foreground);
|
||||
color: var(--background);
|
||||
border-color: var(--foreground);
|
||||
}
|
||||
.status-active {
|
||||
border-color: color-mix(in oklab, var(--foreground) 40%, transparent);
|
||||
}
|
||||
.status-unknown,
|
||||
.status-occasional {
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
padding: 1rem 1.1rem;
|
||||
border: 1px solid color-mix(in oklab, var(--foreground) 9%, transparent);
|
||||
border-radius: 0.9rem;
|
||||
background: color-mix(in oklab, var(--foreground) 2%, transparent);
|
||||
}
|
||||
.stat .label {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.stat .value {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.stat .hint {
|
||||
font-size: 0.8rem;
|
||||
color: var(--site-muted);
|
||||
}
|
||||
.deep,
|
||||
.empty {
|
||||
border: 1px solid color-mix(in oklab, var(--foreground) 9%, transparent);
|
||||
border-radius: 0.9rem;
|
||||
background: color-mix(in oklab, var(--foreground) 2%, transparent);
|
||||
}
|
||||
.deep {
|
||||
outline: none;
|
||||
transition:
|
||||
transform 160ms cubic-bezier(0.2, 0.8, 0.2, 1),
|
||||
background-color 160ms ease,
|
||||
border-color 160ms ease,
|
||||
box-shadow 160ms ease;
|
||||
}
|
||||
.deep:hover {
|
||||
background: color-mix(in oklab, var(--foreground) 6%, transparent);
|
||||
border-color: color-mix(in oklab, var(--foreground) 22%, transparent);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 12px 28px -16px rgb(0 0 0 / 0.55);
|
||||
}
|
||||
.deep:focus-visible {
|
||||
outline: 2px solid var(--foreground);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.deep,
|
||||
.deep:hover {
|
||||
transition: none;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user