ui changes and cs prices
All checks were successful
Test and deploy staging / staging (push) Successful in 11m52s
All checks were successful
Test and deploy staging / staging (push) Successful in 11m52s
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
26
src/routes/projects/cs-prices/+page.server.ts
Normal file
26
src/routes/projects/cs-prices/+page.server.ts
Normal 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 }[]
|
||||
};
|
||||
};
|
||||
657
src/routes/projects/cs-prices/+page.svelte
Normal file
657
src/routes/projects/cs-prices/+page.svelte
Normal 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>
|
||||
24
src/routes/projects/cs-prices/api/search/+server.ts
Normal file
24
src/routes/projects/cs-prices/api/search/+server.ts
Normal 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 });
|
||||
};
|
||||
68
src/routes/projects/cs-prices/api/series/+server.ts
Normal file
68
src/routes/projects/cs-prices/api/series/+server.ts
Normal 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 });
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
×
|
||||
</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user