ui changes and cs prices
All checks were successful
Test and deploy staging / staging (push) Successful in 11m52s

This commit is contained in:
2026-09-25 00:44:06 +00:00
parent c757d0882a
commit abac077541
200 changed files with 6236 additions and 2311 deletions

View File

@@ -2,19 +2,11 @@
import { resolve } from '$app/paths';
import ArrowRight from 'lucide-svelte/icons/arrow-right';
import ExternalLink from 'lucide-svelte/icons/external-link';
import * as Card from '$lib/components/ui/card/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import ProjectIcon from '$lib/components/ProjectIcon.svelte';
import { projects } from '$lib/projects';
const projects = [
{ name: 'animaldex', href: '/projects/animaldex', external: false },
{ name: 'derby lens', href: '/projects/derby', external: false },
{ name: 'cs prices', href: '/projects/cs-prices', external: false },
{ name: 'osu! map finder', href: '/projects/spotify-beatmap-finder', external: false },
{ name: 'game tracker', href: '/projects/game-tracker', external: false },
{ name: 'language learner', href: '/projects/language-learner', external: false },
{ name: 'money tracker', href: '/projects/money-tracker', external: false },
{ name: 'world opinions', href: '/projects/world-opinions', external: false },
{ name: 'vash esports', href: 'https://esports.vash.software', external: true },
{ name: 'beerio kart', href: 'https://beeriokart.stanrunge.dev', external: true }
] as const;
const earlier = [
'distributor',
'realtime editing',
@@ -24,33 +16,55 @@
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
<h1 class="mb-10 text-4xl font-semibold tracking-tight sm:text-5xl">projects</h1>
<ul>
<ul class="grid gap-3 sm:grid-cols-2">
{#each projects as project (project.name)}
<li class="border-t border-[var(--site-border)] text-lg">
{#if project.external}
<a
href={project.href}
rel="external"
class="flex items-center justify-between gap-4 py-5 hover:text-[var(--site-muted)]"
>
<span>{project.name}</span><ExternalLink
size={18}
aria-hidden="true"
class="text-[var(--site-muted)]"
/>
</a>
{:else}
<a
href={resolve(project.href)}
class="flex items-center justify-between gap-4 py-5 hover:text-[var(--site-muted)]"
>
<span>{project.name}</span><ArrowRight
size={18}
aria-hidden="true"
class="text-[var(--site-muted)]"
/>
</a>
{/if}
<li>
<Card.Root class="h-full py-0 transition-colors hover:bg-muted/50"
><Card.Content class="p-0">
{#if project.external}
<a
href={project.href}
rel="external"
class="flex min-h-28 items-start justify-between gap-4 p-5"
>
<span class="flex min-w-0 items-start gap-3">
<span
class="mt-0.5 flex size-10 shrink-0 items-center justify-center rounded-md border border-border bg-[var(--site-surface)] text-[var(--site-muted)]"
>
<ProjectIcon name={project.name} class="size-5" />
</span>
<span class="min-w-0"
><span class="block font-medium">{project.name}</span><span
class="mt-1 block text-sm text-muted-foreground">{project.subtitle}</span
></span
>
</span><ExternalLink
size={18}
aria-hidden="true"
class="text-[var(--site-muted)]"
/>
</a>
{:else}
<a
href={resolve(project.href)}
class="flex min-h-28 items-start justify-between gap-4 p-5"
>
<span class="flex min-w-0 items-start gap-3">
<span
class="mt-0.5 flex size-10 shrink-0 items-center justify-center rounded-md border border-border bg-[var(--site-surface)] text-[var(--site-muted)]"
>
<ProjectIcon name={project.name} class="size-5" />
</span>
<span class="min-w-0"
><span class="block font-medium">{project.name}</span><span
class="mt-1 block text-sm text-muted-foreground">{project.subtitle}</span
></span
>
</span><ArrowRight size={18} aria-hidden="true" class="text-[var(--site-muted)]" />
</a>
{/if}
</Card.Content></Card.Root
>
</li>
{/each}
</ul>
@@ -58,7 +72,7 @@
<ul>
{#each earlier as name (name)}
<li class="border-t border-[var(--site-border)] py-5 text-lg text-[var(--site-muted)]">
{name}
<Badge variant="outline">earlier</Badge> <span class="ml-2">{name}</span>
</li>
{/each}
</ul>

View File

@@ -4,6 +4,8 @@
import { page } from '$app/state';
import { goto, preloadData, pushState } from '$app/navigation';
import AnimalDetails from '$lib/animaldex/AnimalDetails.svelte';
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import PawPrint from 'lucide-svelte/icons/paw-print';
import X from 'lucide-svelte/icons/x';
import type { Map as MaptilerMap, Marker as MaptilerMarker } from '@maptiler/sdk';
@@ -309,12 +311,7 @@
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">animaldex</h1>
<p class="mt-2 text-base text-[var(--site-muted)]">neighborhood animals on the map</p>
</div>
<a
href={resolve('/projects/animaldex/register')}
class="rounded bg-[var(--site-text)] px-4 py-2.5 text-base font-medium text-[var(--site-bg)]"
>
add an animal
</a>
<Button href={resolve('/projects/animaldex/register')}>add an animal</Button>
</div>
{#if submitted}<p role="status" class="mb-5 border-y border-[var(--site-border)] py-3">
sighting submitted. it will appear after review.
@@ -328,20 +325,18 @@
: `${animalCount} ${animalCount === 1 ? 'animal' : 'animals'} in this area`}</span
>
<div class="flex items-center gap-5">
<button
type="button"
onclick={locate}
disabled={locating}
class="underline underline-offset-4">{locating ? 'locating…' : 'my location'}</button
<Button variant="outline" size="sm" type="button" onclick={locate} disabled={locating}
>{locating ? 'locating…' : 'my location'}</Button
>
<button
<Button
variant="outline"
size="sm"
type="button"
onclick={() => (showFilters = !showFilters)}
aria-expanded={showFilters}
class="underline underline-offset-4"
>
filters{activeFilterCount ? ` (${activeFilterCount})` : ''}
</button>
</Button>
</div>
</div>
@@ -349,58 +344,24 @@
<div class="mb-5 border-y border-[var(--site-border)] py-5">
<h2 class="mb-3 text-lg font-medium">filter sightings</h2>
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<input
bind:value={filterSpecies}
placeholder="Species"
aria-label="Species"
class="w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2 text-base"
/>
<input
bind:value={filterBreed}
placeholder="Breed"
aria-label="Breed"
class="w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2 text-base"
/>
<input
bind:value={filterName}
placeholder="Animal name"
aria-label="Animal name"
class="w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2 text-base"
/>
<input
bind:value={filterReporter}
placeholder="Reporter name"
aria-label="Reporter name"
class="w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2 text-base"
/>
<Input bind:value={filterSpecies} placeholder="Species" aria-label="Species" />
<Input bind:value={filterBreed} placeholder="Breed" aria-label="Breed" />
<Input bind:value={filterName} placeholder="Animal name" aria-label="Animal name" />
<Input bind:value={filterReporter} placeholder="Reporter name" aria-label="Reporter name" />
<div class="grid grid-cols-2 gap-3 sm:col-span-2">
<div>
<label for="filter-from" class="mb-1 block text-base">from</label>
<input
id="filter-from"
type="date"
bind:value={filterFrom}
class="w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2 text-base"
/>
<Input id="filter-from" type="date" bind:value={filterFrom} />
</div>
<div>
<label for="filter-to" class="mb-1 block text-base">to</label>
<input
id="filter-to"
type="date"
bind:value={filterTo}
class="w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2 text-base"
/>
<Input id="filter-to" type="date" bind:value={filterTo} />
</div>
</div>
</div>
<div class="mt-4 flex gap-4">
<button
onclick={applyFilters}
class="rounded bg-[var(--site-text)] px-4 py-2 text-base text-[var(--site-bg)]"
>apply</button
>
<button onclick={clearFilters} class="text-base underline underline-offset-4">clear</button>
<Button onclick={applyFilters}>apply</Button>
<Button variant="ghost" onclick={clearFilters}>clear</Button>
</div>
</div>
{/if}

View File

@@ -2,7 +2,7 @@ import type { PageServerLoad } from './$types';
import { db } from '$lib/server/db';
import { animals, sightings } from '$lib/server/db/schema';
import { isAdmin } from '$lib/server/auth';
import { and, eq, isNull, sql } from 'drizzle-orm';
import { and, eq, sql } from 'drizzle-orm';
export const load: PageServerLoad = async (event) => {
const admin = isAdmin(event);

View File

@@ -8,6 +8,9 @@
import MapPin from 'lucide-svelte/icons/map-pin';
import PawPrint from 'lucide-svelte/icons/paw-print';
import X from 'lucide-svelte/icons/x';
import { Button } from '$lib/components/ui/button/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
let { data }: { data: PageData } = $props();
@@ -67,7 +70,7 @@
{#each queue as item (item.animalId)}
{@const status = item.acceptedAt ? 'accepted' : item.deniedAt ? 'denied' : 'pending'}
<div class="border-t border-[var(--site-border)] pt-4">
<Card.Root>
<div class="flex gap-4 p-4">
<div class="h-24 w-24 shrink-0 overflow-hidden rounded bg-[var(--site-surface)]">
{#if item.photoUrl}
@@ -86,9 +89,7 @@
{item.species}{item.breed ? ` · ${item.breed}` : ''}
</p>
</div>
<span class="shrink-0 text-base text-[var(--site-muted)]">
{status}
</span>
<Badge variant="secondary">{status}</Badge>
</div>
{#if item.aiBreedSuggestion}
<p class="mt-1 flex items-center gap-1 text-base text-[var(--site-muted)]">
@@ -114,28 +115,30 @@
</div>
</div>
<div class="flex gap-3 border-t border-[var(--site-border)] px-4 py-3">
<button
<Button
variant="outline"
onclick={() => moderate(item.animalId, 'accept')}
disabled={actionLoading !== null || status === 'accepted'}
class="inline-flex flex-1 items-center justify-center gap-1 rounded border border-[var(--site-border)] py-2 text-base disabled:opacity-50"
class="flex-1"
>
{#if actionLoading === `animals-${item.animalId}-accept`}…{:else}<Check
size={16}
aria-hidden="true"
/> Accept{/if}
</button>
<button
</Button>
<Button
variant="outline"
onclick={() => moderate(item.animalId, 'deny')}
disabled={actionLoading !== null || status === 'denied'}
class="inline-flex flex-1 items-center justify-center gap-1 rounded border border-[var(--site-border)] py-2 text-base disabled:opacity-50"
class="flex-1"
>
{#if actionLoading === `animals-${item.animalId}-deny`}…{:else}<X
size={16}
aria-hidden="true"
/> Deny{/if}
</button>
</Button>
</div>
</div>
</Card.Root>
{/each}
</div>
</section>

View File

@@ -3,7 +3,7 @@ import type { RequestHandler } from './$types';
import { db } from '$lib/server/db';
import { animals, sightings } from '$lib/server/db/schema';
import { moderationActionSchema } from '$lib/validation/sighting';
import { eq, isNull } from 'drizzle-orm';
import { eq } from 'drizzle-orm';
import { requireAdmin } from '$lib/server/auth';
export const POST: RequestHandler = async (event) => {

View File

@@ -1,4 +1,7 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Textarea } from '$lib/components/ui/textarea/index.js';
import { resolve } from '$app/paths';
import type { Map as MaptilerMap, Marker as MaptilerMarker } from '@maptiler/sdk';
import { onDestroy, onMount } from 'svelte';
@@ -257,8 +260,8 @@
<div role="alert" class="border-y border-[var(--site-border)] py-4 text-base">
<p class="font-medium">location required</p>
<p class="mt-1">{locationError}</p>
<button type="button" onclick={requestLocation} class="mt-3 underline underline-offset-4"
>try again</button
<Button type="button" variant="outline" onclick={requestLocation} class="mt-3"
>try again</Button
>
</div>
{/if}
@@ -276,12 +279,13 @@
{#each photoPreviews as preview, i (i)}
<div class="relative h-20 w-20 overflow-hidden rounded">
<img src={preview} alt="" class="h-full w-full object-cover" />
<button
<Button
variant="secondary"
size="icon-xs"
type="button"
onclick={() => removePhoto(i)}
aria-label={`Remove photo ${i + 1}`}
class="absolute top-1 right-1 flex h-5 w-5 items-center justify-center rounded-full bg-black/60 text-xs text-white"
><X size={12} aria-hidden="true" /></button
class="absolute top-1 right-1 rounded-full"><X size={12} aria-hidden="true" /></Button
>
</div>
{/each}
@@ -320,30 +324,25 @@
</h2>
<div class="flex flex-wrap gap-2">
{#each COMMON_SPECIES as s (s)}
<button
<Button
variant={species === s ? 'secondary' : 'outline'}
size="sm"
onclick={() => {
species = s;
customSpecies = '';
}}
class="rounded border border-[var(--site-border)] px-3 py-1.5 text-base {species === s
? 'bg-[var(--site-text)] text-[var(--site-bg)]'
: 'bg-[var(--site-surface)] text-[var(--site-text)] hover:bg-[var(--site-hover)]'}"
>{s}</button
}}>{s}</Button
>
{/each}
<button
<Button
variant={species === '__custom__' ? 'secondary' : 'outline'}
size="sm"
onclick={() => {
species = '__custom__';
}}
class="rounded border border-[var(--site-border)] px-3 py-1.5 text-base {species ===
'__custom__'
? 'bg-[var(--site-text)] text-[var(--site-bg)]'
: 'bg-[var(--site-surface)] text-[var(--site-text)] hover:bg-[var(--site-hover)]'}"
>Other…</button
}}>Other…</Button
>
</div>
{#if species === '__custom__'}
<input
<Input
bind:value={customSpecies}
placeholder="Enter species"
class="mt-3 w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2 text-base"
@@ -355,7 +354,7 @@
<h2 class="mb-1 text-xl font-semibold">
Breed <span class="text-base font-normal text-[var(--site-muted)]">(optional)</span>
</h2>
<input
<Input
bind:value={breed}
placeholder="e.g. Golden Retriever"
class="w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2 text-base"
@@ -369,7 +368,7 @@
>(optional)</span
>
</h2>
<input
<Input
bind:value={animalName}
placeholder="e.g. Fluffy"
class="w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2 text-base"
@@ -381,12 +380,12 @@
>(optional)</span
>
</h2>
<textarea
<Textarea
bind:value={description}
placeholder="Behaviour, features, context…"
rows="3"
rows={3}
class="w-full resize-none rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2 text-base"
></textarea>
/>
</div>
</section>
{/if}
@@ -396,7 +395,7 @@
<h2 class="mb-1 text-xl font-semibold">
When did you see it? <span class="text-[var(--site-muted)]">*</span>
</h2>
<input
<Input
type="datetime-local"
bind:value={seenAt}
max={localDateTime()}
@@ -407,7 +406,7 @@
<h2 class="mb-1 text-xl font-semibold">
Your name <span class="text-base font-normal text-[var(--site-muted)]">(optional)</span>
</h2>
<input
<Input
bind:value={reporterName}
placeholder="Saved for next time"
class="w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2 text-base"
@@ -443,13 +442,13 @@
</div>
{/if}
<button
<Button
onclick={submit}
disabled={submitting || !!locationError || lat === null}
class="w-full rounded bg-[var(--site-text)] py-3 font-medium text-[var(--site-bg)] disabled:cursor-not-allowed disabled:opacity-50"
class="h-11 w-full"
>
{submitting ? 'Submitting…' : isNewSighting ? 'Submit sighting' : 'Register animal'}
</button>
</Button>
<p class="pb-8 text-center text-base text-[var(--site-muted)]">
All submissions are reviewed before appearing on the map.

View File

@@ -0,0 +1,26 @@
import { db } from '$lib/server/db';
import { sql } from 'drizzle-orm';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
const [stats, dimensions, markets] = await Promise.all([
db.execute(sql`SELECT
(SELECT count(*)::int FROM cs_market_items) AS items,
(SELECT greatest(reltuples::bigint, 0) FROM pg_class WHERE oid = 'cs_price_observations'::regclass) AS observations,
(SELECT min(observed_at) FROM cs_price_observations) AS earliest,
(SELECT max(observed_at) FROM cs_price_observations) AS latest`),
db.execute(sql`SELECT key, count(DISTINCT value)::int AS values
FROM cs_item_dimensions GROUP BY key ORDER BY key`),
db.execute(sql`SELECT source, currency, kind FROM cs_price_streams ORDER BY 1, 2, 3`)
]);
return {
stats: stats[0] as {
items: number;
observations: string;
earliest: Date | null;
latest: Date | null;
},
dimensions: dimensions as unknown as { key: string; values: number }[],
markets: markets as unknown as { source: string; currency: string; kind: string }[]
};
};

View File

@@ -0,0 +1,657 @@
<script lang="ts">
import * as Tabs from '$lib/components/ui/tabs/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { SvelteDate, SvelteMap } from 'svelte/reactivity';
import PriceChart, { type PriceSeries } from '$lib/cs-prices/PriceChart.svelte';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
type Result = {
id?: number;
name?: string;
market_hash_name?: string;
value?: string;
items?: number;
};
type Point = {
series_key: string;
bucket: string;
price_cents: number;
items: number;
volume: string | null;
min_cents: number;
max_cents: number;
};
const labels: Record<string, string> = {
item: 'Individual listing',
category: 'Category',
weapon_type: 'Weapon type',
weapon: 'Weapon name',
weapon_skin: 'Weapon and skin',
skin: 'Skin name',
wear: 'Wear',
stattrak: 'StatTrak',
souvenir: 'Souvenir',
variant: 'Variant',
steam_type: 'Steam type'
};
const number = new Intl.NumberFormat('en-US');
let dimension = $state('item');
let sourceChoice = $state('steam-market|USD');
let kind = $state('sale-median');
let markets = $derived(
data.markets.length
? data.markets
: [
{ source: 'steam-market', currency: 'USD', kind: 'sale-median' },
{ source: 'steam-market', currency: 'USD', kind: 'lowest-listing' }
]
);
let sourceOptions = $derived([
...new Set(markets.map((market) => `${market.source}|${market.currency}`))
]);
let kinds = $derived(
markets.filter((market) => `${market.source}|${market.currency}` === sourceChoice)
);
$effect(() => {
if (!sourceOptions.includes(sourceChoice)) changeSource(sourceOptions[0]);
else if (kinds.length && !kinds.some((market) => market.kind === kind)) kind = kinds[0].kind;
});
let range = $state('1y');
let customFrom = $state('');
let customTo = $state('');
let query = $state('');
let results = $state<Result[]>([]);
let selected = $state<Result[]>([]);
let series = $state<PriceSeries[]>([]);
let searching = $state(false);
let loading = $state(false);
let adding = $state(false);
let error = $state('');
let searchError = $state('');
let bucketSeconds = $state(0);
function key(row: Result) {
return dimension === 'item' ? String(row.id) : (row.value ?? '');
}
function label(row: Result) {
return dimension === 'item' ? (row.name ?? '') : (row.value ?? '');
}
function changeDimension(value: string) {
dimension = value;
selected = [];
series = [];
query = '';
}
function changeSource(value: string) {
sourceChoice = value;
kind =
markets.find((market) => `${market.source}|${market.currency}` === value)?.kind ??
'sale-median';
}
function add(row: Result) {
if (selected.length >= 1000 || selected.some((item) => key(item) === key(row))) return;
selected = [...selected, row];
}
function remove(row: Result) {
selected = selected.filter((item) => key(item) !== key(row));
}
function dateRange() {
if (range === 'custom') {
if (
!customFrom ||
!customTo ||
Number.isNaN(Date.parse(customFrom)) ||
Number.isNaN(Date.parse(customTo))
)
return null;
return {
from: new SvelteDate(customFrom).toISOString(),
to: new SvelteDate(customTo).toISOString()
};
}
const to = new SvelteDate();
const from =
range === 'all' && data.stats.earliest
? new SvelteDate(data.stats.earliest)
: new SvelteDate(to);
if (range === '1h') from.setTime(to.getTime() - 3_600_000);
if (range === '24h') from.setTime(to.getTime() - 86_400_000);
if (range === '7d') from.setUTCDate(from.getUTCDate() - 7);
if (range === '30d') from.setUTCDate(from.getUTCDate() - 30);
if (range === '1y') from.setUTCFullYear(from.getUTCFullYear() - 1);
if (range === '5y') from.setUTCFullYear(from.getUTCFullYear() - 5);
if (range === 'all' && !data.stats.earliest) from.setUTCFullYear(from.getUTCFullYear() - 1);
return { from: from.toISOString(), to: to.toISOString() };
}
function bucketLabel(seconds: number) {
return seconds < 3600 ? `${seconds / 60} minute` : `${Math.round(seconds / 3600)} hour`;
}
$effect(() => {
const d = dimension;
const q = query;
const controller = new AbortController();
const timer = setTimeout(async () => {
searching = true;
searchError = '';
try {
const response = await fetch(
`/projects/cs-prices/api/search?dimension=${encodeURIComponent(d)}&q=${encodeURIComponent(q)}`,
{ signal: controller.signal }
);
if (!response.ok) throw new Error(`Search failed (${response.status})`);
results = (await response.json()).results;
} catch (cause) {
if (!controller.signal.aborted) searchError = String(cause);
} finally {
if (!controller.signal.aborted) searching = false;
}
}, 200);
return () => {
clearTimeout(timer);
controller.abort();
};
});
async function addMatches() {
adding = true;
searchError = '';
try {
const response = await fetch(
`/projects/cs-prices/api/search?dimension=${encodeURIComponent(dimension)}&q=${encodeURIComponent(query)}&limit=1000`
);
if (!response.ok) throw new Error(`Search failed (${response.status})`);
const found = (await response.json()).results as Result[];
const all = new SvelteMap(selected.map((row) => [key(row), row]));
for (const row of found) if (all.size < 1000) all.set(key(row), row);
selected = [...all.values()];
} catch (cause) {
searchError = String(cause);
} finally {
adding = false;
}
}
$effect(() => {
const d = dimension;
const k = kind;
const [source, currency] = sourceChoice.split('|');
const selectedRange = dateRange();
const picked = selected.map((item) => ({ key: key(item), name: label(item) }));
const controller = new AbortController();
if (!picked.length) {
series = [];
error = '';
loading = false;
return;
}
if (!selectedRange) {
series = [];
error = 'Choose both dates for the custom range.';
loading = false;
return;
}
const timer = setTimeout(async () => {
loading = true;
error = '';
try {
const response = await fetch('/projects/cs-prices/api/series', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: controller.signal,
body: JSON.stringify({
dimension: d,
kind: k,
source,
currency,
...selectedRange,
...(d === 'item'
? { ids: picked.map((item) => Number(item.key)) }
: { values: picked.map((item) => item.key) })
})
});
if (!response.ok)
throw new Error(
(await response.json()).error ?? `Chart request failed (${response.status})`
);
const payload = (await response.json()) as { points: Point[]; bucketSeconds: number };
const byKey = new SvelteMap<string, [number, number][]>();
for (const point of payload.points) {
const points = byKey.get(point.series_key) ?? [];
points.push([Date.parse(point.bucket), Number(point.price_cents)]);
byKey.set(point.series_key, points);
}
series = picked
.map((item) => ({ ...item, points: byKey.get(item.key) ?? [] }))
.filter((item) => item.points.length);
bucketSeconds = payload.bucketSeconds;
} catch (cause) {
if (!controller.signal.aborted) error = String(cause);
} finally {
if (!controller.signal.aborted) loading = false;
}
}, 100);
return () => {
clearTimeout(timer);
controller.abort();
};
});
</script>
<svelte:head>
<title>CS prices · Stan Runge</title>
<meta
name="description"
content="Explore historical Counter-Strike market prices by item, weapon, skin, wear and more."
/>
</svelte:head>
<div class="cs-page">
<header class="intro">
<div>
<p class="eyebrow">market archive / counter-strike</p>
<h1>CS prices</h1>
<p class="lede">
Explore price histories for individual listings or compare groups of items over time.
</p>
</div>
<div class="stats">
<div><strong>{number.format(data.stats.items)}</strong><span>listings indexed</span></div>
<div>
<strong>≈{number.format(Number(data.stats.observations))}</strong><span
>stored observations</span
>
</div>
</div>
</header>
<div class="workspace">
<aside class="picker" aria-label="Series selection">
<div class="panel-heading">
<h2>Compare</h2>
<Badge variant="secondary">{selected.length} / 1000</Badge>
</div>
<label class="field"
><span>Group by</span><select
value={dimension}
onchange={(event) => changeDimension(event.currentTarget.value)}
>
<option value="item">Individual listing</option>
{#each data.dimensions as option (option.key)}<option value={option.key}
>{labels[option.key] ?? option.key.replaceAll('_', ' ')}</option
>{/each}
</select></label
>
<label class="field"
><span
>Find {dimension === 'item'
? 'listings'
: (labels[dimension] ?? dimension).toLowerCase()}</span
>
<Input
type="search"
bind:value={query}
placeholder={dimension === 'item' ? 'AK-47 | Redline…' : 'Search groups…'}
/></label
>
<div class="result-actions">
<span>{searching ? 'Searching…' : 'First 50 matches'}</span><Button
variant="outline"
size="xs"
onclick={addMatches}
disabled={adding}>{adding ? 'Adding…' : 'Add up to 1000 matches'}</Button
>
</div>
{#if searchError}<p class="error">{searchError}</p>{/if}
<div class="results">
{#each results as result (key(result))}
<button
class="result"
onclick={() => add(result)}
disabled={selected.some((item) => key(item) === key(result)) || selected.length >= 1000}
>
<span
>{label(result)}{#if result.items != null}<small
>{number.format(result.items)} listings</small
>{/if}</span
><span aria-hidden="true">+</span>
</button>
{/each}
</div>
<div class="selected-heading">
<h3>Selected</h3>
{#if selected.length}<Button variant="ghost" size="xs" onclick={() => (selected = [])}
>Clear</Button
>{/if}
</div>
<div class="selected" aria-live="polite">
{#if !selected.length}<p>Choose a listing or group to begin.</p>{/if}
{#each selected as item (key(item))}<Button
variant="outline"
size="xs"
title={`Remove ${label(item)}`}
onclick={() => remove(item)}
><span>{label(item)}</span><span aria-hidden="true">×</span></Button
>{/each}
</div>
</aside>
<section class="visual" aria-label="Price history">
<div class="toolbar">
<div>
<p class="eyebrow">historical comparison</p>
<h2>Price history</h2>
</div>
<div class="controls">
<label
>Source<select
value={sourceChoice}
onchange={(event) => changeSource(event.currentTarget.value)}
>{#each sourceOptions as option (option)}<option value={option}
>{option.replace('|', ' · ')}</option
>{/each}</select
></label
><label
>Range<select bind:value={range}
><option value="1h">1 hour</option><option value="24h">24 hours</option><option
value="7d">7 days</option
><option value="30d">30 days</option><option value="1y">1 year</option><option
value="5y">5 years</option
><option value="all">All history</option><option value="custom">Custom</option
></select
></label
>
</div>
</div>
{#if range === 'custom'}<div class="custom-range">
<label>From<Input type="datetime-local" bind:value={customFrom} /></label><label
>To<Input type="datetime-local" bind:value={customTo} /></label
>
</div>{/if}
<Tabs.Root bind:value={kind} class="tabs"
><Tabs.List class="mb-2"
>{#each kinds as market (market.kind)}<Tabs.Trigger value={market.kind}
>{market.kind.replaceAll('-', ' ')}</Tabs.Trigger
>{/each}</Tabs.List
></Tabs.Root
>
{#if error}<p class="error">{error}</p>{/if}
{#if selected.length === 0}<div class="empty">
<strong>Build a comparison</strong>
<p>Search the catalog and add the items or groups you want to plot.</p>
</div>
{:else if loading}<div class="empty"><p>Loading price history…</p></div>
{:else if series.length === 0}<div class="empty">
<strong>No observations in this range</strong>
<p>Try another period or price type.</p>
</div>
{:else}<PriceChart {series} {kind} currency={sourceChoice.split('|')[1]} />{/if}
<p class="note">
{series.length} series with data · {sourceChoice.replace('|', ' · ')} · {kind.replaceAll(
'-',
' '
)} · {bucketSeconds ? `up to ${bucketLabel(bucketSeconds)} buckets` : 'choose a series'}.
Group lines average each listing equally within a bucket; raw observations remain in the
database.
</p>
</section>
</div>
</div>
<style>
.cs-page {
max-width: 1600px;
margin: 0 auto;
padding: 3rem clamp(1rem, 4vw, 4rem) 5rem;
color: var(--site-text);
}
.intro {
display: flex;
justify-content: space-between;
align-items: end;
gap: 2rem;
margin-bottom: 2.5rem;
}
.eyebrow {
color: var(--site-muted);
text-transform: uppercase;
letter-spacing: 0.2em;
font-size: 0.67rem;
font-weight: 700;
}
h1 {
font-size: clamp(2.8rem, 6vw, 5.2rem);
line-height: 1;
letter-spacing: -0.075em;
margin: 0.7rem 0 1rem;
}
h2 {
font-size: 1.25rem;
letter-spacing: -0.04em;
}
h3 {
font-size: 0.85rem;
}
.lede {
color: var(--site-muted);
max-width: 40rem;
font-size: 0.95rem;
}
.stats {
display: flex;
gap: 2.5rem;
white-space: nowrap;
}
.stats div {
display: flex;
flex-direction: column;
align-items: end;
}
.stats strong {
font-size: 1.5rem;
font-weight: 600;
letter-spacing: -0.06em;
}
.stats span {
color: var(--site-muted);
font-size: 0.69rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.workspace {
display: grid;
grid-template-columns: minmax(260px, 335px) minmax(0, 1fr);
gap: 1rem;
align-items: start;
}
.picker,
.visual {
border: 1px solid var(--site-border);
background: var(--site-surface);
border-radius: 8px;
}
.picker {
padding: 1.2rem;
}
.panel-heading,
.selected-heading,
.result-actions,
.toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.panel-heading {
margin-bottom: 1.4rem;
}
.result-actions {
color: var(--site-muted);
font-size: 0.73rem;
}
.field {
display: block;
margin-bottom: 1rem;
}
.field > span,
.controls label {
display: block;
color: var(--site-muted);
font-size: 0.72rem;
font-weight: 600;
margin-bottom: 0.45rem;
}
select {
width: 100%;
border: 1px solid var(--site-border);
color: var(--site-text);
background: var(--site-bg);
border-radius: 5px;
padding: 0.65rem 0.7rem;
font-size: 0.86rem;
}
button {
cursor: pointer;
}
button:disabled {
cursor: default;
opacity: 0.42;
}
.result-actions {
margin: 0.3rem 0 0.65rem;
}
.results {
border: 1px solid var(--site-border);
border-radius: 5px;
max-height: 260px;
overflow: auto;
}
.result {
width: 100%;
display: flex;
justify-content: space-between;
gap: 0.7rem;
align-items: center;
background: transparent;
color: var(--site-text);
text-align: left;
border: 0;
border-bottom: 1px solid var(--site-border);
padding: 0.55rem 0.7rem;
font-size: 0.76rem;
line-height: 1.35;
}
.result:last-child {
border-bottom: 0;
}
.result:hover:not(:disabled) {
background: var(--site-hover);
}
.result small {
display: block;
color: var(--site-muted);
font-size: 0.68rem;
margin-top: 0.15rem;
}
.selected-heading {
margin: 1.35rem 0 0.55rem;
}
.selected {
max-height: 240px;
overflow: auto;
display: flex;
flex-wrap: wrap;
align-content: start;
gap: 0.3rem;
}
.selected p {
color: var(--site-muted);
font-size: 0.8rem;
}
.visual {
min-width: 0;
padding: 1.4rem;
}
.toolbar {
align-items: end;
margin-bottom: 1.1rem;
}
.toolbar h2 {
font-size: 1.7rem;
margin-top: 0.2rem;
}
.controls {
display: flex;
gap: 0.5rem;
}
.controls select {
min-width: 140px;
}
.custom-range {
display: flex;
gap: 0.6rem;
margin-bottom: 1rem;
}
.custom-range label {
color: var(--site-muted);
font-size: 0.72rem;
flex: 1;
}
.empty {
min-height: 350px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: 0.4rem;
}
.empty strong {
font-size: 1.1rem;
}
.empty p,
.note {
color: var(--site-muted);
font-size: 0.75rem;
}
.note {
border-top: 1px solid var(--site-border);
margin-top: 1rem;
padding-top: 1rem;
}
.error {
color: #e88;
font-size: 0.75rem;
margin: 0.6rem 0;
}
@media (max-width: 900px) {
.workspace {
grid-template-columns: 1fr;
}
.intro {
align-items: start;
}
}
@media (max-width: 600px) {
.intro {
display: block;
}
.stats {
margin-top: 1.5rem;
justify-content: start;
}
.stats div {
align-items: start;
}
.visual {
padding: 1rem;
}
.toolbar {
align-items: start;
flex-direction: column;
}
}
</style>

View File

@@ -0,0 +1,24 @@
import { db } from '$lib/server/db';
import { sql } from 'drizzle-orm';
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ url }) => {
const q = (url.searchParams.get('q') ?? '').trim().slice(0, 100);
const dimension = url.searchParams.get('dimension') ?? 'item';
const offset = Math.min(100_000, Math.max(0, Number(url.searchParams.get('offset') ?? 0) || 0));
const limit = Math.min(1000, Math.max(1, Number(url.searchParams.get('limit') ?? 50) || 50));
const pattern = `%${q.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_')}%`;
if (dimension === 'item') {
const rows = await db.execute(sql`SELECT id, name, market_hash_name, icon_url
FROM cs_market_items WHERE name ILIKE ${pattern} ESCAPE '\'
ORDER BY name, id LIMIT ${limit} OFFSET ${offset}`);
return json({ results: rows });
}
if (!/^[a-z][a-z0-9_]*$/.test(dimension))
return json({ error: 'Invalid dimension' }, { status: 400 });
const rows = await db.execute(sql`SELECT value, count(*)::int AS items
FROM cs_item_dimensions WHERE key = ${dimension} AND value ILIKE ${pattern} ESCAPE '\'
GROUP BY value ORDER BY value LIMIT ${limit} OFFSET ${offset}`);
return json({ results: rows });
};

View File

@@ -0,0 +1,68 @@
import { db } from '$lib/server/db';
import { sql } from 'drizzle-orm';
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
const maxSeries = 1000;
const maxDays = 365 * 20;
export const POST: RequestHandler = async ({ request }) => {
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
const dimension = typeof body.dimension === 'string' ? body.dimension : 'item';
const kind = typeof body.kind === 'string' ? body.kind : 'sale-median';
const source = typeof body.source === 'string' ? body.source : 'steam-market';
const currency = typeof body.currency === 'string' ? body.currency : 'USD';
const ids = Array.isArray(body.ids) ? body.ids : [];
const values = Array.isArray(body.values) ? body.values : [];
if (
!/^[a-z][a-z0-9_]*$/.test(dimension) ||
!/^[a-z0-9][a-z0-9-]{0,79}$/.test(source) ||
!/^[a-z0-9][a-z0-9-]{0,79}$/.test(kind) ||
!/^[A-Z]{3}$/.test(currency) ||
ids.length > maxSeries ||
values.length > maxSeries ||
ids.some((id) => !Number.isSafeInteger(id) || id <= 0) ||
values.some((value) => typeof value !== 'string' || value.length > 200)
)
return json({ error: 'Invalid selection' }, { status: 400 });
if (dimension === 'item' ? !ids.length : !values.length)
return json({ series: [], bucketSeconds: 0 });
const to = new Date(typeof body.to === 'string' ? body.to : Date.now());
const from = new Date(typeof body.from === 'string' ? body.from : to.getTime() - 365 * 86400_000);
if (Number.isNaN(+from) || Number.isNaN(+to) || from >= to || +to - +from > maxDays * 86400_000)
return json({ error: 'Invalid date range' }, { status: 400 });
// Aim for at most 240 buckets per series. Raw observations remain untouched.
const seconds = Math.max(60, Math.ceil((+to - +from) / 1000 / 240 / 60) * 60);
const selection =
dimension === 'item'
? sql`JOIN cs_market_items i ON i.id = p.item_id AND i.id IN (${sql.join(
ids.map((id) => sql`${id}`),
sql`,`
)})`
: sql`JOIN cs_item_dimensions d ON d.item_id = p.item_id AND d.key = ${dimension}
AND d.value IN (${sql.join(
values.map((value) => sql`${value}`),
sql`,`
)})`;
const seriesKey = dimension === 'item' ? sql`i.id::text` : sql`d.value`;
const rows = await db.execute(sql`
WITH per_item AS (
SELECT ${seriesKey} AS series_key, p.item_id,
date_bin(make_interval(secs => ${seconds}), p.observed_at,
TIMESTAMPTZ '1970-01-01') AS bucket,
avg(p.price_cents) AS price_cents,
sum(p.volume) AS volume,
min(p.price_cents) AS min_cents,
max(p.price_cents) AS max_cents
FROM cs_price_observations p ${selection}
WHERE p.source = ${source} AND p.currency = ${currency} AND p.kind = ${kind}
AND p.observed_at >= ${from.toISOString()}::timestamptz
AND p.observed_at <= ${to.toISOString()}::timestamptz
GROUP BY 1, 2, 3
)
SELECT series_key, bucket, round(avg(price_cents))::int AS price_cents,
count(*)::int AS items, sum(volume)::bigint AS volume,
min(min_cents)::int AS min_cents, max(max_cents)::int AS max_cents
FROM per_item GROUP BY 1, 2 ORDER BY 1, 2`);
return json({ bucketSeconds: seconds, currency, source, kind, points: rows });
};

View File

@@ -1,5 +1,8 @@
<script lang="ts">
import { onMount } from 'svelte';
import { resolve } from '$app/paths';
import { SvelteURLSearchParams } from 'svelte/reactivity';
import { Button } from '$lib/components/ui/button/index.js';
import Check from 'lucide-svelte/icons/check';
import ExternalLink from 'lucide-svelte/icons/external-link';
import { horses, race, type Horse } from '$lib/derby/data';
@@ -349,7 +352,7 @@
}
function encodeHash(): string {
const p = new URLSearchParams();
const p = new SvelteURLSearchParams();
p.set('w', (Object.keys(defaultWeights) as (keyof Weights)[]).map((k) => w[k]).join(','));
if (scratched.length) p.set('s', scratched.join(','));
const o = Object.entries(odds).filter(([, v]) => (v ?? 0) > 1);
@@ -431,12 +434,12 @@
</svelte:head>
<div class="shell">
<main class="inner">
<div class="inner">
<nav class="topbar">
<div class="brand"><span class="mark"></span>DERBY LENS</div>
<div class="topbar-right">
<button class="help-toggle" class:on={help} onclick={() => (help = !help)}
>Uitleg {help ? 'aan' : 'uit'}</button
<Button variant={help ? 'secondary' : 'outline'} size="sm" onclick={() => (help = !help)}
>Uitleg {help ? 'aan' : 'uit'}</Button
>
<div class="event-pill">{race.date.toUpperCase()} · {race.track.toUpperCase()}</div>
</div>
@@ -444,9 +447,11 @@
<nav class="race-nav">
{#each races as r (r.slug)}
<a href="/projects/derby/{r.slug}"><b>{r.number}</b> {r.short} <span>{r.time}</span></a>
<a href={resolve('/projects/derby/[race]', { race: r.slug })}
><b>{r.number}</b> {r.short} <span>{r.time}</span></a
>
{/each}
<a href="/projects/derby" class="active"><b>6</b> Derby R <span>17:40</span></a>
<a href={resolve('/projects/derby')} class="active"><b>6</b> Derby R <span>17:40</span></a>
</nav>
{#if scenario}
@@ -503,7 +508,9 @@
>{#if st.done}<Check size={16} aria-hidden="true" />{:else}{i + 1}{/if}</span
>
<div>
<a href={st.href}>{st.title}</a>
<!-- The hash is appended to the resolved Derby route. -->
<!-- eslint-disable svelte/no-navigation-without-resolve -->
<a href={`${resolve('/projects/derby')}${st.href}`}>{st.title}</a>
<p>{st.detail}</p>
{#if i === 0}
<label class="step-check"
@@ -973,6 +980,7 @@
</ul>
<div class="source-links">
{#each race.sources as s (s.href)}
<!-- eslint-disable svelte/no-navigation-without-resolve -->
<a href={s.href} target="_blank" rel="noreferrer"
>{s.label} <ExternalLink size={14} aria-hidden="true" /></a
>
@@ -982,20 +990,14 @@
</details>
<footer><span>DERBY LENS · {race.name}</span><span>SvelteKit</span></footer>
</main>
</div>
</div>
<style>
.shell {
background: #f2f3e8;
color: #07100c;
font-family:
Inter,
ui-sans-serif,
system-ui,
-apple-system,
'Segoe UI',
sans-serif;
background: var(--site-bg);
color: var(--site-text);
font-family: var(--font-body);
font-size: 16px;
line-height: normal;
}
@@ -1018,7 +1020,7 @@
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #07100c;
border-bottom: 1px solid var(--site-text);
padding: 6px 0 18px;
gap: 16px;
}
@@ -1034,17 +1036,17 @@
width: 30px;
height: 30px;
border-radius: 50%;
background: #dcff57;
border: 8px solid #0c1812;
box-shadow: inset 0 0 0 2px #dcff57;
background: var(--site-bg);
border: 8px solid var(--site-text);
box-shadow: inset 0 0 0 2px var(--site-bg);
}
.event-pill {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
border: 1px solid #07100c;
border: 1px solid var(--site-text);
padding: 8px 12px;
border-radius: 99px;
font-size: 0.78rem;
background: #fbfcf4;
background: var(--site-surface);
}
.race-nav {
display: flex;
@@ -1056,19 +1058,19 @@
display: flex;
align-items: center;
gap: 8px;
border: 1px solid #07100c;
border: 1px solid var(--site-text);
border-radius: 99px;
padding: 7px 14px 7px 8px;
text-decoration: none;
font-size: 0.82rem;
font-weight: 700;
background: #fbfcf4;
background: var(--site-surface);
}
.race-nav a b {
width: 24px;
height: 24px;
border-radius: 50%;
border: 1px solid #07100c;
border: 1px solid var(--site-text);
display: grid;
place-items: center;
font-size: 0.75rem;
@@ -1079,17 +1081,17 @@
SFMono-Regular,
Menlo,
monospace;
color: #68736d;
color: var(--site-muted);
}
.race-nav a.active {
background: #07100c;
color: #dcff57;
background: var(--site-text);
color: var(--site-bg);
}
.race-nav a.active b {
border-color: #dcff57;
border-color: var(--site-bg);
}
.race-nav a.active span {
color: #9aa69f;
color: var(--site-muted);
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
@@ -1101,7 +1103,7 @@
margin: 6px 0 0;
font-size: 0.78rem;
line-height: 1.5;
color: #3f4b44;
color: var(--site-muted);
}
.hint b {
font-weight: 800;
@@ -1117,7 +1119,7 @@
margin: 8px 0 4px;
}
.control .hint {
color: #68736d;
color: var(--site-muted);
font-size: 0.72rem;
margin-top: 4px;
}
@@ -1129,16 +1131,16 @@
margin-top: 14px;
padding: 10px 16px;
border-radius: 14px;
background: #f46f43;
color: #07100c;
background: var(--site-hover);
color: var(--site-text);
font-size: 0.85rem;
}
.practice-banner button {
border: 1px solid #07100c;
border: 1px solid var(--site-text);
border-radius: 99px;
padding: 6px 12px;
background: #07100c;
color: #fff;
background: var(--site-text);
color: var(--site-bg);
font-weight: 700;
font-size: 0.78rem;
}
@@ -1148,9 +1150,9 @@
gap: 22px;
margin: 0 0 22px;
padding: 22px 24px;
border: 1px solid #07100c;
border-radius: 24px;
background: #fbfcf4;
border: 1px solid var(--site-text);
border-radius: var(--radius);
background: var(--site-surface);
}
.guide-intro h2 {
margin: 8px 0 12px;
@@ -1159,7 +1161,7 @@
.practice p {
font-size: 0.85rem;
line-height: 1.55;
color: #3f4b44;
color: var(--site-muted);
margin: 0;
}
.steps {
@@ -1175,7 +1177,7 @@
gap: 12px;
align-items: start;
padding: 10px 0;
border-top: 1px solid #cfd4c5;
border-top: 1px solid var(--site-muted);
}
.steps li:first-child {
border-top: 0;
@@ -1185,14 +1187,14 @@
width: 28px;
height: 28px;
border-radius: 50%;
border: 1px solid #07100c;
border: 1px solid var(--site-text);
display: grid;
place-items: center;
font-weight: 900;
font-size: 0.85rem;
}
.steps .done .step-n {
background: #9ee7c4;
background: var(--site-bg);
}
.steps a {
font-weight: 800;
@@ -1204,7 +1206,7 @@
margin: 3px 0 5px;
font-size: 0.8rem;
line-height: 1.45;
color: #3f4b44;
color: var(--site-muted);
}
.step-status {
font:
@@ -1212,12 +1214,12 @@
SFMono-Regular,
Menlo,
monospace;
color: #68736d;
color: var(--site-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.steps .done .step-status {
color: #1d7a4c;
color: var(--site-muted);
}
.step-check {
display: flex;
@@ -1227,10 +1229,10 @@
font-weight: 700;
}
.step-check input {
accent-color: #07100c;
accent-color: var(--site-text);
}
.practice {
border-left: 1px solid #cfd4c5;
border-left: 1px solid var(--site-muted);
padding-left: 22px;
}
.practice > p {
@@ -1242,7 +1244,7 @@
gap: 6px;
}
.scenarios button {
border: 1px solid #07100c;
border: 1px solid var(--site-text);
border-radius: 99px;
padding: 7px 12px;
background: transparent;
@@ -1251,11 +1253,11 @@
text-align: left;
}
.scenarios button.active {
background: #f46f43;
background: var(--site-hover);
}
.scenarios .clear {
background: #07100c;
color: #fff;
background: var(--site-text);
color: var(--site-bg);
}
.scenarios button:disabled {
opacity: 0.35;
@@ -1265,13 +1267,13 @@
margin-top: 14px;
padding: 12px 14px;
border-radius: 14px;
background: #f1dfd6;
background: var(--site-surface);
font-size: 0.8rem;
line-height: 1.5;
}
.lesson p {
margin: 0 0 6px;
color: #07100c;
color: var(--site-text);
font-size: 0.8rem;
}
.lesson a {
@@ -1283,23 +1285,6 @@
align-items: center;
gap: 10px;
}
.help-toggle {
border: 1px solid #07100c;
border-radius: 99px;
padding: 7px 12px;
background: transparent;
font:
700 0.74rem ui-monospace,
SFMono-Regular,
Menlo,
monospace;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.help-toggle.on {
background: #07100c;
color: #dcff57;
}
.r {
text-align: right;
}
@@ -1307,16 +1292,16 @@
font-weight: 800;
}
.muted {
color: #68736d;
color: var(--site-muted);
font-size: 0.85rem;
line-height: 1.5;
margin: 0;
}
.pos {
color: #1d7a4c;
color: var(--site-muted);
}
.neg {
color: #c2461f;
color: var(--site-muted);
}
.eyebrow,
.section-kicker {
@@ -1329,11 +1314,11 @@
letter-spacing: 0.12em;
}
.eyebrow {
color: #dcff57;
color: var(--site-bg);
}
.section-kicker {
font-size: 0.73rem;
color: #68736d;
color: var(--site-muted);
}
h2 {
font-size: 1.65rem;
@@ -1348,8 +1333,8 @@
padding: 24px 0 18px;
}
.hero-main {
background: #0c1812;
color: #f7f8ec;
background: var(--site-text);
color: var(--site-bg);
border-radius: 28px;
padding: 30px;
position: relative;
@@ -1398,13 +1383,13 @@
}
.fact span {
font-size: 0.7rem;
color: #9aa69f;
color: var(--site-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.pick {
background: #dcff57;
border: 1px solid #07100c;
background: var(--site-bg);
border: 1px solid var(--site-text);
border-radius: 28px;
padding: 26px;
display: flex;
@@ -1456,9 +1441,9 @@
.controls,
.board,
.card {
border: 1px solid #07100c;
border-radius: 24px;
background: #fbfcf4;
border: 1px solid var(--site-text);
border-radius: var(--radius);
background: var(--site-surface);
}
.controls {
padding: 20px;
@@ -1473,7 +1458,7 @@
margin: 12px 0 6px;
}
.presets button {
border: 1px solid #07100c;
border: 1px solid var(--site-text);
border-radius: 99px;
padding: 6px 12px;
background: transparent;
@@ -1481,12 +1466,12 @@
font-weight: 700;
}
.presets button.active {
background: #07100c;
color: #dcff57;
background: var(--site-text);
color: var(--site-bg);
}
.control {
padding: 11px 0;
border-top: 1px solid #cfd4c5;
border-top: 1px solid var(--site-muted);
}
.control:first-of-type {
border-top: 0;
@@ -1504,11 +1489,11 @@
SFMono-Regular,
Menlo,
monospace;
color: #68736d;
color: var(--site-muted);
}
input[type='range'] {
width: 100%;
accent-color: #f46f43;
accent-color: var(--site-hover);
margin: 0;
}
.actions {
@@ -1518,7 +1503,7 @@
}
.ghost {
flex: 1;
border: 1px solid #07100c;
border: 1px solid var(--site-text);
border-radius: 12px;
padding: 10px;
background: transparent;
@@ -1526,13 +1511,13 @@
font-size: 0.85rem;
}
.ghost:hover {
background: #07100c;
color: #fff;
background: var(--site-text);
color: var(--site-bg);
}
.notice {
margin: 14px 0 0;
font-size: 0.74rem;
color: #68736d;
color: var(--site-muted);
line-height: 1.4;
}
@@ -1545,7 +1530,7 @@
align-items: end;
justify-content: space-between;
gap: 16px;
border-bottom: 1px solid #07100c;
border-bottom: 1px solid var(--site-text);
}
.board-head h2 {
margin: 6px 0 0;
@@ -1560,7 +1545,7 @@
align-items: center;
gap: 12px;
padding: 12px 8px;
border-bottom: 1px solid #cfd4c5;
border-bottom: 1px solid var(--site-muted);
transition: 0.2s;
}
.horse > :nth-child(1) {
@@ -1588,7 +1573,7 @@
grid-area: edge;
}
.horse:hover {
background: #f2f5df;
background: var(--site-surface);
}
.horse:last-child {
border: 0;
@@ -1602,7 +1587,7 @@
monospace;
text-transform: uppercase;
letter-spacing: 0.1em;
color: #68736d;
color: var(--site-muted);
}
.horse.head:hover {
background: transparent;
@@ -1610,20 +1595,20 @@
.horse input[type='checkbox'] {
width: 16px;
height: 16px;
accent-color: #07100c;
accent-color: var(--site-text);
margin: 0;
}
.num {
width: 34px;
height: 34px;
border-radius: 50%;
border: 1px solid #07100c;
border: 1px solid var(--site-text);
display: grid;
place-items: center;
font-weight: 900;
}
.winner .num {
background: #dcff57;
background: var(--site-bg);
}
.horse-name {
font-weight: 850;
@@ -1631,7 +1616,7 @@
}
.driver {
font-size: 0.74rem;
color: #68736d;
color: var(--site-muted);
margin-top: 3px;
}
.scratched {
@@ -1639,18 +1624,18 @@
}
.bar-wrap {
height: 12px;
background: #e2e6d8;
border-radius: 20px;
background: var(--site-surface);
border-radius: var(--radius);
overflow: hidden;
}
.bar {
height: 100%;
background: #9ee7c4;
border-radius: 20px;
background: var(--site-bg);
border-radius: var(--radius);
transition: width 0.5s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.winner .bar {
background: #f46f43;
background: var(--site-hover);
}
.chance {
font:
@@ -1666,15 +1651,15 @@
SFMono-Regular,
Menlo,
monospace;
color: #68736d;
color: var(--site-muted);
text-align: right;
}
.odds {
width: 100%;
border: 1px solid #cfd4c5;
border: 1px solid var(--site-muted);
border-radius: 8px;
padding: 6px 8px;
background: #fff;
background: var(--site-bg);
font:
700 0.85rem ui-monospace,
SFMono-Regular,
@@ -1683,7 +1668,7 @@
text-align: right;
}
.odds:focus {
outline: 2px solid #f46f43;
outline: 2px solid var(--site-hover);
border-color: transparent;
}
.edge {
@@ -1724,14 +1709,14 @@
monospace;
text-transform: uppercase;
letter-spacing: 0.1em;
color: #68736d;
color: var(--site-muted);
padding: 6px 8px;
border-bottom: 1px solid #07100c;
border-bottom: 1px solid var(--site-text);
text-align: left;
}
.tbl td {
padding: 8px;
border-bottom: 1px solid #cfd4c5;
border-bottom: 1px solid var(--site-muted);
vertical-align: top;
}
.tbl tr:last-child td {
@@ -1744,7 +1729,7 @@
SFMono-Regular,
Menlo,
monospace;
color: #68736d;
color: var(--site-muted);
margin-top: 2px;
}
.comp td {
@@ -1770,7 +1755,7 @@
grid-template-columns: 1fr auto auto;
gap: 10px;
padding: 7px 0;
border-bottom: 1px solid #cfd4c5;
border-bottom: 1px solid var(--site-muted);
font-size: 0.84rem;
}
.combo:last-child {
@@ -1785,7 +1770,7 @@
SFMono-Regular,
Menlo,
monospace;
color: #68736d;
color: var(--site-muted);
min-width: 34px;
text-align: right;
}
@@ -1806,7 +1791,7 @@
}
.tbl td.muted {
font-size: 0.78rem;
color: #68736d;
color: var(--site-muted);
}
.bank {
display: flex;
@@ -1825,33 +1810,33 @@
monospace;
text-transform: uppercase;
letter-spacing: 0.1em;
color: #68736d;
color: var(--site-muted);
}
.bank input {
min-width: 0;
width: 100%;
border: 1px solid #07100c;
border: 1px solid var(--site-text);
border-radius: 10px;
padding: 9px 10px;
background: #fff;
background: var(--site-bg);
font:
700 0.95rem ui-monospace,
SFMono-Regular,
Menlo,
monospace;
color: #07100c;
color: var(--site-text);
}
.stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
border: 1px solid #07100c;
border-radius: 16px;
border: 1px solid var(--site-text);
border-radius: var(--radius);
overflow: hidden;
margin-bottom: 16px;
}
.stats div {
padding: 12px 14px;
border-right: 1px solid #07100c;
border-right: 1px solid var(--site-text);
}
.stats div:last-child {
border: 0;
@@ -1863,7 +1848,7 @@
}
.stats span {
font-size: 0.68rem;
color: #68736d;
color: var(--site-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
@@ -1873,8 +1858,8 @@
}
.method {
margin-top: 22px;
border: 1px solid #07100c;
border-radius: 24px;
border: 1px solid var(--site-text);
border-radius: var(--radius);
overflow: hidden;
}
.method summary {
@@ -1882,7 +1867,7 @@
padding: 18px 24px;
font-weight: 850;
cursor: pointer;
background: #fbfcf4;
background: var(--site-surface);
display: flex;
justify-content: space-between;
}
@@ -1897,8 +1882,8 @@
}
.method-body {
padding: 20px 24px;
border-top: 1px solid #07100c;
background: #e7ebdc;
border-top: 1px solid var(--site-text);
background: var(--site-surface);
}
.method-body p {
margin: 0 0 12px;
@@ -1910,7 +1895,7 @@
margin: 0;
}
.method-body li {
color: #3f4b44;
color: var(--site-muted);
line-height: 1.5;
font-size: 0.84rem;
}
@@ -1925,17 +1910,17 @@
align-items: center;
gap: 4px;
font-size: 0.76rem;
border: 1px solid #07100c;
border: 1px solid var(--site-text);
border-radius: 99px;
padding: 7px 12px;
text-decoration: none;
background: #fbfcf4;
background: var(--site-surface);
}
footer {
display: flex;
justify-content: space-between;
gap: 20px;
color: #68736d;
color: var(--site-muted);
font-size: 0.75rem;
padding: 22px 2px 0;
}
@@ -1967,7 +1952,7 @@
.practice {
border-left: 0;
padding-left: 0;
border-top: 1px solid #cfd4c5;
border-top: 1px solid var(--site-muted);
padding-top: 16px;
}
.controls {
@@ -1996,7 +1981,7 @@
.card,
.guide {
padding: 18px;
border-radius: 20px;
border-radius: var(--radius);
}
.practice-banner {
flex-direction: column;

View File

@@ -1,5 +1,7 @@
<script lang="ts">
import { untrack } from 'svelte';
import { resolve } from '$app/paths';
import { Button } from '$lib/components/ui/button/index.js';
import ExternalLink from 'lucide-svelte/icons/external-link';
import { races, type Runner } from '$lib/derby/races';
import { betValue, fairOdds, formatTime, placings } from '$lib/derby/model';
@@ -278,12 +280,12 @@
</svelte:head>
<div class="shell">
<main class="inner">
<div class="inner">
<nav class="topbar">
<a class="brand" href="/projects/derby"><span class="mark"></span>DERBY LENS</a>
<a class="brand" href={resolve('/projects/derby')}><span class="mark"></span>DERBY LENS</a>
<div class="topbar-right">
<button class="help-toggle" class:on={help} onclick={() => (help = !help)}
>Uitleg {help ? 'aan' : 'uit'}</button
<Button variant={help ? 'secondary' : 'outline'} size="sm" onclick={() => (help = !help)}
>Uitleg {help ? 'aan' : 'uit'}</Button
>
<div class="event-pill">ZO 20 SEP · {race.time} · DUINDIGT</div>
</div>
@@ -291,11 +293,12 @@
<nav class="race-nav">
{#each races as r (r.slug)}
<a href="/projects/derby/{r.slug}" class:active={r.slug === race.slug}
><b>{r.number}</b> {r.short} <span>{r.time}</span></a
<a
href={resolve('/projects/derby/[race]', { race: r.slug })}
class:active={r.slug === race.slug}><b>{r.number}</b> {r.short} <span>{r.time}</span></a
>
{/each}
<a href="/projects/derby"><b>6</b> Derby R <span>17:40</span></a>
<a href={resolve('/projects/derby')}><b>6</b> Derby R <span>17:40</span></a>
</nav>
<section class="hero">
@@ -709,6 +712,7 @@
</ul>
<div class="source-links">
{#each race.sources as s (s.href)}
<!-- eslint-disable svelte/no-navigation-without-resolve -->
<a href={s.href} target="_blank" rel="noreferrer"
>{s.label} <ExternalLink size={14} aria-hidden="true" /></a
>
@@ -720,20 +724,14 @@
<footer>
<span>DERBY LENS · koers {race.number} · {race.name}</span><span>SvelteKit</span>
</footer>
</main>
</div>
</div>
<style>
.shell {
background: #f2f3e8;
color: #07100c;
font-family:
Inter,
ui-sans-serif,
system-ui,
-apple-system,
'Segoe UI',
sans-serif;
background: var(--site-bg);
color: var(--site-text);
font-family: var(--font-body);
font-size: 16px;
line-height: normal;
}
@@ -756,7 +754,7 @@
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #07100c;
border-bottom: 1px solid var(--site-text);
padding: 6px 0 18px;
gap: 16px;
}
@@ -773,17 +771,17 @@
width: 30px;
height: 30px;
border-radius: 50%;
background: #dcff57;
border: 8px solid #0c1812;
box-shadow: inset 0 0 0 2px #dcff57;
background: var(--site-bg);
border: 8px solid var(--site-text);
box-shadow: inset 0 0 0 2px var(--site-bg);
}
.event-pill {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
border: 1px solid #07100c;
border: 1px solid var(--site-text);
padding: 8px 12px;
border-radius: 99px;
font-size: 0.78rem;
background: #fbfcf4;
background: var(--site-surface);
}
.race-nav {
display: flex;
@@ -795,19 +793,19 @@
display: flex;
align-items: center;
gap: 8px;
border: 1px solid #07100c;
border: 1px solid var(--site-text);
border-radius: 99px;
padding: 7px 14px 7px 8px;
text-decoration: none;
font-size: 0.82rem;
font-weight: 700;
background: #fbfcf4;
background: var(--site-surface);
}
.race-nav a b {
width: 24px;
height: 24px;
border-radius: 50%;
border: 1px solid #07100c;
border: 1px solid var(--site-text);
display: grid;
place-items: center;
font-size: 0.75rem;
@@ -818,17 +816,17 @@
SFMono-Regular,
Menlo,
monospace;
color: #68736d;
color: var(--site-muted);
}
.race-nav a.active {
background: #07100c;
color: #dcff57;
background: var(--site-text);
color: var(--site-bg);
}
.race-nav a.active b {
border-color: #dcff57;
border-color: var(--site-bg);
}
.race-nav a.active span {
color: #9aa69f;
color: var(--site-muted);
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
@@ -840,7 +838,7 @@
margin: 6px 0 0;
font-size: 0.78rem;
line-height: 1.5;
color: #3f4b44;
color: var(--site-muted);
}
.hint b {
font-weight: 800;
@@ -856,7 +854,7 @@
margin: 8px 0 4px;
}
.control .hint {
color: #68736d;
color: var(--site-muted);
font-size: 0.72rem;
margin-top: 4px;
}
@@ -866,14 +864,14 @@
gap: 22px;
margin: 0 0 22px;
padding: 22px 24px;
border: 1px solid #07100c;
border-radius: 24px;
background: #fbfcf4;
border: 1px solid var(--site-text);
border-radius: var(--radius);
background: var(--site-surface);
}
.preview {
font-size: 0.86rem;
line-height: 1.55;
color: #3f4b44;
color: var(--site-muted);
margin: 8px 0 0;
}
.preview a {
@@ -884,23 +882,6 @@
align-items: center;
gap: 10px;
}
.help-toggle {
border: 1px solid #07100c;
border-radius: 99px;
padding: 7px 12px;
background: transparent;
font:
700 0.74rem ui-monospace,
SFMono-Regular,
Menlo,
monospace;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.help-toggle.on {
background: #07100c;
color: #dcff57;
}
.r {
text-align: right;
}
@@ -908,16 +889,16 @@
font-weight: 800;
}
.muted {
color: #68736d;
color: var(--site-muted);
font-size: 0.85rem;
line-height: 1.5;
margin: 0;
}
.pos {
color: #1d7a4c;
color: var(--site-muted);
}
.neg {
color: #c2461f;
color: var(--site-muted);
}
.eyebrow,
.section-kicker {
@@ -930,11 +911,11 @@
letter-spacing: 0.12em;
}
.eyebrow {
color: #dcff57;
color: var(--site-bg);
}
.section-kicker {
font-size: 0.73rem;
color: #68736d;
color: var(--site-muted);
}
h2 {
font-size: 1.65rem;
@@ -949,8 +930,8 @@
padding: 24px 0 18px;
}
.hero-main {
background: #0c1812;
color: #f7f8ec;
background: var(--site-text);
color: var(--site-bg);
border-radius: 28px;
padding: 30px;
position: relative;
@@ -1001,13 +982,13 @@
}
.fact span {
font-size: 0.7rem;
color: #9aa69f;
color: var(--site-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.pick {
background: #dcff57;
border: 1px solid #07100c;
background: var(--site-bg);
border: 1px solid var(--site-text);
border-radius: 28px;
padding: 26px;
display: flex;
@@ -1059,9 +1040,9 @@
.controls,
.board,
.card {
border: 1px solid #07100c;
border-radius: 24px;
background: #fbfcf4;
border: 1px solid var(--site-text);
border-radius: var(--radius);
background: var(--site-surface);
}
.controls {
padding: 20px;
@@ -1076,7 +1057,7 @@
margin: 12px 0 6px;
}
.presets button {
border: 1px solid #07100c;
border: 1px solid var(--site-text);
border-radius: 99px;
padding: 6px 12px;
background: transparent;
@@ -1084,12 +1065,12 @@
font-weight: 700;
}
.presets button.active {
background: #07100c;
color: #dcff57;
background: var(--site-text);
color: var(--site-bg);
}
.control {
padding: 11px 0;
border-top: 1px solid #cfd4c5;
border-top: 1px solid var(--site-muted);
}
.control:first-of-type {
border-top: 0;
@@ -1107,11 +1088,11 @@
SFMono-Regular,
Menlo,
monospace;
color: #68736d;
color: var(--site-muted);
}
input[type='range'] {
width: 100%;
accent-color: #f46f43;
accent-color: var(--site-hover);
margin: 0;
}
.actions {
@@ -1121,7 +1102,7 @@
}
.ghost {
flex: 1;
border: 1px solid #07100c;
border: 1px solid var(--site-text);
border-radius: 12px;
padding: 10px;
background: transparent;
@@ -1129,13 +1110,13 @@
font-size: 0.85rem;
}
.ghost:hover {
background: #07100c;
color: #fff;
background: var(--site-text);
color: var(--site-bg);
}
.notice {
margin: 14px 0 0;
font-size: 0.74rem;
color: #68736d;
color: var(--site-muted);
line-height: 1.4;
}
@@ -1148,7 +1129,7 @@
align-items: end;
justify-content: space-between;
gap: 16px;
border-bottom: 1px solid #07100c;
border-bottom: 1px solid var(--site-text);
}
.board-head h2 {
margin: 6px 0 0;
@@ -1163,7 +1144,7 @@
align-items: center;
gap: 12px;
padding: 12px 8px;
border-bottom: 1px solid #cfd4c5;
border-bottom: 1px solid var(--site-muted);
transition: 0.2s;
}
.horse > :nth-child(1) {
@@ -1191,7 +1172,7 @@
grid-area: edge;
}
.horse:hover {
background: #f2f5df;
background: var(--site-surface);
}
.horse:last-child {
border: 0;
@@ -1205,7 +1186,7 @@
monospace;
text-transform: uppercase;
letter-spacing: 0.1em;
color: #68736d;
color: var(--site-muted);
}
.horse.head:hover {
background: transparent;
@@ -1213,20 +1194,20 @@
.horse input[type='checkbox'] {
width: 16px;
height: 16px;
accent-color: #07100c;
accent-color: var(--site-text);
margin: 0;
}
.num {
width: 34px;
height: 34px;
border-radius: 50%;
border: 1px solid #07100c;
border: 1px solid var(--site-text);
display: grid;
place-items: center;
font-weight: 900;
}
.winner .num {
background: #dcff57;
background: var(--site-bg);
}
.horse-name {
font-weight: 850;
@@ -1234,7 +1215,7 @@
}
.driver {
font-size: 0.74rem;
color: #68736d;
color: var(--site-muted);
margin-top: 3px;
}
.scratched {
@@ -1242,18 +1223,18 @@
}
.bar-wrap {
height: 12px;
background: #e2e6d8;
border-radius: 20px;
background: var(--site-surface);
border-radius: var(--radius);
overflow: hidden;
}
.bar {
height: 100%;
background: #9ee7c4;
border-radius: 20px;
background: var(--site-bg);
border-radius: var(--radius);
transition: width 0.5s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.winner .bar {
background: #f46f43;
background: var(--site-hover);
}
.chance {
font:
@@ -1269,15 +1250,15 @@
SFMono-Regular,
Menlo,
monospace;
color: #68736d;
color: var(--site-muted);
text-align: right;
}
.odds {
width: 100%;
border: 1px solid #cfd4c5;
border: 1px solid var(--site-muted);
border-radius: 8px;
padding: 6px 8px;
background: #fff;
background: var(--site-bg);
font:
700 0.85rem ui-monospace,
SFMono-Regular,
@@ -1286,7 +1267,7 @@
text-align: right;
}
.odds:focus {
outline: 2px solid #f46f43;
outline: 2px solid var(--site-hover);
border-color: transparent;
}
.edge {
@@ -1327,14 +1308,14 @@
monospace;
text-transform: uppercase;
letter-spacing: 0.1em;
color: #68736d;
color: var(--site-muted);
padding: 6px 8px;
border-bottom: 1px solid #07100c;
border-bottom: 1px solid var(--site-text);
text-align: left;
}
.tbl td {
padding: 8px;
border-bottom: 1px solid #cfd4c5;
border-bottom: 1px solid var(--site-muted);
vertical-align: top;
}
.tbl tr:last-child td {
@@ -1347,7 +1328,7 @@
SFMono-Regular,
Menlo,
monospace;
color: #68736d;
color: var(--site-muted);
margin-top: 2px;
}
.comp td {
@@ -1373,7 +1354,7 @@
grid-template-columns: 1fr auto auto;
gap: 10px;
padding: 7px 0;
border-bottom: 1px solid #cfd4c5;
border-bottom: 1px solid var(--site-muted);
font-size: 0.84rem;
}
.combo:last-child {
@@ -1388,7 +1369,7 @@
SFMono-Regular,
Menlo,
monospace;
color: #68736d;
color: var(--site-muted);
min-width: 34px;
text-align: right;
}
@@ -1409,7 +1390,7 @@
}
.tbl td.muted {
font-size: 0.78rem;
color: #68736d;
color: var(--site-muted);
}
.bank {
display: flex;
@@ -1428,33 +1409,33 @@
monospace;
text-transform: uppercase;
letter-spacing: 0.1em;
color: #68736d;
color: var(--site-muted);
}
.bank input {
min-width: 0;
width: 100%;
border: 1px solid #07100c;
border: 1px solid var(--site-text);
border-radius: 10px;
padding: 9px 10px;
background: #fff;
background: var(--site-bg);
font:
700 0.95rem ui-monospace,
SFMono-Regular,
Menlo,
monospace;
color: #07100c;
color: var(--site-text);
}
.stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
border: 1px solid #07100c;
border-radius: 16px;
border: 1px solid var(--site-text);
border-radius: var(--radius);
overflow: hidden;
margin-bottom: 16px;
}
.stats div {
padding: 12px 14px;
border-right: 1px solid #07100c;
border-right: 1px solid var(--site-text);
}
.stats div:last-child {
border: 0;
@@ -1466,15 +1447,15 @@
}
.stats span {
font-size: 0.68rem;
color: #68736d;
color: var(--site-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.method {
margin-top: 22px;
border: 1px solid #07100c;
border-radius: 24px;
border: 1px solid var(--site-text);
border-radius: var(--radius);
overflow: hidden;
}
.method summary {
@@ -1482,7 +1463,7 @@
padding: 18px 24px;
font-weight: 850;
cursor: pointer;
background: #fbfcf4;
background: var(--site-surface);
display: flex;
justify-content: space-between;
}
@@ -1497,8 +1478,8 @@
}
.method-body {
padding: 20px 24px;
border-top: 1px solid #07100c;
background: #e7ebdc;
border-top: 1px solid var(--site-text);
background: var(--site-surface);
}
.method-body p {
margin: 0 0 12px;
@@ -1510,7 +1491,7 @@
margin: 0;
}
.method-body li {
color: #3f4b44;
color: var(--site-muted);
line-height: 1.5;
font-size: 0.84rem;
}
@@ -1525,17 +1506,17 @@
align-items: center;
gap: 4px;
font-size: 0.76rem;
border: 1px solid #07100c;
border: 1px solid var(--site-text);
border-radius: 99px;
padding: 7px 12px;
text-decoration: none;
background: #fbfcf4;
background: var(--site-surface);
}
footer {
display: flex;
justify-content: space-between;
gap: 20px;
color: #68736d;
color: var(--site-muted);
font-size: 0.75rem;
padding: 22px 2px 0;
}
@@ -1590,7 +1571,7 @@
.card,
.guide {
padding: 18px;
border-radius: 20px;
border-radius: var(--radius);
}
.hero h1 {
font-size: 2.4rem;

View File

@@ -1,6 +1,8 @@
<script lang="ts">
import { resolve } from '$app/paths';
import ProgressBar from '$lib/components/ProgressBar.svelte';
import * as Card from '$lib/components/ui/card/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
@@ -24,33 +26,33 @@
<section class="mt-10">
<h2 class="text-2xl font-semibold">Pokémon</h2>
<a
href={resolve('/projects/game-tracker/pokemon')}
class="mt-4 block rounded-lg border border-[var(--site-border)] p-5 hover:bg-[var(--site-hover)]"
<Card.Root class="mt-4 py-0 transition-colors hover:bg-muted/50"
><Card.Content class="p-0"
><a href={resolve('/projects/game-tracker/pokemon')} class="block p-5">
<div class="grid grid-cols-[minmax(0,1fr)_8rem_auto] items-center gap-3">
<p class="text-xl font-semibold">catchemall</p>
<div class="w-32">
<ProgressBar
value={data.pokemon.caught.entries}
max={data.pokemon.totals.entries}
label="Pokémon caught"
small
/>
</div>
<p class="text-xl font-semibold tabular-nums">
{percent(data.pokemon.caught.entries, data.pokemon.totals.entries)}%
</p>
</div>
<p class="mt-2 text-[var(--site-muted)]">
{data.pokemon.caught.entries} of {data.pokemon.totals.entries} obtainable entries ·
{data.pokemon.caught.species} of {data.pokemon.totals.species} species · {data.pokemon
.totals.games} games
</p>
</a></Card.Content
></Card.Root
>
<div class="flex flex-wrap items-baseline justify-between gap-3">
<p class="text-xl font-semibold">catchemall</p>
<p class="text-xl font-semibold tabular-nums">
{percent(data.pokemon.caught.entries, data.pokemon.totals.entries)}%
</p>
</div>
<div class="mt-3">
<ProgressBar
value={data.pokemon.caught.entries}
max={data.pokemon.totals.entries}
label="Pokémon caught"
small
/>
</div>
<p class="mt-2 text-[var(--site-muted)]">
{data.pokemon.caught.entries} of {data.pokemon.totals.entries} obtainable entries ·
{data.pokemon.caught.species} of {data.pokemon.totals.species} species · {data.pokemon
.totals.games} games
</p>
</a>
<a
href={resolve('/projects/game-tracker/pokemon/locations')}
class="mt-3 inline-block underline underline-offset-4">browse Pokémon locations</a
<Button variant="link" href={resolve('/projects/game-tracker/pokemon/locations')} class="mt-3"
>browse Pokémon locations</Button
>
</section>
@@ -59,39 +61,43 @@
<ul class="mt-4 grid gap-4 md:grid-cols-2">
{#each data.callOfDuty as game (game.slug)}
<li>
<a
href={resolve('/projects/game-tracker/call-of-duty/[slug]', { slug: game.slug })}
class="block h-full rounded-lg border border-[var(--site-border)] p-5 hover:bg-[var(--site-hover)]"
>
<div class="flex flex-wrap items-baseline justify-between gap-3">
<p class="text-xl font-semibold">{game.name}</p>
<p class="text-[var(--site-muted)]">{game.releaseYear}</p>
</div>
<dl class="mt-4 grid gap-3">
{#each [{ key: 'achievements', label: 'achievements', ...game.achievements }, ...game.milestones] as row (row.key)}
<div>
<div class="flex justify-between gap-3 text-[var(--site-muted)]">
<dt>{row.label}</dt>
<dd class="tabular-nums">{row.value}/{row.total}</dd>
</div>
<div class="mt-1">
<ProgressBar value={row.value} max={row.total} label={row.label} small />
</div>
<Card.Root class="h-full py-0 transition-colors hover:bg-muted/50"
><Card.Content class="p-0"
><a
href={resolve('/projects/game-tracker/call-of-duty/[slug]', { slug: game.slug })}
class="block h-full p-5"
>
<div class="flex flex-wrap items-baseline justify-between gap-3">
<p class="text-xl font-semibold">{game.name}</p>
<p class="text-[var(--site-muted)]">{game.releaseYear}</p>
</div>
{/each}
</dl>
{#if game.tiers.length}
<p class="mt-4 text-[var(--site-muted)]">
{game.tiers
.map((tier) =>
tier.scope === 'class'
? `${tier.label} ${tier.value}/${tier.total} classes`
: `${tier.label} ${tier.achieved ? '✓' : `${percent(tier.value, tier.total)}%`}`
)
.join(' · ')}
</p>
{/if}
</a>
<dl class="mt-4 grid gap-3">
{#each [{ key: 'achievements', label: 'achievements', ...game.achievements }, ...game.milestones] as row (row.key)}
<div
class="grid grid-cols-[minmax(0,1fr)_8rem_auto] items-center gap-3 text-sm text-[var(--site-muted)]"
>
<dt class="truncate">{row.label}</dt>
<div class="w-32">
<ProgressBar value={row.value} max={row.total} label={row.label} small />
</div>
<dd class="min-w-12 text-right tabular-nums">{row.value}/{row.total}</dd>
</div>
{/each}
</dl>
{#if game.tiers.length}
<p class="mt-4 text-[var(--site-muted)]">
{game.tiers
.map((tier) =>
tier.scope === 'class'
? `${tier.label} ${tier.value}/${tier.total} classes`
: `${tier.label} ${tier.achieved ? '✓' : `${percent(tier.value, tier.total)}%`}`
)
.join(' · ')}
</p>
{/if}
</a></Card.Content
></Card.Root
>
</li>
{:else}
<li class="text-[var(--site-muted)]">the Call of Duty catalog has not been loaded yet.</li>

View File

@@ -4,6 +4,7 @@
import { deserialize } from '$app/forms';
import { SvelteSet } from 'svelte/reactivity';
import ProgressBar from '$lib/components/ProgressBar.svelte';
import * as Tabs from '$lib/components/ui/tabs/index.js';
import { appliesTo, summarizeGame } from '$lib/game-tracker/mastery';
import type { PageProps } from './$types';
@@ -131,19 +132,12 @@
</div>
<div class="mt-8 flex flex-wrap items-center justify-between gap-4">
<div role="tablist" class="flex gap-6 text-lg">
{#each tabs as name (name)}
<button
role="tab"
type="button"
aria-selected={tab === name}
onclick={() => (tab = name)}
class={tab === name
? 'underline underline-offset-8'
: 'text-[var(--site-muted)] hover:text-[var(--site-text)]'}>{name}</button
>
{/each}
</div>
<Tabs.Root bind:value={tab}
><Tabs.List
>{#each tabs as name (name)}<Tabs.Trigger value={name}>{name}</Tabs.Trigger
>{/each}</Tabs.List
></Tabs.Root
>
<label class="flex cursor-pointer items-center gap-3">
<input type="checkbox" bind:checked={hideDone} class="h-5 w-5 accent-[var(--site-text)]" />
hide finished

View File

@@ -12,6 +12,8 @@
import { SvelteSet } from 'svelte/reactivity';
import type { PageProps } from './$types';
import ProgressBar from '$lib/components/ProgressBar.svelte';
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import PokemonDetails from '$lib/catch-em-all/PokemonDetails.svelte';
import { summarizeProgress } from '$lib/catch-em-all/progress';
@@ -171,12 +173,7 @@
{data.pokemon.length} Pokémon · {data.games.length} games
</p>
</div>
<button
type="button"
onclick={openRules}
class="rounded border border-[var(--site-border)] px-4 py-2 hover:bg-[var(--site-hover)]"
>set rules</button
>
<Button variant="outline" type="button" onclick={openRules}>set rules</Button>
</div>
{#if !data.user}<p class="mt-6">
@@ -232,12 +229,12 @@
<div class="mt-8 grid gap-4 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
<label class="block"
>search Pokémon
<input
<Input
type="search"
bind:value={search}
oninput={() => (pageNumber = 1)}
placeholder="name or number"
class="mt-2 block w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-3"
class="mt-2 h-11"
/>
</label>
<label class="block"
@@ -348,18 +345,20 @@
)} of {filteredPokemon.length}
</p>
<div class="flex items-center gap-4">
<button
<Button
variant="outline"
size="sm"
type="button"
disabled={pageNumber === 1}
onclick={() => (pageNumber -= 1)}
class="disabled:opacity-40">previous</button
onclick={() => (pageNumber -= 1)}>previous</Button
>
<span class="text-[var(--site-muted)]">{pageNumber}/{pageCount}</span>
<button
<Button
variant="outline"
size="sm"
type="button"
disabled={pageNumber === pageCount}
onclick={() => (pageNumber += 1)}
class="disabled:opacity-40">next</button
onclick={() => (pageNumber += 1)}>next</Button
>
</div>
</div>

View File

@@ -1,5 +1,7 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { Input } from '$lib/components/ui/input/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
@@ -33,12 +35,7 @@
<div class="mt-8 grid gap-4 sm:grid-cols-[minmax(0,1fr)_minmax(0,16rem)]">
<label class="block"
>search
<input
type="search"
bind:value={search}
placeholder="route, city, cave…"
class="mt-2 block w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-3"
/>
<Input type="search" bind:value={search} placeholder="route, city, cave…" class="mt-2 h-11" />
</label>
<label class="block"
>region
@@ -65,20 +62,24 @@
<h2 class="text-2xl font-semibold">{name}</h2>
<ul class="mt-3 grid gap-x-8 sm:grid-cols-2 lg:grid-cols-3">
{#each locations as location (location.id)}
<li class="border-t border-[var(--site-border)]">
<a
href={resolve('/projects/game-tracker/pokemon/locations/[id=integer]', {
id: String(location.id)
})}
class="flex items-baseline justify-between gap-3 py-3 hover:text-[var(--site-muted)]"
<li>
<Card.Root class="transition-colors hover:bg-muted/50"
><Card.Content class="p-0">
<a
href={resolve('/projects/game-tracker/pokemon/locations/[id=integer]', {
id: String(location.id)
})}
class="flex items-baseline justify-between gap-3 p-4"
>
<span>{location.name}</span>
<span class="shrink-0 text-sm text-[var(--site-muted)]"
>{location.pokemon ? `${location.pokemon} Pokémon` : ''}{location.hasPicture
? ' · picture'
: ''}</span
>
</a>
</Card.Content></Card.Root
>
<span>{location.name}</span>
<span class="shrink-0 text-sm text-[var(--site-muted)]"
>{location.pokemon ? `${location.pokemon} Pokémon` : ''}{location.hasPicture
? ' · picture'
: ''}</span
>
</a>
</li>
{/each}
</ul>

View File

@@ -2,6 +2,9 @@
import { resolve } from '$app/paths';
import { enhance } from '$app/forms';
import { page } from '$app/state';
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Textarea } from '$lib/components/ui/textarea/index.js';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
@@ -78,12 +81,13 @@
{#if form?.message}<p role="alert" class="mt-3 text-red-500">{form.message}</p>{/if}
<label class="mt-4 block"
>notes
<textarea
<Textarea
name="description"
rows="4"
maxlength="5000"
class="mt-2 block w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2"
>{data.location.description ?? ''}</textarea
rows={4}
maxlength={5000}
value={data.location.description ?? ''}
class="mt-2"
/>
>
</label>
<div class="mt-4 grid gap-4 sm:grid-cols-2">
@@ -98,23 +102,19 @@
</label>
<label class="block"
>picture credit
<input
<Input
name="imageCredit"
value={data.location.imageCredit ?? ''}
maxlength="300"
maxlength={300}
placeholder="e.g. my own screenshot"
class="mt-2 block w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2"
class="mt-2"
/>
</label>
</div>
{#if data.location.imageUrl}<label class="mt-3 flex items-center gap-2"
><input type="checkbox" name="removePicture" /> remove the current picture</label
>{/if}
<button
disabled={saving}
class="mt-5 rounded bg-[var(--site-text)] px-4 py-2 font-semibold text-[var(--site-bg)] disabled:opacity-40"
>{saving ? 'saving…' : 'save'}</button
>
<Button type="submit" disabled={saving} class="mt-5">{saving ? 'saving…' : 'save'}</Button>
</form>
{/if}

View File

@@ -3,6 +3,9 @@
import ArrowRight from 'lucide-svelte/icons/arrow-right';
import Flame from 'lucide-svelte/icons/flame';
import ProgressBar from '$lib/components/ProgressBar.svelte';
import { Button } from '$lib/components/ui/button/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { COURSES } from '$lib/language-learner/courses';
import { progress } from '$lib/language-learner/progress.svelte';
import type { LangCode } from '$lib/language-learner/types';
@@ -51,37 +54,43 @@
{#if progress.loaded}
<section class="mb-10 grid gap-3 sm:grid-cols-3" aria-label="Today">
<div class="ll-card">
<p class="text-sm ll-muted">streak</p>
<p class="flex items-center gap-2 text-3xl font-semibold">
<Flame
size={26}
aria-hidden="true"
class={progress.streak() ? 'text-[var(--ll-gold)]' : 'll-muted'}
<Card.Root
><Card.Content class="p-5">
<p class="text-sm ll-muted">streak</p>
<p class="flex items-center gap-2 text-3xl font-semibold">
<Flame
size={26}
aria-hidden="true"
class={progress.streak() ? 'text-[var(--ll-gold)]' : 'll-muted'}
/>
{progress.streak()}
{progress.streak() === 1 ? 'day' : 'days'}
</p>
</Card.Content></Card.Root
>
<Card.Root
><Card.Content class="p-5">
<p class="text-sm ll-muted">today</p>
<p class="mb-2 text-3xl font-semibold">
{progress.todayXp()}
<span class="text-base font-normal ll-muted">/ {progress.settings.goal} XP</span>
</p>
<ProgressBar
value={Math.min(progress.todayXp(), progress.settings.goal)}
max={progress.settings.goal}
label="Daily goal"
small
/>
{progress.streak()}
{progress.streak() === 1 ? 'day' : 'days'}
</p>
</div>
<div class="ll-card">
<p class="text-sm ll-muted">today</p>
<p class="mb-2 text-3xl font-semibold">
{progress.todayXp()}
<span class="text-base font-normal ll-muted">/ {progress.settings.goal} XP</span>
</p>
<ProgressBar
value={Math.min(progress.todayXp(), progress.settings.goal)}
max={progress.settings.goal}
label="Daily goal"
small
/>
</div>
<div class="ll-card">
<p class="text-sm ll-muted">total</p>
<p class="text-3xl font-semibold">
{Object.values(progress.data.langs).reduce((n, p) => n + (p?.xp ?? 0), 0)} XP
</p>
</div>
</Card.Content></Card.Root
>
<Card.Root
><Card.Content class="p-5">
<p class="text-sm ll-muted">total</p>
<p class="text-3xl font-semibold">
{Object.values(progress.data.langs).reduce((n, p) => n + (p?.xp ?? 0), 0)} XP
</p>
</Card.Content></Card.Root
>
</section>
{/if}
@@ -93,7 +102,7 @@
class="ll-card flex flex-col gap-3"
>
<div class="flex items-center justify-between">
<span class="ll-badge">{c.code.toUpperCase()}</span>
<Badge variant="outline">{c.code.toUpperCase()}</Badge>
{#if progress.loaded && progress.streak(c.code)}
<span class="ll-pill"
><Flame size={14} aria-hidden="true" /> {progress.streak(c.code)}</span
@@ -195,14 +204,15 @@
<span>Daily goal</span>
<div class="flex gap-2">
{#each goals as goal (goal)}
<button
<Button
variant={progress.settings.goal === goal ? 'secondary' : 'outline'}
size="sm"
type="button"
class="ll-chip"
aria-pressed={progress.settings.goal === goal}
onclick={() => {
progress.settings.goal = goal;
progress.save();
}}>{goal} XP</button
}}>{goal} XP</Button
>
{/each}
</div>
@@ -212,13 +222,9 @@
Progress is saved in this browser. Export it to move it to another device.
</p>
<div class="flex flex-wrap gap-2">
<button type="button" class="ll-button ll-button-quiet" onclick={exportProgress}
>Export</button
>
<button
type="button"
class="ll-button ll-button-quiet"
onclick={() => fileInput?.click()}>Import</button
<Button type="button" variant="outline" onclick={exportProgress}>Export</Button>
<Button variant="outline" type="button" onclick={() => fileInput?.click()}
>Import</Button
>
<input
bind:this={fileInput}
@@ -229,10 +235,8 @@
/>
{#each COURSES as c (c.code)}
{#if progress.data.langs[c.code]}
<button
type="button"
class="ll-button ll-button-quiet"
onclick={() => reset(c.code, c.name)}>Reset {c.name}</button
<Button variant="outline" type="button" onclick={() => reset(c.code, c.name)}
>Reset {c.name}</Button
>
{/if}
{/each}

View File

@@ -8,6 +8,9 @@
import Download from 'lucide-svelte/icons/download';
import X from 'lucide-svelte/icons/x';
import MoneyChart from '$lib/money-tracker/MoneyChart.svelte';
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import * as Tabs from '$lib/components/ui/tabs/index.js';
import {
accountBalances,
accountColor,
@@ -233,11 +236,9 @@
<h1>Money tracker</h1>
</div>
<div class="header-actions">
<button class="button secondary" onclick={() => open('import')}
><Upload size={16} /> Import CSV</button
><button class="button primary" onclick={() => open('transaction')}
><Plus size={16} /> Add transaction</button
>
<Button variant="outline" onclick={() => open('import')}
><Upload size={16} /> Import CSV</Button
><Button onclick={() => open('transaction')}><Plus size={16} /> Add transaction</Button>
</div>
</header>
@@ -277,20 +278,19 @@
<h2>Wealth over time</h2>
<p>Actual balances through today, then the projection.</p>
</div>
<button
class="button secondary"
<Button
variant="outline"
onclick={() => open('projectionSettings', data.projectionSettings ?? {})}
>Edit projection</button
>Edit projection</Button
>
</div>
<div class="subtabs" role="group" aria-label="Chart view">
{#each timelineViews as item (item.id)}<button
type="button"
class:current={timelineView === item.id}
aria-pressed={timelineView === item.id}
onclick={() => (timelineView = item.id)}>{item.label}</button
>{/each}
</div>
<Tabs.Root bind:value={timelineView} class="mb-3"
><Tabs.List
>{#each timelineViews as item (item.id)}<Tabs.Trigger value={item.id}
>{item.label}</Tabs.Trigger
>{/each}</Tabs.List
></Tabs.Root
>
<MoneyChart
series={timeline}
title="Actual and projected wealth over time"
@@ -367,12 +367,12 @@
<h2>Transactions</h2>
<p>{filtered.length} of {data.transactions.length} entries</p>
</div>
<button class="button secondary" onclick={exportCsv} disabled={!data.transactions.length}
><Download size={16} /> Export CSV</button
<Button variant="outline" onclick={exportCsv} disabled={!data.transactions.length}
><Download size={16} /> Export CSV</Button
>
</div>
<div class="filters">
<label class="sr-only" for="transaction-search">Search transactions</label><input
<label class="sr-only" for="transaction-search">Search transactions</label><Input
id="transaction-search"
type="search"
placeholder="Search descriptions"

View File

@@ -1,153 +1,79 @@
<script lang="ts">
let { data } = $props();
import { resolve } from '$app/paths';
import { Button } from '$lib/components/ui/button/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import ProgressBar from '$lib/components/ProgressBar.svelte';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
let searchTerm = $state('');
let filteredTasks = $derived(
data.tasks.filter(
(task) =>
task.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
task.tasksToTopics.some((t) =>
t.topic.name.toLowerCase().includes(searchTerm.toLowerCase())
task.tasksToTopics.some(({ topic }) =>
topic?.name.toLowerCase().includes(searchTerm.toLowerCase())
)
)
);
let earned = $derived(data.tasks.reduce((sum, task) => sum + task.currentPoints, 0));
let possible = $derived(data.tasks.reduce((sum, task) => sum + task.totalPoints, 0));
</script>
<div class="flex flex-col items-center p-4">
<div class="w-full max-w-4xl flex flex-col gap-4 items-center mt-4 mb-8">
<div class="flex justify-center gap-8">
<h1 class="font-bold text-2xl">
Tasks ({data.tasks.length})
</h1>
<a href="/progress/topics">
<button class="px-4 py-2 bg-gray-800 text-white rounded hover:bg-gray-600 transition">
Topics
</button>
</a>
{#if data.editable}
<a href="/progress/create">
<button class="px-4 py-2 bg-green-500 text-white rounded hover:bg-green-600 transition">
Create
</button>
</a>
{/if}
<div class="mx-auto max-w-6xl space-y-6 px-4 py-12 sm:px-6">
<header class="flex flex-wrap items-center justify-between gap-4">
<div>
<h1 class="text-4xl font-semibold tracking-tight">progress</h1>
<p class="mt-2 text-muted-foreground">
{data.tasks.length} tasks · {earned}/{possible} points
</p>
</div>
<!-- Search Bar -->
<div class="w-full max-w-md">
<input
type="text"
bind:value={searchTerm}
placeholder="Search tasks or topics..."
class="w-full px-4 py-2 rounded bg-gray-800 border border-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<div class="flex gap-2">
<Button href={resolve('/projects/progress/topics')} variant="outline">Topics</Button
>{#if data.editable}<Button href={resolve('/projects/progress/create')}>Create task</Button
>{/if}
</div>
Total progress: {data.tasks.reduce((acc, task) => acc + task.currentPoints, 0)} / {data.tasks.reduce(
(acc, task) => acc + task.totalPoints,
0
)} ({(
(data.tasks.reduce((acc, task) => acc + task.currentPoints, 0) /
data.tasks.reduce((acc, task) => acc + task.totalPoints, 0)) *
100
).toFixed(3)}%)
</header>
<ProgressBar value={earned} max={possible} label="Total progress" />
<Input
type="search"
bind:value={searchTerm}
placeholder="Search tasks or topics"
aria-label="Search tasks or topics"
class="max-w-md"
/>
<div class="grid gap-3 md:grid-cols-2">
{#each filteredTasks as task (task.id)}
<Card.Root class="transition-colors hover:bg-muted/50"
><Card.Content class="p-0">
<a
href={resolve('/projects/progress/[id]', { id: String(task.id) })}
class="block space-y-3 p-5"
>
<div class="flex items-center justify-between gap-3">
<h2 class="font-medium">{task.name}</h2>
<span class="text-sm tabular-nums text-muted-foreground"
>{task.currentPoints}/{task.totalPoints}</span
>
</div>
<ProgressBar
value={task.currentPoints}
max={task.totalPoints}
label={`Progress on ${task.name}`}
small
/>
<div class="flex flex-wrap gap-1">
{#each task.tasksToTopics as { topic, topicId } (topicId)}{#if topic}<Badge
variant="secondary">{topic.name}</Badge
>{/if}{/each}
</div>
</a>
</Card.Content></Card.Root
>
{:else}
<p class="text-muted-foreground">No matching tasks.</p>
{/each}
</div>
{#if filteredTasks.length > 0}
<div class="overflow-x-auto w-full">
<!-- Desktop Table View -->
<div class="hidden md:block">
<table class="min-w-full">
<thead>
<tr class="border-b border-white">
<th class="px-6 py-3 text-left text-sm font-semibold">Name</th>
<th class="px-6 py-3 text-left text-sm font-semibold">Topics</th>
<th class="px-6 py-3 text-left text-sm font-semibold min-w-[200px]">Progress</th>
</tr>
</thead>
<tbody>
{#each filteredTasks as task, index}
<tr class="{index % 2 === 0 ? 'bg-black' : 'bg-gray-950'} hover:bg-gray-800">
<td class="px-6 py-2 border-b">
<a href={`/progress/${task.id}`} class="text-blue-500 hover:underline">
{data.tasks.indexOf(task) + 1}. {task.name}
</a>
</td>
<td class="px-6 py-2 border-b">
<a href={`/progress/${task.id}`} class="flex gap-2 flex-wrap">
{#each task.tasksToTopics as topic}
<div
class="relative bg-blue-500 text-white rounded-full px-2 py-1 shadow font-medium hover:bg-blue-600 transition cursor-pointer"
title="Click for more details about {topic.topic.name}"
>
{topic.topic.name}
</div>
{/each}
</a>
</td>
<td class="px-6 py-2 border-b min-w-[200px]">
<div class="relative w-full h-6 bg-gray-700 rounded-full overflow-hidden">
<div
class="absolute h-full bg-green-500"
style="width: {(task.currentPoints / task.totalPoints) * 100}%"
></div>
<span class="absolute inset-0 flex items-center justify-center text-sm">
{task.currentPoints} / {task.totalPoints} ({(
(task.currentPoints / task.totalPoints) *
100
).toFixed(1)}%)
</span>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
<!-- Mobile Card View -->
<div class="md:hidden space-y-4">
{#each filteredTasks as task}
<div class="bg-gray-800 rounded-lg p-2 w-full">
<a
href={`/progress/${task.id}`}
class="text-blue-500 hover:underline text-lg font-bold"
>
{task.name}
</a>
<div class="mt-1">
<div class="mb-1"></div>
<span class="font-medium">Topics:</span>
<div class="flex flex-wrap gap-1 mt-1">
{#each task.tasksToTopics as topic}
<div
class="bg-blue-500 text-white rounded-full px-2 py-1 shadow font-medium hover:bg-blue-600 transition cursor-pointer"
title="Click for more details about {topic.topic.name}"
>
{topic.topic.name}
</div>
{/each}
</div>
</div>
<div class="w-full h-4 bg-gray-700 rounded-full relative overflow-hidden">
<div
class="absolute h-full bg-green-500"
style="width: {(task.currentPoints / task.totalPoints) * 100}%"
></div>
<span class="absolute inset-0 flex items-center justify-center text-xs">
{task.currentPoints} / {task.totalPoints} ({(
(task.currentPoints / task.totalPoints) *
100
).toFixed(1)}%)
</span>
</div>
</div>
{/each}
</div>
</div>
{:else}
<p class="text-gray-500">
No tasks found matching your search. Try a different keyword or clear the search.
</p>
{/if}
</div>

View File

@@ -1,174 +1,122 @@
<script lang="ts">
import Check from 'lucide-svelte/icons/check';
import * as Command from '$lib/components/ui/command';
import * as Popover from '$lib/components/ui/popover';
import { Button } from '$lib/components/ui/button';
import { cn } from '$lib/utils.js';
import { tick } from 'svelte';
import { resolve } from '$app/paths';
import { Button } from '$lib/components/ui/button/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import { Textarea } from '$lib/components/ui/textarea/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import ProgressBar from '$lib/components/ProgressBar.svelte';
import type { PageProps } from './$types';
let { data } = $props();
let open = $state(false);
let value = $state('');
let search = $state('');
let triggerRef = $state<any>();
let addTopicForms = $state<HTMLFormElement[]>([]);
// We want to refocus the trigger button when the user selects
// an item from the list so users can continue navigating the
// rest of the form with the keyboard.
function closeAndFocusTrigger() {
open = false;
tick().then(() => {
triggerRef.focus();
});
}
function getProgress() {
return (data.task?.currentPoints / data.task?.totalPoints) * 100;
}
let { data }: PageProps = $props();
</script>
<h1 class="my-4 text-2xl font-bold text-center">{data.task?.name}</h1>
<div class="my-4">
<div class="flex justify-center gap-4 items-center relative">
<h2 class="text-xl font-semibold">Topics ({data.task?.tasksToTopics.length})</h2>
<Popover.Root bind:open>
<Popover.Trigger bind:this={triggerRef}>
{#if data.editable}
<Button variant="outline" class="justify-between" role="combobox" aria-expanded={open}>
+
</Button>
{/if}
</Popover.Trigger>
<Popover.Content class="w-[200px] p-0">
<Command.Root>
<Command.Input bind:value={search} placeholder="Search topic..." />
<Command.List>
<div class="flex flex-col items-center p-2">
<Command.Group>
{#each data.topics as topic}
<form
action="?/addTopic"
method="post"
bind:this={addTopicForms[topic.id]}
onsubmit={() => (value = topic.id.toString())}
>
<input type="hidden" name="topic-id" value={topic.id} />
<Command.Item
value={topic.name!}
onSelect={() => {
addTopicForms[topic.id].submit();
closeAndFocusTrigger();
}}
<div class="mx-auto max-w-4xl space-y-6 px-4 py-12 sm:px-6">
<Button href={resolve('/projects/progress')} variant="ghost" size="sm">← All tasks</Button>
{#if data.task}
<header>
<h1 class="text-4xl font-semibold tracking-tight">{data.task.name}</h1>
<p class="mt-2 text-muted-foreground">
{data.task.currentPoints}/{data.task.totalPoints} points
</p>
</header>
<ProgressBar
value={data.task.currentPoints}
max={data.task.totalPoints}
label={`Progress on ${data.task.name}`}
/>
<Card.Root
><Card.Header><Card.Title>Topics</Card.Title></Card.Header><Card.Content class="space-y-4">
<div class="flex flex-wrap gap-2">
{#each data.task.tasksToTopics as relation (relation.topicId)}
{#if relation.topic}
<div class="flex items-center gap-1">
<a
href={resolve('/projects/progress/topics/[id]', {
id: String(relation.topic.id)
})}><Badge variant="secondary">{relation.topic.name}</Badge></a
>
{#if data.editable}<form method="POST" action="?/removeTopic">
<input type="hidden" name="topic-id" value={relation.topic.id} /><Button
type="submit"
variant="ghost"
size="icon-xs"
aria-label={`Remove ${relation.topic.name}`}>×</Button
>
<Check
class={cn(
'mr-2 size-4',
value !== topic.id.toString() && 'text-transparent'
)}
/>
{topic.name}
</Command.Item>
</form>
{/each}
</Command.Group>
<form method="post" action="?/createTopic">
<input type="hidden" name="name" value={search} />
<button class="px-4 py-2 rounded bg-secondary">Add Topic</button>
</form>
</div>
</Command.List>
</Command.Root>
</Popover.Content>
</Popover.Root>
</div>
<div class="flex justify-center flex-wrap gap-2 my-1 relative">
{#if data.task?.tasksToTopics}
{#each data.task?.tasksToTopics as topic}
<a href="/progress/topics/{topic.topic.id}">
<div
class="relative bg-blue-500 text-white rounded-full px-2 py-1 shadow font-medium hover:bg-blue-600 transition cursor-pointer group"
title="Click for more details about {topic.topic.name}"
>
{topic.topic.name}
<form
action="?/removeTopic"
method="post"
class="absolute -top-1 -right-1 opacity-0 group-hover:opacity-100 transition"
>
<input type="hidden" name="topic-id" value={topic.topic.id} />
<button
type="submit"
onclick={(e) => {
e.preventDefault();
e.target.closest('form')?.submit();
}}
class="bg-white text-red-500 hover:text-red-700 hover:bg-red-50 font-bold rounded-full w-5 h-5 flex items-center justify-center shadow-sm"
aria-label="Remove topic"
>
&times;
</button>
</form>
</div>
</a>
{/each}
{/if}
</div>
</div>
<div class="my-8">
<h2 class="text-xl font-semibold text-center mb-4">Progress ({getProgress().toFixed(2)}%)</h2>
<div class="flex items-center gap-4 max-w-xl mx-auto">
<form method="POST" action="?/updateProgress" class="flex items-center gap-4 w-full">
<input
type="number"
name="currentPoints"
value={data.task?.currentPoints}
class="w-20 p-2 border rounded"
min="0"
readonly={!data.editable}
onchange={(e) => e.target.form?.submit()}
/>
<div class="flex-1">
<div class="w-full rounded-full h-4">
<progress max="100" value={getProgress()}>{getProgress()}%</progress>
</form>{/if}
</div>
{/if}
{/each}
</div>
</div>
<input
type="number"
name="totalPoints"
value={data.task?.totalPoints}
class="w-20 p-2 border rounded"
readonly={!data.editable}
min="1"
onchange={(e) => e.target.form?.submit()}
/>
</form>
</div>
</div>
<div class="my-8">
<h2 class="text-xl font-semibold text-center mb-4">Notes</h2>
<form method="POST" action="?/updateNotes" class="max-w-xl mx-auto">
<textarea
name="notes"
class="w-full p-3 border rounded min-h-[150px] resize-y"
placeholder="Add notes about this task..."
readonly={!data.editable}
onchange={(e) => data.editable && e.target.form?.submit()}>{data.task?.notes || ''}</textarea
{#if data.editable}
<form method="POST" action="?/addTopic" class="flex flex-wrap items-end gap-2">
<div class="min-w-48 flex-1 space-y-2">
<Label for="topic-id">Add existing topic</Label><select
id="topic-id"
name="topic-id"
required
class="h-9 w-full rounded-md border border-input bg-background px-2.5 text-sm"
><option value="">Choose a topic</option
>{#each data.topics as topic (topic.id)}<option value={topic.id}
>{topic.name}</option
>{/each}</select
>
</div>
<Button type="submit" variant="outline">Add</Button>
</form>
<form method="POST" action="?/createTopic" class="flex flex-wrap items-end gap-2">
<div class="min-w-48 flex-1 space-y-2">
<Label for="new-topic">New topic</Label><Input id="new-topic" name="name" required />
</div>
<Button type="submit" variant="outline">Create</Button>
</form>
{/if}
</Card.Content></Card.Root
>
{#if data.editable}
<div class="flex justify-end mt-2">
<Button type="submit" size="sm">Save Notes</Button>
</div>
{/if}
</form>
<Card.Root
><Card.Header><Card.Title>Progress</Card.Title></Card.Header><Card.Content>
<form method="POST" action="?/updateProgress" class="flex flex-wrap items-end gap-3">
<div class="space-y-2">
<Label for="current-points">Current</Label><Input
id="current-points"
type="number"
name="currentPoints"
min={0}
value={data.task.currentPoints}
readonly={!data.editable}
class="w-28"
/>
</div>
<div class="space-y-2">
<Label for="total-points">Total</Label><Input
id="total-points"
type="number"
name="totalPoints"
min={1}
value={data.task.totalPoints}
readonly={!data.editable}
class="w-28"
/>
</div>
{#if data.editable}<Button type="submit">Save progress</Button>{/if}
</form>
</Card.Content></Card.Root
>
<Card.Root
><Card.Header><Card.Title>Notes</Card.Title></Card.Header><Card.Content
><form method="POST" action="?/updateNotes" class="space-y-3">
<Label for="task-notes" class="sr-only">Notes</Label><Textarea
id="task-notes"
name="notes"
value={data.task.notes ?? ''}
readonly={!data.editable}
class="min-h-36"
/>{#if data.editable}<Button type="submit">Save notes</Button>{/if}
</form></Card.Content
></Card.Root
>
{:else}
<p class="text-muted-foreground">Task not found.</p>
{/if}
</div>

View File

@@ -1,10 +1,21 @@
<form method="post" class="flex flex-col items-center gap-4">
<h1 class="font-bold text-xl mt-8 mb-4">Create Task</h1>
<script lang="ts">
import { resolve } from '$app/paths';
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import * as Card from '$lib/components/ui/card/index.js';
</script>
<div class="flex gap-4">
<label for="name">Name: </label>
<input type="text" name="name" class="border rounded" />
</div>
<button class="px-4 py-2 rounded bg-green-500 m-4">Submit</button>
</form>
<div class="mx-auto max-w-xl space-y-6 px-4 py-12 sm:px-6">
<Button href={resolve('/projects/progress')} variant="ghost" size="sm">← All tasks</Button>
<Card.Root
><Card.Header><Card.Title>Create task</Card.Title></Card.Header><Card.Content
><form method="POST" class="space-y-4">
<div class="space-y-2">
<Label for="task-name">Name</Label><Input id="task-name" name="name" required />
</div>
<Button type="submit">Create</Button>
</form></Card.Content
></Card.Root
>
</div>

View File

@@ -1,29 +1,31 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import * as Table from '$lib/components/ui/table/index.js';
let { data } = $props();
import { Button } from '$lib/components/ui/button/index.js';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
</script>
<Table.Root>
<Table.Header>
<Table.Row>
<Table.Head class="w-[100px]">Name</Table.Head>
<Table.Head>Amount of Tasks</Table.Head>
<Table.Head class="text-right">Actions</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each data.topics as topic}
<Table.Row
onclick={() => goto(`/progress/topics/${topic.id}`)}
class="cursor-pointer hover:bg-muted/50"
>
<Table.Cell class="font-medium">{topic.name}</Table.Cell>
<Table.Cell>{topic.topicsToTasks.length}</Table.Cell>
<Table.Cell class="text-right"></Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
<div class="mx-auto max-w-4xl space-y-6 px-4 py-12 sm:px-6">
<Button href={resolve('/projects/progress')} variant="ghost" size="sm">← All tasks</Button>
<h1 class="text-4xl font-semibold tracking-tight">topics</h1>
<div class="overflow-hidden rounded-md border border-border">
<Table.Root
><Table.Header
><Table.Row
><Table.Head>Name</Table.Head><Table.Head class="text-right">Tasks</Table.Head></Table.Row
></Table.Header
><Table.Body>
{#each data.topics as topic (topic.id)}<Table.Row
><Table.Cell
><a
href={resolve('/projects/progress/topics/[id]', { id: String(topic.id) })}
class="font-medium hover:underline">{topic.name}</a
></Table.Cell
><Table.Cell class="text-right tabular-nums">{topic.topicsToTasks.length}</Table.Cell
></Table.Row
>{/each}
</Table.Body></Table.Root
>
</div>
</div>

View File

@@ -1,14 +1,27 @@
<script lang="ts">
let { data } = $props();
import { resolve } from '$app/paths';
import { Button } from '$lib/components/ui/button/index.js';
import * as Card from '$lib/components/ui/card/index.js';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
</script>
<h1 class="my-4 text-2xl font-bold text-center">{data.topic.name}</h1>
<h2 class="text-xl font-bold">Tasks ({data.topic?.topicsToTasks.length})</h2>
{#each data.topic?.topicsToTasks as task}
<div class="flex gap-4 items-center relative">
<a href="/progress/{task.task.id}" class="text-lg font-semibold hover:underline"
>{task.task.name}</a
>
</div>
{/each}
<div class="mx-auto max-w-4xl space-y-6 px-4 py-12 sm:px-6">
<Button href={resolve('/projects/progress/topics')} variant="ghost" size="sm">← All topics</Button
>
{#if data.topic}
<h1 class="text-4xl font-semibold tracking-tight">{data.topic.name}</h1>
<p class="text-muted-foreground">{data.topic.topicsToTasks.length} tasks</p>
<div class="grid gap-3">
{#each data.topic.topicsToTasks as relation (relation.taskId)}{#if relation.task}<Card.Root
class="transition-colors hover:bg-muted/50"
><Card.Content class="p-0"
><a
href={resolve('/projects/progress/[id]', { id: String(relation.task.id) })}
class="block p-5 font-medium">{relation.task.name}</a
></Card.Content
></Card.Root
>{/if}{/each}
</div>
{:else}<p class="text-muted-foreground">Topic not found.</p>{/if}
</div>

View File

@@ -3,6 +3,9 @@
import type { PageProps } from './$types';
import Artwork from '$lib/map-finder/Artwork.svelte';
import MapCard from '$lib/map-finder/MapCard.svelte';
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import * as Card from '$lib/components/ui/card/index.js';
let { form }: PageProps = $props();
let pending = $state(false);
let filter = $state('all');
@@ -18,7 +21,7 @@
</script>
<div class="mx-auto max-w-5xl px-4 py-6 sm:py-12">
<div class="rounded-3xl bg-white p-5 text-gray-800 shadow sm:p-10">
<Card.Root class="p-5 sm:p-10">
<h1 class="mb-6 text-center text-3xl font-semibold lg:text-4xl">Spotify Map Finder</h1>
<form
method="POST"
@@ -37,32 +40,30 @@
}}
>
<label for="query" class="sr-only">Song or Spotify URL</label>
<input
<Input
id="query"
name="query"
type="text"
value={form?.query ?? ''}
required
maxlength="500"
maxlength={500}
placeholder="Deftones Change or a Spotify URL"
aria-describedby="input-help"
class="block w-full rounded border border-gray-300 bg-gray-100 p-3 focus-visible:outline-2 focus-visible:outline-blue-600"
class="h-11"
/>
<p id="input-help" class="text-sm text-gray-600">
<p id="input-help" class="text-sm text-muted-foreground">
Search a song, or enter a Spotify track, playlist or album URL/URI. Up to 50 tracks.
</p>
<button
disabled={pending}
class="w-full rounded bg-blue-600 px-4 py-3 text-white hover:bg-blue-700 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 disabled:opacity-60"
>{pending ? 'Searching…' : 'Search'}</button
<Button type="submit" disabled={pending} class="w-full"
>{pending ? 'Searching…' : 'Search'}</Button
>
</form>
<div role="status">
{#if pending}<p class="mt-4 text-sm text-gray-600">
{#if pending}<p class="mt-4 text-sm text-muted-foreground">
Searching for maps. Playlists and albums may take a few minutes.
</p>{/if}
</div>
{#if form?.error}<p role="alert" class="mt-4 text-red-700">{form.error}</p>{/if}
{#if form?.error}<p role="alert" class="mt-4 text-destructive">{form.error}</p>{/if}
{#if form?.results}
<section class="mt-8" aria-label="Search results" aria-busy={pending}>
@@ -71,34 +72,36 @@
<Artwork src={form.collection.image} class="h-16 w-16 shrink-0" />
<div class="min-w-0 break-words">
<h2 class="text-xl font-semibold">{form.collection.name}</h2>
{#if form.collection.owner}<p class="text-sm text-gray-600">
{#if form.collection.owner}<p class="text-sm text-muted-foreground">
{form.collection.owner}
</p>{/if}
<p class="text-sm">{results.length} tracks processed · {matched} matched</p>
<a
class="text-sm text-blue-700 underline"
class="text-sm text-foreground underline"
href={`https://open.spotify.com/${form.collection.kind}/${form.collection.id}`}
target="_blank"
rel="noopener noreferrer">Open in Spotify</a
>
</div>
</div>
{#if form.truncated}<p class="mb-4 text-sm text-amber-800">
{#if form.truncated}<p class="mb-4 text-sm text-muted-foreground">
Showing the first {results.length} usable tracks. The collection reached the processing
limit.
</p>{/if}
<label class="mb-6 block text-sm"
>Show
<select bind:value={filter} class="ml-2 rounded border border-gray-300 bg-white p-2">
<select bind:value={filter} class="ml-2 rounded border border-border bg-background p-2">
{#each filters as option (option.key)}<option value={option.key}
>{option.label}</option
>{/each}
</select>
</label>
{/if}
{#if !results.length}<p class="text-gray-600">
{#if !results.length}<p class="text-muted-foreground">
No usable tracks found.
</p>{:else if !visible.length}<p class="text-gray-600">No tracks in this filter.</p>{/if}
</p>{:else if !visible.length}<p class="text-muted-foreground">
No tracks in this filter.
</p>{/if}
<ul class="space-y-8">
{#each visible as result (result.track.id)}
<li>
@@ -109,17 +112,17 @@
{result.track.artists.map((artist) => artist.name).join(', ')} – {result.track
.name}
</h3>
<p class="text-sm text-gray-600">{result.track.album.name}</p>
<p class="text-sm text-muted-foreground">{result.track.album.name}</p>
<a
class="text-sm text-blue-700 underline"
class="text-sm text-foreground underline"
href={`https://open.spotify.com/track/${result.track.id}`}
target="_blank"
rel="noopener noreferrer">Open in Spotify</a
>
</div>
</div>
{#if result.status === 'error'}<p class="text-red-700">{result.error}</p>{:else}
{#if result.status === 'no_match'}<p class="mb-3 text-gray-600">
{#if result.status === 'error'}<p class="text-destructive">{result.error}</p>{:else}
{#if result.status === 'no_match'}<p class="mb-3 text-muted-foreground">
No confident match found.
</p>{/if}
<ul class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
@@ -130,7 +133,7 @@
</ul>
{#if result.matches.some((map) => map.confidence === 'low')}
<details class="mt-4">
<summary class="cursor-pointer text-sm text-gray-600"
<summary class="cursor-pointer text-sm text-muted-foreground"
>Low-confidence candidates ({result.matches.filter(
(map) => map.confidence === 'low'
).length})</summary
@@ -149,5 +152,5 @@
</ul>
</section>
{/if}
</div>
</Card.Root>
</div>

View File

@@ -5,6 +5,7 @@
import PartyCompass from '$lib/world-opinions/PartyCompass.svelte';
import WorldMap, { type MapMode } from '$lib/world-opinions/WorldMap.svelte';
import { countries, countryById, narratives, sources } from '$lib/world-opinions/data';
import { Button } from '$lib/components/ui/button/index.js';
const modes: { id: MapMode; label: string }[] = [
{ id: 'liberal', label: 'what "liberal" means' },
@@ -36,11 +37,12 @@
<section aria-label="World map">
<div class="mb-4 flex flex-wrap gap-2" role="tablist" aria-label="Map layer">
{#each modes as item (item.id)}
<button
class="wo-chip"
<Button
variant={mode === item.id ? 'secondary' : 'outline'}
size="sm"
role="tab"
aria-selected={mode === item.id}
onclick={() => (mode = item.id)}>{item.label}</button
onclick={() => (mode = item.id)}>{item.label}</Button
>
{/each}
</div>
@@ -78,12 +80,13 @@
<h2 class="mb-4 text-2xl font-semibold">one event, many stories</h2>
<div class="mb-5 flex flex-wrap gap-2" role="tablist" aria-label="Event">
{#each narratives as item, index (item.event)}
<button
class="wo-chip"
<Button
variant={eventIndex === index ? 'secondary' : 'outline'}
size="sm"
role="tab"
aria-selected={eventIndex === index}
onclick={() => (eventIndex = index)}
>{item.event.toLowerCase()} <span class="opacity-60">{item.years}</span></button
>{item.event.toLowerCase()} <span class="opacity-60">{item.years}</span></Button
>
{/each}
</div>