Expand website and game trackers
Some checks failed
Test and deploy staging / staging (push) Failing after 5m29s
Some checks failed
Test and deploy staging / staging (push) Failing after 5m29s
This commit is contained in:
BIN
src/lib/assets/projects/animaldex.webp
Normal file
BIN
src/lib/assets/projects/animaldex.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 293 KiB |
@@ -1,4 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { pokemonImage } from '$lib/game-tracker/game-art';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { NativeSelect } from '$lib/components/ui/native-select/index.js';
|
||||
import * as Table from '$lib/components/ui/table/index.js';
|
||||
let moveSearch = $state('');
|
||||
let moveMethod = $state('all');
|
||||
|
||||
import { resolve } from '$app/paths';
|
||||
import { enhance } from '$app/forms';
|
||||
import {
|
||||
@@ -71,7 +78,7 @@
|
||||
{@const species = current.pokemon}
|
||||
<div class="flex flex-wrap items-center gap-5">
|
||||
<img
|
||||
src="https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/{species.number}.png"
|
||||
src={pokemonImage(species.number, 'artwork')}
|
||||
alt=""
|
||||
width="120"
|
||||
height="120"
|
||||
@@ -88,6 +95,114 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="mt-6 rounded-xl border p-4">
|
||||
<h3 class="font-semibold">Base stats</h3>
|
||||
<div class="mt-3 grid gap-2">
|
||||
{#each current.stats as stat (stat.name)}<div
|
||||
class="grid grid-cols-[5rem_2rem_1fr] items-center gap-3 text-xs"
|
||||
>
|
||||
<span>{stat.name}</span><span class="font-semibold tabular-nums">{stat.value}</span>
|
||||
<div class="h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
class="h-full rounded-full bg-emerald-500/70"
|
||||
style:width={`${(stat.value / 255) * 100}%`}
|
||||
></div>
|
||||
</div>
|
||||
</div>{/each}
|
||||
</div>
|
||||
<p class="mt-3 text-xs text-muted-foreground">
|
||||
Total: {current.stats.reduce((sum, s) => sum + s.value, 0)} · default form, current catalog values
|
||||
</p>
|
||||
</section>
|
||||
{#if current.facts}
|
||||
<section class="mt-5">
|
||||
<h3 class="font-semibold">Species research</h3>
|
||||
<dl class="mt-3 grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<dt class="text-muted-foreground">Abilities</dt>
|
||||
<dd>
|
||||
{current.facts.abilities
|
||||
.map((a) => `${a.name}${a.hidden ? ' (hidden)' : ''}`)
|
||||
.join(', ')}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-muted-foreground">Height / weight</dt>
|
||||
<dd>{current.facts.height / 10} m · {current.facts.weight / 10} kg</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-muted-foreground">Egg groups</dt>
|
||||
<dd>{current.facts.eggGroups.join(', ')}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-muted-foreground">Growth rate</dt>
|
||||
<dd>{current.facts.growthRate}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-muted-foreground">Catch rate / base friendship</dt>
|
||||
<dd>{current.facts.captureRate} / {current.facts.baseHappiness ?? '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-muted-foreground">EV yield</dt>
|
||||
<dd>
|
||||
{current.facts.evYield
|
||||
.map((v, i) => (v ? `${v} ${current.stats[i]?.name}` : ''))
|
||||
.filter(Boolean)
|
||||
.join(', ') || 'None'}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
{/if}
|
||||
<section class="mt-6">
|
||||
<h3 class="font-semibold">Moves in {current.entry?.game ?? 'this game'}</h3>
|
||||
{#if current.learnset.length}
|
||||
<div class="mt-3 grid grid-cols-2 gap-3">
|
||||
<Input
|
||||
type="search"
|
||||
aria-label="Search moves"
|
||||
placeholder="Search moves…"
|
||||
bind:value={moveSearch}
|
||||
/><NativeSelect aria-label="Learn method" class="w-full" bind:value={moveMethod}
|
||||
><option value="all">All methods</option
|
||||
>{#each [...new Set(current.learnset.map((m) => m.method))] as method (method)}<option
|
||||
value={method}>{method}</option
|
||||
>{/each}</NativeSelect
|
||||
>
|
||||
</div>
|
||||
<div class="mt-3 max-h-72 overflow-auto">
|
||||
<Table.Root
|
||||
><Table.Header
|
||||
><Table.Row
|
||||
><Table.Head>Move</Table.Head><Table.Head>Learn</Table.Head><Table.Head
|
||||
>Power</Table.Head
|
||||
><Table.Head>Accuracy</Table.Head><Table.Head>PP</Table.Head></Table.Row
|
||||
></Table.Header
|
||||
><Table.Body
|
||||
>{#each current.learnset.filter((m) => (moveMethod === 'all' || moveMethod === m.method) && `${m.name} ${m.type}`
|
||||
.toLowerCase()
|
||||
.includes(moveSearch.toLowerCase())) as move, i (`${move.id}-${move.method}-${move.level}-${i}`)}<Table.Row
|
||||
><Table.Cell
|
||||
><span class="font-medium">{move.name}</span><span
|
||||
class="block text-xs text-muted-foreground">{move.type} · {move.category}</span
|
||||
></Table.Cell
|
||||
><Table.Cell>{move.method}{move.level ? ` · Lv. ${move.level}` : ''}</Table.Cell
|
||||
><Table.Cell>{move.power ?? '—'}</Table.Cell><Table.Cell
|
||||
>{move.accuracy === null ? '—' : `${move.accuracy}%`}</Table.Cell
|
||||
><Table.Cell>{move.pp ?? '—'}</Table.Cell></Table.Row
|
||||
>{/each}</Table.Body
|
||||
></Table.Root
|
||||
>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-muted-foreground">
|
||||
Learning methods are game-specific. Move power, accuracy and PP use the current catalog
|
||||
snapshot.
|
||||
</p>
|
||||
{:else}<p class="mt-2 text-sm text-muted-foreground">
|
||||
No learnset is recorded for this game.
|
||||
</p>{/if}
|
||||
</section>
|
||||
|
||||
{#if current.entry}
|
||||
<section class="mt-6">
|
||||
<h3 class="font-semibold">in {current.entry.game}</h3>
|
||||
@@ -191,7 +306,7 @@
|
||||
<div class="mt-3 grid gap-3 sm:grid-cols-[12rem_minmax(0,1fr)]">
|
||||
<label class="block"
|
||||
>availability
|
||||
<select
|
||||
<NativeSelect
|
||||
name="availability"
|
||||
value={current.entry.availability}
|
||||
class="mt-1 block w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2"
|
||||
@@ -199,7 +314,7 @@
|
||||
{#each availabilityValues as value (value)}<option {value}
|
||||
>{availabilityLabels[value]}</option
|
||||
>{/each}
|
||||
</select>
|
||||
</NativeSelect>
|
||||
</label>
|
||||
<label class="block"
|
||||
>note
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
|
||||
import {
|
||||
buttonVariants,
|
||||
type ButtonVariant,
|
||||
type ButtonSize
|
||||
} from '$lib/components/ui/button/index.js';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
...restProps
|
||||
}: AlertDialogPrimitive.ActionProps & {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Action
|
||||
bind:ref
|
||||
data-slot="alert-dialog-action"
|
||||
class={cn(buttonVariants({ variant, size }), '', className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
|
||||
import {
|
||||
buttonVariants,
|
||||
type ButtonVariant,
|
||||
type ButtonSize
|
||||
} from '$lib/components/ui/button/index.js';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
variant = 'outline',
|
||||
size = 'default',
|
||||
...restProps
|
||||
}: AlertDialogPrimitive.CancelProps & {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Cancel
|
||||
bind:ref
|
||||
data-slot="alert-dialog-cancel"
|
||||
class={cn(buttonVariants({ variant, size }), '', className)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script lang="ts">
|
||||
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
|
||||
import { cn, type WithoutChild, type WithoutChildrenOrChild } from '$lib/utils.js';
|
||||
import AlertDialogOverlay from './alert-dialog-overlay.svelte';
|
||||
import AlertDialogPortal from './alert-dialog-portal.svelte';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
size = 'default',
|
||||
portalProps,
|
||||
...restProps
|
||||
}: WithoutChild<AlertDialogPrimitive.ContentProps> & {
|
||||
size?: 'default' | 'sm';
|
||||
portalProps?: WithoutChildrenOrChild<ComponentProps<typeof AlertDialogPortal>>;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPortal {...portalProps}>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
bind:ref
|
||||
data-slot="alert-dialog-content"
|
||||
data-size={size}
|
||||
class={cn(
|
||||
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 bg-popover text-popover-foreground ring-foreground/10 gap-6 rounded-xl p-6 ring-1 duration-100 data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-lg group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 outline-none',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AlertDialogPrimitive.DescriptionProps = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Description
|
||||
bind:ref
|
||||
data-slot="alert-dialog-description"
|
||||
class={cn(
|
||||
'text-muted-foreground *:[a]:hover:text-foreground text-sm text-balance md:text-pretty *:[a]:underline *:[a]:underline-offset-3',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="alert-dialog-footer"
|
||||
class={cn(
|
||||
'flex flex-col-reverse gap-2 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="alert-dialog-header"
|
||||
class={cn(
|
||||
'grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-6 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
23
src/lib/components/ui/alert-dialog/alert-dialog-media.svelte
Normal file
23
src/lib/components/ui/alert-dialog/alert-dialog-media.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="alert-dialog-media"
|
||||
class={cn(
|
||||
"bg-muted mb-2 inline-flex size-16 items-center justify-center rounded-md sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-8",
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AlertDialogPrimitive.OverlayProps = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Overlay
|
||||
bind:ref
|
||||
data-slot="alert-dialog-overlay"
|
||||
class={cn(
|
||||
'data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs fixed inset-0 z-50',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
|
||||
|
||||
let { ...restProps }: AlertDialogPrimitive.PortalProps = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Portal {...restProps} />
|
||||
20
src/lib/components/ui/alert-dialog/alert-dialog-title.svelte
Normal file
20
src/lib/components/ui/alert-dialog/alert-dialog-title.svelte
Normal file
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AlertDialogPrimitive.TitleProps = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Title
|
||||
bind:ref
|
||||
data-slot="alert-dialog-title"
|
||||
class={cn(
|
||||
'font-heading text-lg font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: AlertDialogPrimitive.TriggerProps = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Trigger bind:ref data-slot="alert-dialog-trigger" {...restProps} />
|
||||
7
src/lib/components/ui/alert-dialog/alert-dialog.svelte
Normal file
7
src/lib/components/ui/alert-dialog/alert-dialog.svelte
Normal file
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
|
||||
|
||||
let { open = $bindable(false), ...restProps }: AlertDialogPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Root bind:open {...restProps} />
|
||||
40
src/lib/components/ui/alert-dialog/index.ts
Normal file
40
src/lib/components/ui/alert-dialog/index.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import Action from './alert-dialog-action.svelte';
|
||||
import Cancel from './alert-dialog-cancel.svelte';
|
||||
import Content from './alert-dialog-content.svelte';
|
||||
import Description from './alert-dialog-description.svelte';
|
||||
import Footer from './alert-dialog-footer.svelte';
|
||||
import Header from './alert-dialog-header.svelte';
|
||||
import Media from './alert-dialog-media.svelte';
|
||||
import Overlay from './alert-dialog-overlay.svelte';
|
||||
import Portal from './alert-dialog-portal.svelte';
|
||||
import Title from './alert-dialog-title.svelte';
|
||||
import Trigger from './alert-dialog-trigger.svelte';
|
||||
import Root from './alert-dialog.svelte';
|
||||
|
||||
export {
|
||||
Root,
|
||||
Title,
|
||||
Action,
|
||||
Cancel,
|
||||
Portal,
|
||||
Footer,
|
||||
Header,
|
||||
Trigger,
|
||||
Overlay,
|
||||
Content,
|
||||
Description,
|
||||
Media,
|
||||
//
|
||||
Root as AlertDialog,
|
||||
Title as AlertDialogTitle,
|
||||
Action as AlertDialogAction,
|
||||
Cancel as AlertDialogCancel,
|
||||
Portal as AlertDialogPortal,
|
||||
Footer as AlertDialogFooter,
|
||||
Header as AlertDialogHeader,
|
||||
Trigger as AlertDialogTrigger,
|
||||
Overlay as AlertDialogOverlay,
|
||||
Content as AlertDialogContent,
|
||||
Description as AlertDialogDescription,
|
||||
Media as AlertDialogMedia
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Collapsible as CollapsiblePrimitive } from 'bits-ui';
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: CollapsiblePrimitive.ContentProps = $props();
|
||||
</script>
|
||||
|
||||
<CollapsiblePrimitive.Content bind:ref data-slot="collapsible-content" {...restProps} />
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Collapsible as CollapsiblePrimitive } from 'bits-ui';
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: CollapsiblePrimitive.TriggerProps = $props();
|
||||
</script>
|
||||
|
||||
<CollapsiblePrimitive.Trigger bind:ref data-slot="collapsible-trigger" {...restProps} />
|
||||
11
src/lib/components/ui/collapsible/collapsible.svelte
Normal file
11
src/lib/components/ui/collapsible/collapsible.svelte
Normal file
@@ -0,0 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { Collapsible as CollapsiblePrimitive } from 'bits-ui';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
open = $bindable(false),
|
||||
...restProps
|
||||
}: CollapsiblePrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<CollapsiblePrimitive.Root bind:ref bind:open data-slot="collapsible" {...restProps} />
|
||||
13
src/lib/components/ui/collapsible/index.ts
Normal file
13
src/lib/components/ui/collapsible/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import Content from './collapsible-content.svelte';
|
||||
import Trigger from './collapsible-trigger.svelte';
|
||||
import Root from './collapsible.svelte';
|
||||
|
||||
export {
|
||||
Root,
|
||||
Content,
|
||||
Trigger,
|
||||
//
|
||||
Root as Collapsible,
|
||||
Content as CollapsibleContent,
|
||||
Trigger as CollapsibleTrigger
|
||||
};
|
||||
12
src/lib/components/ui/native-select/index.ts
Normal file
12
src/lib/components/ui/native-select/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import OptGroup from './native-select-opt-group.svelte';
|
||||
import Option from './native-select-option.svelte';
|
||||
import Root from './native-select.svelte';
|
||||
|
||||
export {
|
||||
Root,
|
||||
Option,
|
||||
OptGroup,
|
||||
Root as NativeSelect,
|
||||
Option as NativeSelectOption,
|
||||
OptGroup as NativeSelectOptGroup
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
<script lang="ts">
|
||||
import type { WithElementRef } from '$lib/utils.js';
|
||||
import type { HTMLOptgroupAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLOptgroupAttributes> = $props();
|
||||
</script>
|
||||
|
||||
<optgroup bind:this={ref} data-slot="native-select-opt-group" {...restProps}>
|
||||
{@render children?.()}
|
||||
</optgroup>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
import type { HTMLOptionAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLOptionAttributes> = $props();
|
||||
</script>
|
||||
|
||||
<option
|
||||
bind:this={ref}
|
||||
data-slot="native-select-option"
|
||||
class={cn('bg-[Canvas] text-[CanvasText]', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</option>
|
||||
40
src/lib/components/ui/native-select/native-select.svelte
Normal file
40
src/lib/components/ui/native-select/native-select.svelte
Normal file
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
import type { HTMLSelectAttributes } from 'svelte/elements';
|
||||
|
||||
type NativeSelectProps = Omit<WithElementRef<HTMLSelectAttributes>, 'size'> & {
|
||||
size?: 'sm' | 'default';
|
||||
};
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
value = $bindable(),
|
||||
class: className,
|
||||
size = 'default',
|
||||
children,
|
||||
...restProps
|
||||
}: NativeSelectProps = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={cn('group/native-select relative w-fit has-[select:disabled]:opacity-50', className)}
|
||||
data-slot="native-select-wrapper"
|
||||
data-size={size}
|
||||
>
|
||||
<select
|
||||
bind:value
|
||||
bind:this={ref}
|
||||
data-slot="native-select"
|
||||
data-size={size}
|
||||
class="border-input placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 dark:hover:bg-input/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 h-9 w-full min-w-0 appearance-none rounded-md border bg-transparent py-1 pr-8 pl-2.5 text-sm shadow-xs transition-[color,box-shadow] select-none focus-visible:ring-3 aria-invalid:ring-3 data-[size=sm]:h-8 outline-none disabled:pointer-events-none disabled:cursor-not-allowed"
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</select>
|
||||
<ChevronDownIcon
|
||||
class="text-muted-foreground top-1/2 right-2.5 size-4 -translate-y-1/2 pointer-events-none absolute select-none"
|
||||
aria-hidden
|
||||
data-slot="native-select-icon"
|
||||
/>
|
||||
</div>
|
||||
7
src/lib/components/ui/slider/index.ts
Normal file
7
src/lib/components/ui/slider/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import Root from './slider.svelte';
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Slider
|
||||
};
|
||||
50
src/lib/components/ui/slider/slider.svelte
Normal file
50
src/lib/components/ui/slider/slider.svelte
Normal file
@@ -0,0 +1,50 @@
|
||||
<script lang="ts">
|
||||
import { Slider as SliderPrimitive } from 'bits-ui';
|
||||
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
value = $bindable(),
|
||||
orientation = 'horizontal',
|
||||
class: className,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<SliderPrimitive.RootProps> = $props();
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Discriminated Unions + Destructing (required for bindable) do not
|
||||
get along, so we shut typescript up by casting `value` to `never`.
|
||||
-->
|
||||
<SliderPrimitive.Root
|
||||
bind:ref
|
||||
bind:value={value as never}
|
||||
data-slot="slider"
|
||||
{orientation}
|
||||
class={cn(
|
||||
'data-vertical:min-h-40 relative flex w-full touch-none items-center select-none data-disabled:opacity-50 data-vertical:h-full data-vertical:w-auto data-vertical:flex-col',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{#snippet children({ thumbItems })}
|
||||
<span
|
||||
data-slot="slider-track"
|
||||
data-orientation={orientation}
|
||||
class={cn(
|
||||
'bg-muted rounded-full data-horizontal:h-1.5 data-horizontal:w-full data-vertical:h-full data-vertical:w-1.5 relative grow overflow-hidden data-horizontal:w-full data-vertical:h-full'
|
||||
)}
|
||||
>
|
||||
<SliderPrimitive.Range
|
||||
data-slot="slider-range"
|
||||
class={cn('bg-primary absolute select-none data-horizontal:h-full data-vertical:w-full')}
|
||||
/>
|
||||
</span>
|
||||
{#each thumbItems as thumb (thumb.index)}
|
||||
<SliderPrimitive.Thumb
|
||||
data-slot="slider-thumb"
|
||||
index={thumb.index}
|
||||
class="border-primary ring-ring/50 size-4 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden block shrink-0 select-none disabled:pointer-events-none disabled:opacity-50"
|
||||
/>
|
||||
{/each}
|
||||
{/snippet}
|
||||
</SliderPrimitive.Root>
|
||||
183
src/lib/game-tracker/CatchEditor.svelte
Normal file
183
src/lib/game-tracker/CatchEditor.svelte
Normal file
@@ -0,0 +1,183 @@
|
||||
<script lang="ts">
|
||||
import { pokemonImage } from '$lib/game-tracker/game-art';
|
||||
import { enhance } from '$app/forms';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
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 { NativeSelect } from '$lib/components/ui/native-select/index.js';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
|
||||
import { stages, obtainedMethods, type Stage } from './journal';
|
||||
import type { PokemonCatch } from '$lib/server/db/schema/tracker-journal';
|
||||
let {
|
||||
open = $bindable(false),
|
||||
species,
|
||||
gameId,
|
||||
record = null,
|
||||
stage = 'story',
|
||||
location = '',
|
||||
signedIn,
|
||||
ondelete,
|
||||
ondetails
|
||||
}: {
|
||||
open?: boolean;
|
||||
species: { id: number; number: number; name: string };
|
||||
gameId: number;
|
||||
record?: PokemonCatch | null;
|
||||
stage?: Stage;
|
||||
location?: string;
|
||||
signedIn: boolean;
|
||||
ondelete?: () => void;
|
||||
ondetails?: () => void;
|
||||
} = $props();
|
||||
let message = $state('');
|
||||
let saving = $state(false);
|
||||
</script>
|
||||
|
||||
<Dialog.Root bind:open>
|
||||
<Dialog.Content class="max-h-[90dvh] overflow-y-auto sm:max-w-2xl">
|
||||
<Dialog.Header
|
||||
><Dialog.Title>{record ? 'Meet' : 'Register'} {record?.nickname || species.name}</Dialog.Title
|
||||
><Dialog.Description
|
||||
>#{species.number} · {species.name}. Keep this Pokémon’s story, moves and party position
|
||||
together.</Dialog.Description
|
||||
></Dialog.Header
|
||||
>
|
||||
{#if ondetails}<Button variant="link" class="justify-self-start px-0" onclick={ondetails}
|
||||
>All catches, moves and species research →</Button
|
||||
>{/if}
|
||||
{#if message}<p role="alert" class="text-destructive">{message}</p>{/if}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/saveCatch"
|
||||
class="space-y-5"
|
||||
use:enhance={() => {
|
||||
saving = true;
|
||||
message = '';
|
||||
return async ({ result, update }) => {
|
||||
await update({ reset: false });
|
||||
saving = false;
|
||||
if (result.type === 'success') open = false;
|
||||
else
|
||||
message =
|
||||
result.type === 'failure'
|
||||
? String(result.data?.message ?? 'Could not save.')
|
||||
: 'Could not save. Try again.';
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="gameId" value={gameId} /><input
|
||||
type="hidden"
|
||||
name="pokemonId"
|
||||
value={species.id}
|
||||
/>{#if record}<input type="hidden" name="id" value={record.id} />{/if}
|
||||
<div class="flex items-center gap-4 rounded-xl bg-muted/40 p-3">
|
||||
<img
|
||||
src={pokemonImage(species.number, record?.shiny ? 'shiny' : 'sprite')}
|
||||
alt={species.name}
|
||||
width="80"
|
||||
height="80"
|
||||
/>
|
||||
<div class="grid flex-1 grid-cols-[1fr_5rem] gap-3">
|
||||
<label
|
||||
>Nickname<Input
|
||||
name="nickname"
|
||||
value={record?.nickname ?? ''}
|
||||
maxlength={50}
|
||||
placeholder={species.name}
|
||||
/></label
|
||||
><label
|
||||
>Level<Input
|
||||
type="number"
|
||||
name="level"
|
||||
min={1}
|
||||
max={100}
|
||||
required
|
||||
value={record?.level ?? 5}
|
||||
/></label
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<label
|
||||
>Party position<NativeSelect
|
||||
name="partySlot"
|
||||
value={record?.partySlot ?? ''}
|
||||
class="mt-1 w-full"
|
||||
><option value="">In the PC</option>{#each [1, 2, 3, 4, 5, 6] as slot (slot)}<option
|
||||
value={slot}>Team slot {slot}</option
|
||||
>{/each}</NativeSelect
|
||||
><span class="text-xs text-muted-foreground"
|
||||
>An occupied slot moves its current Pokémon to the PC.</span
|
||||
></label
|
||||
><label
|
||||
>Obtained by<NativeSelect
|
||||
name="obtained"
|
||||
value={record?.obtained ?? 'caught'}
|
||||
class="mt-1 w-full"
|
||||
>{#each obtainedMethods as method (method)}<option value={method}>{method}</option
|
||||
>{/each}</NativeSelect
|
||||
></label
|
||||
>
|
||||
</div>
|
||||
<fieldset>
|
||||
<legend class="mb-2 font-medium">Current moves</legend>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
{#each [0, 1, 2, 3] as slot (slot)}<Input
|
||||
name="moves"
|
||||
aria-label={`Move ${slot + 1}`}
|
||||
placeholder={`Move ${slot + 1}`}
|
||||
value={record?.moves[slot] ?? ''}
|
||||
maxlength={60}
|
||||
/>{/each}
|
||||
</div>
|
||||
</fieldset>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<label>Nature<Input name="nature" value={record?.nature ?? ''} maxlength={150} /></label
|
||||
><label>Ability<Input name="ability" value={record?.ability ?? ''} maxlength={150} /></label
|
||||
><label>Poké Ball<Input name="ball" value={record?.ball ?? ''} maxlength={150} /></label
|
||||
><label
|
||||
>Caught on<Input
|
||||
type="date"
|
||||
name="caughtOn"
|
||||
value={record?.caughtOn ?? new Date().toISOString().slice(0, 10)}
|
||||
/></label
|
||||
><label
|
||||
>Location<Input
|
||||
name="location"
|
||||
value={record?.location ?? location}
|
||||
maxlength={150}
|
||||
/></label
|
||||
><label
|
||||
>Collection stage<NativeSelect name="stage" value={record?.stage ?? stage} class="w-full"
|
||||
>{#each stages as s (s.value)}<option value={s.value}>{s.label}</option
|
||||
>{/each}</NativeSelect
|
||||
></label
|
||||
>
|
||||
</div>
|
||||
<label class="flex items-center gap-2"
|
||||
><Checkbox name="shiny" value="on" checked={record?.shiny ?? false} />Shiny Pokémon</label
|
||||
>
|
||||
<label class="block"
|
||||
>Notes<Textarea
|
||||
name="notes"
|
||||
value={record?.notes ?? ''}
|
||||
maxlength={2000}
|
||||
placeholder="A lucky encounter, a trade partner, a plan for evolution…"
|
||||
/></label
|
||||
>
|
||||
<Dialog.Footer
|
||||
><Button variant="outline" onclick={() => (open = false)}>Cancel</Button><Button
|
||||
type="submit"
|
||||
disabled={!signedIn || saving}
|
||||
>{saving ? 'Saving…' : record ? 'Save Pokémon' : 'Add to my collection'}</Button
|
||||
></Dialog.Footer
|
||||
>
|
||||
</form>
|
||||
{#if record && ondelete}<Button
|
||||
variant="ghost"
|
||||
class="justify-self-start text-destructive"
|
||||
onclick={ondelete}>Remove this catch record</Button
|
||||
>{/if}
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
91
src/lib/game-tracker/EncounterGrid.svelte
Normal file
91
src/lib/game-tracker/EncounterGrid.svelte
Normal file
@@ -0,0 +1,91 @@
|
||||
<script lang="ts">
|
||||
import { pokemonImage } from './game-art';
|
||||
let {
|
||||
groups,
|
||||
caughtNumbers,
|
||||
onselect
|
||||
}: {
|
||||
groups: [
|
||||
number,
|
||||
{
|
||||
name: string;
|
||||
type1: string;
|
||||
type2: string | null;
|
||||
minLevel: number | null;
|
||||
maxLevel: number | null;
|
||||
}[]
|
||||
][];
|
||||
caughtNumbers: Set<number>;
|
||||
onselect: (number: number) => void;
|
||||
} = $props();
|
||||
let width = $state(600);
|
||||
let height = $state(400);
|
||||
// Fit every species, including broad locations with hundreds of records, into one overview.
|
||||
const columns = $derived(
|
||||
Math.max(
|
||||
1,
|
||||
Math.min(
|
||||
groups.length,
|
||||
Math.ceil(Math.sqrt(((groups.length * width) / Math.max(1, height)) * 0.7))
|
||||
)
|
||||
)
|
||||
);
|
||||
const rows = $derived(Math.max(1, Math.ceil(groups.length / columns)));
|
||||
const cellWidth = $derived(width / columns);
|
||||
const cellHeight = $derived(height / rows);
|
||||
const showName = $derived(cellWidth >= 58 && cellHeight >= 42);
|
||||
const showLevel = $derived(cellWidth >= 65 && cellHeight >= 64);
|
||||
const spriteSize = $derived(
|
||||
Math.max(
|
||||
12,
|
||||
Math.min(48, cellWidth - 8, cellHeight - (showName ? 14 : 0) - (showLevel ? 12 : 0) - 8)
|
||||
)
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="h-full min-h-0 w-full" bind:clientWidth={width} bind:clientHeight={height}>
|
||||
<div
|
||||
class="encounter-grid grid gap-1"
|
||||
role="group"
|
||||
aria-label="All Pokémon in this area"
|
||||
style:grid-template-columns={`repeat(${columns}, minmax(0,1fr))`}
|
||||
style:grid-template-rows={`repeat(${rows}, minmax(0,1fr))`}
|
||||
style:height={`min(100%, ${rows * 108}px)`}
|
||||
>
|
||||
{#each groups as [number, encounters] (number)}
|
||||
{@const p = encounters[0]}
|
||||
{@const levels = encounters
|
||||
.flatMap((e) => [e.minLevel, e.maxLevel])
|
||||
.filter((n): n is number => n !== null)}
|
||||
{@const min = levels.length ? Math.min(...levels) : null}
|
||||
{@const max = levels.length ? Math.max(...levels) : null}
|
||||
<button
|
||||
class="relative flex min-h-0 min-w-0 flex-col items-center justify-center overflow-hidden rounded-lg border bg-background p-0.5 hover:bg-muted focus-visible:z-10 focus-visible:outline-2"
|
||||
class:border-emerald-500={caughtNumbers.has(number)}
|
||||
aria-label={`${p.name} ${p.type1}${p.type2 ? ` / ${p.type2}` : ''} encounters${caughtNumbers.has(number) ? ', caught' : ''}`}
|
||||
title={`${p.name} · Lv. ${min ?? '?'}${max !== min ? `–${max}` : ''}${caughtNumbers.has(number) ? ' · caught' : ''}`}
|
||||
onclick={() => onselect(number)}
|
||||
>
|
||||
{#if caughtNumbers.has(number)}<span
|
||||
class="absolute top-0 right-1 text-[10px] text-emerald-600"
|
||||
aria-hidden="true">✓</span
|
||||
>{/if}
|
||||
<img
|
||||
src={pokemonImage(number)}
|
||||
alt=""
|
||||
width="48"
|
||||
height="48"
|
||||
style:width={`${spriteSize}px`}
|
||||
style:height={`${spriteSize}px`}
|
||||
class="shrink-0 object-contain"
|
||||
/>
|
||||
{#if showName}<span class="block w-full truncate text-center text-[10px] font-medium"
|
||||
>{p.name}</span
|
||||
>{/if}
|
||||
{#if showLevel}<span class="text-[10px] text-muted-foreground"
|
||||
>Lv. {min ?? '?'}{max !== min ? `–${max}` : ''}</span
|
||||
>{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
24
src/lib/game-tracker/GameLogo.svelte
Normal file
24
src/lib/game-tracker/GameLogo.svelte
Normal file
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$lib/utils.js';
|
||||
import { base } from '$app/paths';
|
||||
let {
|
||||
slug,
|
||||
name,
|
||||
class: className = ''
|
||||
}: { slug: string; name: string; class?: string } = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
class={cn(
|
||||
'inline-flex h-16 w-28 shrink-0 items-center justify-center overflow-hidden rounded-md',
|
||||
slug === 'call-of-duty' ? 'bg-zinc-900 p-2' : '',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={`${base}/game-logos/${slug}.webp`}
|
||||
alt={`${name} artwork`}
|
||||
class={`max-h-full max-w-full object-contain ${slug === 'osu' ? 'rounded-full bg-[#ff66aa]' : ''}`}
|
||||
loading="lazy"
|
||||
/>
|
||||
</span>
|
||||
732
src/lib/game-tracker/PokemonJournal.svelte
Normal file
732
src/lib/game-tracker/PokemonJournal.svelte
Normal file
@@ -0,0 +1,732 @@
|
||||
<script lang="ts">
|
||||
import { pokemonImage } from '$lib/game-tracker/game-art';
|
||||
import { onMount } from 'svelte';
|
||||
let hydrated = $state(false);
|
||||
onMount(() => {
|
||||
hydrated = true;
|
||||
});
|
||||
import { untrack } from 'svelte';
|
||||
import { deserialize, enhance } from '$app/forms';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import * as Tabs from '$lib/components/ui/tabs/index.js';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { NativeSelect } from '$lib/components/ui/native-select/index.js';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
|
||||
import MapIcon from 'lucide-svelte/icons/map';
|
||||
import BookOpen from 'lucide-svelte/icons/book-open';
|
||||
import Archive from 'lucide-svelte/icons/archive';
|
||||
import Trophy from 'lucide-svelte/icons/trophy';
|
||||
import ProgressMeter from './ProgressMeter.svelte';
|
||||
import PokemonProgress from './PokemonProgress.svelte';
|
||||
import PokemonDetails from '$lib/catch-em-all/PokemonDetails.svelte';
|
||||
import PokemonMap from './PokemonMap.svelte';
|
||||
import CatchEditor from './CatchEditor.svelte';
|
||||
import { stages, firstCatches, type Stage } from './journal';
|
||||
import { gameRegions, initialLocation, milestonesFor } from './pokemon-world';
|
||||
import {
|
||||
availabilityLabels,
|
||||
availabilityValues,
|
||||
obtainableAvailability
|
||||
} from '$lib/availability';
|
||||
import type { PageData } from '../../routes/projects/game-tracker/pokemon/$types';
|
||||
import type { PokemonCatch } from '$lib/server/db/schema/tracker-journal';
|
||||
let { data }: { data: PageData } = $props();
|
||||
const game = $derived(data.activeGame!);
|
||||
const gameProgress = $derived(data.progress.games.find((g) => g.id === game.id));
|
||||
const seenIds = new SvelteSet(untrack(() => data.seenIds));
|
||||
$effect(() => {
|
||||
for (const id of data.seenIds) seenIds.add(id);
|
||||
});
|
||||
const seenNumbers = $derived(
|
||||
new Set(data.pokemon.filter((p) => seenIds.has(p.id)).map((p) => p.number))
|
||||
);
|
||||
const pendingSeen = new SvelteSet<number>();
|
||||
async function setSeen(number: number, seen: boolean) {
|
||||
const species = speciesByNumber.get(number);
|
||||
if (!species || pendingSeen.has(number)) return;
|
||||
pendingSeen.add(number);
|
||||
message = '';
|
||||
const body = new FormData();
|
||||
body.set('gameId', String(game.id));
|
||||
body.set('pokemonId', String(species.id));
|
||||
body.set('seen', seen ? 'on' : 'off');
|
||||
try {
|
||||
const result = deserialize(await (await fetch('?/setSeen', { method: 'POST', body })).text());
|
||||
if (result.type !== 'success')
|
||||
throw new Error(
|
||||
result.type === 'failure'
|
||||
? String(result.data?.message ?? 'Could not save sighting.')
|
||||
: 'Could not save sighting.'
|
||||
);
|
||||
if (seen) seenIds.add(species.id);
|
||||
else seenIds.delete(species.id);
|
||||
pendingSeen.delete(number);
|
||||
void invalidateAll().catch(() => {
|
||||
message = 'Sighting saved. Reload to refresh the progress totals.';
|
||||
});
|
||||
} catch (error) {
|
||||
message = error instanceof Error ? error.message : 'Could not save sighting.';
|
||||
} finally {
|
||||
pendingSeen.delete(number);
|
||||
}
|
||||
}
|
||||
const regions = $derived(gameRegions(game.slug ?? '', data.regions));
|
||||
let stage = $state<Stage>(untrack(() => data.journey?.stage ?? 'story'));
|
||||
let location = $state(
|
||||
untrack(
|
||||
() =>
|
||||
data.journey?.location ??
|
||||
initialLocation(
|
||||
data.activeGame?.slug ?? '',
|
||||
gameRegions(data.activeGame?.slug ?? '', data.regions)[0] ?? ''
|
||||
)
|
||||
)
|
||||
);
|
||||
let tab = $state('adventure');
|
||||
let search = $state('');
|
||||
let dex = $state(untrack(() => data.dexNumbers[0]?.dex ?? 'national'));
|
||||
let status = $state('all');
|
||||
let pageNumber = $state(1);
|
||||
let pcPage = $state(1);
|
||||
let viewportWidth = $state(1280);
|
||||
const dexPageSize = $derived(viewportWidth < 640 ? 18 : viewportWidth < 1024 ? 36 : 60);
|
||||
const pcPageSize = $derived(viewportWidth < 640 ? 12 : 30);
|
||||
let message = $state('');
|
||||
let saving = $state(false);
|
||||
const pending = new SvelteSet<number>();
|
||||
const caught = $derived(new Set(data.caughtIds));
|
||||
const dexes = $derived([...new Map(data.dexNumbers.map((d) => [d.dex, d.dexName])).entries()]);
|
||||
const regionalNumbers = $derived(
|
||||
new Map(data.dexNumbers.filter((d) => d.dex === dex).map((d) => [d.pokemonId, d.number]))
|
||||
);
|
||||
const regionalOrder = $derived(
|
||||
new Map(
|
||||
data.pokemon.flatMap((p) => {
|
||||
const d = data.dexNumbers.find((d) => d.pokemonId === p.id);
|
||||
return d ? [[p.number, d.number] as const] : [];
|
||||
})
|
||||
)
|
||||
);
|
||||
const speciesByNumber = $derived(new Map(data.pokemon.map((p) => [p.number, p])));
|
||||
const first = $derived(firstCatches(data.catches.map((c) => c.catch)));
|
||||
const caughtNumbers = $derived(
|
||||
new Set(
|
||||
data.pokemon
|
||||
.filter((p) => p.entries.some(([id, g]) => g === game.id && caught.has(id)))
|
||||
.map((p) => p.number)
|
||||
)
|
||||
);
|
||||
const party = $derived(
|
||||
data.catches
|
||||
.filter((c) => c.catch.partySlot !== null)
|
||||
.sort((a, b) => a.catch.partySlot! - b.catch.partySlot!)
|
||||
);
|
||||
const inGame = $derived(data.pokemon.filter((p) => p.entries.some(([, g]) => g === game.id)));
|
||||
const owned = $derived(inGame.filter((p) => caughtNumbers.has(p.number)));
|
||||
const filtered = $derived(
|
||||
inGame
|
||||
.filter((p) => {
|
||||
if (dex !== 'national' && !regionalNumbers.has(p.id)) return false;
|
||||
if (
|
||||
(status === 'caught' && !caughtNumbers.has(p.number)) ||
|
||||
(status === 'missing' && caughtNumbers.has(p.number))
|
||||
)
|
||||
return false;
|
||||
return `${p.name} ${p.number} ${p.type1} ${p.type2 ?? ''}`
|
||||
.toLowerCase()
|
||||
.includes(search.toLowerCase().trim());
|
||||
})
|
||||
.sort((a, b) =>
|
||||
dex === 'national'
|
||||
? a.number - b.number
|
||||
: (regionalNumbers.get(a.id) ?? 0) - (regionalNumbers.get(b.id) ?? 0)
|
||||
)
|
||||
);
|
||||
const pageCount = $derived(Math.max(1, Math.ceil(filtered.length / dexPageSize)));
|
||||
const pcSpecies = $derived(
|
||||
owned.filter((p) =>
|
||||
`${p.name} ${p.number} ${first.get(p.id)?.nickname ?? ''}`
|
||||
.toLowerCase()
|
||||
.includes(search.toLowerCase().trim())
|
||||
)
|
||||
);
|
||||
const visiblePc = $derived(pcSpecies.slice((pcPage - 1) * pcPageSize, pcPage * pcPageSize));
|
||||
$effect(() => {
|
||||
if (pageNumber > pageCount) pageNumber = pageCount;
|
||||
});
|
||||
$effect(() => {
|
||||
if (pcPage > Math.max(1, Math.ceil(pcSpecies.length / pcPageSize))) pcPage = 1;
|
||||
});
|
||||
const milestones = $derived([
|
||||
...milestonesFor(game.slug ?? '', regions[0] ?? '').map((m) => ({
|
||||
...m,
|
||||
done: data.milestones.find((r) => r.key === m.key)?.done ?? false
|
||||
})),
|
||||
...data.milestones.filter((m) => m.key.startsWith('custom:'))
|
||||
]);
|
||||
const stageMilestones = $derived(milestones.filter((m) => m.stage === stage));
|
||||
let milestoneOpen = $state(false);
|
||||
let milestoneMessage = $state('');
|
||||
let detailsNumber = $state<number | null>(null);
|
||||
let catchNumber = $state<number | null>(null);
|
||||
let catchOpen = $state(false);
|
||||
let catchRecord = $state<PokemonCatch | null>(null);
|
||||
let deleteOpen = $state(false);
|
||||
const catchSpecies = $derived(catchNumber === null ? null : speciesByNumber.get(catchNumber));
|
||||
function editCatch(number: number, record: PokemonCatch | null = null) {
|
||||
catchNumber = number;
|
||||
catchRecord = record;
|
||||
catchOpen = true;
|
||||
}
|
||||
function openSpecies(number: number) {
|
||||
detailsNumber = number;
|
||||
}
|
||||
async function toggle(entryId: number, checked: boolean) {
|
||||
if (pending.has(entryId)) return;
|
||||
pending.add(entryId);
|
||||
message = '';
|
||||
const body = new FormData();
|
||||
body.set('entryId', String(entryId));
|
||||
if (checked) body.set('caught', 'on');
|
||||
try {
|
||||
const r = deserialize(
|
||||
await (await fetch('?/togglePokemonCaught', { method: 'POST', body })).text()
|
||||
);
|
||||
if (r.type !== 'success')
|
||||
throw new Error(
|
||||
r.type === 'failure' ? String(r.data?.message ?? 'Could not save.') : 'Could not save.'
|
||||
);
|
||||
await invalidateAll();
|
||||
} catch (e) {
|
||||
message = e instanceof Error ? e.message : 'Could not save.';
|
||||
} finally {
|
||||
pending.delete(entryId);
|
||||
}
|
||||
}
|
||||
async function removeCatch() {
|
||||
if (!catchRecord) return;
|
||||
saving = true;
|
||||
const body = new FormData();
|
||||
body.set('id', String(catchRecord.id));
|
||||
try {
|
||||
const result = deserialize(
|
||||
await (await fetch('?/deleteCatch', { method: 'POST', body })).text()
|
||||
);
|
||||
if (result.type !== 'success') throw new Error('Could not remove catch.');
|
||||
await invalidateAll();
|
||||
catchOpen = false;
|
||||
deleteOpen = false;
|
||||
} catch (e) {
|
||||
message = e instanceof Error ? e.message : 'Could not remove catch.';
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window bind:innerWidth={viewportWidth} />
|
||||
|
||||
<PokemonProgress progress={data.progress} gameId={game.id} />
|
||||
|
||||
{#if message}<p role="alert" class="mt-4 text-destructive">{message}</p>{/if}
|
||||
<div class="mt-2 grid items-center gap-2 lg:grid-cols-[minmax(280px,.8fr)_minmax(0,2fr)]">
|
||||
<div>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/saveJourney"
|
||||
class="flex flex-wrap items-end gap-2"
|
||||
use:enhance={() => {
|
||||
saving = true;
|
||||
message = '';
|
||||
return async ({ result, update }) => {
|
||||
await update({ reset: false });
|
||||
saving = false;
|
||||
message =
|
||||
result.type === 'success'
|
||||
? 'Adventure saved.'
|
||||
: result.type === 'failure'
|
||||
? String(result.data?.message ?? 'Could not save.')
|
||||
: 'Could not save.';
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="gameId" value={game.id} /><input
|
||||
type="hidden"
|
||||
name="location"
|
||||
value={location}
|
||||
/>
|
||||
<label class="min-w-40 max-w-xs flex-1 text-xs font-medium"
|
||||
>My playthrough<NativeSelect class="mt-1 w-full" name="stage" bind:value={stage}
|
||||
>{#each stages as s (s.value)}<option value={s.value}>{s.label}</option
|
||||
>{/each}</NativeSelect
|
||||
></label
|
||||
>
|
||||
<Button type="submit" size="sm" variant="outline" disabled={!data.user || saving}
|
||||
>Save my place</Button
|
||||
>
|
||||
</form>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="?/setCompleted"
|
||||
use:enhance
|
||||
class="mt-1 flex items-center gap-2 text-[10px] text-muted-foreground"
|
||||
>
|
||||
<input type="hidden" name="gameId" value={game.id} />
|
||||
<input type="hidden" name="completed" value={data.journey?.completed ? 'off' : 'on'} />
|
||||
<Button
|
||||
type="submit"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
class="h-5 text-[10px]"
|
||||
disabled={!data.user ||
|
||||
!hydrated ||
|
||||
(!!gameProgress?.story.total && gameProgress.story.value === gameProgress.story.total)}
|
||||
>{gameProgress?.completed ? '✓ Story complete' : 'Mark story complete'}</Button
|
||||
>
|
||||
{#if gameProgress}<span
|
||||
>{gameProgress.story.value} / {gameProgress.story.total} story milestones</span
|
||||
>{/if}
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<section class="rounded-xl border bg-card p-2" aria-label="Current team">
|
||||
<div class="mb-1 flex items-center justify-between px-1 text-xs">
|
||||
<h2 class="font-semibold">Current team</h2>
|
||||
<span class="text-xs text-muted-foreground">{party.length} / 6 in your team</span>
|
||||
</div>
|
||||
<div class={`grid grid-cols-6 gap-1 ${party.length ? '' : 'max-sm:hidden'}`}>
|
||||
{#each [1, 2, 3, 4, 5, 6] as slot (slot)}
|
||||
{@const member = party.find((p) => p.catch.partySlot === slot)}
|
||||
{#if member}<button
|
||||
class="flex min-w-0 items-center gap-1 rounded-lg border bg-muted/30 p-1 text-left transition hover:bg-muted max-sm:flex-col max-sm:text-center"
|
||||
onclick={() => editCatch(member.number, member.catch)}
|
||||
><img
|
||||
src={pokemonImage(member.number, member.catch.shiny ? 'shiny' : 'sprite')}
|
||||
alt={member.name}
|
||||
width="40"
|
||||
height="40"
|
||||
class="size-10 shrink-0"
|
||||
/><span class="min-w-0 max-w-full flex-1"
|
||||
><span class="block truncate text-[11px] font-semibold"
|
||||
>{member.catch.nickname || member.name}</span
|
||||
><span class="block text-[11px] text-muted-foreground"
|
||||
>Lv. {member.catch.level}{member.catch.shiny ? ' · ✦' : ''}</span
|
||||
></span
|
||||
></button
|
||||
>
|
||||
{:else}<button
|
||||
class="flex min-h-12 items-center justify-center gap-1 rounded-lg border border-dashed text-muted-foreground hover:bg-muted/30 max-sm:flex-col"
|
||||
onclick={() => (tab = 'pc')}
|
||||
aria-label={`Choose a Pokémon for team slot ${slot}`}
|
||||
><span class="text-xl">+</span><span class="mt-1 text-xs">Slot {slot}</span></button
|
||||
>{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<Tabs.Root bind:value={tab} class="mt-3">
|
||||
<Tabs.List
|
||||
class="sticky top-0 z-20 mb-3 flex h-auto w-full flex-wrap justify-start gap-1 border-b bg-background/95 p-1"
|
||||
><Tabs.Trigger
|
||||
disabled={!hydrated}
|
||||
value="adventure"
|
||||
class="px-3 py-2.5"
|
||||
aria-label="Explore the map"
|
||||
><MapIcon class="mr-1.5 size-4" aria-hidden="true" /><span class="sm:hidden">Map</span><span
|
||||
class="hidden sm:inline">Explore the map</span
|
||||
></Tabs.Trigger
|
||||
><Tabs.Trigger disabled={!hydrated} value="dex" class="px-3 py-2.5"
|
||||
><BookOpen class="mr-1.5 size-4" aria-hidden="true" />Pokédex</Tabs.Trigger
|
||||
><Tabs.Trigger disabled={!hydrated} value="pc" class="px-3 py-2.5"
|
||||
><Archive class="mr-1.5 size-4" aria-hidden="true" />My PC · {owned.length}</Tabs.Trigger
|
||||
><Tabs.Trigger
|
||||
disabled={!hydrated}
|
||||
value="milestones"
|
||||
class="px-3 py-2.5"
|
||||
aria-label="Badges & milestones"
|
||||
><Trophy class="mr-1.5 size-4" aria-hidden="true" /><span class="sm:hidden">Badges</span><span
|
||||
class="hidden sm:inline">Badges & milestones</span
|
||||
></Tabs.Trigger
|
||||
></Tabs.List
|
||||
>
|
||||
<Tabs.Content value="adventure">
|
||||
<PokemonMap
|
||||
{stage}
|
||||
{regionalOrder}
|
||||
gameId={game.id}
|
||||
{regions}
|
||||
locations={data.locations}
|
||||
bind:location
|
||||
{caughtNumbers}
|
||||
{seenNumbers}
|
||||
{pendingSeen}
|
||||
onseen={setSeen}
|
||||
onpokemon={openSpecies}
|
||||
oncatch={(number) => editCatch(number)}
|
||||
canRegister={!!data.user && hydrated}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="dex">
|
||||
{#if gameProgress}<div class="mb-3 grid grid-cols-2 gap-4">
|
||||
<ProgressMeter
|
||||
label={dex === 'national' ? 'Game collection' : 'Regional dex caught'}
|
||||
progress={dex === 'national' ? gameProgress.collection : gameProgress.regional}
|
||||
compact
|
||||
/><ProgressMeter
|
||||
label={dex === 'national' ? 'Seen in this game' : 'Regional dex seen'}
|
||||
progress={dex === 'national' ? gameProgress.seen : gameProgress.regionalSeen}
|
||||
color="sky"
|
||||
compact
|
||||
/>
|
||||
</div>{/if}
|
||||
<div class="mb-5 grid gap-3 sm:grid-cols-[1fr_1fr_10rem]">
|
||||
<Input
|
||||
aria-label="Search Pokémon"
|
||||
type="search"
|
||||
placeholder="Name, number or type…"
|
||||
bind:value={search}
|
||||
oninput={() => (pageNumber = 1)}
|
||||
/><NativeSelect
|
||||
aria-label="Pokédex order"
|
||||
class="w-full"
|
||||
bind:value={dex}
|
||||
onchange={() => (pageNumber = 1)}
|
||||
>{#each dexes as [key, name] (key)}<option value={key}>Regional · {name}</option
|
||||
>{/each}<option value="national">National Pokédex</option></NativeSelect
|
||||
><NativeSelect
|
||||
aria-label="Catch filter"
|
||||
class="w-full"
|
||||
bind:value={status}
|
||||
onchange={() => (pageNumber = 1)}
|
||||
><option value="all">All Pokémon</option><option value="missing">Still to catch</option
|
||||
><option value="caught">Caught</option></NativeSelect
|
||||
>
|
||||
</div>
|
||||
<p class="mb-4 text-sm text-muted-foreground">
|
||||
Showing {Math.min(dexPageSize, filtered.length - (pageNumber - 1) * dexPageSize)} of {filtered.length}
|
||||
Pokémon · {dex === 'national' ? 'National order' : 'Regional order'}{!dexes.length
|
||||
? ' · No regional Pokédex is recorded for this title.'
|
||||
: ''}
|
||||
</p>
|
||||
<div class="grid grid-cols-2 gap-2 sm:grid-cols-4 lg:grid-cols-6">
|
||||
{#each filtered.slice((pageNumber - 1) * dexPageSize, pageNumber * dexPageSize) as p (p.id)}
|
||||
{@const entry = p.entries.find(([, g]) => g === game.id)!}{@const code =
|
||||
availabilityValues[entry[2]]}{@const isCaught = caught.has(entry[0])}
|
||||
<article
|
||||
class={`overflow-hidden rounded-xl border bg-card ${isCaught ? 'border-emerald-600/40' : ''}`}
|
||||
>
|
||||
<button
|
||||
class="w-full p-2 text-left hover:bg-muted/40"
|
||||
onclick={() => openSpecies(p.number)}
|
||||
><div class="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>#{dex === 'national' ? p.number : regionalNumbers.get(p.id)}</span
|
||||
>{#if isCaught}<span class="text-emerald-600">✓ caught</span>{/if}
|
||||
</div>
|
||||
<img
|
||||
src={pokemonImage(p.number)}
|
||||
alt=""
|
||||
width="96"
|
||||
height="96"
|
||||
loading="lazy"
|
||||
class="mx-auto size-12"
|
||||
/>
|
||||
<p class="truncate text-sm font-semibold">{p.name}</p>
|
||||
<p class="mt-1 text-[11px] text-muted-foreground">
|
||||
{p.type1}{p.type2 ? ` / ${p.type2}` : ''}
|
||||
</p>
|
||||
<p class="mt-1 truncate text-[10px] text-muted-foreground" title={entry[3]}>
|
||||
{availabilityLabels[code]}
|
||||
</p></button
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2 border-t px-3 py-2">
|
||||
<label class="flex items-center gap-1 text-[10px]"
|
||||
><Checkbox
|
||||
aria-label={`Seen ${p.name} in ${game.name}`}
|
||||
checked={isCaught || seenIds.has(p.id)}
|
||||
disabled={!hydrated || !data.user || isCaught || pendingSeen.has(p.number)}
|
||||
onCheckedChange={(v) => setSeen(p.number, v)}
|
||||
/>seen</label
|
||||
>
|
||||
<label class="flex items-center gap-2 text-xs"
|
||||
><Checkbox
|
||||
aria-label={`Caught ${p.name} in ${game.name}`}
|
||||
checked={isCaught}
|
||||
disabled={!data.user ||
|
||||
pending.has(entry[0]) ||
|
||||
(!isCaught && !obtainableAvailability.has(code))}
|
||||
onCheckedChange={(v) => toggle(entry[0], v)}
|
||||
/>caught</label
|
||||
><Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
disabled={!hydrated || !data.user}
|
||||
onclick={() => editCatch(p.number)}>+ log</Button
|
||||
>
|
||||
</div>
|
||||
</article>
|
||||
{:else}<p class="col-span-full py-10 text-center text-muted-foreground">
|
||||
No Pokémon match these filters.
|
||||
</p>{/each}
|
||||
</div>
|
||||
<div class="mt-5 flex items-center justify-between">
|
||||
<Button variant="outline" disabled={pageNumber === 1} onclick={() => pageNumber--}
|
||||
>Previous</Button
|
||||
><span class="text-sm text-muted-foreground">{pageNumber} / {pageCount}</span><Button
|
||||
variant="outline"
|
||||
disabled={pageNumber === pageCount}
|
||||
onclick={() => pageNumber++}>Next</Button
|
||||
>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="pc">
|
||||
<div class="mb-5 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">Your living collection</h2>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
National order · first registered catch per species · {data.catches.length} individual records
|
||||
</p>
|
||||
</div>
|
||||
<Input
|
||||
class="max-w-xs"
|
||||
aria-label="Search your PC"
|
||||
placeholder="Species or nickname…"
|
||||
bind:value={search}
|
||||
oninput={() => (pcPage = 1)}
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-5">
|
||||
{#if gameProgress}<ProgressMeter
|
||||
label="PC collection"
|
||||
progress={gameProgress.collection}
|
||||
/>{/if}
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2 sm:grid-cols-6">
|
||||
{#each visiblePc as p (p.id)}{@const record = first.get(p.id)}{@const copies =
|
||||
data.catches.filter((c) => c.catch.pokemonId === p.id).length}
|
||||
<button
|
||||
class="relative rounded-xl border bg-card p-2 text-center hover:bg-muted/60"
|
||||
onclick={() => (record ? editCatch(p.number, record) : openSpecies(p.number))}
|
||||
><span class="absolute top-2 left-2 text-[10px] text-muted-foreground">#{p.number}</span
|
||||
>{#if copies > 1}<span class="absolute top-2 right-2 text-[10px]">×{copies}</span
|
||||
>{/if}<img
|
||||
src={pokemonImage(p.number, record?.shiny ? 'shiny' : 'sprite')}
|
||||
alt={p.name}
|
||||
width="56"
|
||||
height="56"
|
||||
loading="lazy"
|
||||
class="mx-auto"
|
||||
/><span class="block truncate text-xs font-semibold">{record?.nickname || p.name}</span
|
||||
><span class="text-[10px] text-muted-foreground"
|
||||
>{record
|
||||
? `Lv. ${record.level}${record.partySlot ? ' · team' : ''}`
|
||||
: 'Add catch details'}</span
|
||||
></button
|
||||
>
|
||||
{:else}<div class="col-span-full rounded-xl border border-dashed px-5 py-12 text-center">
|
||||
<h3 class="font-semibold">A new adventure starts with one companion.</h3>
|
||||
<p class="mt-2 text-sm text-muted-foreground">
|
||||
Register your starter in the Pokédex, or explore a route to find your next catch.
|
||||
</p>
|
||||
<Button class="mt-4" variant="outline" onclick={() => (tab = 'dex')}
|
||||
>Find my starter</Button
|
||||
>
|
||||
</div>{/each}
|
||||
</div>
|
||||
{#if pcSpecies.length > pcPageSize}<div class="mt-5 flex justify-between">
|
||||
<Button variant="outline" disabled={pcPage === 1} onclick={() => pcPage--}
|
||||
>Previous box</Button
|
||||
><span>Box {pcPage} / {Math.ceil(pcSpecies.length / pcPageSize)}</span><Button
|
||||
variant="outline"
|
||||
disabled={pcPage * pcPageSize >= pcSpecies.length}
|
||||
onclick={() => pcPage++}>Next box</Button
|
||||
>
|
||||
</div>{/if}
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="milestones">
|
||||
{#if gameProgress}<div class="mb-4 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<ProgressMeter
|
||||
label="Story milestones"
|
||||
progress={gameProgress.story}
|
||||
color="amber"
|
||||
/><ProgressMeter
|
||||
label="Badges & trials"
|
||||
progress={gameProgress.badges}
|
||||
color="amber"
|
||||
/><ProgressMeter
|
||||
label="Postgame"
|
||||
progress={gameProgress.postgame}
|
||||
color="violet"
|
||||
/><ProgressMeter label="Transfers & events" progress={gameProgress.transfers} color="sky" />
|
||||
</div>{/if}
|
||||
<div class="mb-5 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold">Milestones along the way</h2>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
{stageMilestones.filter((m) => m.done).length} / {stageMilestones.length} · {stages.find(
|
||||
(s) => s.value === stage
|
||||
)?.label}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!hydrated || !data.user}
|
||||
onclick={() => (milestoneOpen = true)}>+ Custom milestone</Button
|
||||
>
|
||||
</div>
|
||||
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each stageMilestones as m (m.key)}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/saveMilestone"
|
||||
use:enhance
|
||||
class="flex items-center gap-3 rounded-xl border bg-card p-4"
|
||||
>
|
||||
<input type="hidden" name="gameId" value={game.id} /><input
|
||||
type="hidden"
|
||||
name="key"
|
||||
value={m.key}
|
||||
/><input type="hidden" name="title" value={m.title} /><input
|
||||
type="hidden"
|
||||
name="category"
|
||||
value={m.category}
|
||||
/><input type="hidden" name="stage" value={m.stage} /><input
|
||||
type="hidden"
|
||||
name="done"
|
||||
value={m.done ? '' : 'on'}
|
||||
/><Button
|
||||
type="submit"
|
||||
variant={m.done ? 'secondary' : 'outline'}
|
||||
size="icon"
|
||||
disabled={!hydrated || !data.user}
|
||||
aria-label={`${m.done ? 'Undo' : 'Complete'} ${m.title}`}
|
||||
>{m.done ? '✓' : m.category === 'badge' ? '◇' : '○'}</Button
|
||||
>
|
||||
<div>
|
||||
<p class="text-sm font-semibold">{m.title}</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">{m.category}</p>
|
||||
</div>
|
||||
</form>
|
||||
{/each}
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
|
||||
<Dialog.Root
|
||||
open={detailsNumber !== null}
|
||||
onOpenChange={(v) => {
|
||||
if (!v) detailsNumber = null;
|
||||
}}
|
||||
><Dialog.Content class="max-h-[90dvh] overflow-y-auto sm:max-w-3xl"
|
||||
><Dialog.Header
|
||||
><Dialog.Title>Pokédex field notes</Dialog.Title><Dialog.Description
|
||||
>Encounters, evolution, stats and your own catches in {game.name}.</Dialog.Description
|
||||
></Dialog.Header
|
||||
>
|
||||
{#if detailsNumber !== null}
|
||||
{@const p = speciesByNumber.get(detailsNumber)}
|
||||
<div class="rounded-xl border p-4">
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<h3 class="font-semibold">My {p?.name} collection</h3>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!hydrated || !data.user}
|
||||
onclick={() => editCatch(detailsNumber!)}>Register a catch</Button
|
||||
>
|
||||
</div>
|
||||
<div class="mt-3 space-y-2">
|
||||
{#each data.catches.filter((c) => c.number === detailsNumber) as c (c.catch.id)}<button
|
||||
class="flex w-full items-center justify-between rounded-lg bg-muted/40 p-3 text-left"
|
||||
onclick={() => editCatch(c.number, c.catch)}
|
||||
><span
|
||||
><span class="font-semibold">{c.catch.nickname || c.name}</span><span
|
||||
class="mt-1 block text-xs text-muted-foreground"
|
||||
>{c.catch.moves.join(' · ') || 'No moves recorded'}</span
|
||||
></span
|
||||
><span class="text-xs"
|
||||
>Lv. {c.catch.level}{c.catch.partySlot ? ` · slot ${c.catch.partySlot}` : ''}</span
|
||||
></button
|
||||
>{:else}<p class="text-sm text-muted-foreground">
|
||||
No individual catches registered yet.
|
||||
</p>{/each}
|
||||
</div>
|
||||
</div>
|
||||
<PokemonDetails
|
||||
number={detailsNumber}
|
||||
gameId={game.id}
|
||||
isAdmin={data.isAdmin}
|
||||
onsaved={() => invalidateAll()}
|
||||
/>
|
||||
{/if}
|
||||
</Dialog.Content></Dialog.Root
|
||||
>
|
||||
{#if catchSpecies}{#key `${catchSpecies.id}-${catchRecord?.id ?? 'new'}`}<CatchEditor
|
||||
bind:open={catchOpen}
|
||||
species={catchSpecies}
|
||||
gameId={game.id}
|
||||
record={catchRecord}
|
||||
{stage}
|
||||
location={data.locations.find((p) => p.slug === location)?.name ?? location}
|
||||
signedIn={!!data.user}
|
||||
ondelete={() => (deleteOpen = true)}
|
||||
ondetails={() => {
|
||||
catchOpen = false;
|
||||
detailsNumber = catchNumber;
|
||||
}}
|
||||
/>{/key}{/if}
|
||||
<AlertDialog.Root bind:open={deleteOpen}
|
||||
><AlertDialog.Content
|
||||
><AlertDialog.Header
|
||||
><AlertDialog.Title>Remove this catch record?</AlertDialog.Title><AlertDialog.Description
|
||||
>This removes its nickname, moves and party slot. Other catches and the historical caught
|
||||
checkmark remain saved.</AlertDialog.Description
|
||||
></AlertDialog.Header
|
||||
><AlertDialog.Footer
|
||||
><AlertDialog.Cancel>Keep Pokémon</AlertDialog.Cancel><Button
|
||||
variant="destructive"
|
||||
onclick={removeCatch}
|
||||
disabled={saving}>Remove record</Button
|
||||
></AlertDialog.Footer
|
||||
></AlertDialog.Content
|
||||
></AlertDialog.Root
|
||||
>
|
||||
<Dialog.Root bind:open={milestoneOpen}
|
||||
><Dialog.Content
|
||||
><Dialog.Header
|
||||
><Dialog.Title>Set a new milestone</Dialog.Title><Dialog.Description
|
||||
>A battle, a ribbon, a rare encounter, or your own challenge.</Dialog.Description
|
||||
></Dialog.Header
|
||||
>
|
||||
{#if milestoneMessage}<p role="alert" class="text-destructive">{milestoneMessage}</p>{/if}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/saveMilestone"
|
||||
class="space-y-4"
|
||||
use:enhance={() =>
|
||||
async ({ result, update }) => {
|
||||
await update();
|
||||
if (result.type === 'success') {
|
||||
milestoneOpen = false;
|
||||
milestoneMessage = '';
|
||||
} else
|
||||
milestoneMessage =
|
||||
result.type === 'failure'
|
||||
? String(result.data?.message ?? 'Could not save milestone.')
|
||||
: 'Could not save milestone.';
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="gameId" value={game.id} /><input
|
||||
type="hidden"
|
||||
name="stage"
|
||||
value={stage}
|
||||
/><input type="hidden" name="category" value="event" /><label class="block"
|
||||
>Milestone<Input name="title" required maxlength={200} /></label
|
||||
><Button type="submit">Add milestone</Button>
|
||||
</form></Dialog.Content
|
||||
></Dialog.Root
|
||||
>
|
||||
534
src/lib/game-tracker/PokemonMap.svelte
Normal file
534
src/lib/game-tracker/PokemonMap.svelte
Normal file
@@ -0,0 +1,534 @@
|
||||
<script lang="ts">
|
||||
import { pokemonImage } from '$lib/game-tracker/game-art';
|
||||
import { untrack } from 'svelte';
|
||||
import { base } from '$app/paths';
|
||||
import { Slider } from '$lib/components/ui/slider/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { NativeSelect } from '$lib/components/ui/native-select/index.js';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import * as Tabs from '$lib/components/ui/tabs/index.js';
|
||||
import EncounterGrid from './EncounterGrid.svelte';
|
||||
import ProgressMeter from './ProgressMeter.svelte';
|
||||
import { fitViewport } from './fit-viewport';
|
||||
import { type AtlasNode } from './pokemon-world';
|
||||
import mapArtwork from '../../../data/pokemon-map-art.json';
|
||||
const regionMaps: Record<string, (typeof mapArtwork)['sinnoh']> = mapArtwork;
|
||||
type Place = { id: number; slug: string; name: string; region: string | null; count: number };
|
||||
type Encounter = {
|
||||
pokemonId: number;
|
||||
number: number;
|
||||
name: string;
|
||||
type1: string;
|
||||
type2: string | null;
|
||||
area: string | null;
|
||||
method: string;
|
||||
minLevel: number | null;
|
||||
maxLevel: number | null;
|
||||
rarity: number | null;
|
||||
conditions: string[];
|
||||
form: string | null;
|
||||
note: string | null;
|
||||
};
|
||||
let {
|
||||
gameId,
|
||||
regions,
|
||||
locations,
|
||||
location = $bindable(''),
|
||||
caughtNumbers,
|
||||
seenNumbers,
|
||||
pendingSeen,
|
||||
onseen,
|
||||
onpokemon,
|
||||
oncatch,
|
||||
canRegister,
|
||||
stage,
|
||||
regionalOrder
|
||||
}: {
|
||||
stage: string;
|
||||
regionalOrder: Map<number, number>;
|
||||
gameId: number;
|
||||
regions: string[];
|
||||
locations: Place[];
|
||||
location?: string;
|
||||
caughtNumbers: Set<number>;
|
||||
seenNumbers: Set<number>;
|
||||
pendingSeen: Set<number>;
|
||||
onseen: (number: number, seen: boolean) => void;
|
||||
onpokemon: (number: number) => void;
|
||||
oncatch: (number: number) => void;
|
||||
canRegister: boolean;
|
||||
} = $props();
|
||||
let region = $state(
|
||||
untrack(() => {
|
||||
const saved = locations.find((p) => p.slug === location)?.region?.toLowerCase();
|
||||
return saved && regions.includes(saved) ? saved : (regions[0] ?? '');
|
||||
})
|
||||
);
|
||||
let zoom = $state(1);
|
||||
let filter = $state('');
|
||||
let method = $state('all');
|
||||
let area = $state('all');
|
||||
let mobileView = $state('encounters');
|
||||
let overviewOpen = $state(false);
|
||||
let encounterNumber = $state<number | null>(null);
|
||||
const detailRows = $derived(groups.find(([n]) => n === encounterNumber)?.[1] ?? []);
|
||||
const areas = $derived([...new Set(encounters.flatMap((e) => (e.area ? [e.area] : [])))]);
|
||||
let encounters = $state<Encounter[]>([]);
|
||||
let loading = $state(true);
|
||||
let loadError = $state('');
|
||||
let retry = $state(0);
|
||||
let includeSpecial = $derived(stage !== 'story');
|
||||
let viewport: HTMLDivElement;
|
||||
let viewportWidth = $state(640);
|
||||
let viewportHeight = $state(560);
|
||||
let showNames = $state(false);
|
||||
const mapWidth = $derived(
|
||||
map ? Math.min(viewportWidth, (viewportHeight * map.width) / map.height) * zoom : viewportWidth
|
||||
);
|
||||
let dragging: { x: number; y: number; left: number; top: number } | null = null;
|
||||
const map = $derived(regionMaps[region]);
|
||||
const places = $derived(
|
||||
locations
|
||||
.filter((p) => p.region?.toLowerCase() === region || (regions.length === 1 && p.count > 0))
|
||||
.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }))
|
||||
);
|
||||
const selected = $derived(
|
||||
locations.find(
|
||||
(p) => p.slug === location && (p.region?.toLowerCase() === region || p.count > 0)
|
||||
)
|
||||
);
|
||||
const selectedLocationId = $derived(selected?.id);
|
||||
const node = $derived(map?.nodes.find((n) => n.slug === location));
|
||||
const methods = $derived([...new Set(encounters.map((e) => e.method))]);
|
||||
const special = (e: Encounter) =>
|
||||
e.conditions.some((c) => /pok.radar|swarm|slot 2|national|radio/i.test(c));
|
||||
const groups = $derived(
|
||||
[
|
||||
...Map.groupBy(
|
||||
encounters.filter(
|
||||
(e) =>
|
||||
(area === 'all' || e.area === area) &&
|
||||
(method === 'all' || e.method === method) &&
|
||||
(includeSpecial || !special(e))
|
||||
),
|
||||
(e) => e.number
|
||||
)
|
||||
].sort(([a], [b]) => (regionalOrder.get(a) ?? a + 10000) - (regionalOrder.get(b) ?? b + 10000))
|
||||
);
|
||||
const hiddenSpecial = $derived(encounters.filter(special).length);
|
||||
const placeMap = $derived(new Map(places.map((p) => [p.slug, p])));
|
||||
const choices = $derived(
|
||||
[
|
||||
...new Map(
|
||||
[
|
||||
...places.map((p) => ({ slug: p.slug, name: p.name, count: p.count })),
|
||||
...(map?.nodes ?? [])
|
||||
.filter((n) => !placeMap.has(n.slug))
|
||||
.map((n) => ({ slug: n.slug, name: n.name, count: 0 }))
|
||||
].map((p) => [p.slug, p])
|
||||
).values()
|
||||
].filter((p) => p.name.toLowerCase().includes(filter.toLowerCase()))
|
||||
);
|
||||
$effect(() => {
|
||||
if (!regions.includes(region)) region = regions[0] ?? '';
|
||||
});
|
||||
$effect(() => {
|
||||
const id = selectedLocationId;
|
||||
const game = gameId;
|
||||
void retry;
|
||||
encounters = [];
|
||||
method = 'all';
|
||||
area = 'all';
|
||||
encounterNumber = null;
|
||||
loadError = '';
|
||||
loading = !!id;
|
||||
if (!id) return;
|
||||
const controller = new AbortController();
|
||||
fetch(`${base}/projects/game-tracker/pokemon/api/encounters?game=${game}&location=${id}`, {
|
||||
signal: controller.signal
|
||||
})
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error('Could not load this location.');
|
||||
return r.json();
|
||||
})
|
||||
.then((rows: Encounter[]) => {
|
||||
if (!controller.signal.aborted) encounters = rows;
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!controller.signal.aborted) loadError = e.message;
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) loading = false;
|
||||
});
|
||||
return () => controller.abort();
|
||||
});
|
||||
function choose(point: AtlasNode) {
|
||||
location = point.slug;
|
||||
mobileView = 'encounters';
|
||||
}
|
||||
</script>
|
||||
|
||||
<Tabs.Root bind:value={mobileView} class="mb-2 lg:hidden"
|
||||
><Tabs.List class="w-full"
|
||||
><Tabs.Trigger value="encounters" class="flex-1">Area Pokémon</Tabs.Trigger><Tabs.Trigger
|
||||
value="map"
|
||||
class="flex-1">Region map</Tabs.Trigger
|
||||
></Tabs.List
|
||||
></Tabs.Root
|
||||
>
|
||||
<div class="grid gap-3 lg:grid-cols-[minmax(0,.85fr)_minmax(0,1.15fr)]">
|
||||
<section
|
||||
class={`min-w-0 overflow-hidden rounded-xl border bg-card ${mobileView === 'map' ? '' : 'max-lg:hidden'}`}
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 border-b px-3 py-2">
|
||||
<div>
|
||||
<p class="text-xs font-semibold tracking-[.18em] text-muted-foreground uppercase">
|
||||
Field atlas
|
||||
</p>
|
||||
<h2 class="text-xl font-semibold">{map?.name ?? 'Region map'}</h2>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if regions.length > 1}<NativeSelect
|
||||
aria-label="Map region"
|
||||
bind:value={region}
|
||||
onchange={() => {
|
||||
location = '';
|
||||
zoom = 1;
|
||||
}}
|
||||
>{#each regions as r (r)}<option value={r}>{regionMaps[r]?.name ?? r}</option
|
||||
>{/each}</NativeSelect
|
||||
>{/if}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label="Zoom out"
|
||||
disabled={zoom <= 1}
|
||||
onclick={() => (zoom = Math.max(1, zoom - 0.5))}>−</Button
|
||||
>
|
||||
<div class="hidden w-24 sm:block">
|
||||
<Slider
|
||||
type="single"
|
||||
min={1}
|
||||
max={4}
|
||||
step={0.5}
|
||||
bind:value={zoom}
|
||||
aria-label="Map zoom"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" onclick={() => (zoom = 1)} aria-label="Reset map zoom"
|
||||
>{Math.round(zoom * 100)}%</Button
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
aria-label="Zoom in"
|
||||
disabled={zoom >= 4}
|
||||
onclick={() => (zoom = Math.min(4, zoom + 0.5))}>+</Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Scrolling and the location picker offer keyboard alternatives to dragging. -->
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex (scrollable map viewport must be keyboard focusable) -->
|
||||
<div
|
||||
bind:this={viewport}
|
||||
bind:clientWidth={viewportWidth}
|
||||
bind:clientHeight={viewportHeight}
|
||||
class="atlas-viewport h-80 overflow-auto"
|
||||
use:fitViewport={{ minimum: 180, bottom: 64 }}
|
||||
role="region"
|
||||
aria-label={`${map?.name} interactive map`}
|
||||
tabindex="0"
|
||||
onpointerdown={(e) => {
|
||||
if (e.target instanceof Element && e.target.closest('button')) return;
|
||||
dragging = {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
left: viewport.scrollLeft,
|
||||
top: viewport.scrollTop
|
||||
};
|
||||
viewport.setPointerCapture(e.pointerId);
|
||||
}}
|
||||
onpointermove={(e) => {
|
||||
if (dragging) {
|
||||
viewport.scrollLeft = dragging.left - (e.clientX - dragging.x);
|
||||
viewport.scrollTop = dragging.top - (e.clientY - dragging.y);
|
||||
}
|
||||
}}
|
||||
onpointerup={() => (dragging = null)}
|
||||
onpointercancel={() => (dragging = null)}
|
||||
>
|
||||
{#if map}
|
||||
<div
|
||||
class="atlas-canvas relative mx-auto"
|
||||
style:width={`${mapWidth}px`}
|
||||
style:height={`${(mapWidth * map.height) / map.width}px`}
|
||||
>
|
||||
<img
|
||||
src={`${base}${map.image}`}
|
||||
alt={`${map.name} region map from the Pokémon games`}
|
||||
width={map.width}
|
||||
height={map.height}
|
||||
draggable="false"
|
||||
class="absolute inset-0 h-full w-full select-none"
|
||||
style:image-rendering={map.pixelated ? 'pixelated' : 'auto'}
|
||||
/>
|
||||
{#each map.nodes as point (point.slug)}
|
||||
<button
|
||||
type="button"
|
||||
class="map-pin absolute -translate-x-1/2 -translate-y-1/2 rounded-full border-2 shadow-sm transition-colors focus-visible:z-30 focus-visible:outline-2 focus-visible:outline-offset-4"
|
||||
class:selected={location === point.slug}
|
||||
class:town={point.kind === 'town'}
|
||||
style:left={`${(point.x / map.width) * 100}%`}
|
||||
style:top={`${(point.y / map.height) * 100}%`}
|
||||
title={`${point.name} · ${placeMap.get(point.slug)?.count ?? 0} recorded species`}
|
||||
aria-label={`Explore ${point.name}`}
|
||||
aria-pressed={location === point.slug}
|
||||
onclick={() => choose(point)}
|
||||
>
|
||||
<span
|
||||
class="pin-label pointer-events-none absolute bottom-full left-1/2 mb-1 -translate-x-1/2 whitespace-nowrap rounded bg-background/95 px-1.5 py-0.5 text-[10px] font-semibold text-foreground shadow-sm"
|
||||
class:visible-label={(showNames && point.kind === 'town') ||
|
||||
location === point.slug}>{point.name}</span
|
||||
>
|
||||
</button>
|
||||
{/each}
|
||||
<div
|
||||
class="hidden pointer-events-none absolute bottom-4 left-4 rounded-lg border bg-background/90 px-3 py-2 text-xs text-muted-foreground"
|
||||
>
|
||||
<span class="mr-2 text-amber-500">●</span>selected location · drag to explore
|
||||
</div>
|
||||
</div>
|
||||
{:else}<p class="p-8 text-muted-foreground">
|
||||
No regional map is recorded for this title yet.
|
||||
</p>{/if}
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-wrap justify-between gap-2 border-t px-4 py-3 text-xs text-muted-foreground"
|
||||
>
|
||||
<label class="flex items-center gap-2"><Checkbox bind:checked={showNames} />Town names</label
|
||||
><span>{map?.precision}</span>{#if map}<!-- Catalog or validated external URL. -->
|
||||
<!-- eslint-disable svelte/no-navigation-without-resolve -->
|
||||
<a href={map.source} target="_blank" rel="noreferrer" class="underline">map source ↗</a>
|
||||
<!-- eslint-enable svelte/no-navigation-without-resolve -->{/if}
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
class={`min-w-0 rounded-xl border bg-card p-3 ${mobileView === 'encounters' ? '' : 'max-lg:hidden'}`}
|
||||
aria-label="Area encounters"
|
||||
>
|
||||
<div class="mb-2 flex items-center justify-between gap-2">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold">
|
||||
{selected?.name ?? node?.name ?? 'Choose a location'}
|
||||
</h2>
|
||||
<p class="text-[11px] text-muted-foreground">
|
||||
{groups.length} Pokémon · {groups.filter(([n]) => caughtNumbers.has(n)).length} caught
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => (overviewOpen = true)}
|
||||
disabled={!groups.length}>Expand encounters</Button
|
||||
>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<Input
|
||||
type="search"
|
||||
aria-label="Find a location"
|
||||
placeholder="Find a route…"
|
||||
bind:value={filter}
|
||||
/>
|
||||
<NativeSelect class="w-full" aria-label="Encounter location" bind:value={location}
|
||||
><option value="">Choose a location</option>{#each choices as p (p.slug)}<option
|
||||
value={p.slug}>{p.name}</option
|
||||
>{/each}</NativeSelect
|
||||
>
|
||||
{#if areas.length > 1}<NativeSelect aria-label="Encounter area" bind:value={area}
|
||||
><option value="all">All areas / floors</option>{#each areas as a (a)}<option value={a}
|
||||
>{a}</option
|
||||
>{/each}</NativeSelect
|
||||
>{/if}
|
||||
{#if methods.length > 1}<NativeSelect aria-label="Encounter method" bind:value={method}
|
||||
><option value="all">All encounter methods</option>{#each methods as m (m)}<option
|
||||
value={m}>{m}</option
|
||||
>{/each}</NativeSelect
|
||||
>{/if}
|
||||
</div>
|
||||
<div class="mt-2 grid grid-cols-2 gap-4">
|
||||
<ProgressMeter
|
||||
label="Area caught"
|
||||
progress={{
|
||||
value: groups.filter(([n]) => caughtNumbers.has(n)).length,
|
||||
total: groups.length
|
||||
}}
|
||||
compact
|
||||
/>
|
||||
<ProgressMeter
|
||||
label="Area seen"
|
||||
progress={{
|
||||
value: groups.filter(([n]) => caughtNumbers.has(n) || seenNumbers.has(n)).length,
|
||||
total: groups.length
|
||||
}}
|
||||
color="sky"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
{#if hiddenSpecial}<label class="mt-2 flex items-center gap-2 text-[11px] text-muted-foreground"
|
||||
><Checkbox bind:checked={includeSpecial} />Include special conditions (radar, swarms, other
|
||||
cartridges)</label
|
||||
>{/if}
|
||||
<div class="mt-2 h-64" use:fitViewport={{ bottom: 48, minimum: 120 }} aria-live="polite">
|
||||
{#if loading}<p class="py-8 text-sm text-muted-foreground">Searching the tall grass…</p>
|
||||
{:else if loadError}<p role="alert">{loadError}</p>
|
||||
<Button variant="outline" onclick={() => retry++}>Try again</Button>
|
||||
{:else if groups.length}<EncounterGrid
|
||||
{groups}
|
||||
{caughtNumbers}
|
||||
onselect={(number) => (encounterNumber = number)}
|
||||
/>
|
||||
{:else}<div class="rounded-xl border border-dashed p-5 text-sm text-muted-foreground">
|
||||
{location
|
||||
? 'No encounters are recorded here for these filters. Check the location guide for gifts, special events or missing data.'
|
||||
: 'Pick a marker or choose a location to explore encounters.'}
|
||||
</div>{/if}
|
||||
</div>
|
||||
{#if selected || node}<a
|
||||
class="mt-2 inline-block text-[10px] text-muted-foreground underline"
|
||||
href={`https://bulbapedia.bulbagarden.net/wiki/${encodeURIComponent((selected?.name ?? node?.name ?? '').startsWith('Route') ? `${map?.name ?? ''} ${selected?.name ?? node?.name}` : (selected?.name ?? node?.name ?? ''))}`}
|
||||
target="_blank"
|
||||
rel="noreferrer">Location guide on Bulbapedia ↗</a
|
||||
>{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<Dialog.Root bind:open={overviewOpen}
|
||||
><Dialog.Content class="flex h-[90dvh] max-w-[96vw] flex-col gap-3 p-4 sm:max-w-[96vw]"
|
||||
><Dialog.Header
|
||||
><Dialog.Title>{selected?.name ?? node?.name} · all {groups.length} Pokémon</Dialog.Title
|
||||
><Dialog.Description
|
||||
>Every species matching your area and method filters. Tap a portrait for levels, encounter
|
||||
conditions and catch registration.</Dialog.Description
|
||||
></Dialog.Header
|
||||
>
|
||||
<div class="min-h-0 flex-1">
|
||||
<EncounterGrid {groups} {caughtNumbers} onselect={(number) => (encounterNumber = number)} />
|
||||
</div></Dialog.Content
|
||||
></Dialog.Root
|
||||
>
|
||||
<Dialog.Root
|
||||
open={encounterNumber !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) encounterNumber = null;
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="max-h-[85dvh] overflow-y-auto sm:max-w-2xl">
|
||||
<Dialog.Header
|
||||
><Dialog.Title
|
||||
>{detailRows[0]?.name ?? 'Pokémon'} · {selected?.name ?? node?.name}</Dialog.Title
|
||||
><Dialog.Description>All recorded encounters matching your filters.</Dialog.Description
|
||||
></Dialog.Header
|
||||
>
|
||||
{#if encounterNumber !== null}<div class="flex items-center gap-3">
|
||||
<img src={pokemonImage(encounterNumber)} alt="" width="64" height="64" />
|
||||
<p class="text-sm">
|
||||
{detailRows[0]?.type1}{detailRows[0]?.type2
|
||||
? ` / ${detailRows[0].type2}`
|
||||
: ''}{caughtNumbers.has(encounterNumber) ? ' · ✓ caught' : ''}
|
||||
</p>
|
||||
</div>{/if}
|
||||
<div class="space-y-2">
|
||||
{#each detailRows as row, i (i)}<div class="rounded-lg border p-3 text-xs">
|
||||
<p class="font-medium">
|
||||
{row.method}{row.area ? ` · ${row.area}` : ''} · Lv. {row.minLevel ??
|
||||
'?'}{row.maxLevel !== row.minLevel ? `–${row.maxLevel}` : ''}{row.rarity !== null
|
||||
? ` · ${row.rarity}%`
|
||||
: ''}
|
||||
</p>
|
||||
{#if row.conditions.length || row.form || row.note}<p class="mt-1 text-muted-foreground">
|
||||
{[row.form, ...row.conditions, row.note].filter(Boolean).join(' · ')}
|
||||
</p>{/if}
|
||||
</div>{/each}
|
||||
</div>
|
||||
<Dialog.Footer
|
||||
><Button
|
||||
variant="secondary"
|
||||
disabled={!canRegister ||
|
||||
(encounterNumber !== null &&
|
||||
(caughtNumbers.has(encounterNumber) || pendingSeen.has(encounterNumber)))}
|
||||
onclick={() => {
|
||||
if (encounterNumber !== null) onseen(encounterNumber, !seenNumbers.has(encounterNumber));
|
||||
}}
|
||||
>{encounterNumber !== null &&
|
||||
(seenNumbers.has(encounterNumber) || caughtNumbers.has(encounterNumber))
|
||||
? '✓ Seen · undo'
|
||||
: 'Mark as seen'}</Button
|
||||
><Button
|
||||
variant="outline"
|
||||
onclick={() => {
|
||||
const n = encounterNumber!;
|
||||
encounterNumber = null;
|
||||
overviewOpen = false;
|
||||
onpokemon(n);
|
||||
}}>Species research</Button
|
||||
><Button
|
||||
disabled={!canRegister}
|
||||
onclick={() => {
|
||||
const n = encounterNumber!;
|
||||
encounterNumber = null;
|
||||
overviewOpen = false;
|
||||
oncatch(n);
|
||||
}}>Register a catch</Button
|
||||
></Dialog.Footer
|
||||
>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<style>
|
||||
.atlas-viewport {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
color-mix(in oklch, var(--background), #549da3 12%),
|
||||
color-mix(in oklch, var(--background), #8fa965 10%)
|
||||
);
|
||||
cursor: grab;
|
||||
}
|
||||
.atlas-viewport:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
.map-pin {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #6ca68e;
|
||||
border-color: var(--background);
|
||||
}
|
||||
.map-pin.town {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #ebc481;
|
||||
}
|
||||
.map-pin.selected {
|
||||
background: #f59e0b;
|
||||
border-color: #fff;
|
||||
z-index: 20;
|
||||
box-shadow: 0 0 0 5px #f59e0b40;
|
||||
}
|
||||
.pin-label {
|
||||
visibility: hidden;
|
||||
}
|
||||
@media (max-width: 639px) {
|
||||
.map-pin .pin-label.visible-label {
|
||||
visibility: hidden;
|
||||
}
|
||||
.map-pin.selected .pin-label {
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
.visible-label,
|
||||
.map-pin:hover .pin-label,
|
||||
.map-pin:focus-visible .pin-label {
|
||||
visibility: visible;
|
||||
}
|
||||
</style>
|
||||
104
src/lib/game-tracker/PokemonProgress.svelte
Normal file
104
src/lib/game-tracker/PokemonProgress.svelte
Normal file
@@ -0,0 +1,104 @@
|
||||
<script lang="ts">
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import ChartNoAxesCombined from 'lucide-svelte/icons/chart-no-axes-combined';
|
||||
import ProgressMeter from './ProgressMeter.svelte';
|
||||
import type { pokemonProgress } from './progress';
|
||||
let { progress, gameId }: { progress: ReturnType<typeof pokemonProgress>; gameId: number } =
|
||||
$props();
|
||||
let open = $state(false);
|
||||
const current = $derived(progress.games.find((g) => g.id === gameId));
|
||||
</script>
|
||||
|
||||
<section class="mt-2 rounded-lg border bg-card px-3 py-2.5" aria-label="Pokémon completion">
|
||||
<div class="mb-2 flex items-center justify-between gap-3">
|
||||
<h2 class="text-xs font-semibold">Quick progress</h2>
|
||||
<Button variant="ghost" size="xs" class="h-6 gap-1 text-[10px]" onclick={() => (open = true)}
|
||||
><ChartNoAxesCombined class="size-3.5" aria-hidden="true" />Full breakdown</Button
|
||||
>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-x-5 gap-y-2 sm:grid-cols-4">
|
||||
{#if current}<ProgressMeter
|
||||
label={`${current.name} completion`}
|
||||
progress={current.overall}
|
||||
color="amber"
|
||||
compact
|
||||
hint="Collection, story, postgame and transfers/events contribute equally where available."
|
||||
/>{/if}
|
||||
<ProgressMeter
|
||||
label="National collection"
|
||||
progress={progress.caught}
|
||||
compact
|
||||
hint={`${progress.caught.value} / ${progress.caught.total} unique species caught across selected games.`}
|
||||
/>
|
||||
<ProgressMeter
|
||||
label="Species seen at least once"
|
||||
progress={progress.seen}
|
||||
color="sky"
|
||||
compact
|
||||
hint={`${progress.seen.value} / ${progress.seen.total} species. Catches count as seen.`}
|
||||
/>
|
||||
<ProgressMeter
|
||||
label="Games completed"
|
||||
progress={progress.completed}
|
||||
color="violet"
|
||||
compact
|
||||
hint={`${progress.completed.value} / ${progress.completed.total} stories cleared.`}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Dialog.Root bind:open
|
||||
><Dialog.Content class="max-h-[85dvh] overflow-y-auto sm:max-w-3xl">
|
||||
<Dialog.Header
|
||||
><Dialog.Title>Your Pokémon progress</Dialog.Title><Dialog.Description
|
||||
>Every catch, discovery and milestone adds up. Collection totals exclude unconfirmed
|
||||
availability unless you have recorded a catch.</Dialog.Description
|
||||
></Dialog.Header
|
||||
>
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
<ProgressMeter label="National collection" progress={progress.caught} />
|
||||
<ProgressMeter label="Species seen at least once" progress={progress.seen} color="sky" />
|
||||
<ProgressMeter label="Games completed" progress={progress.completed} color="violet" />
|
||||
<ProgressMeter
|
||||
label="All adventures"
|
||||
progress={progress.overall}
|
||||
color="amber"
|
||||
detail="Equal weight per selected game"
|
||||
/>
|
||||
<ProgressMeter label="All species / game pairs" progress={progress.collection} />
|
||||
</div>
|
||||
{#if current}
|
||||
<h3 class="border-t pt-4 font-semibold">{current.name} · your adventure</h3>
|
||||
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3">
|
||||
<ProgressMeter label="Game collection" progress={current.collection} />
|
||||
<ProgressMeter
|
||||
label="Obtainable in-game"
|
||||
progress={current.inGame}
|
||||
hint="Catch, evolve and breed; trades, events and transfers have their own collection stage."
|
||||
/>
|
||||
<ProgressMeter label="Seen in this game" progress={current.seen} color="sky" />
|
||||
{#if current.regional.total}<ProgressMeter
|
||||
label="Regional dex caught"
|
||||
progress={current.regional}
|
||||
/><ProgressMeter
|
||||
label="Regional dex seen"
|
||||
progress={current.regionalSeen}
|
||||
color="sky"
|
||||
/>{/if}
|
||||
{#each [{ label: 'Story milestones', progress: current.story }, { label: 'Badges & trials', progress: current.badges }, { label: 'League battles', progress: current.league }, { label: 'Boss battles', progress: current.bosses }, { label: 'Postgame milestones', progress: current.postgame }, { label: 'Trades, transfers & events', progress: current.transfers }, { label: 'Personal milestones', progress: current.custom }] as metric (metric.label)}
|
||||
{#if metric.progress.total}<ProgressMeter
|
||||
label={metric.label}
|
||||
progress={metric.progress}
|
||||
color="amber"
|
||||
/>{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Game completion averages collection, story, postgame and transfer/event milestones. Games
|
||||
completed counts stories you mark cleared, or games with every default story milestone
|
||||
finished. These percentages describe the imported catalog and your selected games.
|
||||
</p>
|
||||
</Dialog.Content></Dialog.Root
|
||||
>
|
||||
58
src/lib/game-tracker/ProgressMeter.svelte
Normal file
58
src/lib/game-tracker/ProgressMeter.svelte
Normal file
@@ -0,0 +1,58 @@
|
||||
<script lang="ts">
|
||||
import { Progress } from '$lib/components/ui/progress/index.js';
|
||||
import { fraction, type ProgressCount } from './progress';
|
||||
let {
|
||||
label,
|
||||
progress,
|
||||
detail,
|
||||
hint = '',
|
||||
color = 'emerald',
|
||||
compact = false
|
||||
}: {
|
||||
label: string;
|
||||
progress: ProgressCount;
|
||||
detail?: string;
|
||||
hint?: string;
|
||||
color?: 'emerald' | 'sky' | 'amber' | 'violet' | 'pink';
|
||||
compact?: boolean;
|
||||
} = $props();
|
||||
const percent = $derived(fraction(progress) * 100);
|
||||
// Keep tiny catalog gains visible without rounding unfinished progress up to 100%.
|
||||
const display = $derived(
|
||||
percent === 100
|
||||
? '100'
|
||||
: percent === 0
|
||||
? '0'
|
||||
: percent < 0.01
|
||||
? '<0.01'
|
||||
: Math.min(99.99, percent).toFixed(2)
|
||||
);
|
||||
const colors = {
|
||||
emerald: '[&_[data-slot=progress-indicator]]:bg-emerald-500',
|
||||
sky: '[&_[data-slot=progress-indicator]]:bg-sky-500',
|
||||
amber: '[&_[data-slot=progress-indicator]]:bg-amber-500',
|
||||
violet: '[&_[data-slot=progress-indicator]]:bg-violet-500',
|
||||
pink: '[&_[data-slot=progress-indicator]]:bg-pink-500'
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="min-w-0 space-y-1" title={hint}>
|
||||
<div class="flex items-baseline justify-between gap-2">
|
||||
<span class={compact ? 'truncate text-[10px] text-muted-foreground' : 'text-xs font-medium'}
|
||||
>{label}</span
|
||||
>
|
||||
<span class={`shrink-0 font-semibold tabular-nums ${compact ? 'text-xs' : 'text-sm'}`}
|
||||
>{display}%{percent === 100 ? ' ✓' : ''}</span
|
||||
>
|
||||
</div>
|
||||
<Progress
|
||||
value={percent}
|
||||
max={100}
|
||||
aria-label={label}
|
||||
aria-valuetext={`${display}%${detail ? ` · ${detail}` : ''}`}
|
||||
class={`h-1.5 ${colors[color]} [&_[data-slot=progress-indicator]]:duration-700 [&_[data-slot=progress-indicator]]:ease-out motion-reduce:[&_[data-slot=progress-indicator]]:transition-none`}
|
||||
/>
|
||||
{#if !compact}<p class="text-[10px] text-muted-foreground">
|
||||
{detail ?? `${progress.value.toLocaleString()} / ${progress.total.toLocaleString()}`}
|
||||
</p>{/if}
|
||||
</div>
|
||||
46
src/lib/game-tracker/WeaponArtwork.svelte
Normal file
46
src/lib/game-tracker/WeaponArtwork.svelte
Normal file
@@ -0,0 +1,46 @@
|
||||
<script lang="ts">
|
||||
import { base } from '$app/paths';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import artwork from '../../../data/cod-art.json';
|
||||
let { game, name, icon }: { game: string; name: string; icon: string | null } = $props();
|
||||
const source = $derived(
|
||||
artwork.find((a) => a.game === game && a.name.toLowerCase() === name.toLowerCase())
|
||||
);
|
||||
const src = $derived(source ? `${base}${source.path}` : icon);
|
||||
</script>
|
||||
|
||||
{#if src}
|
||||
<Dialog.Root>
|
||||
<Dialog.Trigger
|
||||
class="shrink-0 overflow-hidden rounded-md border focus-visible:outline-2"
|
||||
aria-label={`View ${name} artwork`}
|
||||
><img
|
||||
{src}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
width="112"
|
||||
height="64"
|
||||
class="h-16 w-28 object-contain"
|
||||
/></Dialog.Trigger
|
||||
>
|
||||
<Dialog.Content class="sm:max-w-3xl"
|
||||
><Dialog.Header
|
||||
><Dialog.Title>{name}</Dialog.Title><Dialog.Description
|
||||
>{source?.credit ?? 'Weapon artwork'}</Dialog.Description
|
||||
></Dialog.Header
|
||||
><img
|
||||
{src}
|
||||
alt={`${name} weapon reference for ${game.replaceAll('-', ' ')}`}
|
||||
class="max-h-[65dvh] w-full object-contain"
|
||||
/>{#if source}<!-- Catalog or validated external URL. -->
|
||||
<!-- eslint-disable svelte/no-navigation-without-resolve -->
|
||||
<a
|
||||
href={source.source}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="text-xs text-muted-foreground underline">Image source ↗</a
|
||||
>
|
||||
<!-- eslint-enable svelte/no-navigation-without-resolve -->{/if}</Dialog.Content
|
||||
>
|
||||
</Dialog.Root>
|
||||
{/if}
|
||||
138
src/lib/game-tracker/WeaponRankControls.svelte
Normal file
138
src/lib/game-tracker/WeaponRankControls.svelte
Normal file
@@ -0,0 +1,138 @@
|
||||
<script lang="ts">
|
||||
import { deserialize } from '$app/forms';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import Minus from 'lucide-svelte/icons/minus';
|
||||
import Plus from 'lucide-svelte/icons/plus';
|
||||
let {
|
||||
itemId,
|
||||
name,
|
||||
level,
|
||||
prestige,
|
||||
levelCap = null,
|
||||
prestigeCap = 1000,
|
||||
prestigeRequiresMaxLevel = false,
|
||||
compact = false,
|
||||
disabled,
|
||||
onupdate,
|
||||
onedit
|
||||
}: {
|
||||
itemId: number;
|
||||
name: string;
|
||||
level: number;
|
||||
prestige: number;
|
||||
levelCap?: number | null;
|
||||
prestigeCap?: number;
|
||||
prestigeRequiresMaxLevel?: boolean;
|
||||
compact?: boolean;
|
||||
disabled: boolean;
|
||||
onupdate: (rank: { itemId: number; level: number; prestige: number }) => void;
|
||||
onedit: () => void;
|
||||
} = $props();
|
||||
let saving = $state(false);
|
||||
let error = $state('');
|
||||
let saved = $state(false);
|
||||
async function adjust(field: 'level' | 'prestige', delta: number) {
|
||||
if (saving) return;
|
||||
saving = true;
|
||||
error = '';
|
||||
saved = false;
|
||||
const body = new FormData();
|
||||
body.set('itemId', String(itemId));
|
||||
body.set('field', field);
|
||||
body.set('delta', String(delta));
|
||||
try {
|
||||
const result = deserialize(
|
||||
await (await fetch('?/adjustWeaponRank', { method: 'POST', body })).text()
|
||||
);
|
||||
if (result.type !== 'success')
|
||||
throw new Error(
|
||||
result.type === 'failure'
|
||||
? String(result.data?.message ?? 'Could not save.')
|
||||
: 'Could not save.'
|
||||
);
|
||||
const rank = result.data?.rank as { itemId: number; level: number; prestige: number };
|
||||
onupdate(rank);
|
||||
saved = true;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Could not save. Try again.';
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-1">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div
|
||||
class="flex items-center gap-1 rounded-lg border bg-background p-1"
|
||||
aria-label={`${name} level controls`}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class={compact ? 'size-7' : 'size-10'}
|
||||
aria-label={`Level down ${name}`}
|
||||
disabled={disabled || saving || level <= 1}
|
||||
onclick={() => adjust('level', -1)}
|
||||
><Minus class={compact ? 'size-3' : 'size-4'} aria-hidden="true" /></Button
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class={compact
|
||||
? 'h-8 min-w-11 flex-col gap-0 px-1 tabular-nums'
|
||||
: 'h-10 min-w-16 flex-col gap-0 px-2 tabular-nums'}
|
||||
aria-label={`Edit ${name} level and prestige`}
|
||||
disabled={disabled || saving}
|
||||
onclick={onedit}
|
||||
><span
|
||||
class={compact ? 'text-[8px] text-muted-foreground' : 'text-[10px] text-muted-foreground'}
|
||||
>LEVEL</span
|
||||
><span class={compact ? 'text-sm font-semibold' : 'text-lg font-semibold'}>{level}{#if levelCap}<span class="text-muted-foreground">/{levelCap}</span>{/if}</span
|
||||
></Button
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
class={compact ? 'size-7' : 'size-10'}
|
||||
aria-label={`Level up ${name}`}
|
||||
disabled={disabled || saving || level >= (levelCap ?? 10000)}
|
||||
onclick={() => adjust('level', 1)}
|
||||
><Plus class={compact ? 'size-3' : 'size-4'} aria-hidden="true" /></Button
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-1 rounded-lg border bg-background p-1"
|
||||
aria-label={`${name} prestige controls`}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class={compact ? 'size-7' : 'size-10'}
|
||||
aria-label={`Prestige down ${name}`}
|
||||
disabled={disabled || saving || prestige <= 0}
|
||||
onclick={() => adjust('prestige', -1)}
|
||||
><Minus class={compact ? 'size-3' : 'size-4'} aria-hidden="true" /></Button
|
||||
>
|
||||
<span class="min-w-12 text-center tabular-nums"
|
||||
><span
|
||||
class={compact
|
||||
? 'block text-[8px] text-muted-foreground'
|
||||
: 'block text-[10px] text-muted-foreground'}>PRESTIGE</span
|
||||
><span class={compact ? 'text-sm font-semibold' : 'text-lg font-semibold'}>{prestige}{#if prestigeCap < 1000}<span class="text-muted-foreground">/{prestigeCap}</span>{/if}</span
|
||||
></span
|
||||
>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
class={compact ? 'size-7' : 'size-10'}
|
||||
aria-label={`Prestige up ${name}`}
|
||||
disabled={disabled || saving || prestige >= prestigeCap || (prestigeRequiresMaxLevel && level < (levelCap ?? 10000))}
|
||||
onclick={() => adjust('prestige', 1)}
|
||||
><Plus class={compact ? 'size-3' : 'size-4'} aria-hidden="true" /></Button
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<p class="min-h-3 text-[9px] text-muted-foreground" role="status">
|
||||
{saving ? 'Saving…' : error || (saved ? 'Saved' : 'Tap the level to edit both values')}
|
||||
</p>
|
||||
</div>
|
||||
28
src/lib/game-tracker/fit-viewport.ts
Normal file
28
src/lib/game-tracker/fit-viewport.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/** Size a companion panel from its real page position, allowing room for its footer. */
|
||||
export function fitViewport(node: HTMLElement, { minimum = 140, maximum = 560, bottom = 48 } = {}) {
|
||||
let frame = 0;
|
||||
const measure = () => {
|
||||
cancelAnimationFrame(frame);
|
||||
frame = requestAnimationFrame(() => {
|
||||
if (!node.getClientRects().length) return;
|
||||
const top = node.getBoundingClientRect().top + window.scrollY;
|
||||
const height = Math.round(
|
||||
Math.max(minimum, Math.min(maximum, window.innerHeight - top - bottom))
|
||||
);
|
||||
if (node.style.height !== `${height}px`) node.style.height = `${height}px`;
|
||||
});
|
||||
};
|
||||
const observer = new ResizeObserver(measure);
|
||||
// Ancestors capture team, filters, tabs and responsive header changes above the panel.
|
||||
for (let parent = node.parentElement; parent; parent = parent.parentElement)
|
||||
observer.observe(parent);
|
||||
window.addEventListener('resize', measure);
|
||||
measure();
|
||||
return {
|
||||
destroy() {
|
||||
observer.disconnect();
|
||||
window.removeEventListener('resize', measure);
|
||||
cancelAnimationFrame(frame);
|
||||
}
|
||||
};
|
||||
}
|
||||
15
src/lib/game-tracker/game-art.ts
Normal file
15
src/lib/game-tracker/game-art.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import releases from '../../../data/pokemon-releases.json';
|
||||
import { base } from '$app/paths';
|
||||
|
||||
const dates: Record<string, string> = releases.dates;
|
||||
export function pokemonRelease(slug: string | null) {
|
||||
return dates[slug ?? ''] ?? '';
|
||||
}
|
||||
export function chronologicalPokemon<T extends { slug: string | null; id: number }>(games: T[]) {
|
||||
return [...games].sort(
|
||||
(a, b) => pokemonRelease(a.slug).localeCompare(pokemonRelease(b.slug)) || a.id - b.id
|
||||
);
|
||||
}
|
||||
export function pokemonImage(number: number, kind: 'sprite' | 'shiny' | 'artwork' = 'sprite') {
|
||||
return `${base}/game-art/pokemon/${kind}/${number}.webp`;
|
||||
}
|
||||
78
src/lib/game-tracker/journal.test.ts
Normal file
78
src/lib/game-tracker/journal.test.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { allowedOsuUrl, boundedInteger, firstCatches, validDate } from './journal';
|
||||
import { gameRegions, milestonesFor, regionMaps } from './pokemon-world';
|
||||
|
||||
describe('journal input and collection rules', () => {
|
||||
it('keeps the first registered catch even when a duplicate has an earlier caught date', () => {
|
||||
const original = { id: 2, pokemonId: 387, nickname: 'Sprout', caughtOn: '2026-09-25' };
|
||||
const duplicate = { id: 8, pokemonId: 387, nickname: 'Other', caughtOn: '2020-01-01' };
|
||||
const input = [duplicate, original, { ...original, id: 9, pokemonId: 399 }];
|
||||
expect(firstCatches(input).get(387)).toBe(original);
|
||||
expect(firstCatches(input).size).toBe(2);
|
||||
expect(input[0]).toBe(duplicate);
|
||||
});
|
||||
it('rejects malformed or impossible numeric values', () => {
|
||||
for (const value of [null, '', '-1', '1e2', '1.5', ' 2', 'NaN', '101'])
|
||||
expect(boundedInteger(value, 1, 100)).toBeNull();
|
||||
expect(boundedInteger('100', 1, 100)).toBe(100);
|
||||
expect(boundedInteger('0', 0, 100)).toBe(0);
|
||||
});
|
||||
it('accepts actual calendar dates including leap days', () => {
|
||||
expect(validDate('2024-02-29')).toBe(true);
|
||||
for (const date of [
|
||||
'2026-02-29',
|
||||
'2026-04-31',
|
||||
'0000-01-01',
|
||||
'2026-13-01',
|
||||
'2026-09-25T00:00:00Z'
|
||||
])
|
||||
expect(validDate(date)).toBe(false);
|
||||
});
|
||||
it('accepts osu links and rejects lookalike hosts, credentials and unrelated routes', () => {
|
||||
expect(allowedOsuUrl('https://osu.ppy.sh/beatmapsets/123#osu/456', 'beatmap')).toBe(true);
|
||||
expect(allowedOsuUrl('https://osu.ppy.sh/scores/osu/123', 'score')).toBe(true);
|
||||
for (const url of [
|
||||
'javascript:alert(1)',
|
||||
'http://osu.ppy.sh/beatmaps/1',
|
||||
'https://osu.ppy.sh.evil.test/beatmaps/1',
|
||||
'https://user@osu.ppy.sh/beatmaps/1',
|
||||
'https://osu.ppy.sh:123/beatmaps/1',
|
||||
'https://osu.ppy.sh/users/1'
|
||||
])
|
||||
expect(allowedOsuUrl(url, 'beatmap')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('atlas and milestone integrity', () => {
|
||||
it('has unique locations and finite map coordinates in every region', () => {
|
||||
for (const map of Object.values(regionMaps)) {
|
||||
expect(new Set(map.nodes.map((n) => n.slug)).size).toBe(map.nodes.length);
|
||||
expect(map.source).toMatch(/^https:\/\//);
|
||||
for (const node of map.nodes)
|
||||
expect(Number.isFinite(node.x) && Number.isFinite(node.y)).toBe(true);
|
||||
}
|
||||
expect(regionMaps.sinnoh.nodes.some((n) => n.slug === 'sinnoh-route-201')).toBe(true);
|
||||
});
|
||||
it('uses the right region and version-specific leaders', () => {
|
||||
expect(gameRegions('legends-arceus', ['sinnoh'])).toEqual(['hisui']);
|
||||
expect(gameRegions('heartgold', ['kanto', 'johto'])).toEqual(['johto', 'kanto']);
|
||||
expect(gameRegions('unknown', ['unknown'])).toEqual([]);
|
||||
expect(
|
||||
milestonesFor('platinum', 'sinnoh').filter((m) => m.category === 'badge')[2].title
|
||||
).toContain('Fantina');
|
||||
expect(
|
||||
milestonesFor('diamond', 'sinnoh').filter((m) => m.category === 'badge')[2].title
|
||||
).toContain('Maylene');
|
||||
expect(milestonesFor('emerald', 'hoenn').some((m) => m.title === 'Champion · Wallace')).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
milestonesFor('ultra-sun', 'alola').some((m) => m.title === 'Elite Four · Molayne')
|
||||
).toBe(true);
|
||||
expect(
|
||||
milestonesFor('heartgold', 'johto').filter(
|
||||
(m) => m.stage === 'postgame' && m.category === 'badge'
|
||||
)
|
||||
).toHaveLength(8);
|
||||
});
|
||||
});
|
||||
67
src/lib/game-tracker/journal.ts
Normal file
67
src/lib/game-tracker/journal.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
export const stages = [
|
||||
{
|
||||
value: 'story',
|
||||
label: 'The adventure',
|
||||
description: 'Catch as you go, earn badges and become Champion.'
|
||||
},
|
||||
{
|
||||
value: 'postgame',
|
||||
label: 'Beyond the credits',
|
||||
description: 'Explore the postgame and finish what you can in this game.'
|
||||
},
|
||||
{
|
||||
value: 'collection',
|
||||
label: 'Complete the collection',
|
||||
description: 'Trades, transfers and event Pokémon.'
|
||||
}
|
||||
] as const;
|
||||
export type Stage = (typeof stages)[number]['value'];
|
||||
export const obtainedMethods = [
|
||||
'caught',
|
||||
'starter',
|
||||
'gift',
|
||||
'evolved',
|
||||
'bred',
|
||||
'trade',
|
||||
'transfer',
|
||||
'event'
|
||||
] as const;
|
||||
export const isStage = (value: string): value is Stage => stages.some((s) => s.value === value);
|
||||
|
||||
export function boundedInteger(value: FormDataEntryValue | null, min: number, max: number) {
|
||||
if (typeof value !== 'string' || !/^\d+$/.test(value)) return null;
|
||||
const number = Number(value);
|
||||
return Number.isSafeInteger(number) && number >= min && number <= max ? number : null;
|
||||
}
|
||||
export function validDate(value: string) {
|
||||
return (
|
||||
/^\d{4}-\d{2}-\d{2}$/.test(value) &&
|
||||
Number(value.slice(0, 4)) > 0 &&
|
||||
!Number.isNaN(Date.parse(value)) &&
|
||||
new Date(value).toISOString().slice(0, 10) === value
|
||||
);
|
||||
}
|
||||
export function firstCatches<T extends { id: number; pokemonId: number }>(catches: T[]) {
|
||||
const first = new Map<number, T>();
|
||||
for (const caught of [...catches].sort((a, b) => a.id - b.id))
|
||||
if (!first.has(caught.pokemonId)) first.set(caught.pokemonId, caught);
|
||||
return first;
|
||||
}
|
||||
export function allowedOsuUrl(value: string, kind: 'beatmap' | 'score') {
|
||||
if (!value) return true;
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return (
|
||||
url.protocol === 'https:' &&
|
||||
url.hostname === 'osu.ppy.sh' &&
|
||||
!url.username &&
|
||||
!url.password &&
|
||||
!url.port &&
|
||||
(kind === 'beatmap'
|
||||
? /^\/(beatmaps|b|beatmapsets)\/\d+\/?$/.test(url.pathname)
|
||||
: /^\/scores\/(?:osu\/|taiko\/|fruits\/|mania\/)?\d+\/?$/.test(url.pathname))
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,13 @@ export type MasteryRules = {
|
||||
tiers: Tier[];
|
||||
classes: string[];
|
||||
steamApps: { id: number; label: string }[];
|
||||
rankCaps?: {
|
||||
player: { level: number; prestige: number };
|
||||
weaponPrestige: number;
|
||||
weaponLevelsByClass: Record<string, number>;
|
||||
weaponLevelOverrides?: Record<string, number>;
|
||||
sourceUrl?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type Weapon = { id: number; name: string; category: string; postLaunch: boolean };
|
||||
|
||||
305
src/lib/game-tracker/pokemon-world.ts
Normal file
305
src/lib/game-tracker/pokemon-world.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
import atlas from '../../../data/pokemon-atlas.json';
|
||||
export type AtlasNode = {
|
||||
slug: string;
|
||||
name: string;
|
||||
x: number;
|
||||
y: number;
|
||||
kind: string;
|
||||
cells: number[][];
|
||||
};
|
||||
export type RegionMap = {
|
||||
name: string;
|
||||
width: number;
|
||||
height: number;
|
||||
nodes: AtlasNode[];
|
||||
source: string;
|
||||
precision: string;
|
||||
};
|
||||
export const regionMaps: Record<string, RegionMap> = atlas.regions;
|
||||
export function gameRegions(slug: string, regions: string[]) {
|
||||
if (['gold', 'silver', 'crystal', 'heartgold', 'soulsilver'].includes(slug))
|
||||
return ['johto', 'kanto'];
|
||||
if (slug === 'legends-arceus') return ['hisui'];
|
||||
if (slug.includes('teal-mask')) return ['kitakami'];
|
||||
if (slug.includes('indigo-disk')) return ['blueberry'];
|
||||
if (['colosseum', 'xd'].includes(slug)) return ['orre'];
|
||||
return regions.filter((r) => r in regionMaps);
|
||||
}
|
||||
export function initialLocation(slug: string, region: string) {
|
||||
if (['diamond', 'pearl'].includes(slug)) return 'lake-verity';
|
||||
return (
|
||||
(
|
||||
{
|
||||
sinnoh: 'sinnoh-route-201',
|
||||
kanto: 'pallet-town',
|
||||
johto: 'new-bark-town',
|
||||
hoenn: 'hoenn-route-101',
|
||||
unova: slug.endsWith('-2') ? 'aspertia-city' : 'nuvema-town',
|
||||
kalos: 'aquacorde-town',
|
||||
alola: 'alola-route-1',
|
||||
galar: 'postwick',
|
||||
hisui: 'obsidian-fieldlands',
|
||||
paldea: 'cabo-poco',
|
||||
orre: 'phenac-city',
|
||||
kitakami: 'mossui-town',
|
||||
blueberry: 'central-plaza'
|
||||
} as Record<string, string>
|
||||
)[region] ?? ''
|
||||
);
|
||||
}
|
||||
|
||||
export type Milestone = {
|
||||
key: string;
|
||||
title: string;
|
||||
category: string;
|
||||
stage: 'story' | 'postgame' | 'collection';
|
||||
};
|
||||
export function milestonesFor(slug: string, region: string): Milestone[] {
|
||||
const rows: Milestone[] = [];
|
||||
const add = (title: string, category: string, stage: Milestone['stage'] = 'story') =>
|
||||
rows.push({
|
||||
key: `${category}:${title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`,
|
||||
title,
|
||||
category,
|
||||
stage
|
||||
});
|
||||
const badges: Record<string, string[]> = {
|
||||
kanto: [
|
||||
'Boulder · Brock',
|
||||
'Cascade · Misty',
|
||||
'Thunder · Lt. Surge',
|
||||
'Rainbow · Erika',
|
||||
'Soul · Koga',
|
||||
'Marsh · Sabrina',
|
||||
'Volcano · Blaine',
|
||||
'Earth · Giovanni'
|
||||
],
|
||||
johto: [
|
||||
'Zephyr · Falkner',
|
||||
'Hive · Bugsy',
|
||||
'Plain · Whitney',
|
||||
'Fog · Morty',
|
||||
'Storm · Chuck',
|
||||
'Mineral · Jasmine',
|
||||
'Glacier · Pryce',
|
||||
'Rising · Clair'
|
||||
],
|
||||
hoenn: [
|
||||
'Stone · Roxanne',
|
||||
'Knuckle · Brawly',
|
||||
'Dynamo · Wattson',
|
||||
'Heat · Flannery',
|
||||
'Balance · Norman',
|
||||
'Feather · Winona',
|
||||
'Mind · Tate & Liza',
|
||||
`Rain · ${slug === 'emerald' ? 'Juan' : 'Wallace'}`
|
||||
],
|
||||
sinnoh:
|
||||
slug === 'platinum'
|
||||
? [
|
||||
'Coal · Roark',
|
||||
'Forest · Gardenia',
|
||||
'Relic · Fantina',
|
||||
'Cobble · Maylene',
|
||||
'Fen · Crasher Wake',
|
||||
'Mine · Byron',
|
||||
'Icicle · Candice',
|
||||
'Beacon · Volkner'
|
||||
]
|
||||
: [
|
||||
'Coal · Roark',
|
||||
'Forest · Gardenia',
|
||||
'Cobble · Maylene',
|
||||
'Fen · Crasher Wake',
|
||||
'Relic · Fantina',
|
||||
'Mine · Byron',
|
||||
'Icicle · Candice',
|
||||
'Beacon · Volkner'
|
||||
],
|
||||
unova: slug.endsWith('-2')
|
||||
? [
|
||||
'Basic · Cheren',
|
||||
'Toxic · Roxie',
|
||||
'Insect · Burgh',
|
||||
'Bolt · Elesa',
|
||||
'Quake · Clay',
|
||||
'Jet · Skyla',
|
||||
'Legend · Drayden',
|
||||
'Wave · Marlon'
|
||||
]
|
||||
: [
|
||||
'Trio · Cilan / Chili / Cress',
|
||||
'Basic · Lenora',
|
||||
'Insect · Burgh',
|
||||
'Bolt · Elesa',
|
||||
'Quake · Clay',
|
||||
'Jet · Skyla',
|
||||
'Freeze · Brycen',
|
||||
`Legend · ${slug === 'white' ? 'Iris' : 'Drayden'}`
|
||||
],
|
||||
kalos: [
|
||||
'Bug · Viola',
|
||||
'Cliff · Grant',
|
||||
'Rumble · Korrina',
|
||||
'Plant · Ramos',
|
||||
'Voltage · Clemont',
|
||||
'Fairy · Valerie',
|
||||
'Psychic · Olympia',
|
||||
'Iceberg · Wulfric'
|
||||
],
|
||||
galar: [
|
||||
'Grass · Milo',
|
||||
'Water · Nessa',
|
||||
'Fire · Kabu',
|
||||
slug === 'shield' ? 'Ghost · Allister' : 'Fighting · Bea',
|
||||
'Fairy · Opal',
|
||||
slug === 'shield' ? 'Ice · Melony' : 'Rock · Gordie',
|
||||
'Dark · Piers',
|
||||
'Dragon · Raihan'
|
||||
],
|
||||
paldea: [
|
||||
'Bug · Katy',
|
||||
'Grass · Brassius',
|
||||
'Electric · Iono',
|
||||
'Water · Kofu',
|
||||
'Normal · Larry',
|
||||
'Ghost · Ryme',
|
||||
'Psychic · Tulip',
|
||||
'Ice · Grusha'
|
||||
]
|
||||
};
|
||||
if (
|
||||
/legends-za|mega-dimension|champions|isle-of-armor|crown-tundra|teal-mask|indigo-disk/.test(
|
||||
slug
|
||||
)
|
||||
) {
|
||||
if (slug.includes('isle-of-armor'))
|
||||
['Master dojo trials', 'Tower of Darkness / Waters', 'Final battle with Mustard'].forEach(
|
||||
(t) => add(t, 'boss')
|
||||
);
|
||||
if (slug.includes('crown-tundra'))
|
||||
[
|
||||
'Calyrex expedition',
|
||||
'Legendary giants expedition',
|
||||
'Legendary birds expedition',
|
||||
'Galarian Star Tournament'
|
||||
].forEach((t) => add(t, 'event'));
|
||||
if (slug.includes('teal-mask'))
|
||||
['Festival of Masks', 'Loyal Three battles', 'Ogerpon', 'Final battle with Kieran'].forEach(
|
||||
(t) => add(t, 'boss')
|
||||
);
|
||||
if (slug.includes('indigo-disk'))
|
||||
['Crispin', 'Amarys', 'Lacey', 'Drayton', 'Kieran', 'Terapagos'].forEach((t) =>
|
||||
add(t, 'boss')
|
||||
);
|
||||
} else {
|
||||
(badges[region] ?? []).forEach((t) => add(t, 'badge'));
|
||||
const leagues: Record<string, string[]> = {
|
||||
kanto: ['Lorelei', 'Bruno', 'Agatha', 'Lance'],
|
||||
johto: ['Will', 'Koga', 'Bruno', 'Karen'],
|
||||
hoenn: ['Sidney', 'Phoebe', 'Glacia', 'Drake'],
|
||||
sinnoh: ['Aaron', 'Bertha', 'Flint', 'Lucian'],
|
||||
unova: ['Shauntal', 'Grimsley', 'Caitlin', 'Marshal'],
|
||||
kalos: ['Malva', 'Siebold', 'Wikstrom', 'Drasna'],
|
||||
alola: [slug.startsWith('ultra-') ? 'Molayne' : 'Hala', 'Olivia', 'Acerola', 'Kahili'],
|
||||
paldea: ['Rika', 'Poppy', 'Larry', 'Hassel']
|
||||
};
|
||||
(leagues[region] ?? []).forEach((t) => add(`Elite Four · ${t}`, 'league'));
|
||||
const champion: Record<string, string> = {
|
||||
kanto: slug.startsWith('lets-go') ? 'Trace' : 'Blue',
|
||||
johto: 'Lance',
|
||||
hoenn: slug === 'emerald' ? 'Wallace' : 'Steven',
|
||||
sinnoh: 'Cynthia',
|
||||
unova: slug.endsWith('-2') ? 'Iris' : 'Alder',
|
||||
kalos: 'Diantha',
|
||||
galar: 'Leon',
|
||||
paldea: 'Geeta'
|
||||
};
|
||||
if (champion[region])
|
||||
add(
|
||||
`Champion · ${champion[region]}`,
|
||||
'league',
|
||||
region === 'unova' && !slug.endsWith('-2') ? 'postgame' : 'story'
|
||||
);
|
||||
if (region === 'sinnoh') {
|
||||
[
|
||||
'Commander Mars · Valley Windworks',
|
||||
'Commander Jupiter · Eterna',
|
||||
'Team Galactic · Lake battles',
|
||||
'Cyrus · Galactic HQ',
|
||||
slug === 'platinum' ? 'Cyrus · Distortion World' : 'Cyrus · Spear Pillar'
|
||||
].forEach((t) => add(t, 'boss'));
|
||||
['National Pokédex', 'Battle Zone', 'Stark Mountain · Heatran', 'Battle Tower'].forEach((t) =>
|
||||
add(t, 'event', 'postgame')
|
||||
);
|
||||
}
|
||||
if (region === 'johto') {
|
||||
['Brock', 'Misty', 'Lt. Surge', 'Erika', 'Janine', 'Sabrina', 'Blaine', 'Blue'].forEach((t) =>
|
||||
add(`Kanto badge · ${t}`, 'badge', 'postgame')
|
||||
);
|
||||
add('Red · Mt. Silver', 'boss', 'postgame');
|
||||
}
|
||||
if (region === 'kanto') {
|
||||
add('Team Rocket · Silph Co.', 'boss');
|
||||
add('Cerulean Cave · Mewtwo', 'event', 'postgame');
|
||||
}
|
||||
if (region === 'hoenn') {
|
||||
add('Team Aqua / Magma finale', 'boss');
|
||||
add('Legendary weather Pokémon', 'event');
|
||||
if (['omega-ruby', 'alpha-sapphire'].includes(slug))
|
||||
add('Delta Episode', 'event', 'postgame');
|
||||
}
|
||||
if (region === 'unova') ['N', 'Ghetsis'].forEach((t) => add(`Team Plasma · ${t}`, 'boss'));
|
||||
if (region === 'kalos') add('Lysandre · Team Flare finale', 'boss');
|
||||
if (region === 'alola') {
|
||||
[
|
||||
'Verdant Cavern',
|
||||
'Brooklet Hill',
|
||||
'Wela Volcano Park',
|
||||
'Lush Jungle',
|
||||
'Hokulani Observatory',
|
||||
'Thrifty Megamart',
|
||||
'Vast Poni Canyon'
|
||||
].forEach((t) => add(`Trial · ${t}`, 'badge'));
|
||||
['Hala', 'Olivia', 'Nanu', 'Hapu'].forEach((t) => add(`Grand trial · ${t}`, 'boss'));
|
||||
add(slug.startsWith('ultra-') ? 'Ultra Necrozma' : 'Lusamine', 'boss');
|
||||
add(slug.startsWith('ultra-') ? 'Title match · Hau' : 'Title match · Kukui', 'league');
|
||||
}
|
||||
if (region === 'galar')
|
||||
['Champion Cup semifinals', 'Champion Cup finals', 'Rose', 'Eternatus'].forEach((t) =>
|
||||
add(t, 'boss')
|
||||
);
|
||||
if (region === 'hisui') {
|
||||
['Kleavor', 'Lilligant', 'Arcanine', 'Electrode', 'Avalugg'].forEach((t) =>
|
||||
add(`Noble · ${t}`, 'boss')
|
||||
);
|
||||
add('Dialga & Palkia', 'boss');
|
||||
add('Volo & Giratina', 'boss', 'postgame');
|
||||
add('Arceus', 'boss', 'postgame');
|
||||
}
|
||||
if (region === 'paldea') {
|
||||
[
|
||||
'Klawf',
|
||||
'Bombirdier',
|
||||
'Orthworm',
|
||||
'Great Tusk / Iron Treads',
|
||||
'Dondozo & Tatsugiri'
|
||||
].forEach((t) => add(`Titan · ${t}`, 'boss'));
|
||||
['Giacomo', 'Mela', 'Atticus', 'Ortega', 'Eri', 'Cassiopeia'].forEach((t) =>
|
||||
add(`Team Star · ${t}`, 'boss')
|
||||
);
|
||||
['Arven', 'Nemona', 'The Way Home'].forEach((t) => add(t, 'boss'));
|
||||
add('Academy Ace Tournament', 'event', 'postgame');
|
||||
}
|
||||
if (region === 'orre')
|
||||
(slug === 'xd'
|
||||
? ['Lovrina', 'Snattle', 'Gorigan', 'Greevil']
|
||||
: ['Miror B.', 'Dakim', 'Venus', 'Ein', 'Nascour', 'Evice']
|
||||
).forEach((t) => add(`Cipher · ${t}`, 'boss'));
|
||||
}
|
||||
add('Finish my in-game collection', 'collection', 'postgame');
|
||||
['Trade evolutions', 'Version exclusives', 'Transfers from other games', 'Event Pokémon'].forEach(
|
||||
(t) => add(t, 'collection', 'collection')
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
108
src/lib/game-tracker/progress.test.ts
Normal file
108
src/lib/game-tracker/progress.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { combinedProgress, fraction, challengeProgress, pokemonProgress } from './progress';
|
||||
import { milestonesFor } from './pokemon-world';
|
||||
|
||||
const input: Parameters<typeof pokemonProgress>[0] = {
|
||||
games: [{ id: 1, name: 'Platinum', slug: 'platinum', regions: ['sinnoh'] }],
|
||||
entries: [
|
||||
{ id: 1, gameId: 1, pokemonId: 387, availability: 'catchable' },
|
||||
{ id: 2, gameId: 1, pokemonId: 399, availability: 'catchable' },
|
||||
{ id: 3, gameId: 1, pokemonId: 1, availability: 'transfer_only' },
|
||||
{ id: 4, gameId: 1, pokemonId: 99, availability: 'unknown' },
|
||||
{ id: 5, gameId: 2, pokemonId: 387, availability: 'catchable' }
|
||||
],
|
||||
caughtIds: [],
|
||||
seen: [],
|
||||
journeys: [],
|
||||
milestones: [],
|
||||
dex: [
|
||||
{ gameId: 1, pokemonId: 387 },
|
||||
{ gameId: 1, pokemonId: 399 },
|
||||
{ gameId: 1, pokemonId: 387 }
|
||||
],
|
||||
speciesTotal: 1025
|
||||
};
|
||||
|
||||
describe('tracker completion calculations', () => {
|
||||
it('balances categories, omits empty catalogs and clamps fractions', () => {
|
||||
expect(
|
||||
fraction(
|
||||
combinedProgress([
|
||||
{ value: 1, total: 2 },
|
||||
{ value: 100, total: 100 },
|
||||
{ value: 0, total: 0 }
|
||||
])
|
||||
)
|
||||
).toBe(0.75);
|
||||
expect(fraction({ value: 3, total: 2 })).toBe(1);
|
||||
expect(fraction({ value: 0, total: 0 })).toBe(0);
|
||||
expect(fraction(combinedProgress([]))).toBe(0);
|
||||
});
|
||||
it('credits partial challenge targets and explicit unlocks without overcounting', () => {
|
||||
const result = challengeProgress(
|
||||
[
|
||||
{ id: 1, target: 100 },
|
||||
{ id: 2, target: null },
|
||||
{ id: 3, target: 5 }
|
||||
],
|
||||
[
|
||||
{ challengeId: 1, current: 25, done: false },
|
||||
{ challengeId: 2, current: 0, done: true },
|
||||
{ challengeId: 3, current: 99, done: false }
|
||||
]
|
||||
);
|
||||
expect(result).toEqual({ value: 2.25, total: 3 });
|
||||
});
|
||||
it('counts a species once globally, once per game, and caught implies seen', () => {
|
||||
const result = pokemonProgress({
|
||||
...input,
|
||||
games: [...input.games, { id: 2, name: 'Diamond', slug: 'diamond', regions: ['sinnoh'] }],
|
||||
caughtIds: [1, 5],
|
||||
seen: [
|
||||
{ gameId: 1, pokemonId: 387 },
|
||||
{ gameId: 1, pokemonId: 399 }
|
||||
]
|
||||
});
|
||||
expect(result.caught.value).toBe(1);
|
||||
expect(result.seen.value).toBe(2);
|
||||
expect(result.collection.value).toBe(2);
|
||||
expect(result.games[0].regional).toEqual({ value: 1, total: 2 });
|
||||
});
|
||||
it('includes confirmed trade/transfer targets, excludes unknowns and scopes selected games', () => {
|
||||
const result = pokemonProgress({
|
||||
...input,
|
||||
caughtIds: [1, 5],
|
||||
seen: [{ gameId: 2, pokemonId: 999 }]
|
||||
});
|
||||
expect(result.games[0].collection).toEqual({ value: 1, total: 3 });
|
||||
expect(result.games[0].inGame).toEqual({ value: 1, total: 2 });
|
||||
expect(result.seen.value).toBe(1);
|
||||
expect(pokemonProgress({ ...input, games: [] }).completed).toEqual({ value: 0, total: 0 });
|
||||
});
|
||||
it('allows real catches to confirm unknown availability', () => {
|
||||
expect(pokemonProgress({ ...input, caughtIds: [4] }).games[0].collection).toEqual({
|
||||
value: 1,
|
||||
total: 4
|
||||
});
|
||||
});
|
||||
it('credits story completion without pretending all collection tasks are complete', () => {
|
||||
const before = pokemonProgress(input);
|
||||
const after = pokemonProgress({ ...input, journeys: [{ gameId: 1, completed: true }] });
|
||||
expect(after.completed.value).toBe(1);
|
||||
expect(fraction(after.games[0].overall)).toBeGreaterThan(fraction(before.games[0].overall));
|
||||
expect(fraction(after.games[0].overall)).toBeLessThan(1);
|
||||
expect(after.games[0].badges.value).toBe(0);
|
||||
});
|
||||
it('infers a cleared game from all default story milestones, not arbitrary custom ones', () => {
|
||||
const milestones = milestonesFor('platinum', 'sinnoh')
|
||||
.filter((m) => m.stage === 'story')
|
||||
.map((m) => ({ ...m, gameId: 1, done: true }));
|
||||
expect(pokemonProgress({ ...input, milestones }).completed.value).toBe(1);
|
||||
expect(
|
||||
pokemonProgress({
|
||||
...input,
|
||||
milestones: [{ gameId: 1, key: 'custom:win', stage: 'story', category: 'boss', done: true }]
|
||||
}).completed.value
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
125
src/lib/game-tracker/progress.ts
Normal file
125
src/lib/game-tracker/progress.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { obtainableAvailability, type Availability } from '$lib/availability';
|
||||
import { gameRegions, milestonesFor } from './pokemon-world';
|
||||
|
||||
export type ProgressCount = { value: number; total: number };
|
||||
export const fraction = ({ value, total }: ProgressCount) =>
|
||||
total > 0 ? Math.max(0, Math.min(1, value / total)) : 0;
|
||||
export function combinedProgress(parts: ProgressCount[]): ProgressCount {
|
||||
const available = parts.filter((p) => p.total > 0);
|
||||
return { value: available.reduce((n, p) => n + fraction(p), 0), total: available.length };
|
||||
}
|
||||
export function sumProgress(parts: ProgressCount[]): ProgressCount {
|
||||
return parts.reduce((a, p) => ({ value: a.value + p.value, total: a.total + p.total }), {
|
||||
value: 0,
|
||||
total: 0
|
||||
});
|
||||
}
|
||||
export function challengeProgress(
|
||||
challenges: { id: number; target: number | null }[],
|
||||
progress: { challengeId: number; current: number; done: boolean }[]
|
||||
) {
|
||||
const saved = new Map(progress.map((p) => [p.challengeId, p]));
|
||||
return {
|
||||
value: challenges.reduce((n, c) => {
|
||||
const p = saved.get(c.id);
|
||||
return n + (p?.done ? 1 : fraction({ value: p?.current ?? 0, total: c.target ?? 0 }));
|
||||
}, 0),
|
||||
total: challenges.length
|
||||
};
|
||||
}
|
||||
|
||||
export function pokemonProgress(input: {
|
||||
games: { id: number; name: string; slug: string | null; regions: string[] }[];
|
||||
entries: { id: number; gameId: number; pokemonId: number; availability: Availability }[];
|
||||
caughtIds: number[];
|
||||
seen: { gameId: number; pokemonId: number }[];
|
||||
journeys: { gameId: number; completed: boolean }[];
|
||||
milestones: { gameId: number; key: string; stage: string; category: string; done: boolean }[];
|
||||
dex: { gameId: number; pokemonId: number }[];
|
||||
speciesTotal: number;
|
||||
}) {
|
||||
const caught = new Set(input.caughtIds);
|
||||
const entries = Map.groupBy(input.entries, (e) => e.gameId);
|
||||
const sightings = Map.groupBy(input.seen, (e) => e.gameId);
|
||||
const milestones = Map.groupBy(input.milestones, (m) => m.gameId);
|
||||
const dexes = Map.groupBy(input.dex, (d) => d.gameId);
|
||||
const allCaught = new Set<number>();
|
||||
const allSeen = new Set<number>();
|
||||
const games = input.games.map((game) => {
|
||||
const list = entries.get(game.id) ?? [];
|
||||
const caughtSpecies = new Set(list.filter((e) => caught.has(e.id)).map((e) => e.pokemonId));
|
||||
const seenSpecies = new Set([
|
||||
...caughtSpecies,
|
||||
...(sightings.get(game.id) ?? []).map((s) => s.pokemonId)
|
||||
]);
|
||||
for (const id of caughtSpecies) allCaught.add(id);
|
||||
for (const id of seenSpecies) allSeen.add(id);
|
||||
const collect = (ids: number[], source = caughtSpecies): ProgressCount => {
|
||||
const unique = new Set(ids);
|
||||
return { value: [...unique].filter((id) => source.has(id)).length, total: unique.size };
|
||||
};
|
||||
const collection = collect(
|
||||
list
|
||||
.filter((e) => e.availability !== 'unknown' || caughtSpecies.has(e.pokemonId))
|
||||
.map((e) => e.pokemonId)
|
||||
);
|
||||
const inGame = collect(
|
||||
list.filter((e) => obtainableAvailability.has(e.availability)).map((e) => e.pokemonId)
|
||||
);
|
||||
const seen = collect(
|
||||
list.map((e) => e.pokemonId),
|
||||
seenSpecies
|
||||
);
|
||||
const regionalIds = (dexes.get(game.id) ?? []).map((d) => d.pokemonId);
|
||||
const regional = collect(regionalIds);
|
||||
const regionalSeen = collect(regionalIds, seenSpecies);
|
||||
const saved = new Map((milestones.get(game.id) ?? []).map((m) => [m.key, m]));
|
||||
const defaults = milestonesFor(
|
||||
game.slug ?? '',
|
||||
gameRegions(game.slug ?? '', game.regions)[0] ?? ''
|
||||
);
|
||||
const count = (rows: { key: string }[]): ProgressCount => ({
|
||||
value: rows.filter((m) => saved.get(m.key)?.done).length,
|
||||
total: rows.length
|
||||
});
|
||||
const story = count(defaults.filter((m) => m.stage === 'story'));
|
||||
const postgame = count(defaults.filter((m) => m.stage === 'postgame'));
|
||||
const transfers = count(defaults.filter((m) => m.stage === 'collection'));
|
||||
const badges = count(defaults.filter((m) => m.category === 'badge'));
|
||||
const league = count(defaults.filter((m) => m.category === 'league'));
|
||||
const bosses = count(defaults.filter((m) => m.category === 'boss'));
|
||||
const custom = count([...saved.values()].filter((m) => m.key.startsWith('custom:')));
|
||||
const completed =
|
||||
input.journeys.some((j) => j.gameId === game.id && j.completed) ||
|
||||
(story.total > 0 && story.value === story.total);
|
||||
const adventure = { value: story.value + Number(completed), total: story.total + 1 };
|
||||
return {
|
||||
id: game.id,
|
||||
name: game.name,
|
||||
slug: game.slug,
|
||||
collection,
|
||||
inGame,
|
||||
seen,
|
||||
regional,
|
||||
regionalSeen,
|
||||
story,
|
||||
adventure,
|
||||
postgame,
|
||||
transfers,
|
||||
badges,
|
||||
league,
|
||||
bosses,
|
||||
custom,
|
||||
completed,
|
||||
overall: combinedProgress([collection, adventure, postgame, transfers])
|
||||
};
|
||||
});
|
||||
return {
|
||||
games,
|
||||
caught: { value: allCaught.size, total: input.speciesTotal },
|
||||
seen: { value: allSeen.size, total: input.speciesTotal },
|
||||
completed: { value: games.filter((g) => g.completed).length, total: games.length },
|
||||
collection: sumProgress(games.map((g) => g.collection)),
|
||||
overall: combinedProgress(games.map((g) => g.overall))
|
||||
};
|
||||
}
|
||||
43
src/lib/game-tracker/rank-limits.ts
Normal file
43
src/lib/game-tracker/rank-limits.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { MasteryRules } from './mastery';
|
||||
|
||||
export type RankLimits = {
|
||||
maxLevel: number | null;
|
||||
maxPrestige: number;
|
||||
canPrestigeOnlyAtMaxLevel: boolean;
|
||||
rankable: boolean;
|
||||
};
|
||||
|
||||
const capsFor = (rules: MasteryRules) => rules.rankCaps;
|
||||
|
||||
export function playerRankLimits(rules: MasteryRules): RankLimits {
|
||||
const caps = capsFor(rules)?.player;
|
||||
return {
|
||||
maxLevel: caps?.level ?? null,
|
||||
maxPrestige: caps?.prestige ?? 1000,
|
||||
canPrestigeOnlyAtMaxLevel: false,
|
||||
rankable: true
|
||||
};
|
||||
}
|
||||
|
||||
export function weaponRankLimits(
|
||||
rules: MasteryRules,
|
||||
weapon: Pick<{ category: string; name: string }, 'category' | 'name'>
|
||||
): RankLimits {
|
||||
const caps = capsFor(rules);
|
||||
if (!caps) {
|
||||
return {
|
||||
maxLevel: null,
|
||||
maxPrestige: 1000,
|
||||
canPrestigeOnlyAtMaxLevel: false,
|
||||
rankable: true
|
||||
};
|
||||
}
|
||||
const maxLevel =
|
||||
caps.weaponLevelOverrides?.[weapon.name] ?? caps.weaponLevelsByClass[weapon.category] ?? null;
|
||||
return {
|
||||
maxLevel,
|
||||
maxPrestige: caps.weaponPrestige,
|
||||
canPrestigeOnlyAtMaxLevel: true,
|
||||
rankable: maxLevel !== null
|
||||
};
|
||||
}
|
||||
@@ -39,12 +39,17 @@ const publicPages: Record<string, { title: string; description: string }> = {
|
||||
'/projects/game-tracker': {
|
||||
title: 'Game Tracker',
|
||||
description:
|
||||
'Completionist progress for every Pokémon in every game and Call of Duty achievements, weapons and camos.'
|
||||
'Pokémon adventures and collections, Call of Duty ranks and unlocks, and osu! scores and training.'
|
||||
},
|
||||
'/projects/game-tracker/pokemon': {
|
||||
title: 'Catchemall',
|
||||
title: 'Pokémon journal',
|
||||
description:
|
||||
'Track which Pokémon you have caught across 53 games, with where to find them and how to evolve or breed them.'
|
||||
'Explore Pokémon encounters and regional Pokédexes, record catches and teams, and track badges across your playthroughs.'
|
||||
},
|
||||
'/projects/game-tracker/osu': {
|
||||
title: 'osu! training',
|
||||
description:
|
||||
'Keep memorable scores, beatmap goals and a training journal for speed, stamina and consistency.'
|
||||
},
|
||||
'/projects/game-tracker/pokemon/locations': {
|
||||
title: 'Pokémon locations',
|
||||
|
||||
@@ -5,6 +5,7 @@ export * from './blog';
|
||||
export * from './progress';
|
||||
export * from './catch-em-all';
|
||||
export * from './game-tracker';
|
||||
export * from './tracker-journal';
|
||||
export * from './animaldex';
|
||||
export * from './lists';
|
||||
export * from './money-tracker';
|
||||
|
||||
299
src/lib/server/db/schema/tracker-journal.ts
Normal file
299
src/lib/server/db/schema/tracker-journal.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
import {
|
||||
boolean,
|
||||
check,
|
||||
date,
|
||||
index,
|
||||
integer,
|
||||
jsonb,
|
||||
pgTable,
|
||||
text,
|
||||
unique
|
||||
} from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { user } from './auth';
|
||||
import { games, pokemon } from './catch-em-all';
|
||||
import { trackerGames, trackerItems } from './game-tracker';
|
||||
import { id, timestamps } from './helpers';
|
||||
|
||||
export const pokemonResearch = pgTable('pokemon_research', {
|
||||
pokemonId: integer('pokemon_id')
|
||||
.primaryKey()
|
||||
.references(() => pokemon.id, { onDelete: 'cascade' }),
|
||||
facts: jsonb('facts')
|
||||
.$type<{
|
||||
height: number;
|
||||
weight: number;
|
||||
baseExperience: number | null;
|
||||
captureRate: number;
|
||||
baseHappiness: number | null;
|
||||
growthRate: string;
|
||||
genderRate: number;
|
||||
hatchCounter: number | null;
|
||||
eggGroups: string[];
|
||||
abilities: { name: string; hidden: boolean }[];
|
||||
evYield: number[];
|
||||
}>()
|
||||
.notNull()
|
||||
});
|
||||
|
||||
export const pokemonDexNumbers = pgTable(
|
||||
'pokemon_dex_numbers',
|
||||
{
|
||||
id: id(),
|
||||
pokemonId: integer('pokemon_id')
|
||||
.notNull()
|
||||
.references(() => pokemon.id, { onDelete: 'cascade' }),
|
||||
gameId: integer('game_id')
|
||||
.notNull()
|
||||
.references(() => games.id, { onDelete: 'cascade' }),
|
||||
dex: text('dex').notNull(),
|
||||
dexName: text('dex_name').notNull(),
|
||||
number: integer('number').notNull()
|
||||
},
|
||||
(t) => [unique().on(t.pokemonId, t.gameId, t.dex), index().on(t.gameId)]
|
||||
);
|
||||
|
||||
export const pokemonMoves = pgTable('pokemon_moves', {
|
||||
id: integer('id').primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
type: text('type').notNull(),
|
||||
category: text('category').notNull(),
|
||||
power: integer('power'),
|
||||
accuracy: integer('accuracy'),
|
||||
pp: integer('pp'),
|
||||
priority: integer('priority').notNull(),
|
||||
target: text('target').notNull()
|
||||
});
|
||||
|
||||
export const pokemonLearnsets = pgTable(
|
||||
'pokemon_learnsets',
|
||||
{
|
||||
id: id(),
|
||||
pokemonId: integer('pokemon_id')
|
||||
.notNull()
|
||||
.references(() => pokemon.id, { onDelete: 'cascade' }),
|
||||
versionGroup: integer('version_group').notNull(),
|
||||
moves: jsonb('moves').$type<[moveId: number, method: string, level: number][]>().notNull()
|
||||
},
|
||||
(t) => [unique().on(t.pokemonId, t.versionGroup)]
|
||||
);
|
||||
|
||||
export const pokemonGameResearch = pgTable('pokemon_game_research', {
|
||||
gameId: integer('game_id')
|
||||
.primaryKey()
|
||||
.references(() => games.id, { onDelete: 'cascade' }),
|
||||
versionGroup: integer('version_group').notNull(),
|
||||
regions: text('regions').array().notNull()
|
||||
});
|
||||
|
||||
export const pokemonJourneys = pgTable(
|
||||
'pokemon_journeys',
|
||||
{
|
||||
id: id(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
gameId: integer('game_id')
|
||||
.notNull()
|
||||
.references(() => games.id, { onDelete: 'cascade' }),
|
||||
stage: text('stage', { enum: ['story', 'postgame', 'collection'] })
|
||||
.notNull()
|
||||
.default('story'),
|
||||
location: text('location'),
|
||||
completed: boolean('completed').notNull().default(false),
|
||||
...timestamps
|
||||
},
|
||||
(t) => [unique().on(t.userId, t.gameId)]
|
||||
);
|
||||
|
||||
export const pokemonSeen = pgTable(
|
||||
'pokemon_seen',
|
||||
{
|
||||
id: id(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
gameId: integer('game_id')
|
||||
.notNull()
|
||||
.references(() => games.id, { onDelete: 'cascade' }),
|
||||
pokemonId: integer('pokemon_id')
|
||||
.notNull()
|
||||
.references(() => pokemon.id, { onDelete: 'cascade' }),
|
||||
...timestamps
|
||||
},
|
||||
(t) => [unique().on(t.userId, t.gameId, t.pokemonId), index().on(t.userId)]
|
||||
);
|
||||
|
||||
export const pokemonCatches = pgTable(
|
||||
'pokemon_catches',
|
||||
{
|
||||
id: id(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
gameId: integer('game_id')
|
||||
.notNull()
|
||||
.references(() => games.id, { onDelete: 'cascade' }),
|
||||
pokemonId: integer('pokemon_id')
|
||||
.notNull()
|
||||
.references(() => pokemon.id, { onDelete: 'cascade' }),
|
||||
nickname: text('nickname').notNull().default(''),
|
||||
level: integer('level').notNull().default(1),
|
||||
moves: text('moves').array().notNull().default([]),
|
||||
partySlot: integer('party_slot'),
|
||||
shiny: boolean('shiny').notNull().default(false),
|
||||
nature: text('nature'),
|
||||
ability: text('ability'),
|
||||
ball: text('ball'),
|
||||
location: text('location'),
|
||||
obtained: text('obtained').notNull().default('caught'),
|
||||
stage: text('stage', { enum: ['story', 'postgame', 'collection'] })
|
||||
.notNull()
|
||||
.default('story'),
|
||||
notes: text('notes').notNull().default(''),
|
||||
caughtOn: date('caught_on'),
|
||||
...timestamps
|
||||
},
|
||||
(t) => [
|
||||
index().on(t.userId, t.gameId, t.pokemonId),
|
||||
unique().on(t.userId, t.gameId, t.partySlot),
|
||||
check('pokemon_catch_level', sql`${t.level} between 1 and 100`),
|
||||
check('pokemon_party_slot', sql`${t.partySlot} between 1 and 6`),
|
||||
check('pokemon_four_moves', sql`cardinality(${t.moves}) <= 4`)
|
||||
]
|
||||
);
|
||||
|
||||
export const pokemonMilestones = pgTable(
|
||||
'pokemon_milestones',
|
||||
{
|
||||
id: id(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
gameId: integer('game_id')
|
||||
.notNull()
|
||||
.references(() => games.id, { onDelete: 'cascade' }),
|
||||
key: text('key').notNull(),
|
||||
title: text('title').notNull(),
|
||||
category: text('category').notNull(),
|
||||
stage: text('stage').notNull().default('story'),
|
||||
done: boolean('done').notNull().default(false),
|
||||
notes: text('notes').notNull().default(''),
|
||||
...timestamps
|
||||
},
|
||||
(t) => [unique().on(t.userId, t.gameId, t.key)]
|
||||
);
|
||||
|
||||
export const trackerRanks = pgTable(
|
||||
'tracker_ranks',
|
||||
{
|
||||
id: id(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
gameId: integer('game_id')
|
||||
.notNull()
|
||||
.references(() => trackerGames.id, { onDelete: 'cascade' }),
|
||||
level: integer('level').notNull().default(1),
|
||||
prestige: integer('prestige').notNull().default(0),
|
||||
...timestamps
|
||||
},
|
||||
(t) => [
|
||||
unique().on(t.userId, t.gameId),
|
||||
check(
|
||||
'tracker_rank_range',
|
||||
sql`${t.level} between 1 and 10000 and ${t.prestige} between 0 and 1000`
|
||||
)
|
||||
]
|
||||
);
|
||||
|
||||
export const trackerWeaponRanks = pgTable(
|
||||
'tracker_weapon_ranks',
|
||||
{
|
||||
id: id(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
itemId: integer('item_id')
|
||||
.notNull()
|
||||
.references(() => trackerItems.id, { onDelete: 'cascade' }),
|
||||
level: integer('level').notNull().default(1),
|
||||
prestige: integer('prestige').notNull().default(0),
|
||||
...timestamps
|
||||
},
|
||||
(t) => [
|
||||
unique().on(t.userId, t.itemId),
|
||||
check(
|
||||
'tracker_weapon_rank_range',
|
||||
sql`${t.level} between 1 and 10000 and ${t.prestige} between 0 and 1000`
|
||||
)
|
||||
]
|
||||
);
|
||||
|
||||
export const trackerChallenges = pgTable(
|
||||
'tracker_challenges',
|
||||
{
|
||||
id: id(),
|
||||
gameId: integer('game_id')
|
||||
.notNull()
|
||||
.references(() => trackerGames.id, { onDelete: 'cascade' }),
|
||||
key: text('key').notNull(),
|
||||
name: text('name').notNull(),
|
||||
category: text('category').notNull(),
|
||||
requirement: text('requirement').notNull(),
|
||||
reward: text('reward'),
|
||||
icon: text('icon'),
|
||||
target: integer('target'),
|
||||
sourceUrl: text('source_url').notNull()
|
||||
},
|
||||
(t) => [unique().on(t.gameId, t.key)]
|
||||
);
|
||||
|
||||
export const trackerChallengeProgress = pgTable(
|
||||
'tracker_challenge_progress',
|
||||
{
|
||||
id: id(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
challengeId: integer('challenge_id')
|
||||
.notNull()
|
||||
.references(() => trackerChallenges.id, { onDelete: 'cascade' }),
|
||||
current: integer('current').notNull().default(0),
|
||||
done: boolean('done').notNull().default(false),
|
||||
...timestamps
|
||||
},
|
||||
(t) => [unique().on(t.userId, t.challengeId)]
|
||||
);
|
||||
|
||||
export const osuEntries = pgTable(
|
||||
'osu_entries',
|
||||
{
|
||||
id: id(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => user.id, { onDelete: 'cascade' }),
|
||||
kind: text('kind', { enum: ['score', 'goal', 'training'] }).notNull(),
|
||||
title: text('title').notNull(),
|
||||
mode: text('mode').notNull().default('osu'),
|
||||
beatmapUrl: text('beatmap_url'),
|
||||
scoreUrl: text('score_url'),
|
||||
goal: text('goal').notNull().default('pass'),
|
||||
skill: text('skill').notNull().default('stamina'),
|
||||
bpm: integer('bpm'),
|
||||
duration: integer('duration'),
|
||||
accuracy: text('accuracy'),
|
||||
combo: integer('combo'),
|
||||
misses: integer('misses'),
|
||||
pp: text('pp'),
|
||||
mods: text('mods').notNull().default('NM'),
|
||||
rate: text('rate').notNull().default('1.00'),
|
||||
achieved: boolean('achieved').notNull().default(false),
|
||||
playedOn: date('played_on').notNull(),
|
||||
notes: text('notes').notNull().default(''),
|
||||
...timestamps
|
||||
},
|
||||
(t) => [index().on(t.userId, t.kind)]
|
||||
);
|
||||
|
||||
export type PokemonCatch = typeof pokemonCatches.$inferSelect;
|
||||
@@ -1,5 +1,6 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { and, asc, eq, inArray } from 'drizzle-orm';
|
||||
import { pokemonResearch, pokemonGameResearch, pokemonLearnsets, pokemonMoves } from './db/schema';
|
||||
import { db } from '$lib/server/db';
|
||||
import {
|
||||
games,
|
||||
@@ -104,8 +105,50 @@ export async function pokemonDetail(number: number, gameId: number | null) {
|
||||
)
|
||||
: [];
|
||||
|
||||
const [research] = await db
|
||||
.select()
|
||||
.from(pokemonResearch)
|
||||
.where(eq(pokemonResearch.pokemonId, species.id));
|
||||
const [gameResearch] = gameId
|
||||
? await db.select().from(pokemonGameResearch).where(eq(pokemonGameResearch.gameId, gameId))
|
||||
: [];
|
||||
const [learnset] = gameResearch
|
||||
? await db
|
||||
.select()
|
||||
.from(pokemonLearnsets)
|
||||
.where(
|
||||
and(
|
||||
eq(pokemonLearnsets.pokemonId, species.id),
|
||||
eq(pokemonLearnsets.versionGroup, gameResearch.versionGroup)
|
||||
)
|
||||
)
|
||||
: [];
|
||||
const moveIds = [...new Set((learnset?.moves ?? []).map((m) => m[0]))];
|
||||
const moves = moveIds.length
|
||||
? await db.select().from(pokemonMoves).where(inArray(pokemonMoves.id, moveIds))
|
||||
: [];
|
||||
const moveMap = new Map(moves.map((m) => [m.id, m]));
|
||||
|
||||
const numbers = new Map(members.map((member) => [member.id, member.number]));
|
||||
return {
|
||||
facts: research?.facts ?? null,
|
||||
stats: [
|
||||
{ name: 'HP', value: species.hp },
|
||||
{ name: 'Attack', value: species.attack },
|
||||
{ name: 'Defense', value: species.defense },
|
||||
{ name: 'Sp. Atk', value: species.specialAttack },
|
||||
{ name: 'Sp. Def', value: species.specialDefense },
|
||||
{ name: 'Speed', value: species.speed }
|
||||
],
|
||||
learnset: (learnset?.moves ?? [])
|
||||
.flatMap(([id, method, level]) => {
|
||||
const move = moveMap.get(id);
|
||||
return move ? [{ ...move, method, level }] : [];
|
||||
})
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.method.localeCompare(b.method) || a.level - b.level || a.name.localeCompare(b.name)
|
||||
),
|
||||
pokemon: {
|
||||
number: species.number,
|
||||
name: species.name,
|
||||
|
||||
278
src/lib/server/pokemon-journal.ts
Normal file
278
src/lib/server/pokemon-journal.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import { and, asc, eq, sql } from 'drizzle-orm';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import type { Actions } from '@sveltejs/kit';
|
||||
import { db } from './db';
|
||||
import {
|
||||
games,
|
||||
pokemon,
|
||||
pokemonCatches,
|
||||
pokemonJourneys,
|
||||
pokemonMilestones,
|
||||
pokemonSeen,
|
||||
pokemonGameEntries,
|
||||
userEntries
|
||||
} from './db/schema';
|
||||
import { boundedInteger, isStage, obtainedMethods, validDate } from '$lib/game-tracker/journal';
|
||||
|
||||
// Serialize mutations for one user's game, including competing party assignments.
|
||||
async function lockGame(
|
||||
tx: Parameters<Parameters<typeof db.transaction>[0]>[0],
|
||||
userId: string,
|
||||
gameId: number
|
||||
) {
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${userId}), ${gameId})`);
|
||||
}
|
||||
|
||||
export const pokemonJournalActions = {
|
||||
setSeen: async ({ request, locals }) => {
|
||||
if (!locals.user) return fail(401, { message: 'Sign in to record a sighting.' });
|
||||
const form = await request.formData();
|
||||
const gameId = boundedInteger(form.get('gameId'), 1, 2147483647);
|
||||
const pokemonId = boundedInteger(form.get('pokemonId'), 1, 2147483647);
|
||||
const seen = form.get('seen');
|
||||
if (!gameId || !pokemonId || !['on', 'off'].includes(String(seen)))
|
||||
return fail(400, { message: 'Invalid sighting.' });
|
||||
const [entry] = await db
|
||||
.select({ id: pokemonGameEntries.id })
|
||||
.from(pokemonGameEntries)
|
||||
.where(
|
||||
and(eq(pokemonGameEntries.gameId, gameId), eq(pokemonGameEntries.pokemonId, pokemonId))
|
||||
);
|
||||
if (!entry) return fail(404, { message: 'Species not found in this game.' });
|
||||
return db.transaction(async (tx) => {
|
||||
await lockGame(tx, locals.user!.id, gameId);
|
||||
if (seen === 'on')
|
||||
await tx
|
||||
.insert(pokemonSeen)
|
||||
.values({ userId: locals.user!.id, gameId, pokemonId })
|
||||
.onConflictDoNothing();
|
||||
else {
|
||||
const [caught] = await tx
|
||||
.select({ id: userEntries.id })
|
||||
.from(userEntries)
|
||||
.where(
|
||||
and(
|
||||
eq(userEntries.userId, locals.user!.id),
|
||||
eq(userEntries.pokemonGameEntryId, entry.id)
|
||||
)
|
||||
);
|
||||
if (caught) return fail(400, { message: 'Caught Pokémon also count as seen.' });
|
||||
await tx
|
||||
.delete(pokemonSeen)
|
||||
.where(
|
||||
and(
|
||||
eq(pokemonSeen.userId, locals.user!.id),
|
||||
eq(pokemonSeen.gameId, gameId),
|
||||
eq(pokemonSeen.pokemonId, pokemonId)
|
||||
)
|
||||
);
|
||||
}
|
||||
return { saved: true };
|
||||
});
|
||||
},
|
||||
setCompleted: async ({ request, locals }) => {
|
||||
if (!locals.user) return fail(401, { message: 'Sign in to record a completed game.' });
|
||||
const form = await request.formData();
|
||||
const gameId = boundedInteger(form.get('gameId'), 1, 2147483647);
|
||||
if (!gameId || !['on', 'off'].includes(String(form.get('completed'))))
|
||||
return fail(400, { message: 'Invalid completion state.' });
|
||||
if (!(await db.select({ id: games.id }).from(games).where(eq(games.id, gameId))).length)
|
||||
return fail(404, { message: 'Game not found.' });
|
||||
const completed = form.get('completed') === 'on';
|
||||
await db
|
||||
.insert(pokemonJourneys)
|
||||
.values({ userId: locals.user.id, gameId, completed })
|
||||
.onConflictDoUpdate({
|
||||
target: [pokemonJourneys.userId, pokemonJourneys.gameId],
|
||||
set: { completed, updatedAt: new Date() }
|
||||
});
|
||||
return { saved: true };
|
||||
},
|
||||
saveCatch: async ({ request, locals }) => {
|
||||
if (!locals.user) return fail(401, { message: 'Sign in to register a catch.' });
|
||||
const data = await request.formData();
|
||||
const gameId = boundedInteger(data.get('gameId'), 1, 2147483647);
|
||||
const pokemonId = boundedInteger(data.get('pokemonId'), 1, 2147483647);
|
||||
const id = data.get('id') ? boundedInteger(data.get('id'), 1, 2147483647) : null;
|
||||
const level = boundedInteger(data.get('level'), 1, 100);
|
||||
const partySlot = data.get('partySlot') ? boundedInteger(data.get('partySlot'), 1, 6) : null;
|
||||
const text = (key: string) => String(data.get(key) ?? '').trim();
|
||||
const stage = text('stage');
|
||||
const obtained = text('obtained');
|
||||
const moves = data
|
||||
.getAll('moves')
|
||||
.map((v) => String(v).trim())
|
||||
.filter(Boolean);
|
||||
if (
|
||||
!gameId ||
|
||||
!pokemonId ||
|
||||
!level ||
|
||||
(data.get('id') && !id) ||
|
||||
(data.get('partySlot') && !partySlot) ||
|
||||
!isStage(stage) ||
|
||||
!(obtainedMethods as readonly string[]).includes(obtained) ||
|
||||
moves.length > 4 ||
|
||||
moves.some((m) => m.length > 60) ||
|
||||
new Set(moves).size !== moves.length ||
|
||||
text('nickname').length > 50 ||
|
||||
text('notes').length > 2000 ||
|
||||
['location', 'nature', 'ability', 'ball'].some((k) => text(k).length > 150) ||
|
||||
(text('caughtOn') && !validDate(text('caughtOn')))
|
||||
)
|
||||
return fail(400, {
|
||||
message:
|
||||
'Check the catch details: level 1–100, up to four different moves and a valid date.'
|
||||
});
|
||||
const [entry] = await db
|
||||
.select()
|
||||
.from(pokemonGameEntries)
|
||||
.where(
|
||||
and(eq(pokemonGameEntries.gameId, gameId), eq(pokemonGameEntries.pokemonId, pokemonId))
|
||||
);
|
||||
if (!entry) return fail(400, { message: 'This species has no catalog entry for this game.' });
|
||||
const userId = locals.user.id;
|
||||
const values = {
|
||||
userId,
|
||||
gameId,
|
||||
pokemonId,
|
||||
level,
|
||||
partySlot,
|
||||
stage,
|
||||
obtained,
|
||||
moves,
|
||||
nickname: text('nickname'),
|
||||
notes: text('notes'),
|
||||
shiny: data.get('shiny') === 'on',
|
||||
location: text('location') || null,
|
||||
nature: text('nature') || null,
|
||||
ability: text('ability') || null,
|
||||
ball: text('ball') || null,
|
||||
caughtOn: text('caughtOn') || null
|
||||
};
|
||||
return db.transaction(async (tx) => {
|
||||
await lockGame(tx, userId, gameId);
|
||||
if (id) {
|
||||
const [owned] = await tx
|
||||
.select({ id: pokemonCatches.id })
|
||||
.from(pokemonCatches)
|
||||
.where(
|
||||
and(
|
||||
eq(pokemonCatches.id, id),
|
||||
eq(pokemonCatches.userId, userId),
|
||||
eq(pokemonCatches.gameId, gameId),
|
||||
eq(pokemonCatches.pokemonId, pokemonId)
|
||||
)
|
||||
);
|
||||
if (!owned) return fail(404, { message: 'Catch not found.' });
|
||||
}
|
||||
if (partySlot)
|
||||
await tx
|
||||
.update(pokemonCatches)
|
||||
.set({ partySlot: null })
|
||||
.where(
|
||||
and(
|
||||
eq(pokemonCatches.userId, userId),
|
||||
eq(pokemonCatches.gameId, gameId),
|
||||
eq(pokemonCatches.partySlot, partySlot)
|
||||
)
|
||||
);
|
||||
if (id)
|
||||
await tx
|
||||
.update(pokemonCatches)
|
||||
.set(values)
|
||||
.where(and(eq(pokemonCatches.id, id), eq(pokemonCatches.userId, userId)));
|
||||
else await tx.insert(pokemonCatches).values(values);
|
||||
await tx
|
||||
.insert(userEntries)
|
||||
.values({ userId, pokemonGameEntryId: entry.id })
|
||||
.onConflictDoNothing();
|
||||
await tx.insert(pokemonSeen).values({ userId, gameId, pokemonId }).onConflictDoNothing();
|
||||
return { saved: true };
|
||||
});
|
||||
},
|
||||
deleteCatch: async ({ request, locals }) => {
|
||||
if (!locals.user) return fail(401, { message: 'Sign in to edit your catches.' });
|
||||
const data = await request.formData();
|
||||
const id = boundedInteger(data.get('id'), 1, 2147483647);
|
||||
if (!id) return fail(400, { message: 'Invalid catch.' });
|
||||
const [owned] = await db
|
||||
.select()
|
||||
.from(pokemonCatches)
|
||||
.where(and(eq(pokemonCatches.id, id), eq(pokemonCatches.userId, locals.user.id)));
|
||||
if (!owned) return fail(404, { message: 'Catch not found.' });
|
||||
await db.transaction(async (tx) => {
|
||||
await lockGame(tx, locals.user!.id, owned.gameId);
|
||||
await tx
|
||||
.delete(pokemonCatches)
|
||||
.where(and(eq(pokemonCatches.id, id), eq(pokemonCatches.userId, locals.user!.id)));
|
||||
// The historical caught checkmark deliberately survives deleting a journal record.
|
||||
});
|
||||
return { saved: true };
|
||||
},
|
||||
saveJourney: async ({ request, locals }) => {
|
||||
if (!locals.user) return fail(401, { message: 'Sign in to save your adventure.' });
|
||||
const data = await request.formData();
|
||||
const gameId = boundedInteger(data.get('gameId'), 1, 2147483647);
|
||||
const stage = String(data.get('stage') ?? '');
|
||||
const location = String(data.get('location') ?? '').trim();
|
||||
if (!gameId || !isStage(stage) || location.length > 150)
|
||||
return fail(400, { message: 'Invalid journey.' });
|
||||
if (!(await db.select({ id: games.id }).from(games).where(eq(games.id, gameId))).length)
|
||||
return fail(404, { message: 'Game not found.' });
|
||||
await db
|
||||
.insert(pokemonJourneys)
|
||||
.values({ userId: locals.user.id, gameId, stage, location: location || null })
|
||||
.onConflictDoUpdate({
|
||||
target: [pokemonJourneys.userId, pokemonJourneys.gameId],
|
||||
set: { stage, location: location || null, updatedAt: new Date() }
|
||||
});
|
||||
return { saved: true };
|
||||
},
|
||||
saveMilestone: async ({ request, locals }) => {
|
||||
if (!locals.user) return fail(401, { message: 'Sign in to track milestones.' });
|
||||
const data = await request.formData();
|
||||
const gameId = boundedInteger(data.get('gameId'), 1, 2147483647);
|
||||
const text = (k: string) => String(data.get(k) ?? '').trim();
|
||||
const key = text('key') || `custom:${crypto.randomUUID()}`;
|
||||
if (
|
||||
!gameId ||
|
||||
!isStage(text('stage')) ||
|
||||
!text('title') ||
|
||||
text('title').length > 200 ||
|
||||
text('notes').length > 2000 ||
|
||||
key.length > 200 ||
|
||||
!['badge', 'league', 'boss', 'event', 'collection'].includes(text('category'))
|
||||
)
|
||||
return fail(400, { message: 'Check the milestone details.' });
|
||||
if (!(await db.select({ id: games.id }).from(games).where(eq(games.id, gameId))).length)
|
||||
return fail(404, { message: 'Game not found.' });
|
||||
const values = {
|
||||
userId: locals.user.id,
|
||||
gameId,
|
||||
key,
|
||||
title: text('title'),
|
||||
category: text('category'),
|
||||
stage: text('stage'),
|
||||
notes: text('notes'),
|
||||
done: data.get('done') === 'on'
|
||||
};
|
||||
await db
|
||||
.insert(pokemonMilestones)
|
||||
.values(values)
|
||||
.onConflictDoUpdate({
|
||||
target: [pokemonMilestones.userId, pokemonMilestones.gameId, pokemonMilestones.key],
|
||||
set: values
|
||||
});
|
||||
return { saved: true };
|
||||
}
|
||||
} satisfies Actions;
|
||||
|
||||
export async function catchesFor(userId: string | undefined, gameId: number) {
|
||||
if (!userId) return [];
|
||||
return db
|
||||
.select({ catch: pokemonCatches, number: pokemon.number, name: pokemon.name })
|
||||
.from(pokemonCatches)
|
||||
.innerJoin(pokemon, eq(pokemon.id, pokemonCatches.pokemonId))
|
||||
.where(and(eq(pokemonCatches.userId, userId), eq(pokemonCatches.gameId, gameId)))
|
||||
.orderBy(asc(pokemon.number), asc(pokemonCatches.id));
|
||||
}
|
||||
95
src/lib/server/tracker-progress.ts
Normal file
95
src/lib/server/tracker-progress.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { eq, count } from 'drizzle-orm';
|
||||
import { db } from './db';
|
||||
import { pokemonProgress } from '$lib/game-tracker/progress';
|
||||
import {
|
||||
games,
|
||||
pokemon,
|
||||
pokemonGameEntries,
|
||||
pokemonGameResearch,
|
||||
pokemonDexNumbers,
|
||||
pokemonJourneys,
|
||||
pokemonMilestones,
|
||||
pokemonSeen,
|
||||
userEntries,
|
||||
userGames,
|
||||
catchEmAllSettings
|
||||
} from './db/schema';
|
||||
|
||||
export async function loadPokemonProgress(userId?: string) {
|
||||
const [
|
||||
gameRows,
|
||||
entries,
|
||||
research,
|
||||
dex,
|
||||
caught,
|
||||
seen,
|
||||
journeys,
|
||||
milestones,
|
||||
selected,
|
||||
settings,
|
||||
totals
|
||||
] = await Promise.all([
|
||||
db.select({ id: games.id, name: games.name, slug: games.slug }).from(games),
|
||||
db
|
||||
.select({
|
||||
id: pokemonGameEntries.id,
|
||||
gameId: pokemonGameEntries.gameId,
|
||||
pokemonId: pokemonGameEntries.pokemonId,
|
||||
availability: pokemonGameEntries.availability
|
||||
})
|
||||
.from(pokemonGameEntries),
|
||||
db
|
||||
.select({ gameId: pokemonGameResearch.gameId, regions: pokemonGameResearch.regions })
|
||||
.from(pokemonGameResearch),
|
||||
db
|
||||
.select({ gameId: pokemonDexNumbers.gameId, pokemonId: pokemonDexNumbers.pokemonId })
|
||||
.from(pokemonDexNumbers),
|
||||
userId
|
||||
? db
|
||||
.select({ id: userEntries.pokemonGameEntryId })
|
||||
.from(userEntries)
|
||||
.where(eq(userEntries.userId, userId))
|
||||
: [],
|
||||
userId
|
||||
? db
|
||||
.select({ gameId: pokemonSeen.gameId, pokemonId: pokemonSeen.pokemonId })
|
||||
.from(pokemonSeen)
|
||||
.where(eq(pokemonSeen.userId, userId))
|
||||
: [],
|
||||
userId
|
||||
? db
|
||||
.select({ gameId: pokemonJourneys.gameId, completed: pokemonJourneys.completed })
|
||||
.from(pokemonJourneys)
|
||||
.where(eq(pokemonJourneys.userId, userId))
|
||||
: [],
|
||||
userId ? db.select().from(pokemonMilestones).where(eq(pokemonMilestones.userId, userId)) : [],
|
||||
userId
|
||||
? db.select({ gameId: userGames.gameId }).from(userGames).where(eq(userGames.userId, userId))
|
||||
: [],
|
||||
userId
|
||||
? db
|
||||
.select({ id: catchEmAllSettings.id })
|
||||
.from(catchEmAllSettings)
|
||||
.where(eq(catchEmAllSettings.userId, userId))
|
||||
: [],
|
||||
db.select({ count: count() }).from(pokemon)
|
||||
]);
|
||||
const configured = settings.length > 0 || selected.length > 0;
|
||||
const ids = new Set(selected.map((g) => g.gameId));
|
||||
const regions = new Map(research.map((r) => [r.gameId, r.regions]));
|
||||
return {
|
||||
...pokemonProgress({
|
||||
games: gameRows
|
||||
.filter((g) => !configured || ids.has(g.id))
|
||||
.map((g) => ({ ...g, regions: regions.get(g.id) ?? [] })),
|
||||
entries,
|
||||
dex,
|
||||
caughtIds: caught.map((c) => c.id),
|
||||
seen,
|
||||
journeys,
|
||||
milestones,
|
||||
speciesTotal: totals[0].count
|
||||
}),
|
||||
configured
|
||||
};
|
||||
}
|
||||
@@ -223,6 +223,8 @@
|
||||
>
|
||||
<div class="my-1 h-px bg-border"></div>
|
||||
{#each projects as project (project.name)}
|
||||
<!-- Internal project links use resolve; external URLs are from the project catalog. -->
|
||||
<!-- eslint-disable svelte/no-navigation-without-resolve -->
|
||||
<a
|
||||
href={project.external ? project.href : resolve(project.href)}
|
||||
rel={project.external ? 'external' : undefined}
|
||||
@@ -241,6 +243,7 @@
|
||||
>
|
||||
{#if project.external}<ExternalLink size={14} class="ml-auto shrink-0" />{/if}
|
||||
</a>
|
||||
<!-- eslint-enable svelte/no-navigation-without-resolve -->
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -343,7 +346,10 @@
|
||||
</div>
|
||||
</header>
|
||||
<main id="main" class="min-w-0 flex-1">{@render children()}</main>
|
||||
<footer class="mt-16 border-t border-[var(--site-border)]">
|
||||
<footer
|
||||
class="mt-16 border-t border-[var(--site-border)]"
|
||||
class:tracker-footer={page.route.id === '/projects/game-tracker'}
|
||||
>
|
||||
<div class="mx-auto max-w-6xl space-y-5 px-4 py-8 text-[var(--site-muted)] sm:px-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3 text-base">
|
||||
<span
|
||||
@@ -374,3 +380,21 @@
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@media (min-width: 1024px) and (min-height: 650px) {
|
||||
.tracker-footer {
|
||||
margin-top: 0;
|
||||
}
|
||||
.tracker-footer > div {
|
||||
padding-top: 10px;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.tracker-footer nav {
|
||||
display: none;
|
||||
}
|
||||
.tracker-footer > div > div {
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
let hydrated = $state(false);
|
||||
onMount(() => {
|
||||
hydrated = true;
|
||||
});
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import { enhance } from '$app/forms';
|
||||
import { resolve } from '$app/paths';
|
||||
import { calendarDate, relativeTime } from '$lib/relative-time';
|
||||
@@ -27,19 +33,25 @@
|
||||
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
|
||||
<div class="mb-10 flex flex-wrap items-center justify-between gap-4">
|
||||
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">blog</h1>
|
||||
{#if data.isAdmin}<details class="relative">
|
||||
<summary class="cursor-pointer text-base underline underline-offset-4">new article</summary>
|
||||
<form
|
||||
method="POST"
|
||||
use:enhance
|
||||
class="absolute top-full right-0 z-10 mt-3 w-72 max-w-[calc(100vw-2rem)] space-y-3 rounded-lg border border-[var(--site-border)] bg-[var(--site-surface)] p-4 shadow-lg"
|
||||
{#if data.isAdmin}<Dialog.Root>
|
||||
<Dialog.Trigger disabled={!hydrated} class="underline underline-offset-4"
|
||||
>new article</Dialog.Trigger
|
||||
>
|
||||
<label class="block"
|
||||
>title<Input name="title" required maxlength={256} class="mt-2" /></label
|
||||
<Dialog.Content
|
||||
><Dialog.Header
|
||||
><Dialog.Title>new article</Dialog.Title><Dialog.Description
|
||||
>Give your draft a title, then continue writing.</Dialog.Description
|
||||
></Dialog.Header
|
||||
>
|
||||
<Button type="submit">create draft</Button>
|
||||
</form>
|
||||
</details>{/if}
|
||||
{#if form?.message}<p role="alert" class="text-destructive">{form.message}</p>{/if}
|
||||
<form method="POST" use:enhance class="space-y-4">
|
||||
<label class="block"
|
||||
>title<Input name="title" required maxlength={256} class="mt-2" /></label
|
||||
>
|
||||
<Button type="submit">create draft</Button>
|
||||
</form>
|
||||
</Dialog.Content></Dialog.Root
|
||||
>{/if}
|
||||
</div>
|
||||
{#if form?.message}<p role="alert" class="mb-5 text-red-500">{form.message}</p>{/if}
|
||||
<div class="mb-8 space-y-4">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
|
||||
import { enhance } from '$app/forms';
|
||||
import { resolve } from '$app/paths';
|
||||
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
||||
@@ -108,16 +109,36 @@
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
<details class="mt-12 border-t border-[var(--site-border)] pt-5">
|
||||
<summary class="cursor-pointer text-base text-[var(--site-muted)]">delete article</summary>
|
||||
<form method="POST" action="?/delete" class="mt-3 space-y-3">
|
||||
<p class="text-base">
|
||||
This permanently deletes the article. Type <strong>{data.blogArticle.title}</strong> to confirm.
|
||||
</p>
|
||||
<label class="block text-base"
|
||||
>article title<Input name="confirmTitle" required autocomplete="off" class="mt-2" /></label
|
||||
>
|
||||
<Button type="submit" variant="destructive">delete article</Button>
|
||||
</form>
|
||||
</details>
|
||||
<div class="mt-12 border-t border-[var(--site-border)] pt-5">
|
||||
<AlertDialog.Root>
|
||||
<AlertDialog.Trigger class="text-base text-muted-foreground"
|
||||
>delete article</AlertDialog.Trigger
|
||||
><AlertDialog.Content
|
||||
><AlertDialog.Header
|
||||
><AlertDialog.Title>Delete article?</AlertDialog.Title><AlertDialog.Description
|
||||
>This permanently removes the article.</AlertDialog.Description
|
||||
></AlertDialog.Header
|
||||
>
|
||||
<form method="POST" action="?/delete" class="mt-3 space-y-3">
|
||||
<p class="text-base">
|
||||
This permanently deletes the article. Type <strong>{data.blogArticle.title}</strong> to confirm.
|
||||
</p>
|
||||
<label class="block text-base"
|
||||
>article title<Input
|
||||
name="confirmTitle"
|
||||
required
|
||||
autocomplete="off"
|
||||
class="mt-2"
|
||||
/></label
|
||||
>
|
||||
<AlertDialog.Footer
|
||||
><AlertDialog.Cancel>Keep article</AlertDialog.Cancel><Button
|
||||
type="submit"
|
||||
variant="destructive">delete article</Button
|
||||
></AlertDialog.Footer
|
||||
>
|
||||
</form>
|
||||
</AlertDialog.Content></AlertDialog.Root
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
let hydrated = $state(false);
|
||||
onMount(() => {
|
||||
hydrated = true;
|
||||
});
|
||||
import { NativeSelect } from '$lib/components/ui/native-select/index.js';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import { enhance } from '$app/forms';
|
||||
import { resolve } from '$app/paths';
|
||||
import ProgressBar from '$lib/components/ProgressBar.svelte';
|
||||
@@ -45,30 +52,44 @@
|
||||
<div class="mx-auto max-w-6xl px-4 py-12 sm:px-6 sm:py-16">
|
||||
<div class="mb-10 flex flex-wrap items-center justify-between gap-4">
|
||||
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">lists</h1>
|
||||
{#if data.isAdmin}<details class="relative">
|
||||
<summary class="cursor-pointer text-base underline underline-offset-4">new list</summary>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/newList"
|
||||
enctype="multipart/form-data"
|
||||
use:enhance={uploadPicture}
|
||||
class="absolute top-full right-0 z-10 mt-3 w-80 max-w-[calc(100vw-2rem)] space-y-3 rounded-lg border border-[var(--site-border)] bg-[var(--site-surface)] p-4 shadow-lg"
|
||||
{#if data.isAdmin}<Dialog.Root>
|
||||
<Dialog.Trigger disabled={!hydrated} class="underline underline-offset-4"
|
||||
>new list</Dialog.Trigger
|
||||
>
|
||||
<label class="block"
|
||||
>name<Input name="name" required maxlength={200} class="mt-2" /></label
|
||||
<Dialog.Content
|
||||
><Dialog.Header
|
||||
><Dialog.Title>new list</Dialog.Title><Dialog.Description
|
||||
>Create a collection or track progress through a list.</Dialog.Description
|
||||
></Dialog.Header
|
||||
>
|
||||
<label class="block"
|
||||
>type<select
|
||||
name="type"
|
||||
class="mt-2 w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2"
|
||||
><option value="normal">normal</option><option value="progress">progress</option
|
||||
></select
|
||||
></label
|
||||
{#if form?.message}<p role="alert" class="text-destructive">
|
||||
{form.message}
|
||||
</p>{/if}{#if uploadError}<p role="alert" class="text-destructive">
|
||||
{uploadError}
|
||||
</p>{/if}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/newList"
|
||||
enctype="multipart/form-data"
|
||||
use:enhance={uploadPicture}
|
||||
class="space-y-4"
|
||||
>
|
||||
<ListPictureDropzone label="List picture (optional)" />
|
||||
<Button type="submit" disabled={uploading}>create list</Button>
|
||||
</form>
|
||||
</details>{/if}
|
||||
<label class="block"
|
||||
>name<Input name="name" required maxlength={200} class="mt-2" /></label
|
||||
>
|
||||
<label class="block"
|
||||
>type<NativeSelect
|
||||
name="type"
|
||||
class="mt-2 w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2"
|
||||
><option value="normal">normal</option><option value="progress">progress</option
|
||||
></NativeSelect
|
||||
></label
|
||||
>
|
||||
<ListPictureDropzone label="List picture (optional)" />
|
||||
<Button type="submit" disabled={uploading}>create list</Button>
|
||||
</form>
|
||||
</Dialog.Content></Dialog.Root
|
||||
>{/if}
|
||||
</div>
|
||||
{#if form?.message}<p role="alert" class="mb-5 text-red-500">{form.message}</p>{/if}
|
||||
{#if uploadError}<p role="alert" class="mb-5 text-red-500">{uploadError}</p>{/if}
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { NativeSelect } from '$lib/components/ui/native-select/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { Textarea } from '$lib/components/ui/textarea/index.js';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
|
||||
import * as Collapsible from '$lib/components/ui/collapsible/index.js';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
let addOpen = $state(false);
|
||||
import { resolve } from '$app/paths';
|
||||
import ArrowLeft from 'lucide-svelte/icons/arrow-left';
|
||||
import Check from 'lucide-svelte/icons/check';
|
||||
@@ -28,7 +37,10 @@
|
||||
update: () => Promise<void>;
|
||||
}) => {
|
||||
await update();
|
||||
if (result.type === 'success') editingItem = null;
|
||||
if (result.type === 'success') {
|
||||
editingItem = null;
|
||||
addOpen = false;
|
||||
}
|
||||
};
|
||||
uploadError = '';
|
||||
const picture = formData.get('picture');
|
||||
@@ -65,12 +77,6 @@
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:window
|
||||
onkeydown={(event) => {
|
||||
if (event.key === 'Escape') editingItem = null;
|
||||
}}
|
||||
/>
|
||||
|
||||
<section class="mx-auto max-w-4xl space-y-6 px-4 py-10 sm:px-6 sm:py-14">
|
||||
<a
|
||||
href={resolve('/lists')}
|
||||
@@ -94,11 +100,11 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{#if data.isAdmin}<button
|
||||
{#if data.isAdmin}<Button
|
||||
type="button"
|
||||
onclick={() => (managing = !managing)}
|
||||
aria-pressed={managing}
|
||||
class="text-base underline underline-offset-4">{managing ? 'done' : 'manage list'}</button
|
||||
class="text-base underline underline-offset-4">{managing ? 'done' : 'manage list'}</Button
|
||||
>{/if}
|
||||
</div>
|
||||
{#if form?.message}<p role="alert" class="text-red-700">{form.message}</p>{/if}
|
||||
@@ -123,9 +129,11 @@
|
||||
{/if}
|
||||
{/if}
|
||||
{#if data.isAdmin && managing}
|
||||
<details class="border-b border-[var(--site-border)] pb-5">
|
||||
<summary class="cursor-pointer text-base text-[var(--site-muted)]">list settings</summary>
|
||||
<div class="mt-5 space-y-5">
|
||||
<Collapsible.Root class="border-b border-[var(--site-border)] pb-5">
|
||||
<Collapsible.Trigger class="cursor-pointer text-base text-[var(--site-muted)]"
|
||||
>list settings</Collapsible.Trigger
|
||||
>
|
||||
<Collapsible.Content class="mt-5 space-y-5">
|
||||
<form
|
||||
method="POST"
|
||||
action="?/updateList"
|
||||
@@ -134,65 +142,78 @@
|
||||
class="grid gap-3 sm:grid-cols-2 sm:items-end"
|
||||
>
|
||||
<label
|
||||
>List name<input
|
||||
>List name<Input
|
||||
name="name"
|
||||
value={data.list.name}
|
||||
required
|
||||
maxlength="200"
|
||||
maxlength={200}
|
||||
class="block w-full rounded border p-2"
|
||||
/></label
|
||||
>
|
||||
<label
|
||||
>Slug<input
|
||||
>Slug<Input
|
||||
name="slug"
|
||||
value={data.list.slug}
|
||||
required
|
||||
maxlength="120"
|
||||
maxlength={120}
|
||||
pattern="[a-z0-9]+(-[a-z0-9]+)*"
|
||||
class="block w-full rounded border p-2"
|
||||
/></label
|
||||
>
|
||||
<label
|
||||
>Type<select name="type" class="block w-full rounded border p-2">
|
||||
>Type<NativeSelect name="type" class="block w-full rounded border p-2">
|
||||
<option value="normal" selected={!data.list.isProgress}>Normal</option>
|
||||
<option value="progress" selected={data.list.isProgress}>Progress</option>
|
||||
</select></label
|
||||
</NativeSelect></label
|
||||
>
|
||||
<div class="sm:col-span-2">
|
||||
<ListPictureDropzone label="List picture (optional)" />
|
||||
{#if data.list.pictureUrl}<label class="mt-2 block text-sm"
|
||||
><input type="checkbox" name="removePicture" /> Remove current picture</label
|
||||
><Checkbox name="removePicture" value="on" /> Remove current picture</label
|
||||
>{/if}
|
||||
</div>
|
||||
<button disabled={uploading} class="rounded border p-2 disabled:opacity-50"
|
||||
>Save list</button
|
||||
<Button type="submit" disabled={uploading} class="rounded border p-2 disabled:opacity-50"
|
||||
>Save list</Button
|
||||
>
|
||||
</form>
|
||||
<details class="border-t border-[var(--site-border)] pt-4">
|
||||
<summary class="cursor-pointer font-medium text-red-700">Delete this list</summary>
|
||||
<form method="POST" action="?/deleteList" class="mt-3 space-y-3">
|
||||
<p class="text-sm">
|
||||
This deletes the list, its items, and everyone’s saved progress. Type <strong
|
||||
>{data.list.name}</strong
|
||||
> to confirm.
|
||||
</p>
|
||||
<label class="block"
|
||||
>List name<input
|
||||
name="confirmName"
|
||||
required
|
||||
autocomplete="off"
|
||||
class="mt-1 block w-full rounded border p-2"
|
||||
/></label
|
||||
<AlertDialog.Root>
|
||||
<AlertDialog.Trigger class="font-medium text-destructive"
|
||||
>Delete this list</AlertDialog.Trigger
|
||||
><AlertDialog.Content
|
||||
><AlertDialog.Header
|
||||
><AlertDialog.Title>Delete this list?</AlertDialog.Title><AlertDialog.Description
|
||||
>Deleting this list also removes its items and saved progress.</AlertDialog.Description
|
||||
></AlertDialog.Header
|
||||
>
|
||||
<button class="rounded bg-red-700 px-4 py-2 text-white">Delete list</button>
|
||||
</form>
|
||||
</details>
|
||||
</div>
|
||||
</details>
|
||||
<form method="POST" action="?/deleteList" class="mt-3 space-y-3">
|
||||
<p class="text-sm">
|
||||
This deletes the list, its items, and everyone’s saved progress. Type <strong
|
||||
>{data.list.name}</strong
|
||||
> to confirm.
|
||||
</p>
|
||||
<label class="block"
|
||||
>List name<Input
|
||||
name="confirmName"
|
||||
required
|
||||
autocomplete="off"
|
||||
class="mt-1 block w-full rounded border p-2"
|
||||
/></label
|
||||
>
|
||||
<AlertDialog.Footer
|
||||
><AlertDialog.Cancel>Keep list</AlertDialog.Cancel><Button
|
||||
type="submit"
|
||||
variant="destructive">Delete list</Button
|
||||
></AlertDialog.Footer
|
||||
>
|
||||
</form>
|
||||
</AlertDialog.Content></AlertDialog.Root
|
||||
>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
{/if}
|
||||
<label class="block">
|
||||
<span class="sr-only">Search list items</span>
|
||||
<input
|
||||
<Input
|
||||
type="search"
|
||||
bind:value={search}
|
||||
placeholder="Search list items"
|
||||
@@ -223,19 +244,20 @@
|
||||
<form method="POST" action="?/setCompletion" use:enhance class="shrink-0">
|
||||
<input type="hidden" name="itemId" value={item.id} />
|
||||
<input type="hidden" name="completed" value={completed.has(item.id) ? 'no' : 'yes'} />
|
||||
<button
|
||||
<Button
|
||||
type="submit"
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full border border-[var(--site-border)] text-base"
|
||||
aria-label={`${completed.has(item.id) ? 'Uncheck' : 'Check'} ${item.name}`}
|
||||
>
|
||||
{#if completed.has(item.id)}<Check size={16} aria-hidden="true" />{/if}
|
||||
</button>
|
||||
</Button>
|
||||
</form>
|
||||
{/if}
|
||||
{#if data.isAdmin && managing}<button
|
||||
{#if data.isAdmin && managing}<Button
|
||||
type="button"
|
||||
onclick={() => (editingItem = item)}
|
||||
class="shrink-0 rounded border border-[var(--site-border)] px-3 py-1 text-sm hover:bg-[var(--site-surface)]"
|
||||
aria-label={`Edit ${item.name}`}>Edit</button
|
||||
aria-label={`Edit ${item.name}`}>Edit</Button
|
||||
>{/if}
|
||||
</div>
|
||||
</li>
|
||||
@@ -243,20 +265,21 @@
|
||||
{search ? 'No matching items' : 'no items yet'}
|
||||
</li>{/each}
|
||||
</ul>
|
||||
{#if data.isAdmin && managing && editingItem}
|
||||
<div class="fixed inset-0 z-40 bg-black/60"></div>
|
||||
<div class="fixed inset-0 z-50 overflow-y-auto p-4 sm:p-8">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={`Edit ${editingItem.name}`}
|
||||
class="mx-auto max-w-xl rounded-lg bg-[var(--site-bg)] p-5 shadow-xl"
|
||||
<Dialog.Root
|
||||
open={!!(data.isAdmin && managing && editingItem)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) editingItem = null;
|
||||
}}
|
||||
>
|
||||
<Dialog.Content class="max-h-[90dvh] overflow-y-auto sm:max-w-xl">
|
||||
<Dialog.Header
|
||||
><Dialog.Title>Edit item</Dialog.Title><Dialog.Description
|
||||
>Update the name, picture and description.</Dialog.Description
|
||||
></Dialog.Header
|
||||
>
|
||||
<div class="mb-4 flex items-center justify-between gap-4">
|
||||
<h2 class="text-xl font-semibold">Edit item</h2>
|
||||
<button type="button" onclick={() => (editingItem = null)} class="underline">Close</button
|
||||
>
|
||||
</div>
|
||||
{#if form?.message}<p role="alert" class="text-destructive">{form.message}</p>{/if}
|
||||
{#if uploadError}<p role="alert" class="text-destructive">{uploadError}</p>{/if}
|
||||
{#if editingItem}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/updateItem"
|
||||
@@ -266,30 +289,32 @@
|
||||
>
|
||||
<input type="hidden" name="itemId" value={editingItem.id} />
|
||||
<label
|
||||
>Name<input
|
||||
>Name<Input
|
||||
name="name"
|
||||
value={editingItem.name}
|
||||
required
|
||||
maxlength="500"
|
||||
maxlength={500}
|
||||
class="block w-full rounded border p-2"
|
||||
/></label
|
||||
>
|
||||
<ListPictureDropzone label="Replace picture (optional)" />
|
||||
{#if editingItem.pictureUrl}<label class="text-sm"
|
||||
><input type="checkbox" name="removePicture" /> Remove current picture</label
|
||||
><Checkbox name="removePicture" value="on" /> Remove current picture</label
|
||||
>{/if}
|
||||
<label
|
||||
>Description (optional)<textarea
|
||||
>Description (optional)<Textarea
|
||||
name="description"
|
||||
maxlength="5000"
|
||||
rows="3"
|
||||
class="block w-full rounded border p-2">{editingItem.description ?? ''}</textarea
|
||||
></label
|
||||
maxlength={5000}
|
||||
rows={3}
|
||||
class="block w-full rounded border p-2"
|
||||
value={editingItem.description ?? ''}
|
||||
/></label
|
||||
>
|
||||
<button
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={uploading}
|
||||
class="justify-self-start rounded bg-gray-900 px-4 py-2 text-white disabled:opacity-50"
|
||||
>Save item</button
|
||||
>Save item</Button
|
||||
>
|
||||
</form>
|
||||
<form
|
||||
@@ -298,51 +323,66 @@
|
||||
use:enhance={() =>
|
||||
async ({ result, update }) => {
|
||||
await update();
|
||||
if (result.type === 'success') editingItem = null;
|
||||
if (result.type === 'success') {
|
||||
editingItem = null;
|
||||
addOpen = false;
|
||||
}
|
||||
}}
|
||||
class="mt-5 border-t border-[var(--site-border)] pt-4"
|
||||
>
|
||||
<input type="hidden" name="itemId" value={editingItem.id} />
|
||||
<button class="text-sm text-red-700 underline" aria-label={`Remove ${editingItem.name}`}
|
||||
>Remove item</button
|
||||
<Button
|
||||
type="submit"
|
||||
class="text-sm text-red-700 underline"
|
||||
aria-label={`Remove ${editingItem.name}`}>Remove item</Button
|
||||
>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}</Dialog.Content
|
||||
></Dialog.Root
|
||||
>
|
||||
{#if data.isAdmin && managing}
|
||||
<details class="border-t border-[var(--site-border)] pt-5">
|
||||
<summary class="cursor-pointer font-medium">add item</summary>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/addItem"
|
||||
enctype="multipart/form-data"
|
||||
use:enhance={uploadPicture}
|
||||
class="mt-5 grid gap-3"
|
||||
>
|
||||
<label
|
||||
>Name<input
|
||||
name="name"
|
||||
required
|
||||
maxlength="500"
|
||||
class="block w-full rounded border p-2"
|
||||
/></label
|
||||
<Dialog.Root bind:open={addOpen}
|
||||
><Dialog.Trigger class="underline underline-offset-4">add item</Dialog.Trigger><Dialog.Content
|
||||
><Dialog.Header
|
||||
><Dialog.Title>Add item</Dialog.Title><Dialog.Description
|
||||
>Add a new entry to this list.</Dialog.Description
|
||||
></Dialog.Header
|
||||
>
|
||||
<ListPictureDropzone />
|
||||
<label
|
||||
>Description (optional)<textarea
|
||||
name="description"
|
||||
maxlength="5000"
|
||||
rows="3"
|
||||
class="block w-full rounded border p-2"></textarea></label
|
||||
{#if form?.message}<p role="alert" class="text-destructive">{form.message}</p>{/if}
|
||||
{#if uploadError}<p role="alert" class="text-destructive">{uploadError}</p>{/if}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/addItem"
|
||||
enctype="multipart/form-data"
|
||||
use:enhance={uploadPicture}
|
||||
class="mt-5 grid gap-3"
|
||||
>
|
||||
<button
|
||||
disabled={uploading}
|
||||
class="justify-self-start rounded bg-gray-900 px-4 py-2 text-white disabled:opacity-50"
|
||||
>Add item</button
|
||||
>
|
||||
</form>
|
||||
</details>
|
||||
<label
|
||||
>Name<Input
|
||||
name="name"
|
||||
required
|
||||
maxlength={500}
|
||||
class="block w-full rounded border p-2"
|
||||
/></label
|
||||
>
|
||||
<ListPictureDropzone />
|
||||
<label
|
||||
>Description (optional)<Textarea
|
||||
name="description"
|
||||
maxlength={5000}
|
||||
rows={3}
|
||||
class="block w-full rounded border p-2"
|
||||
/></label
|
||||
>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={uploading}
|
||||
class="justify-self-start rounded bg-gray-900 px-4 py-2 text-white disabled:opacity-50"
|
||||
>Add item</Button
|
||||
>
|
||||
</form>
|
||||
</Dialog.Content></Dialog.Root
|
||||
>
|
||||
<noscript><p>Enable JavaScript to upload item pictures.</p></noscript>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
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 * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import type { Map as MaptilerMap, Marker as MaptilerMarker } from '@maptiler/sdk';
|
||||
|
||||
interface TrailPoint {
|
||||
@@ -35,7 +35,6 @@
|
||||
}
|
||||
|
||||
let mapContainer: HTMLDivElement;
|
||||
let detailsDialog: HTMLDialogElement;
|
||||
let map: MaptilerMap | null = null;
|
||||
let destroyed = false;
|
||||
let htmlMarkers: MaptilerMarker[] = [];
|
||||
@@ -79,18 +78,6 @@
|
||||
openAnimal(animalId);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (page.state.animal) {
|
||||
if (!detailsDialog.open) detailsDialog.showModal();
|
||||
} else if (detailsDialog.open) {
|
||||
detailsDialog.close();
|
||||
}
|
||||
});
|
||||
|
||||
function onDialogClose() {
|
||||
if (page.state.animal) history.back();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
(async () => {
|
||||
try {
|
||||
@@ -417,34 +404,28 @@
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<dialog
|
||||
bind:this={detailsDialog}
|
||||
onclose={onDialogClose}
|
||||
onclick={(event) => {
|
||||
if (event.target === detailsDialog) detailsDialog.close();
|
||||
<Dialog.Root
|
||||
open={!!page.state.animal}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && page.state.animal) history.back();
|
||||
}}
|
||||
aria-label={page.state.animal
|
||||
? page.state.animal.animalName || page.state.animal.species
|
||||
: 'Animal details'}
|
||||
class="m-auto max-h-[calc(100dvh-2rem)] w-[calc(100%-2rem)] max-w-5xl overflow-y-auto rounded-xl border border-[var(--site-border)] bg-[var(--site-bg)] p-0 text-[var(--site-text)] backdrop:bg-black/70"
|
||||
>
|
||||
{#if page.state.animal}
|
||||
<div class="sticky top-0 z-10 flex justify-end gap-4 bg-[var(--site-bg)] px-5 py-3 sm:px-8">
|
||||
<Dialog.Content class="max-h-[90dvh] overflow-y-auto sm:max-w-5xl">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title
|
||||
>{page.state.animal?.animalName ||
|
||||
page.state.animal?.species ||
|
||||
'Animal details'}</Dialog.Title
|
||||
>
|
||||
<Dialog.Description>Sightings, photos and the story so far.</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
{#if page.state.animal}
|
||||
<a
|
||||
href={resolve('/projects/animaldex/[id=integer]', { id: String(page.state.animal.id) })}
|
||||
data-sveltekit-reload
|
||||
class="text-base underline underline-offset-4">open page</a
|
||||
class="underline underline-offset-4">open page</a
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => detailsDialog.close()}
|
||||
aria-label="Close animal details"
|
||||
class="flex h-8 w-8 items-center justify-center rounded hover:bg-[var(--site-surface)]"
|
||||
><X size={20} aria-hidden="true" /></button
|
||||
>
|
||||
</div>
|
||||
<div class="px-5 pb-8 sm:px-8">
|
||||
<AnimalDetails animal={page.state.animal} />
|
||||
</div>
|
||||
{/if}
|
||||
</dialog>
|
||||
{/if}
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { NativeSelect } from '$lib/components/ui/native-select/index.js';
|
||||
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';
|
||||
@@ -275,7 +276,7 @@
|
||||
<Badge variant="secondary">{selected.length} / 1000</Badge>
|
||||
</div>
|
||||
<label class="field"
|
||||
><span>Group by</span><select
|
||||
><span>Group by</span><NativeSelect
|
||||
value={dimension}
|
||||
onchange={(event) => changeDimension(event.currentTarget.value)}
|
||||
>
|
||||
@@ -283,7 +284,7 @@
|
||||
{#each data.dimensions as option (option.key)}<option value={option.key}
|
||||
>{labels[option.key] ?? option.key.replaceAll('_', ' ')}</option
|
||||
>{/each}
|
||||
</select></label
|
||||
</NativeSelect></label
|
||||
>
|
||||
<label class="field"
|
||||
><span
|
||||
@@ -347,21 +348,21 @@
|
||||
</div>
|
||||
<div class="controls">
|
||||
<label
|
||||
>Source<select
|
||||
>Source<NativeSelect
|
||||
value={sourceChoice}
|
||||
onchange={(event) => changeSource(event.currentTarget.value)}
|
||||
>{#each sourceOptions as option (option)}<option value={option}
|
||||
>{option.replace('|', ' · ')}</option
|
||||
>{/each}</select
|
||||
>{/each}</NativeSelect
|
||||
></label
|
||||
><label
|
||||
>Range<select bind:value={range}
|
||||
>Range<NativeSelect 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
|
||||
></NativeSelect
|
||||
></label
|
||||
>
|
||||
</div>
|
||||
@@ -504,7 +505,7 @@
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.45rem;
|
||||
}
|
||||
select {
|
||||
:global([data-slot='native-select-wrapper']) {
|
||||
width: 100%;
|
||||
border: 1px solid var(--site-border);
|
||||
color: var(--site-text);
|
||||
@@ -587,7 +588,7 @@
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.controls select {
|
||||
.controls :global([data-slot='native-select-wrapper']) {
|
||||
min-width: 140px;
|
||||
}
|
||||
.custom-range {
|
||||
|
||||
@@ -1,80 +1,88 @@
|
||||
import { and, asc, countDistinct, eq, inArray, sql } from 'drizzle-orm';
|
||||
import { asc, eq } from 'drizzle-orm';
|
||||
import { db } from '$lib/server/db';
|
||||
import {
|
||||
catchEmAllSettings,
|
||||
pokemonGameEntries,
|
||||
trackerGames,
|
||||
trackerItems,
|
||||
trackerProgress,
|
||||
userEntries,
|
||||
userGames
|
||||
trackerWeaponRanks,
|
||||
trackerRanks,
|
||||
trackerChallenges,
|
||||
trackerChallengeProgress,
|
||||
osuEntries
|
||||
} from '$lib/server/db/schema';
|
||||
import { loadPokemonProgress } from '$lib/server/tracker-progress';
|
||||
import { challengeProgress, combinedProgress, sumProgress } from '$lib/game-tracker/progress';
|
||||
import { summarizeGame } from '$lib/game-tracker/mastery';
|
||||
import { chronologicalPokemon, pokemonRelease } from '$lib/game-tracker/game-art';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
const obtainable = inArray(pokemonGameEntries.availability, ['catchable', 'evolve', 'breed']);
|
||||
|
||||
async function pokemonSummary(userId: string | undefined) {
|
||||
let gameIds: number[] | null = null;
|
||||
if (userId) {
|
||||
const [settings] = await db
|
||||
.select({ id: catchEmAllSettings.id })
|
||||
.from(catchEmAllSettings)
|
||||
.where(eq(catchEmAllSettings.userId, userId));
|
||||
const selected = await db
|
||||
.select({ id: userGames.gameId })
|
||||
.from(userGames)
|
||||
.where(eq(userGames.userId, userId));
|
||||
if (settings || selected.length) gameIds = selected.map((row) => row.id);
|
||||
}
|
||||
const scope =
|
||||
gameIds === null ? obtainable : and(obtainable, inArray(pokemonGameEntries.gameId, gameIds));
|
||||
const [totals] = await db
|
||||
.select({
|
||||
entries: sql<number>`count(*)`.mapWith(Number),
|
||||
species: countDistinct(pokemonGameEntries.pokemonId),
|
||||
games: countDistinct(pokemonGameEntries.gameId)
|
||||
})
|
||||
.from(pokemonGameEntries)
|
||||
.where(scope);
|
||||
const [caught] = userId
|
||||
? await db
|
||||
.select({
|
||||
entries: sql<number>`count(*)`.mapWith(Number),
|
||||
species: countDistinct(pokemonGameEntries.pokemonId)
|
||||
})
|
||||
.from(userEntries)
|
||||
.innerJoin(pokemonGameEntries, eq(pokemonGameEntries.id, userEntries.pokemonGameEntryId))
|
||||
.where(and(eq(userEntries.userId, userId), scope))
|
||||
: [{ entries: 0, species: 0 }];
|
||||
return { totals, caught };
|
||||
const progress = await loadPokemonProgress(userId);
|
||||
return {
|
||||
...progress,
|
||||
games: chronologicalPokemon(progress.configured ? progress.games : []).map((g) => ({
|
||||
...g,
|
||||
releaseYear: pokemonRelease(g.slug).slice(0, 4)
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
async function callOfDutySummary(userId: string | undefined) {
|
||||
const [gamesList, items, progress] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(trackerGames)
|
||||
.where(eq(trackerGames.franchise, 'call-of-duty'))
|
||||
.orderBy(asc(trackerGames.sortOrder)),
|
||||
db
|
||||
.select({
|
||||
id: trackerItems.id,
|
||||
gameId: trackerItems.gameId,
|
||||
kind: trackerItems.kind,
|
||||
name: trackerItems.name,
|
||||
category: trackerItems.category,
|
||||
postLaunch: trackerItems.postLaunch
|
||||
})
|
||||
.from(trackerItems),
|
||||
userId
|
||||
? db
|
||||
.select({ itemId: trackerProgress.itemId, milestone: trackerProgress.milestone })
|
||||
.from(trackerProgress)
|
||||
.where(eq(trackerProgress.userId, userId))
|
||||
: Promise.resolve([])
|
||||
]);
|
||||
const [gamesList, items, progress, ranks, playerRanks, challenges, challengeRows] =
|
||||
await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(trackerGames)
|
||||
.where(eq(trackerGames.franchise, 'call-of-duty'))
|
||||
.orderBy(asc(trackerGames.releaseYear), asc(trackerGames.sortOrder)),
|
||||
db
|
||||
.select({
|
||||
id: trackerItems.id,
|
||||
gameId: trackerItems.gameId,
|
||||
kind: trackerItems.kind,
|
||||
name: trackerItems.name,
|
||||
category: trackerItems.category,
|
||||
postLaunch: trackerItems.postLaunch
|
||||
})
|
||||
.from(trackerItems),
|
||||
userId
|
||||
? db
|
||||
.select({ itemId: trackerProgress.itemId, milestone: trackerProgress.milestone })
|
||||
.from(trackerProgress)
|
||||
.where(eq(trackerProgress.userId, userId))
|
||||
: Promise.resolve([]),
|
||||
userId
|
||||
? db
|
||||
.select({ itemId: trackerWeaponRanks.itemId, prestige: trackerWeaponRanks.prestige })
|
||||
.from(trackerWeaponRanks)
|
||||
.where(eq(trackerWeaponRanks.userId, userId))
|
||||
: Promise.resolve([]),
|
||||
userId
|
||||
? db
|
||||
.select({ gameId: trackerRanks.gameId })
|
||||
.from(trackerRanks)
|
||||
.where(eq(trackerRanks.userId, userId))
|
||||
: Promise.resolve([]),
|
||||
db
|
||||
.select({
|
||||
id: trackerChallenges.id,
|
||||
gameId: trackerChallenges.gameId,
|
||||
target: trackerChallenges.target
|
||||
})
|
||||
.from(trackerChallenges),
|
||||
userId
|
||||
? db
|
||||
.select({
|
||||
challengeId: trackerChallengeProgress.challengeId,
|
||||
current: trackerChallengeProgress.current,
|
||||
done: trackerChallengeProgress.done
|
||||
})
|
||||
.from(trackerChallengeProgress)
|
||||
.where(eq(trackerChallengeProgress.userId, userId))
|
||||
: []
|
||||
]);
|
||||
const done = new Set(progress.map((row) => `${row.itemId}:${row.milestone}`));
|
||||
for (const rank of ranks) if (rank.prestige > 0) done.add(`${rank.itemId}:maxLevel`);
|
||||
const byGame = Map.groupBy(items, (item) => item.gameId);
|
||||
return gamesList.map((game) => {
|
||||
const list = byGame.get(game.id) ?? [];
|
||||
@@ -84,7 +92,28 @@ async function callOfDutySummary(userId: string | undefined) {
|
||||
list.filter((item) => item.kind === 'achievement').map((item) => item.id),
|
||||
(id, milestone) => done.has(`${id}:${milestone}`)
|
||||
);
|
||||
|
||||
const weaponProgress = sumProgress(summary.milestones);
|
||||
const challengesProgress = challengeProgress(
|
||||
challenges.filter((c) => c.gameId === game.id),
|
||||
challengeRows
|
||||
);
|
||||
const ranksProgress = {
|
||||
value:
|
||||
ranks.filter((rank) =>
|
||||
list.some((item) => item.kind === 'weapon' && item.id === rank.itemId)
|
||||
).length + Number(playerRanks.some((rank) => rank.gameId === game.id)),
|
||||
total: list.filter((item) => item.kind === 'weapon').length + 1
|
||||
};
|
||||
return {
|
||||
overall: combinedProgress([
|
||||
weaponProgress,
|
||||
ranksProgress,
|
||||
summary.achievements,
|
||||
challengesProgress
|
||||
]),
|
||||
weaponProgress,
|
||||
challengesProgress,
|
||||
slug: game.slug,
|
||||
name: game.shortName,
|
||||
releaseYear: game.releaseYear,
|
||||
@@ -108,9 +137,23 @@ async function callOfDutySummary(userId: string | undefined) {
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
const [pokemon, callOfDuty] = await Promise.all([
|
||||
const [pokemon, callOfDuty, osu] = await Promise.all([
|
||||
pokemonSummary(locals.user?.id),
|
||||
callOfDutySummary(locals.user?.id)
|
||||
callOfDutySummary(locals.user?.id),
|
||||
locals.user
|
||||
? db
|
||||
.select({ kind: osuEntries.kind, achieved: osuEntries.achieved })
|
||||
.from(osuEntries)
|
||||
.where(eq(osuEntries.userId, locals.user.id))
|
||||
: []
|
||||
]);
|
||||
return { pokemon, callOfDuty };
|
||||
return {
|
||||
pokemon,
|
||||
callOfDuty,
|
||||
codOverall: combinedProgress(callOfDuty.map((g) => g.overall)),
|
||||
osuGoals: {
|
||||
value: osu.filter((e) => e.kind === 'goal' && e.achieved).length,
|
||||
total: osu.filter((e) => e.kind === 'goal').length
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,107 +1,211 @@
|
||||
<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 GameLogo from '$lib/game-tracker/GameLogo.svelte';
|
||||
import ProgressMeter from '$lib/game-tracker/ProgressMeter.svelte';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
function percent(value: number, total: number) {
|
||||
return total > 0 ? Math.round((value / total) * 100) : 0;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-14">
|
||||
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">game tracker</h1>
|
||||
<p class="mt-2 text-[var(--site-muted)]">
|
||||
completionist progress: every Pokémon in every game, and every achievement, weapon and camo in
|
||||
Call of Duty.
|
||||
</p>
|
||||
{#if !data.user}<p class="mt-6">
|
||||
<div class="tracker-library mx-auto flex w-full max-w-[1600px] flex-col px-4 py-5 sm:px-6">
|
||||
<div class="mb-5 flex flex-wrap items-end justify-between gap-2">
|
||||
<div>
|
||||
<p class="text-xs tracking-[.18em] text-muted-foreground uppercase">Your next session</p>
|
||||
<h1 class="mt-1 text-3xl font-semibold tracking-tight">Game tracker</h1>
|
||||
</div>
|
||||
{#if !data.user}<a
|
||||
class="text-sm text-muted-foreground underline"
|
||||
href={resolve('/login?returnTo=/projects/game-tracker')}>Sign in to save progress</a
|
||||
>{:else}<a
|
||||
class="text-xs text-muted-foreground underline"
|
||||
href={resolve('/projects/game-tracker/credits')}>Artwork credits</a
|
||||
>{/if}
|
||||
</div>
|
||||
<div
|
||||
class="mb-4 grid grid-cols-2 gap-3 rounded-xl border bg-card p-3 lg:grid-cols-4"
|
||||
aria-label="Your completion dashboard"
|
||||
>
|
||||
<ProgressMeter
|
||||
label="National collection"
|
||||
progress={data.pokemon.caught}
|
||||
detail={`${data.pokemon.caught.value} unique species caught`}
|
||||
hint="Unique species caught across the Pokémon games in your rules, out of the full species catalog."
|
||||
/>
|
||||
<ProgressMeter
|
||||
label="Species seen at least once"
|
||||
progress={data.pokemon.seen}
|
||||
color="sky"
|
||||
detail={`${data.pokemon.seen.value} species discovered`}
|
||||
/>
|
||||
<ProgressMeter
|
||||
label="Pokémon games completed"
|
||||
progress={data.pokemon.completed}
|
||||
color="amber"
|
||||
hint="Games marked story complete, or with all default story milestones checked."
|
||||
/>
|
||||
<ProgressMeter
|
||||
label="Call of Duty overall"
|
||||
progress={data.codOverall}
|
||||
color="violet"
|
||||
detail="Mastery · achievements · challenges"
|
||||
/>
|
||||
</div>
|
||||
<div class="series-grid grid min-h-0 flex-1 gap-4">
|
||||
<section
|
||||
class="series-column flex min-h-0 min-w-0 flex-col rounded-2xl border bg-card p-3"
|
||||
aria-labelledby="pokemon-series"
|
||||
>
|
||||
<a
|
||||
href={resolve('/login?returnTo=/projects/game-tracker')}
|
||||
class="underline underline-offset-4">sign in</a
|
||||
> to save your progress.
|
||||
</p>{/if}
|
||||
|
||||
<section class="mt-10">
|
||||
<h2 class="text-2xl font-semibold">Pokémon</h2>
|
||||
<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
|
||||
href={resolve('/projects/game-tracker/pokemon')}
|
||||
class="series-heading mb-3 flex items-center gap-3 rounded-xl bg-amber-500/10 p-3 transition-colors hover:bg-amber-500/20"
|
||||
>
|
||||
<GameLogo slug="pokemon" name="Pokémon" class="h-12 w-24" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 id="pokemon-series" class="font-semibold">Pokémon</h2>
|
||||
<p class="text-xs text-muted-foreground">Open your adventure journal →</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
{data.pokemon.caught.value} / {data.pokemon.caught.total} species
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
<div class="mb-3 space-y-2 rounded-lg bg-muted/20 p-2">
|
||||
<ProgressMeter
|
||||
label="Across every adventure"
|
||||
progress={data.pokemon.overall}
|
||||
color="amber"
|
||||
compact
|
||||
hint="Equal weight per game; collection and story stages each contribute."
|
||||
/>
|
||||
<ProgressMeter
|
||||
label="Every game’s collection"
|
||||
progress={data.pokemon.collection}
|
||||
compact
|
||||
hint="Each confirmed species/game pairing counts once, including trades and events."
|
||||
/>
|
||||
</div>
|
||||
{#if data.pokemon.games.length}
|
||||
<div class="collection-grid grid grid-cols-2 gap-2">
|
||||
{#each data.pokemon.games as game (game.id)}
|
||||
<a
|
||||
class="game-row flex min-h-0 min-w-0 items-center gap-2 rounded-lg border px-2 py-1.5 transition-colors hover:border-amber-500/50 hover:bg-amber-500/5"
|
||||
href={resolve(`/projects/game-tracker/pokemon?game=${game.slug ?? game.id}`)}
|
||||
title={`${game.name} · ${game.releaseYear}`}
|
||||
>
|
||||
<GameLogo
|
||||
slug={`pokemon/${game.slug}`}
|
||||
name={game.name}
|
||||
class="game-cover h-10 w-10"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="game-name text-xs leading-tight font-medium">{game.name}</p>
|
||||
<p class="game-year mt-0.5 text-[10px] text-muted-foreground">
|
||||
{game.releaseYear}{game.completed ? ' · ✓ story' : ''}
|
||||
</p>
|
||||
<ProgressMeter
|
||||
label="Game progress"
|
||||
progress={game.overall}
|
||||
color="amber"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}<a
|
||||
class="rounded-xl border border-dashed p-5 text-sm text-muted-foreground hover:text-foreground"
|
||||
href={resolve('/projects/game-tracker/pokemon')}
|
||||
>Choose your games in “set rules” to build your collection here.</a
|
||||
>{/if}
|
||||
</section>
|
||||
<section
|
||||
class="series-column flex min-h-0 min-w-0 flex-col rounded-2xl border bg-card p-3"
|
||||
aria-labelledby="cod-series"
|
||||
>
|
||||
<div class="series-heading mb-3 flex items-center gap-3 rounded-xl bg-zinc-500/10 p-3">
|
||||
<GameLogo slug="call-of-duty" name="Call of Duty" class="h-12 w-24" />
|
||||
<div>
|
||||
<h2 id="cod-series" class="font-semibold">Call of Duty</h2>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{data.callOfDuty.length} campaigns of progress
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3 px-1">
|
||||
<ProgressMeter label="COD completion" progress={data.codOverall} color="violet" compact />
|
||||
</div>
|
||||
<div class="cod-rows grid min-h-0 gap-1.5">
|
||||
{#each data.callOfDuty as game (game.slug)}
|
||||
<a
|
||||
href={resolve(`/projects/game-tracker/call-of-duty/${game.slug}`)}
|
||||
class="game-row flex min-h-0 items-center gap-3 rounded-lg border p-2 transition-colors hover:border-primary/40 hover:bg-muted/50"
|
||||
>
|
||||
<GameLogo slug={`cod/${game.slug}`} name={game.name} class="h-11 w-24" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm leading-tight font-medium">{game.name}</p>
|
||||
<p class="mt-1 text-[11px] text-muted-foreground">
|
||||
{game.releaseYear}<span class="ml-2"
|
||||
>{game.achievements.value}/{game.achievements.total} achievements</span
|
||||
>
|
||||
</p>
|
||||
<ProgressMeter
|
||||
label={`${game.name} progress`}
|
||||
progress={game.overall}
|
||||
color="violet"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
<p class="text-xl font-semibold tabular-nums">
|
||||
{percent(data.pokemon.caught.entries, data.pokemon.totals.entries)}%
|
||||
</p>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
class="series-column flex min-w-0 flex-col rounded-2xl border bg-card p-3"
|
||||
aria-labelledby="osu-series"
|
||||
>
|
||||
<div class="series-heading mb-3 flex items-center gap-3 rounded-xl bg-pink-500/10 p-3">
|
||||
<GameLogo slug="osu" name="osu!" class="h-12 w-12" />
|
||||
<div>
|
||||
<h2 id="osu-series" class="font-semibold">osu!</h2>
|
||||
<p class="text-xs text-muted-foreground">One more attempt</p>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={resolve('/projects/game-tracker/osu')}
|
||||
class="rounded-xl border p-4 transition-colors hover:border-pink-500/50 hover:bg-pink-500/5"
|
||||
><div class="flex items-center gap-3">
|
||||
<GameLogo slug="osu" name="osu!" class="h-14 w-14" />
|
||||
<div>
|
||||
<p class="font-semibold">osu!</p>
|
||||
<p class="text-xs text-muted-foreground">2007</p>
|
||||
</div>
|
||||
<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
|
||||
>
|
||||
<Button variant="link" href={resolve('/projects/game-tracker/pokemon/locations')} class="mt-3"
|
||||
>browse Pokémon locations</Button
|
||||
>
|
||||
</section>
|
||||
|
||||
<section class="mt-12">
|
||||
<h2 class="text-2xl font-semibold">Call of Duty</h2>
|
||||
<ul class="mt-4 grid gap-4 md:grid-cols-2">
|
||||
{#each data.callOfDuty as game (game.slug)}
|
||||
<li>
|
||||
<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>
|
||||
<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>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
<p class="mt-4 text-sm">Your training room →</p>
|
||||
<p class="mt-1 text-xs leading-relaxed text-muted-foreground">
|
||||
Scores, dream FCs, stamina and speed. Build your next personal best.
|
||||
</p>
|
||||
<div class="mt-4">
|
||||
<ProgressMeter label="osu! goals achieved" progress={data.osuGoals} color="pink" />
|
||||
</div></a
|
||||
>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.series-grid {
|
||||
grid-template-columns: minmax(0, 1.5fr) minmax(0, 1.2fr) minmax(0, 0.8fr);
|
||||
align-items: start;
|
||||
}
|
||||
.game-row {
|
||||
min-height: 64px;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.series-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@media (max-width: 639px) {
|
||||
.series-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
import { error, fail } from '@sveltejs/kit';
|
||||
import { and, asc, eq } from 'drizzle-orm';
|
||||
import achievementArt from '../../../../../../data/cod-achievement-art.json';
|
||||
const achievementImages: Record<string, string> = achievementArt;
|
||||
import { and, asc, eq, sql } from 'drizzle-orm';
|
||||
import { db } from '$lib/server/db';
|
||||
import { trackerGames, trackerItems, trackerProgress } from '$lib/server/db/schema';
|
||||
import {
|
||||
trackerGames,
|
||||
trackerItems,
|
||||
trackerProgress,
|
||||
trackerRanks,
|
||||
trackerWeaponRanks,
|
||||
trackerChallenges,
|
||||
trackerChallengeProgress
|
||||
} from '$lib/server/db/schema';
|
||||
import { boundedInteger } from '$lib/game-tracker/journal';
|
||||
import { appliesTo } from '$lib/game-tracker/mastery';
|
||||
import { playerRankLimits, weaponRankLimits } from '$lib/game-tracker/rank-limits';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
async function findGame(slug: string) {
|
||||
@@ -36,9 +48,61 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
.innerJoin(trackerItems, eq(trackerItems.id, trackerProgress.itemId))
|
||||
.where(and(eq(trackerProgress.userId, locals.user.id), eq(trackerItems.gameId, game.id)))
|
||||
: [];
|
||||
const [ranks, weaponRanks, challenges, challengeProgress] = await Promise.all([
|
||||
locals.user
|
||||
? db
|
||||
.select()
|
||||
.from(trackerRanks)
|
||||
.where(and(eq(trackerRanks.userId, locals.user.id), eq(trackerRanks.gameId, game.id)))
|
||||
: [],
|
||||
locals.user
|
||||
? db
|
||||
.select({
|
||||
itemId: trackerWeaponRanks.itemId,
|
||||
level: trackerWeaponRanks.level,
|
||||
prestige: trackerWeaponRanks.prestige
|
||||
})
|
||||
.from(trackerWeaponRanks)
|
||||
.innerJoin(trackerItems, eq(trackerItems.id, trackerWeaponRanks.itemId))
|
||||
.where(
|
||||
and(eq(trackerWeaponRanks.userId, locals.user.id), eq(trackerItems.gameId, game.id))
|
||||
)
|
||||
: [],
|
||||
db
|
||||
.select()
|
||||
.from(trackerChallenges)
|
||||
.where(eq(trackerChallenges.gameId, game.id))
|
||||
.orderBy(asc(trackerChallenges.category), asc(trackerChallenges.id)),
|
||||
locals.user
|
||||
? db
|
||||
.select({
|
||||
challengeId: trackerChallengeProgress.challengeId,
|
||||
current: trackerChallengeProgress.current,
|
||||
done: trackerChallengeProgress.done
|
||||
})
|
||||
.from(trackerChallengeProgress)
|
||||
.innerJoin(
|
||||
trackerChallenges,
|
||||
eq(trackerChallenges.id, trackerChallengeProgress.challengeId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(trackerChallengeProgress.userId, locals.user.id),
|
||||
eq(trackerChallenges.gameId, game.id)
|
||||
)
|
||||
)
|
||||
: []
|
||||
]);
|
||||
return {
|
||||
rank: ranks[0] ?? null,
|
||||
weaponRanks,
|
||||
challenges,
|
||||
challengeProgress,
|
||||
game: { slug: game.slug, name: game.name, shortName: game.shortName, rules: game.rules },
|
||||
items,
|
||||
items: items.map((item) => ({
|
||||
...item,
|
||||
icon: item.icon ? (achievementImages[item.icon] ?? item.icon) : null
|
||||
})),
|
||||
done: progress.map((row) => `${row.itemId}:${row.milestone}`),
|
||||
seo: {
|
||||
title: `${game.shortName} · Game Tracker`,
|
||||
@@ -48,6 +112,151 @@ export const load: PageServerLoad = async ({ params, locals }) => {
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
adjustWeaponRank: async ({ request, locals, params }) => {
|
||||
if (!locals.user) return fail(401, { message: 'Sign in to save weapon progress.' });
|
||||
const game = await findGame(params.slug);
|
||||
const form = await request.formData();
|
||||
const itemId = boundedInteger(form.get('itemId'), 1, 2147483647);
|
||||
const field = form.get('field');
|
||||
const delta = form.get('delta') === '1' ? 1 : form.get('delta') === '-1' ? -1 : null;
|
||||
if (!itemId || !['level', 'prestige'].includes(String(field)) || delta === null)
|
||||
return fail(400, { message: 'Invalid weapon adjustment.' });
|
||||
const [item] = await db
|
||||
.select({ id: trackerItems.id, name: trackerItems.name, category: trackerItems.category })
|
||||
.from(trackerItems)
|
||||
.where(
|
||||
and(
|
||||
eq(trackerItems.id, itemId),
|
||||
eq(trackerItems.gameId, game.id),
|
||||
eq(trackerItems.kind, 'weapon')
|
||||
)
|
||||
);
|
||||
if (!item) return fail(404, { message: 'Weapon not found in this game.' });
|
||||
const limits = weaponRankLimits(game.rules, item);
|
||||
if (!limits.rankable)
|
||||
return fail(400, {
|
||||
message: 'This weapon does not have a weapon-rank progression in this game.'
|
||||
});
|
||||
const maxLevel = limits.maxLevel ?? 10000;
|
||||
if (field === 'prestige' && delta > 0 && limits.canPrestigeOnlyAtMaxLevel) {
|
||||
const [current] = await db
|
||||
.select({ level: trackerWeaponRanks.level, prestige: trackerWeaponRanks.prestige })
|
||||
.from(trackerWeaponRanks)
|
||||
.where(
|
||||
and(eq(trackerWeaponRanks.userId, locals.user.id), eq(trackerWeaponRanks.itemId, itemId))
|
||||
);
|
||||
if (!current || current.level < maxLevel || current.prestige >= limits.maxPrestige)
|
||||
return fail(400, { message: `Reach weapon level ${maxLevel} before increasing prestige.` });
|
||||
}
|
||||
// Arithmetic happens in Postgres, so simultaneous taps from different devices cannot lose a level.
|
||||
const [rank] = await db
|
||||
.insert(trackerWeaponRanks)
|
||||
.values({
|
||||
userId: locals.user.id,
|
||||
itemId,
|
||||
level: field === 'level' ? Math.max(1, Math.min(maxLevel, 1 + delta)) : 1,
|
||||
prestige: field === 'prestige' ? Math.max(0, delta) : 0
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [trackerWeaponRanks.userId, trackerWeaponRanks.itemId],
|
||||
set: {
|
||||
...(field === 'level'
|
||||
? {
|
||||
level: sql`greatest(1, least(${maxLevel}, ${trackerWeaponRanks.level} + ${delta}))`
|
||||
}
|
||||
: {
|
||||
prestige: sql`greatest(0, least(${limits.maxPrestige}, ${trackerWeaponRanks.prestige} + ${delta}))`,
|
||||
...(delta > 0 ? { level: 1 } : {})
|
||||
}),
|
||||
updatedAt: new Date()
|
||||
}
|
||||
})
|
||||
.returning({
|
||||
itemId: trackerWeaponRanks.itemId,
|
||||
level: trackerWeaponRanks.level,
|
||||
prestige: trackerWeaponRanks.prestige
|
||||
});
|
||||
return { rank };
|
||||
},
|
||||
saveRank: async ({ request, locals, params }) => {
|
||||
if (!locals.user) return fail(401, { message: 'Sign in to save your rank.' });
|
||||
const game = await findGame(params.slug);
|
||||
const data = await request.formData();
|
||||
const level = boundedInteger(data.get('level'), 1, 10000);
|
||||
const prestige = boundedInteger(data.get('prestige'), 0, 1000);
|
||||
const itemId = data.get('itemId') ? boundedInteger(data.get('itemId'), 1, 2147483647) : null;
|
||||
if (level === null || prestige === null || (data.get('itemId') && !itemId))
|
||||
return fail(400, { message: 'Enter a valid level and prestige.' });
|
||||
if (itemId) {
|
||||
const [item] = await db
|
||||
.select()
|
||||
.from(trackerItems)
|
||||
.where(
|
||||
and(
|
||||
eq(trackerItems.id, itemId),
|
||||
eq(trackerItems.gameId, game.id),
|
||||
eq(trackerItems.kind, 'weapon')
|
||||
)
|
||||
);
|
||||
if (!item) return fail(404, { message: 'Weapon not found in this game.' });
|
||||
const limits = weaponRankLimits(game.rules, item);
|
||||
if (
|
||||
!limits.rankable ||
|
||||
(limits.maxLevel !== null && level > limits.maxLevel) ||
|
||||
prestige > limits.maxPrestige
|
||||
)
|
||||
return fail(400, { message: 'Enter a rank within this weapon’s level and prestige caps.' });
|
||||
await db
|
||||
.insert(trackerWeaponRanks)
|
||||
.values({ userId: locals.user.id, itemId, level, prestige })
|
||||
.onConflictDoUpdate({
|
||||
target: [trackerWeaponRanks.userId, trackerWeaponRanks.itemId],
|
||||
set: { level, prestige, updatedAt: new Date() }
|
||||
});
|
||||
} else {
|
||||
const limits = playerRankLimits(game.rules);
|
||||
if ((limits.maxLevel !== null && level > limits.maxLevel) || prestige > limits.maxPrestige)
|
||||
return fail(400, { message: 'Enter a rank within this player level and prestige caps.' });
|
||||
await db
|
||||
.insert(trackerRanks)
|
||||
.values({ userId: locals.user.id, gameId: game.id, level, prestige })
|
||||
.onConflictDoUpdate({
|
||||
target: [trackerRanks.userId, trackerRanks.gameId],
|
||||
set: { level, prestige, updatedAt: new Date() }
|
||||
});
|
||||
}
|
||||
return { saved: true };
|
||||
},
|
||||
saveChallenge: async ({ request, locals, params }) => {
|
||||
if (!locals.user) return fail(401, { message: 'Sign in to track challenges.' });
|
||||
const game = await findGame(params.slug);
|
||||
const data = await request.formData();
|
||||
const challengeId = boundedInteger(data.get('challengeId'), 1, 2147483647);
|
||||
const current = boundedInteger(data.get('current'), 0, 2147483647);
|
||||
if (!challengeId || current === null)
|
||||
return fail(400, { message: 'Invalid challenge progress.' });
|
||||
const [challenge] = await db
|
||||
.select()
|
||||
.from(trackerChallenges)
|
||||
.where(and(eq(trackerChallenges.id, challengeId), eq(trackerChallenges.gameId, game.id)));
|
||||
if (!challenge) return fail(404, { message: 'Challenge not found.' });
|
||||
if (challenge.target !== null && current > challenge.target)
|
||||
return fail(400, { message: 'Progress cannot exceed the target.' });
|
||||
const values = {
|
||||
userId: locals.user.id,
|
||||
challengeId,
|
||||
current,
|
||||
done: data.get('done') === 'on' || (challenge.target !== null && current >= challenge.target)
|
||||
};
|
||||
await db
|
||||
.insert(trackerChallengeProgress)
|
||||
.values(values)
|
||||
.onConflictDoUpdate({
|
||||
target: [trackerChallengeProgress.userId, trackerChallengeProgress.challengeId],
|
||||
set: values
|
||||
});
|
||||
return { saved: true };
|
||||
},
|
||||
toggle: async ({ request, locals, params }) => {
|
||||
if (!locals.user) return fail(401, { message: 'Log in to track your progress' });
|
||||
const game = await findGame(params.slug);
|
||||
|
||||
@@ -1,14 +1,39 @@
|
||||
<script lang="ts">
|
||||
import WeaponRankControls from '$lib/game-tracker/WeaponRankControls.svelte';
|
||||
import WeaponArtwork from '$lib/game-tracker/WeaponArtwork.svelte';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
|
||||
import { NativeSelect } from '$lib/components/ui/native-select/index.js';
|
||||
import Crosshair from 'lucide-svelte/icons/crosshair';
|
||||
import Trophy from 'lucide-svelte/icons/trophy';
|
||||
import Target from 'lucide-svelte/icons/target';
|
||||
import ChevronDown from 'lucide-svelte/icons/chevron-down';
|
||||
import * as Collapsible from '$lib/components/ui/collapsible/index.js';
|
||||
import GameLogo from '$lib/game-tracker/GameLogo.svelte';
|
||||
import ProgressMeter from '$lib/game-tracker/ProgressMeter.svelte';
|
||||
import { combinedProgress, sumProgress, challengeProgress } from '$lib/game-tracker/progress';
|
||||
import { playerRankLimits, weaponRankLimits } from '$lib/game-tracker/rank-limits';
|
||||
let rankOpen = $state(false);
|
||||
let rankWeapon = $state<number | null>(null);
|
||||
let challengeSearch = $state('');
|
||||
let challengeCategory = $state('all');
|
||||
let rankSaving = $state(false);
|
||||
let challengePage = $state(1);
|
||||
let weaponSearch = $state('');
|
||||
let weaponClass = $state('all');
|
||||
let overviewOpen = $state(false);
|
||||
|
||||
import { onMount } from 'svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import { deserialize } from '$app/forms';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import ProgressBar from '$lib/components/ProgressBar.svelte';
|
||||
import { deserialize, enhance } from '$app/forms';
|
||||
import { SvelteSet, SvelteMap } from 'svelte/reactivity';
|
||||
import * as Tabs from '$lib/components/ui/tabs/index.js';
|
||||
import { appliesTo, summarizeGame } from '$lib/game-tracker/mastery';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
let { data, form }: PageProps = $props();
|
||||
// rebuilt whenever the page data changes, so the server render already shows progress
|
||||
const done = $derived(new SvelteSet(data.done));
|
||||
const pending = new SvelteSet<string>();
|
||||
@@ -16,7 +41,10 @@
|
||||
// checkboxes stay disabled until hydration, so early clicks can't silently go unsaved
|
||||
let hydrated = $state(false);
|
||||
onMount(() => (hydrated = true));
|
||||
const isDone = (id: number, milestone: string) => done.has(`${id}:${milestone}`);
|
||||
const weaponRanks = $derived(new SvelteMap(data.weaponRanks.map((r) => [r.itemId, r])));
|
||||
const isDone = (id: number, milestone: string) =>
|
||||
done.has(`${id}:${milestone}`) ||
|
||||
(milestone === 'maxLevel' && (weaponRanks.get(id)?.prestige ?? 0) > 0);
|
||||
|
||||
async function toggle(itemId: number, milestone: string, checked: boolean) {
|
||||
const key = `${itemId}:${milestone}`;
|
||||
@@ -55,6 +83,24 @@
|
||||
isDone
|
||||
)
|
||||
);
|
||||
const masteryProgress = $derived(sumProgress(summary.milestones));
|
||||
const unlockProgress = $derived(challengeProgress(data.challenges, data.challengeProgress));
|
||||
const rankProgress = $derived({
|
||||
value: weaponRanks.size + Number(!!data.rank),
|
||||
total: weapons.length + 1
|
||||
});
|
||||
const overall = $derived(
|
||||
combinedProgress([masteryProgress, rankProgress, summary.achievements, unlockProgress])
|
||||
);
|
||||
const classesProgress = $derived(
|
||||
rules.classes.map((category) => {
|
||||
const members = weapons.filter((w) => w.category === category);
|
||||
const tasks = members.flatMap((w) =>
|
||||
rules.milestones.filter((m) => appliesTo(m, w)).map((m) => isDone(w.id, m.key))
|
||||
);
|
||||
return { category, value: tasks.filter(Boolean).length, total: tasks.length };
|
||||
})
|
||||
);
|
||||
type Bar = {
|
||||
label: string;
|
||||
description?: string;
|
||||
@@ -70,39 +116,74 @@
|
||||
label: tier.scope === 'class' ? `${tier.label} classes` : tier.label
|
||||
}))
|
||||
]);
|
||||
const classes = $derived(
|
||||
rules.classes
|
||||
.map((name) => {
|
||||
const list = weapons.filter((weapon) => weapon.category === name);
|
||||
const milestones = rules.milestones.filter((milestone) =>
|
||||
list.some((weapon) => appliesTo(milestone, weapon))
|
||||
);
|
||||
const tiers = rules.tiers
|
||||
.filter((tier) => tier.scope === 'class')
|
||||
.map((tier) => ({ tier, count: summary.mastery.classTiers.get(tier.key)?.get(name) }))
|
||||
.filter((entry) => entry.count);
|
||||
return { name, list, milestones, tiers };
|
||||
})
|
||||
.filter((entry) => entry.list.length)
|
||||
const weaponRows = $derived(
|
||||
weapons.filter(
|
||||
(w) =>
|
||||
(weaponClass === 'all' || w.category === weaponClass) &&
|
||||
w.name.toLowerCase().includes(weaponSearch.toLowerCase().trim()) &&
|
||||
(!hideDone ||
|
||||
rules.milestones.filter((m) => appliesTo(m, w)).some((m) => !isDone(w.id, m.key)))
|
||||
)
|
||||
);
|
||||
const categories = $derived([...Map.groupBy(achievements, (item) => item.category)]);
|
||||
|
||||
const tabs = ['weapons', 'achievements'] as const;
|
||||
const tabs = ['weapons', 'achievements', 'challenges'] as const;
|
||||
let tab = $state<(typeof tabs)[number]>('weapons');
|
||||
let hideDone = $state(false);
|
||||
function percent(value: number, total: number) {
|
||||
return total > 0 ? Math.round((value / total) * 100) : 0;
|
||||
}
|
||||
const rank = $derived(rankWeapon ? weaponRanks.get(rankWeapon) : data.rank);
|
||||
const selectedWeapon = $derived(data.items.find((w) => w.id === rankWeapon));
|
||||
const playerLimits = $derived(playerRankLimits(rules));
|
||||
const rankLimits = $derived(
|
||||
rankWeapon && selectedWeapon ? weaponRankLimits(rules, selectedWeapon) : playerLimits
|
||||
);
|
||||
const challengeCategories = $derived([...new Set(data.challenges.map((c) => c.category))]);
|
||||
const challengeRows = $derived(
|
||||
data.challenges.filter(
|
||||
(c) =>
|
||||
(challengeCategory === 'all' || c.category === challengeCategory) &&
|
||||
`${c.name} ${c.requirement} ${c.reward ?? ''}`
|
||||
.toLowerCase()
|
||||
.includes(challengeSearch.toLowerCase()) &&
|
||||
(!hideDone || !data.challengeProgress.find((p) => p.challengeId === c.id)?.done)
|
||||
)
|
||||
);
|
||||
$effect(() => {
|
||||
if (challengePage > Math.max(1, Math.ceil(challengeRows.length / 36))) challengePage = 1;
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-14">
|
||||
<div class="mx-auto max-w-[1600px] px-3 py-4 sm:px-5">
|
||||
<a
|
||||
href={resolve('/projects/game-tracker')}
|
||||
class="text-[var(--site-muted)] hover:text-[var(--site-text)]">← game tracker</a
|
||||
>
|
||||
<h1 class="mt-3 text-4xl font-semibold tracking-tight sm:text-5xl">{data.game.shortName}</h1>
|
||||
<p class="mt-2 text-[var(--site-muted)]">
|
||||
{weapons.length} weapons · {achievements.length} achievements
|
||||
<div class="mt-3 flex flex-wrap items-center gap-3">
|
||||
<GameLogo slug={`cod/${data.game.slug}`} name={data.game.shortName} class="h-12 w-24" />
|
||||
<div class="flex-1">
|
||||
<h1 class="text-2xl font-semibold tracking-tight">{data.game.shortName}</h1>
|
||||
<p class="mt-2 text-muted-foreground">
|
||||
Level {data.rank?.level ?? '—'}{playerLimits.maxLevel ? ` / ${playerLimits.maxLevel}` : ''} ·
|
||||
Prestige {data.rank?.prestige ?? '—'}{data.game.rules.rankCaps
|
||||
? ` / ${playerLimits.maxPrestige}`
|
||||
: ''}{data.rank?.level === playerLimits.maxLevel &&
|
||||
data.rank?.prestige === playerLimits.maxPrestige &&
|
||||
data.game.rules.rankCaps
|
||||
? ' · Prestige Master'
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!hydrated || !data.user}
|
||||
onclick={() => {
|
||||
rankWeapon = null;
|
||||
rankOpen = true;
|
||||
}}>Update my rank</Button
|
||||
>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-[var(--site-muted)]">
|
||||
{weapons.length} weapons · {achievements.length} achievements · {data.challenges.length} challenges
|
||||
& unlocks
|
||||
</p>
|
||||
{#if !data.user}<p class="mt-6">
|
||||
<a
|
||||
@@ -110,124 +191,170 @@
|
||||
class="underline underline-offset-4">sign in</a
|
||||
> to save your progress.
|
||||
</p>{/if}
|
||||
{#if form?.message}<p role="alert" class="mt-4 text-destructive">{form.message}</p>{/if}
|
||||
{#if saveError}<p role="alert" class="mt-5 text-red-500">{saveError}</p>{/if}
|
||||
|
||||
<div
|
||||
class="mt-8 grid gap-x-10 gap-y-7 border-y border-[var(--site-border)] py-6 sm:grid-cols-2 lg:grid-cols-3"
|
||||
class="mt-3 grid grid-cols-2 gap-3 rounded-xl border bg-card p-3 sm:grid-cols-5"
|
||||
aria-label="Game completion"
|
||||
>
|
||||
{#each bars as bar (bar.label)}
|
||||
<div class="min-w-0" title={bar.description}>
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<p class="font-medium">{bar.label}</p>
|
||||
<p class="text-xl font-semibold tabular-nums">
|
||||
{bar.achieved ? '✓' : `${percent(bar.value, bar.total)}%`}
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<ProgressBar value={bar.value} max={bar.total} label={bar.label} small />
|
||||
</div>
|
||||
<p class="mt-2 text-[var(--site-muted)]">{bar.value} of {bar.total}</p>
|
||||
</div>
|
||||
{/each}
|
||||
<ProgressMeter
|
||||
label="Game completion"
|
||||
progress={overall}
|
||||
color="violet"
|
||||
compact
|
||||
hint="Recorded ranks, weapon mastery, achievements and challenges contribute equally. Partial challenge targets count."
|
||||
/>
|
||||
<ProgressMeter label="Weapon mastery" progress={masteryProgress} color="amber" compact />
|
||||
<ProgressMeter
|
||||
label="Player & weapon ranks"
|
||||
progress={rankProgress}
|
||||
color="pink"
|
||||
compact
|
||||
hint="Percentage of this game’s player and weapon ranks that you have entered."
|
||||
/>
|
||||
<ProgressMeter label="Achievements" progress={summary.achievements} color="sky" compact />
|
||||
<ProgressMeter label="Challenges & unlocks" progress={unlockProgress} compact />
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex flex-wrap items-center justify-between gap-4">
|
||||
<div
|
||||
class="sticky top-0 z-20 mt-4 flex flex-wrap items-center justify-between gap-3 border-y bg-background/95 py-2"
|
||||
>
|
||||
<Tabs.Root bind:value={tab}
|
||||
><Tabs.List
|
||||
>{#each tabs as name (name)}<Tabs.Trigger value={name}>{name}</Tabs.Trigger
|
||||
>{#each tabs as name (name)}<Tabs.Trigger disabled={!hydrated} value={name}>
|
||||
{#if name === 'weapons'}<Crosshair class="mr-1.5 size-4" aria-hidden="true" />Weapons
|
||||
{:else if name === 'achievements'}<Trophy
|
||||
class="mr-1.5 size-4"
|
||||
aria-hidden="true"
|
||||
/>Achievements
|
||||
{:else}<Target class="mr-1.5 size-4" aria-hidden="true" />Challenges{/if}</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
|
||||
<Button variant="ghost" size="sm" onclick={() => (overviewOpen = true)}
|
||||
>Progress overview</Button
|
||||
>
|
||||
<label class="flex cursor-pointer items-center gap-2 text-xs">
|
||||
<Checkbox bind:checked={hideDone} />
|
||||
Hide finished
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if tab === 'weapons'}
|
||||
{#if rules.tiers.length || rules.milestones.length}
|
||||
<p class="mt-4 max-w-3xl text-[var(--site-muted)]">
|
||||
{[...rules.milestones, ...rules.tiers]
|
||||
.filter((rule) => rule.description)
|
||||
.map((rule) => `${rule.label}: ${rule.description}`)
|
||||
.join('. ')}. DLC weapons don’t count toward class or overall camos.
|
||||
</p>
|
||||
{/if}
|
||||
{#each classes as group (group.name)}
|
||||
{@const rows = group.list.filter(
|
||||
(weapon) =>
|
||||
!hideDone ||
|
||||
group.milestones.some(
|
||||
(milestone) => appliesTo(milestone, weapon) && !isDone(weapon.id, milestone.key)
|
||||
)
|
||||
)}
|
||||
<section class="mt-10">
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-3">
|
||||
<h2 class="text-2xl font-semibold">{group.name}</h2>
|
||||
<p class="text-[var(--site-muted)]">
|
||||
{#each group.tiers as { tier, count } (tier.key)}
|
||||
<span class={count?.achieved ? 'ml-4 text-[var(--site-text)]' : 'ml-4'}
|
||||
>{tier.label}
|
||||
{count?.achieved ? '✓' : `${count?.value}/${count?.total}`}</span
|
||||
<div class="my-3 flex flex-wrap items-center gap-2">
|
||||
<Input
|
||||
class="min-w-40 flex-1"
|
||||
aria-label="Find a weapon"
|
||||
type="search"
|
||||
placeholder="Find a weapon…"
|
||||
bind:value={weaponSearch}
|
||||
/>
|
||||
<NativeSelect aria-label="Weapon class" bind:value={weaponClass}
|
||||
><option value="all">All weapon classes</option>{#each rules.classes as name (name)}<option
|
||||
value={name}>{name}</option
|
||||
>{/each}</NativeSelect
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5"
|
||||
aria-label="Weapon progress"
|
||||
>
|
||||
{#each weaponRows as weapon (weapon.id)}
|
||||
{@const current = weaponRanks.get(weapon.id)}
|
||||
{@const limits = weaponRankLimits(rules, weapon)}
|
||||
{@const tiers = summary.mastery.weaponTiers(weapon)}
|
||||
{@const allMilestones = rules.milestones.filter((m) => appliesTo(m, weapon))}
|
||||
<article
|
||||
class="weapon-card min-w-0 rounded-lg border bg-card p-2.5"
|
||||
aria-label={weapon.name}
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<WeaponArtwork game={data.game.slug} name={weapon.name} icon={weapon.icon} />
|
||||
<div class="min-w-0 flex-1">
|
||||
<h2 class="truncate text-sm font-semibold" title={weapon.name}>{weapon.name}</h2>
|
||||
<p class="truncate text-[10px] text-muted-foreground" title={weapon.category}>
|
||||
{weapon.category}{weapon.postLaunch ? ' · DLC' : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
{#if limits.rankable}
|
||||
<WeaponRankControls
|
||||
itemId={weapon.id}
|
||||
name={weapon.name}
|
||||
level={current?.level ?? 1}
|
||||
prestige={current?.prestige ?? 0}
|
||||
levelCap={limits.maxLevel}
|
||||
prestigeCap={limits.maxPrestige}
|
||||
prestigeRequiresMaxLevel={limits.canPrestigeOnlyAtMaxLevel}
|
||||
compact
|
||||
disabled={!hydrated || !data.user}
|
||||
onupdate={(r) => weaponRanks.set(r.itemId, r)}
|
||||
onedit={() => {
|
||||
rankWeapon = weapon.id;
|
||||
rankOpen = true;
|
||||
}}
|
||||
/>
|
||||
{:else}<p class="py-1 text-[10px] text-muted-foreground">No weapon level rank</p>{/if}
|
||||
</div>
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<ProgressMeter
|
||||
label={`${weapon.name} mastery`}
|
||||
progress={{
|
||||
value: allMilestones.filter((m) => isDone(weapon.id, m.key)).length,
|
||||
total: allMilestones.length
|
||||
}}
|
||||
color="amber"
|
||||
compact
|
||||
/>
|
||||
{#if tiers.length}<span
|
||||
class="shrink-0 text-[10px] text-amber-600"
|
||||
title="Mastery tier"
|
||||
>
|
||||
{/each}
|
||||
</p>
|
||||
</div>
|
||||
{#if rows.length}
|
||||
<table class="mt-3 w-full border-collapse">
|
||||
<thead>
|
||||
<tr class="border-b border-[var(--site-border)] text-left text-[var(--site-muted)]">
|
||||
<th scope="col" class="py-2 font-medium">weapon</th>
|
||||
{#each group.milestones as milestone (milestone.key)}<th
|
||||
scope="col"
|
||||
class="w-20 py-2 text-center font-medium sm:w-28"
|
||||
title={milestone.description}>{milestone.label}</th
|
||||
{tiers.map((t) => t.label).join(' · ')}
|
||||
</span>{/if}
|
||||
</div>
|
||||
{#if allMilestones.length}<Collapsible.Root class="mt-2 border-t pt-1.5">
|
||||
<Collapsible.Trigger
|
||||
class="group flex w-full items-center justify-between rounded py-1 text-left text-[11px] text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<span
|
||||
>Camos & milestones <span class="ml-1 tabular-nums"
|
||||
>{allMilestones.filter((m) => isDone(weapon.id, m.key))
|
||||
.length}/{allMilestones.length}</span
|
||||
></span
|
||||
>
|
||||
<ChevronDown
|
||||
class="size-3.5 transition-transform group-data-[state=open]:rotate-180"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Content class="mt-1 grid grid-cols-2 gap-x-2">
|
||||
{#each allMilestones as milestone (milestone.key)}<label
|
||||
class="flex min-h-7 items-center gap-1.5 text-[10px]"
|
||||
><Checkbox
|
||||
class="size-3.5"
|
||||
checked={isDone(weapon.id, milestone.key)}
|
||||
disabled={!hydrated ||
|
||||
!data.user ||
|
||||
pending.has(`${weapon.id}:${milestone.key}`)}
|
||||
aria-label={`${weapon.name}: ${milestone.label}`}
|
||||
title={milestone.description}
|
||||
onCheckedChange={(checked) => toggle(weapon.id, milestone.key, checked)}
|
||||
/>{milestone.label}</label
|
||||
>{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each rows as weapon (weapon.id)}
|
||||
{@const tiers = summary.mastery.weaponTiers(weapon)}
|
||||
<tr class="border-b border-[var(--site-border)]">
|
||||
<th scope="row" class="py-2 text-left font-normal">
|
||||
{weapon.name}
|
||||
{#if weapon.postLaunch}<span class="ml-1 text-sm text-[var(--site-muted)]"
|
||||
>DLC</span
|
||||
>{/if}
|
||||
{#if tiers.length}<span class="block text-sm text-[var(--site-muted)]"
|
||||
>{tiers.map((tier) => tier.label).join(' · ')}</span
|
||||
>{/if}
|
||||
</th>
|
||||
{#each group.milestones as milestone (milestone.key)}
|
||||
<td class="py-2 text-center">
|
||||
{#if appliesTo(milestone, weapon)}
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-5 w-5 cursor-pointer accent-[var(--site-text)]"
|
||||
checked={isDone(weapon.id, milestone.key)}
|
||||
disabled={!hydrated ||
|
||||
!data.user ||
|
||||
pending.has(`${weapon.id}:${milestone.key}`)}
|
||||
aria-label={`${weapon.name}: ${milestone.label}`}
|
||||
title={data.user
|
||||
? milestone.description
|
||||
: 'Sign in to save your progress'}
|
||||
onchange={(event) =>
|
||||
toggle(weapon.id, milestone.key, event.currentTarget.checked)}
|
||||
/>
|
||||
{:else}<span class="text-[var(--site-muted)]">—</span>{/if}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{:else}
|
||||
<p class="mt-3 text-[var(--site-muted)]">all done.</p>
|
||||
{/if}
|
||||
</section>
|
||||
{/each}
|
||||
{:else}
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>{/if}
|
||||
</article>
|
||||
{:else}<p class="col-span-full py-8 text-center text-muted-foreground">
|
||||
No weapons match these filters.
|
||||
</p>{/each}
|
||||
</div>
|
||||
<p class="mt-3 text-xs text-muted-foreground">
|
||||
Showing {weaponRows.length} of {weapons.length} weapons · Changes save immediately. Prestige + starts
|
||||
the next prestige at level 1.
|
||||
</p>
|
||||
{:else if tab === 'achievements'}
|
||||
{#each categories as [category, list] (category)}
|
||||
{@const rows = list.filter((item) => !hideDone || !isDone(item.id, 'done'))}
|
||||
<section class="mt-10">
|
||||
@@ -237,6 +364,17 @@
|
||||
{list.filter((item) => isDone(item.id, 'done')).length} of {list.length}
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<ProgressMeter
|
||||
label={`${category} achievements`}
|
||||
progress={{
|
||||
value: list.filter((item) => isDone(item.id, 'done')).length,
|
||||
total: list.length
|
||||
}}
|
||||
color="sky"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
<ul class="mt-3">
|
||||
{#each rows as item (item.id)}
|
||||
<li class="border-t border-[var(--site-border)]">
|
||||
@@ -256,13 +394,12 @@
|
||||
>{item.description}</span
|
||||
>{/if}
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
<Checkbox
|
||||
class="h-5 w-5 shrink-0 cursor-pointer accent-[var(--site-text)]"
|
||||
checked={isDone(item.id, 'done')}
|
||||
disabled={!hydrated || !data.user || pending.has(`${item.id}:done`)}
|
||||
aria-label={`Unlocked ${item.name}`}
|
||||
onchange={(event) => toggle(item.id, 'done', event.currentTarget.checked)}
|
||||
onCheckedChange={(checked) => toggle(item.id, 'done', checked)}
|
||||
/>
|
||||
</label>
|
||||
</li>
|
||||
@@ -274,5 +411,230 @@
|
||||
</ul>
|
||||
</section>
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="mt-5 grid gap-3 sm:grid-cols-2">
|
||||
<Input
|
||||
type="search"
|
||||
aria-label="Search challenges"
|
||||
placeholder="Search challenges, emblems and calling cards…"
|
||||
bind:value={challengeSearch}
|
||||
oninput={() => (challengePage = 1)}
|
||||
/><NativeSelect
|
||||
class="w-full"
|
||||
aria-label="Challenge category"
|
||||
bind:value={challengeCategory}
|
||||
onchange={() => (challengePage = 1)}
|
||||
><option value="all">All categories</option
|
||||
>{#each challengeCategories as category (category)}<option value={category}
|
||||
>{category}</option
|
||||
>{/each}</NativeSelect
|
||||
>
|
||||
</div>
|
||||
<div class="mt-5 grid gap-4 md:grid-cols-2">
|
||||
{#each challengeRows.slice((challengePage - 1) * 36, challengePage * 36) as challenge (challenge.id)}
|
||||
{@const progress = data.challengeProgress.find((p) => p.challengeId === challenge.id)}
|
||||
<article class="rounded-xl border bg-card p-5">
|
||||
<div class="flex gap-3">
|
||||
{#if challenge.icon}<img
|
||||
src={challenge.icon}
|
||||
alt=""
|
||||
width="80"
|
||||
height="48"
|
||||
loading="lazy"
|
||||
class="h-12 w-20 object-contain"
|
||||
/>{/if}
|
||||
<div>
|
||||
<p class="text-xs text-muted-foreground">{challenge.category}</p>
|
||||
<h2 class="font-semibold">{challenge.name}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-3 text-sm text-muted-foreground">{challenge.requirement}</p>
|
||||
{#if challenge.reward}<p class="mt-2 text-sm">Unlock: {challenge.reward}</p>{/if}
|
||||
<div class="mt-3">
|
||||
<ProgressMeter
|
||||
label={`${challenge.name} progress`}
|
||||
progress={{
|
||||
value: progress?.done ? (challenge.target ?? 1) : (progress?.current ?? 0),
|
||||
total: challenge.target ?? 1
|
||||
}}
|
||||
detail={progress?.done
|
||||
? 'Unlocked ✓'
|
||||
: challenge.target
|
||||
? `${progress?.current ?? 0} / ${challenge.target}`
|
||||
: 'Not unlocked yet'}
|
||||
/>
|
||||
</div>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/saveChallenge"
|
||||
use:enhance
|
||||
class="mt-4 flex flex-wrap items-end gap-3"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name="challengeId"
|
||||
value={challenge.id}
|
||||
/>{#if challenge.target}<label class="flex-1 text-xs"
|
||||
>Progress / {challenge.target}<Input
|
||||
type="number"
|
||||
name="current"
|
||||
min={0}
|
||||
max={challenge.target}
|
||||
value={progress?.current ?? 0}
|
||||
required
|
||||
/></label
|
||||
>{:else}<input type="hidden" name="current" value="0" />{/if}<label
|
||||
class="mb-2 flex items-center gap-2 text-sm"
|
||||
><Checkbox name="done" value="on" checked={progress?.done ?? false} />Unlocked</label
|
||||
><Button type="submit" size="sm" variant="outline" disabled={!hydrated || !data.user}
|
||||
>Save</Button
|
||||
>
|
||||
</form>
|
||||
<!-- Catalog or validated external URL. -->
|
||||
<!-- eslint-disable svelte/no-navigation-without-resolve -->
|
||||
<a
|
||||
href={challenge.sourceUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="mt-3 inline-block text-xs text-muted-foreground underline"
|
||||
>Source & requirements ↗</a
|
||||
>
|
||||
<!-- eslint-enable svelte/no-navigation-without-resolve -->
|
||||
</article>
|
||||
{:else}<p class="py-8 text-muted-foreground">
|
||||
{data.challenges.length
|
||||
? 'No matching challenges.'
|
||||
: 'No multiplayer challenge catalog has been imported for this game yet.'}
|
||||
</p>{/each}
|
||||
</div>
|
||||
<div class="mt-5 flex items-center justify-between">
|
||||
<Button variant="outline" disabled={challengePage === 1} onclick={() => challengePage--}
|
||||
>Previous</Button
|
||||
><span class="text-sm text-muted-foreground"
|
||||
>{challengeRows.length} challenges · {challengePage} / {Math.max(
|
||||
1,
|
||||
Math.ceil(challengeRows.length / 36)
|
||||
)}</span
|
||||
><Button
|
||||
variant="outline"
|
||||
disabled={challengePage * 36 >= challengeRows.length}
|
||||
onclick={() => challengePage++}>Next</Button
|
||||
>
|
||||
</div>
|
||||
<p class="mt-5 text-xs text-muted-foreground">
|
||||
Requirements adapted from <a
|
||||
href="https://callofduty.fandom.com/wiki/Challenges"
|
||||
class="underline">Call of Duty Wiki contributors</a
|
||||
>
|
||||
under <a href="https://www.fandom.com/licensing" class="underline">CC BY-SA</a>. Each card
|
||||
links to its source. This catalog includes confirmed source rows; coverage is not exhaustive.
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Dialog.Root bind:open={rankOpen}
|
||||
><Dialog.Content
|
||||
><Dialog.Header
|
||||
><Dialog.Title>{selectedWeapon?.name ?? 'Player'} level & prestige</Dialog.Title
|
||||
><Dialog.Description
|
||||
>Record your current rank in {data.game.shortName}. Weapon prestige also records that max
|
||||
level has been reached.</Dialog.Description
|
||||
></Dialog.Header
|
||||
>{#if form?.message}<p role="alert" class="text-destructive">{form.message}</p>{/if}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/saveRank"
|
||||
class="space-y-4"
|
||||
use:enhance={() => {
|
||||
rankSaving = true;
|
||||
return async ({ result, update }) => {
|
||||
await update();
|
||||
rankSaving = false;
|
||||
if (result.type === 'success') rankOpen = false;
|
||||
};
|
||||
}}
|
||||
>
|
||||
{#if rankWeapon}<input type="hidden" name="itemId" value={rankWeapon} />{/if}
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<label
|
||||
>Current level<Input
|
||||
type="number"
|
||||
name="level"
|
||||
min={1}
|
||||
max={rankLimits.maxLevel ?? 10000}
|
||||
value={rank?.level ?? 1}
|
||||
required
|
||||
/></label
|
||||
><label
|
||||
>Prestige<Input
|
||||
type="number"
|
||||
name="prestige"
|
||||
min={0}
|
||||
max={rankLimits.maxPrestige}
|
||||
value={rank?.prestige ?? 0}
|
||||
required
|
||||
/></label
|
||||
>
|
||||
</div>
|
||||
<Dialog.Footer
|
||||
><Button variant="outline" onclick={() => (rankOpen = false)}>Cancel</Button><Button
|
||||
type="submit"
|
||||
disabled={rankSaving || !data.user}>{rankSaving ? 'Saving…' : 'Save rank'}</Button
|
||||
></Dialog.Footer
|
||||
>
|
||||
</form></Dialog.Content
|
||||
></Dialog.Root
|
||||
>
|
||||
|
||||
<Dialog.Root bind:open={overviewOpen}
|
||||
><Dialog.Content class="max-h-[85dvh] overflow-y-auto sm:max-w-3xl"
|
||||
><Dialog.Header
|
||||
><Dialog.Title>Progress overview</Dialog.Title><Dialog.Description
|
||||
>Mastery, camos and achievements in {data.game.shortName}.</Dialog.Description
|
||||
></Dialog.Header
|
||||
>
|
||||
|
||||
<div
|
||||
class="grid gap-x-10 gap-y-7 border-y border-[var(--site-border)] py-6 sm:grid-cols-2 lg:grid-cols-3"
|
||||
>
|
||||
{#each bars as bar (bar.label)}
|
||||
<ProgressMeter label={bar.label} progress={bar} color="amber" hint={bar.description} />
|
||||
{/each}
|
||||
</div>
|
||||
<h3 class="font-semibold">Weapon classes</h3>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
{#each classesProgress as group (group.category)}<ProgressMeter
|
||||
label={group.category}
|
||||
progress={group}
|
||||
color="amber"
|
||||
/>{/each}
|
||||
</div>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<ProgressMeter
|
||||
label="Ranks entered"
|
||||
progress={rankProgress}
|
||||
color="pink"
|
||||
detail="Your player rank plus a saved rank for each weapon."
|
||||
/>
|
||||
<ProgressMeter
|
||||
label="Challenges & unlocks"
|
||||
progress={unlockProgress}
|
||||
detail={`${data.challengeProgress.filter((p) => p.done).length} unlocked · partial targets count too`}
|
||||
/><ProgressMeter
|
||||
label="Weapons prestiged"
|
||||
progress={{
|
||||
value: weapons.filter((w) => (weaponRanks.get(w.id)?.prestige ?? 0) > 0).length,
|
||||
total: weapons.length
|
||||
}}
|
||||
color="violet"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{[...rules.milestones, ...rules.tiers]
|
||||
.filter((r) => r.description)
|
||||
.map((r) => `${r.label}: ${r.description}`)
|
||||
.join('. ')}. DLC weapons do not gate class or overall camos.
|
||||
</p>
|
||||
</Dialog.Content></Dialog.Root
|
||||
>
|
||||
|
||||
15
src/routes/projects/game-tracker/credits/+page.server.ts
Normal file
15
src/routes/projects/game-tracker/credits/+page.server.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import images from '../../../../../data/game-images.json';
|
||||
import maps from '../../../../../data/pokemon-map-art.json';
|
||||
export function load() {
|
||||
const sources = new Map<string, { source: string; credit: string; count: number }>();
|
||||
for (const image of images) {
|
||||
const existing = sources.get(image.source);
|
||||
if (existing) existing.count++;
|
||||
else sources.set(image.source, { source: image.source, credit: image.credit, count: 1 });
|
||||
}
|
||||
return {
|
||||
sources: [...sources.values()],
|
||||
maps: Object.values(maps).map(({ name, source, credit }) => ({ name, source, credit })),
|
||||
total: images.length
|
||||
};
|
||||
}
|
||||
41
src/routes/projects/game-tracker/credits/+page.svelte
Normal file
41
src/routes/projects/game-tracker/credits/+page.svelte
Normal file
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import type { PageProps } from './$types';
|
||||
let { data }: PageProps = $props();
|
||||
</script>
|
||||
|
||||
<!-- Download manifest links point to reviewed external image sources. -->
|
||||
<!-- eslint-disable svelte/no-navigation-without-resolve -->
|
||||
<div class="mx-auto max-w-4xl px-4 py-10 sm:px-6">
|
||||
<a href={resolve('/projects/game-tracker')} class="text-sm text-muted-foreground hover:underline"
|
||||
>← Game tracker</a
|
||||
>
|
||||
<h1 class="mt-6 text-3xl font-semibold">Artwork credits</h1>
|
||||
<p class="mt-3 text-muted-foreground">
|
||||
Game covers, logos, sprites, maps and screenshots belong to their respective owners. The tracker
|
||||
stores WebP copies for consistent, reliable display. Map imagery is shared across games in the
|
||||
same region; later-region landmark positions are approximate.
|
||||
</p>
|
||||
<h2 class="mt-8 text-xl font-semibold">Regional maps</h2>
|
||||
<ul class="mt-4 space-y-3">
|
||||
{#each data.maps as map (map.name)}<li>
|
||||
<a href={map.source} target="_blank" rel="noreferrer" class="font-medium underline"
|
||||
>{map.name}</a
|
||||
>
|
||||
<p class="text-xs text-muted-foreground">{map.credit}</p>
|
||||
</li>{/each}
|
||||
</ul>
|
||||
<h2 class="mt-8 text-xl font-semibold">Covers, sprites and other artwork</h2>
|
||||
<p class="mt-2 text-sm text-muted-foreground">
|
||||
{data.total.toLocaleString()} downloaded image records. Box art is archived by Libretro and Serebii;
|
||||
Pokémon sprites and official artwork are archived by PokéAPI. COD covers and achievement icons come
|
||||
from Steam.
|
||||
</p>
|
||||
<ul class="mt-4 space-y-3">
|
||||
{#each data.sources as item (item.source)}<li>
|
||||
<a href={item.source} target="_blank" rel="noreferrer" class="text-sm underline"
|
||||
>{item.credit}</a
|
||||
><span class="ml-2 text-xs text-muted-foreground">{item.count} images</span>
|
||||
</li>{/each}
|
||||
</ul>
|
||||
</div>
|
||||
133
src/routes/projects/game-tracker/osu/+page.server.ts
Normal file
133
src/routes/projects/game-tracker/osu/+page.server.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { and, desc, eq } from 'drizzle-orm';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { db } from '$lib/server/db';
|
||||
import { osuEntries } from '$lib/server/db/schema';
|
||||
import { allowedOsuUrl, boundedInteger, validDate } from '$lib/game-tracker/journal';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => ({
|
||||
entries: locals.user
|
||||
? await db
|
||||
.select()
|
||||
.from(osuEntries)
|
||||
.where(eq(osuEntries.userId, locals.user.id))
|
||||
.orderBy(desc(osuEntries.playedOn), desc(osuEntries.id))
|
||||
: [],
|
||||
seo: {
|
||||
title: 'osu! training · Game Tracker',
|
||||
description:
|
||||
'A personal journal for memorable scores, beatmap goals and speed, stamina, aim and reading practice.'
|
||||
}
|
||||
});
|
||||
export const actions = {
|
||||
save: async ({ request, locals }) => {
|
||||
if (!locals.user) return fail(401, { message: 'Sign in to save training.' });
|
||||
const data = await request.formData();
|
||||
const t = (k: string) => String(data.get(k) ?? '').trim();
|
||||
const id = t('id') ? boundedInteger(data.get('id'), 1, 2147483647) : null;
|
||||
const kind = t('kind');
|
||||
const mode = t('mode');
|
||||
const goal = t('goal') || 'pass';
|
||||
const skill = t('skill') || 'stamina';
|
||||
if (
|
||||
(t('id') && !id) ||
|
||||
!['score', 'goal', 'training'].includes(kind) ||
|
||||
!['osu', 'taiko', 'fruits', 'mania'].includes(mode) ||
|
||||
!['pass', 'fc', 'accuracy', 'rank', 'custom'].includes(goal) ||
|
||||
!['stamina', 'speed', 'aim', 'reading', 'rhythm', 'finger-control', 'consistency'].includes(
|
||||
skill
|
||||
) ||
|
||||
!t('title') ||
|
||||
t('title').length > 250 ||
|
||||
t('notes').length > 5000 ||
|
||||
t('mods').length > 80 ||
|
||||
!validDate(t('playedOn'))
|
||||
)
|
||||
return fail(400, { message: 'Check the title, date and training details.' });
|
||||
if (!allowedOsuUrl(t('beatmapUrl'), 'beatmap') || !allowedOsuUrl(t('scoreUrl'), 'score'))
|
||||
return fail(400, { message: 'Use an https://osu.ppy.sh beatmap or score link.' });
|
||||
const optionalInt = (key: string, min: number, max: number) =>
|
||||
t(key) ? boundedInteger(data.get(key), min, max) : null;
|
||||
const bpm = optionalInt('bpm', 1, 2000),
|
||||
duration = optionalInt('duration', 1, 86400),
|
||||
combo = optionalInt('combo', 0, 1000000),
|
||||
misses = optionalInt('misses', 0, 1000000);
|
||||
const decimal = (key: string, min: number, max: number) =>
|
||||
!t(key) ||
|
||||
(/^\d+(\.\d{1,4})?$/.test(t(key)) && Number(t(key)) >= min && Number(t(key)) <= max);
|
||||
if (
|
||||
(t('bpm') && bpm === null) ||
|
||||
(t('duration') && duration === null) ||
|
||||
(t('combo') && combo === null) ||
|
||||
(t('misses') && misses === null) ||
|
||||
!decimal('accuracy', 0, 100) ||
|
||||
!decimal('pp', 0, 100000) ||
|
||||
!t('rate') ||
|
||||
!decimal('rate', 0.1, 4) ||
|
||||
(kind === 'training' && (!bpm || !duration))
|
||||
)
|
||||
return fail(400, {
|
||||
message:
|
||||
'Training needs BPM and duration. Check numeric fields, accuracy (0–100) and rate (0.1–4).'
|
||||
});
|
||||
const values = {
|
||||
userId: locals.user.id,
|
||||
kind: kind as 'score' | 'goal' | 'training',
|
||||
mode,
|
||||
goal,
|
||||
skill,
|
||||
title: t('title'),
|
||||
notes: t('notes'),
|
||||
bpm,
|
||||
duration,
|
||||
combo,
|
||||
misses,
|
||||
accuracy: t('accuracy') || null,
|
||||
pp: t('pp') || null,
|
||||
rate: t('rate'),
|
||||
mods: t('mods') || 'NM',
|
||||
beatmapUrl: t('beatmapUrl') || null,
|
||||
scoreUrl: t('scoreUrl') || null,
|
||||
playedOn: t('playedOn'),
|
||||
achieved: data.get('achieved') === 'on'
|
||||
};
|
||||
if (id) {
|
||||
const updated = await db
|
||||
.update(osuEntries)
|
||||
.set(values)
|
||||
.where(and(eq(osuEntries.id, id), eq(osuEntries.userId, locals.user.id)))
|
||||
.returning({ id: osuEntries.id });
|
||||
if (!updated.length) return fail(404, { message: 'Entry not found.' });
|
||||
} else await db.insert(osuEntries).values(values);
|
||||
return { saved: true };
|
||||
},
|
||||
toggleGoal: async ({ request, locals }) => {
|
||||
if (!locals.user) return fail(401, { message: 'Sign in to update goals.' });
|
||||
const data = await request.formData();
|
||||
const id = boundedInteger(data.get('id'), 1, 2147483647);
|
||||
if (!id) return fail(400, { message: 'Invalid goal.' });
|
||||
const updated = await db
|
||||
.update(osuEntries)
|
||||
.set({ achieved: data.get('achieved') === 'on' })
|
||||
.where(
|
||||
and(
|
||||
eq(osuEntries.id, id),
|
||||
eq(osuEntries.userId, locals.user.id),
|
||||
eq(osuEntries.kind, 'goal')
|
||||
)
|
||||
)
|
||||
.returning({ id: osuEntries.id });
|
||||
return updated.length ? { saved: true } : fail(404, { message: 'Goal not found.' });
|
||||
},
|
||||
delete: async ({ request, locals }) => {
|
||||
if (!locals.user) return fail(401, { message: 'Sign in to remove entries.' });
|
||||
const data = await request.formData();
|
||||
const id = boundedInteger(data.get('id'), 1, 2147483647);
|
||||
if (!id) return fail(400, { message: 'Invalid entry.' });
|
||||
const removed = await db
|
||||
.delete(osuEntries)
|
||||
.where(and(eq(osuEntries.id, id), eq(osuEntries.userId, locals.user.id)))
|
||||
.returning({ id: osuEntries.id });
|
||||
return removed.length ? { saved: true } : fail(404, { message: 'Entry not found.' });
|
||||
}
|
||||
} satisfies Actions;
|
||||
603
src/routes/projects/game-tracker/osu/+page.svelte
Normal file
603
src/routes/projects/game-tracker/osu/+page.svelte
Normal file
@@ -0,0 +1,603 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
let hydrated = $state(false);
|
||||
onMount(() => {
|
||||
hydrated = true;
|
||||
});
|
||||
import { resolve } from '$app/paths';
|
||||
import { enhance } from '$app/forms';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
|
||||
import * as Tabs from '$lib/components/ui/tabs/index.js';
|
||||
import * as Table from '$lib/components/ui/table/index.js';
|
||||
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 { NativeSelect } from '$lib/components/ui/native-select/index.js';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
|
||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||
import GameLogo from '$lib/game-tracker/GameLogo.svelte';
|
||||
import ProgressMeter from '$lib/game-tracker/ProgressMeter.svelte';
|
||||
import type { PageProps } from './$types';
|
||||
let { data, form }: PageProps = $props();
|
||||
type Entry = (typeof data.entries)[number];
|
||||
let tab = $state('goals');
|
||||
let open = $state(false);
|
||||
let deleteOpen = $state(false);
|
||||
let editing = $state<Entry | null>(null);
|
||||
let kind = $state<'score' | 'goal' | 'training'>('goal');
|
||||
let saving = $state(false);
|
||||
let search = $state('');
|
||||
let modeFilter = $state('all');
|
||||
let skillFilter = $state('stamina');
|
||||
let minDuration = $state(30);
|
||||
const skills = ['stamina', 'speed', 'aim', 'reading', 'rhythm', 'finger-control', 'consistency'];
|
||||
const modeName: Record<string, string> = {
|
||||
osu: 'osu!',
|
||||
taiko: 'osu!taiko',
|
||||
fruits: 'osu!catch',
|
||||
mania: 'osu!mania'
|
||||
};
|
||||
const scoped = $derived(
|
||||
data.entries.filter(
|
||||
(e) =>
|
||||
(modeFilter === 'all' || e.mode === modeFilter) &&
|
||||
`${e.title} ${e.notes} ${e.mods}`.toLowerCase().includes(search.toLowerCase())
|
||||
)
|
||||
);
|
||||
const scores = $derived(scoped.filter((e) => e.kind === 'score'));
|
||||
const goals = $derived(scoped.filter((e) => e.kind === 'goal'));
|
||||
const sessions = $derived(scoped.filter((e) => e.kind === 'training'));
|
||||
const allGoals = $derived(data.entries.filter((e) => e.kind === 'goal'));
|
||||
const eligible = $derived(
|
||||
sessions.filter((s) => s.skill === skillFilter && (s.duration ?? 0) >= minDuration)
|
||||
);
|
||||
const best = $derived(eligible.reduce((n, e) => Math.max(n, (e.bpm ?? 0) * Number(e.rate)), 0));
|
||||
const chart = $derived(
|
||||
[...eligible].sort((a, b) => a.playedOn.localeCompare(b.playedOn) || a.id - b.id).slice(-30)
|
||||
);
|
||||
const maxBpm = $derived(Math.max(1, ...chart.map((e) => (e.bpm ?? 0) * Number(e.rate))));
|
||||
const minBpm = $derived(
|
||||
Math.max(0, Math.min(...chart.map((e) => (e.bpm ?? 0) * Number(e.rate))) - 20)
|
||||
);
|
||||
function create(next: typeof kind, entry: Entry | null = null) {
|
||||
kind = next;
|
||||
editing = entry;
|
||||
open = true;
|
||||
}
|
||||
function bpm(entry: Entry) {
|
||||
return Math.round((entry.bpm ?? 0) * Number(entry.rate) * 10) / 10;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-6xl px-3 py-4 sm:px-5">
|
||||
<a
|
||||
href={resolve('/projects/game-tracker')}
|
||||
class="text-sm text-muted-foreground hover:text-foreground">← game tracker</a
|
||||
>
|
||||
<div class="mt-3 flex flex-wrap items-center gap-3">
|
||||
<GameLogo slug="osu" name="osu!" class="h-12 w-12 rounded-full bg-pink-500/10 text-pink-500" />
|
||||
<div class="flex-1">
|
||||
<p class="text-xs font-semibold tracking-[.2em] text-pink-500 uppercase">One more try</p>
|
||||
<h1 class="mt-1 text-2xl font-semibold tracking-tight">osu! training</h1>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
Chase the FC. Build your stamina. Remember the scores that mattered.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
disabled={!hydrated || !data.user}
|
||||
onclick={() => create(tab === 'scores' ? 'score' : tab === 'training' ? 'training' : 'goal')}
|
||||
>+ {tab === 'scores'
|
||||
? 'Record a score'
|
||||
: tab === 'training'
|
||||
? 'Log practice'
|
||||
: 'Set a goal'}</Button
|
||||
>
|
||||
</div>
|
||||
{#if !data.user}<p class="mt-6">
|
||||
<a class="underline" href={resolve('/login?returnTo=/projects/game-tracker/osu')}>Sign in</a> to
|
||||
start your training journal.
|
||||
</p>{/if}
|
||||
{#if form?.message && !open && !deleteOpen}<p class="mt-4 text-destructive" role="alert">
|
||||
{form.message}
|
||||
</p>{/if}
|
||||
<div class="my-3 grid grid-cols-3 gap-2">
|
||||
<div class="rounded-xl border px-3 py-2">
|
||||
<p class="text-xl font-semibold tabular-nums">
|
||||
{data.entries.filter((e) => e.kind === 'score').length}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">memorable scores</p>
|
||||
</div>
|
||||
<div class="rounded-xl border px-3 py-2">
|
||||
<p class="text-xl font-semibold tabular-nums">
|
||||
{data.entries.filter((e) => e.kind === 'goal' && e.achieved).length}<span
|
||||
class="text-base text-muted-foreground"
|
||||
>
|
||||
/ {data.entries.filter((e) => e.kind === 'goal').length}</span
|
||||
>
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">goals achieved</p>
|
||||
</div>
|
||||
<div class="rounded-xl border px-3 py-2">
|
||||
<p class="text-xl font-semibold tabular-nums">
|
||||
{Math.round(
|
||||
data.entries
|
||||
.filter((e) => e.kind === 'training')
|
||||
.reduce((n, e) => n + (e.duration ?? 0), 0) / 60
|
||||
)}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">minutes of logged practice</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3 grid grid-cols-2 gap-3 rounded-xl border bg-card p-3 sm:grid-cols-4">
|
||||
<ProgressMeter
|
||||
label="All map goals"
|
||||
progress={{ value: allGoals.filter((g) => g.achieved).length, total: allGoals.length }}
|
||||
color="pink"
|
||||
compact
|
||||
/>
|
||||
{#each [{ kind: 'pass', label: 'Pass goals' }, { kind: 'fc', label: 'Full combo goals' }, { kind: 'accuracy', label: 'Accuracy goals' }] as group (group.kind)}
|
||||
<ProgressMeter
|
||||
label={group.label}
|
||||
progress={{
|
||||
value: allGoals.filter((g) => g.goal === group.kind && g.achieved).length,
|
||||
total: allGoals.filter((g) => g.goal === group.kind).length
|
||||
}}
|
||||
color="pink"
|
||||
compact
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="mb-5 flex gap-3">
|
||||
<Input
|
||||
type="search"
|
||||
bind:value={search}
|
||||
aria-label="Search osu journal"
|
||||
placeholder="Search maps, scores or notes…"
|
||||
/><NativeSelect aria-label="Game mode" bind:value={modeFilter}
|
||||
><option value="all">All modes</option
|
||||
>{#each Object.entries(modeName) as [value, label] (value)}<option {value}>{label}</option
|
||||
>{/each}</NativeSelect
|
||||
>
|
||||
</div>
|
||||
<Tabs.Root bind:value={tab}
|
||||
><Tabs.List
|
||||
><Tabs.Trigger disabled={!hydrated} value="goals">Map goals</Tabs.Trigger><Tabs.Trigger
|
||||
disabled={!hydrated}
|
||||
value="scores">Score showcase</Tabs.Trigger
|
||||
><Tabs.Trigger disabled={!hydrated} value="training">Training log</Tabs.Trigger></Tabs.List
|
||||
>
|
||||
<Tabs.Content value="goals" class="mt-5"
|
||||
><div class="grid gap-4 sm:grid-cols-2">
|
||||
{#each goals as goal (goal.id)}<article class="rounded-xl border bg-card p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<Badge variant={goal.achieved ? 'secondary' : 'outline'}
|
||||
>{goal.achieved ? '✓ achieved' : goal.goal.toUpperCase()}</Badge
|
||||
><span class="text-xs text-muted-foreground">{modeName[goal.mode]} · {goal.mods}</span
|
||||
>
|
||||
</div>
|
||||
<h2 class="mt-3 text-lg font-semibold">{goal.title}</h2>
|
||||
{#if goal.notes}<p class="mt-2 whitespace-pre-wrap text-sm text-muted-foreground">
|
||||
{goal.notes}
|
||||
</p>{/if}
|
||||
<div class="mt-4 flex items-center gap-3">
|
||||
{#if goal.beatmapUrl}<!-- Catalog or validated external URL. -->
|
||||
<!-- eslint-disable svelte/no-navigation-without-resolve -->
|
||||
<a href={goal.beatmapUrl} target="_blank" rel="noreferrer" class="text-sm underline"
|
||||
>Open beatmap ↗</a
|
||||
>
|
||||
<!-- eslint-enable svelte/no-navigation-without-resolve -->{/if}<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onclick={() => create('goal', goal)}>Edit</Button
|
||||
>
|
||||
<form method="POST" action="?/toggleGoal" use:enhance class="ml-auto">
|
||||
<input type="hidden" name="id" value={goal.id} /><input
|
||||
type="hidden"
|
||||
name="achieved"
|
||||
value={goal.achieved ? '' : 'on'}
|
||||
/><Button type="submit" variant={goal.achieved ? 'outline' : 'default'} size="sm"
|
||||
>{goal.achieved ? 'Reopen' : 'Achieved!'}</Button
|
||||
>
|
||||
</form>
|
||||
</div>
|
||||
</article>{:else}<div
|
||||
class="col-span-full rounded-xl border border-dashed p-12 text-center"
|
||||
>
|
||||
<p class="text-xl font-semibold">What’s your next breakthrough?</p>
|
||||
<p class="mt-2 text-sm text-muted-foreground">
|
||||
A first pass, a clean FC, 99% accuracy, or a challenge of your own.
|
||||
</p>
|
||||
<Button
|
||||
class="mt-5"
|
||||
variant="outline"
|
||||
disabled={!hydrated || !data.user}
|
||||
onclick={() => create('goal')}>Set a goal</Button
|
||||
>
|
||||
</div>{/each}
|
||||
</div></Tabs.Content
|
||||
>
|
||||
<Tabs.Content value="scores" class="mt-5"
|
||||
><div class="grid gap-4 sm:grid-cols-2">
|
||||
{#each scores as score (score.id)}<article class="rounded-xl border bg-card p-5">
|
||||
<div class="flex items-center justify-between">
|
||||
<Badge variant="secondary">{score.mods}</Badge><span
|
||||
class="text-xs text-muted-foreground"
|
||||
>{score.playedOn} · {modeName[score.mode]}</span
|
||||
>
|
||||
</div>
|
||||
<h2 class="mt-3 text-lg font-semibold">{score.title}</h2>
|
||||
<div class="mt-4 flex flex-wrap gap-5">
|
||||
{#if score.accuracy}<p class="text-2xl font-semibold">
|
||||
{score.accuracy}<span class="text-sm text-muted-foreground">%</span>
|
||||
</p>{/if}{#if score.pp}<p class="text-2xl font-semibold text-pink-500">
|
||||
{score.pp}<span class="text-sm"> pp</span>
|
||||
</p>{/if}{#if score.combo !== null}<p class="text-2xl font-semibold">
|
||||
{score.combo}<span class="text-sm text-muted-foreground">x</span>
|
||||
</p>{/if}
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-muted-foreground">
|
||||
{score.misses !== null ? `${score.misses} misses · ` : ''}{score.rate}× rate
|
||||
</p>
|
||||
{#if score.notes}<p class="mt-3 whitespace-pre-wrap text-sm text-muted-foreground">
|
||||
{score.notes}
|
||||
</p>{/if}
|
||||
<div class="mt-4 flex gap-4">
|
||||
{#if score.scoreUrl}<!-- Catalog or validated external URL. -->
|
||||
<!-- eslint-disable svelte/no-navigation-without-resolve -->
|
||||
<a href={score.scoreUrl} target="_blank" rel="noreferrer" class="text-sm underline"
|
||||
>View score ↗</a
|
||||
>
|
||||
<!-- eslint-enable svelte/no-navigation-without-resolve -->{/if}{#if score.beatmapUrl}<!-- Catalog or validated external URL. -->
|
||||
<!-- eslint-disable svelte/no-navigation-without-resolve -->
|
||||
<a
|
||||
href={score.beatmapUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="text-sm underline">Beatmap ↗</a
|
||||
>
|
||||
<!-- eslint-enable svelte/no-navigation-without-resolve -->{/if}<Button
|
||||
class="ml-auto"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onclick={() => create('score', score)}>Edit</Button
|
||||
>
|
||||
</div>
|
||||
</article>{:else}<div
|
||||
class="col-span-full rounded-xl border border-dashed p-12 text-center"
|
||||
>
|
||||
<p class="text-xl font-semibold">Some scores deserve a place on the wall.</p>
|
||||
<p class="mt-2 text-sm text-muted-foreground">
|
||||
Keep the play, your accuracy, mods, pp, and why it mattered.
|
||||
</p>
|
||||
<Button
|
||||
class="mt-5"
|
||||
variant="outline"
|
||||
disabled={!hydrated || !data.user}
|
||||
onclick={() => create('score')}>Record a score</Button
|
||||
>
|
||||
</div>{/each}
|
||||
</div></Tabs.Content
|
||||
>
|
||||
<Tabs.Content value="training" class="mt-5">
|
||||
<section class="rounded-xl border p-5">
|
||||
<div class="flex flex-wrap items-end justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold tracking-widest text-muted-foreground uppercase">
|
||||
Sustained pace
|
||||
</p>
|
||||
<p class="mt-2 text-3xl font-semibold">
|
||||
{best || '—'} <span class="text-base text-muted-foreground">effective BPM</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<label class="text-xs"
|
||||
>Skill<NativeSelect bind:value={skillFilter} class="mt-1"
|
||||
>{#each skills as skill (skill)}<option value={skill}>{skill}</option
|
||||
>{/each}</NativeSelect
|
||||
></label
|
||||
><label class="text-xs"
|
||||
>Minimum seconds<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={86400}
|
||||
bind:value={minDuration}
|
||||
class="mt-1 w-28"
|
||||
/></label
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-3 text-xs text-muted-foreground">
|
||||
Best recorded pace for the selected mode and skill, sustained for at least {minDuration} seconds.
|
||||
Effective BPM = map BPM × playback rate.
|
||||
</p>
|
||||
{#if chart.length > 1}<svg
|
||||
viewBox="0 0 640 150"
|
||||
role="img"
|
||||
aria-label={`Last ${chart.length} qualifying ${skillFilter} sessions, ${minBpm} to ${maxBpm} effective BPM`}
|
||||
class="mt-5 h-40 w-full overflow-visible"
|
||||
><line x1="10" x2="630" y1="135" y2="135" stroke="currentColor" opacity=".2" /><polyline
|
||||
points={chart
|
||||
.map(
|
||||
(e, i) =>
|
||||
`${10 + (i / (chart.length - 1)) * 620},${130 - ((bpm(e) - minBpm) / (maxBpm - minBpm || 1)) * 110}`
|
||||
)
|
||||
.join(' ')}
|
||||
fill="none"
|
||||
stroke="#ec4899"
|
||||
stroke-width="2"
|
||||
/>{#each chart as e, i (e.id)}<circle
|
||||
cx={10 + (i / (chart.length - 1)) * 620}
|
||||
cy={130 - ((bpm(e) - minBpm) / (maxBpm - minBpm || 1)) * 110}
|
||||
r="4"
|
||||
fill="#ec4899"
|
||||
><title>{e.playedOn} · {bpm(e)} BPM · {e.duration}s · {e.title}</title></circle
|
||||
>{/each}</svg
|
||||
>{:else}<p class="mt-5 rounded-lg bg-muted/40 p-4 text-sm text-muted-foreground">
|
||||
Log two qualifying sessions to see your pace over time.
|
||||
</p>{/if}
|
||||
</section>
|
||||
<div class="mt-5 overflow-x-auto">
|
||||
<Table.Root
|
||||
><Table.Header
|
||||
><Table.Row
|
||||
><Table.Head>Session</Table.Head><Table.Head>Skill</Table.Head><Table.Head
|
||||
>BPM</Table.Head
|
||||
><Table.Head>Duration</Table.Head><Table.Head>Accuracy</Table.Head><Table.Head
|
||||
></Table.Head></Table.Row
|
||||
></Table.Header
|
||||
><Table.Body
|
||||
>{#each sessions as session (session.id)}<Table.Row
|
||||
><Table.Cell
|
||||
><span class="font-medium">{session.title}</span><span
|
||||
class="block text-xs text-muted-foreground"
|
||||
>{session.playedOn} · {modeName[session.mode]}</span
|
||||
></Table.Cell
|
||||
><Table.Cell>{session.skill}</Table.Cell><Table.Cell
|
||||
>{bpm(session)}<span class="block text-xs text-muted-foreground"
|
||||
>{session.bpm} × {session.rate}</span
|
||||
></Table.Cell
|
||||
><Table.Cell>{session.duration}s</Table.Cell><Table.Cell
|
||||
>{session.accuracy ? `${session.accuracy}%` : '—'}</Table.Cell
|
||||
><Table.Cell
|
||||
><Button size="sm" variant="ghost" onclick={() => create('training', session)}
|
||||
>Edit</Button
|
||||
></Table.Cell
|
||||
></Table.Row
|
||||
>{:else}<Table.Row
|
||||
><Table.Cell colspan={6} class="py-10 text-center text-muted-foreground"
|
||||
>Log a practice session to start building your training history.</Table.Cell
|
||||
></Table.Row
|
||||
>{/each}</Table.Body
|
||||
></Table.Root
|
||||
>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
</div>
|
||||
|
||||
<Dialog.Root bind:open
|
||||
><Dialog.Content class="max-h-[90dvh] overflow-y-auto sm:max-w-2xl"
|
||||
><Dialog.Header
|
||||
><Dialog.Title
|
||||
>{editing ? 'Edit' : kind === 'score' ? 'Record' : kind === 'goal' ? 'Set' : 'Log'}
|
||||
{kind === 'training' ? 'practice session' : kind}</Dialog.Title
|
||||
><Dialog.Description
|
||||
>{kind === 'goal'
|
||||
? 'Give yourself something worth chasing.'
|
||||
: kind === 'score'
|
||||
? 'Save the numbers and the story behind this play.'
|
||||
: 'Track the pace you can sustain and what you want to work on next.'}</Dialog.Description
|
||||
></Dialog.Header
|
||||
>{#if form?.message}<p role="alert" class="text-destructive">{form.message}</p>{/if}
|
||||
{#key `${kind}-${editing?.id ?? 'new'}`}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/save"
|
||||
class="space-y-4"
|
||||
use:enhance={() => {
|
||||
saving = true;
|
||||
return async ({ result, update }) => {
|
||||
await update({ reset: false });
|
||||
saving = false;
|
||||
if (result.type === 'success') open = false;
|
||||
};
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="kind" value={kind} />{#if editing}<input
|
||||
type="hidden"
|
||||
name="id"
|
||||
value={editing.id}
|
||||
/>{/if}
|
||||
<label class="block"
|
||||
>{kind === 'training' ? 'Session name' : 'Map / achievement'}<Input
|
||||
name="title"
|
||||
required
|
||||
maxlength={250}
|
||||
value={editing?.title ?? ''}
|
||||
placeholder={kind === 'training'
|
||||
? '180 BPM stream practice'
|
||||
: 'Artist — Title [Difficulty]'}
|
||||
/></label
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<label
|
||||
>Mode<NativeSelect name="mode" value={editing?.mode ?? 'osu'} class="w-full"
|
||||
>{#each Object.entries(modeName) as [value, label] (value)}<option {value}
|
||||
>{label}</option
|
||||
>{/each}</NativeSelect
|
||||
></label
|
||||
><label
|
||||
>Date<Input
|
||||
type="date"
|
||||
name="playedOn"
|
||||
required
|
||||
value={editing?.playedOn ?? new Date().toISOString().slice(0, 10)}
|
||||
/></label
|
||||
>
|
||||
</div>
|
||||
<label class="block"
|
||||
>Beatmap link (optional)<Input
|
||||
type="url"
|
||||
name="beatmapUrl"
|
||||
placeholder="https://osu.ppy.sh/beatmaps/…"
|
||||
value={editing?.beatmapUrl ?? ''}
|
||||
/></label
|
||||
>
|
||||
{#if kind === 'score'}<label class="block"
|
||||
>Score link (optional)<Input
|
||||
type="url"
|
||||
name="scoreUrl"
|
||||
placeholder="https://osu.ppy.sh/scores/…"
|
||||
value={editing?.scoreUrl ?? ''}
|
||||
/></label
|
||||
>{/if}
|
||||
{#if kind === 'goal'}<div class="flex items-end gap-4">
|
||||
<label class="flex-1"
|
||||
>Goal<NativeSelect class="w-full" name="goal" value={editing?.goal ?? 'pass'}
|
||||
><option value="pass">Pass</option><option value="fc">Full combo</option><option
|
||||
value="accuracy">Accuracy target</option
|
||||
><option value="rank">Rank / pp target</option><option value="custom"
|
||||
>Personal challenge</option
|
||||
></NativeSelect
|
||||
></label
|
||||
><label class="mb-2 flex items-center gap-2"
|
||||
><Checkbox
|
||||
name="achieved"
|
||||
value="on"
|
||||
checked={editing?.achieved ?? false}
|
||||
/>Achieved</label
|
||||
>
|
||||
</div>{/if}
|
||||
{#if kind === 'training'}<label class="block"
|
||||
>Skill<NativeSelect name="skill" value={editing?.skill ?? 'stamina'} class="w-full"
|
||||
>{#each skills as skill (skill)}<option value={skill}>{skill}</option
|
||||
>{/each}</NativeSelect
|
||||
></label
|
||||
>{/if}
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<label
|
||||
>Mods<Input
|
||||
name="mods"
|
||||
value={editing?.mods ?? 'NM'}
|
||||
maxlength={80}
|
||||
placeholder="NM, HD, DT…"
|
||||
/></label
|
||||
><label
|
||||
>Playback rate<Input
|
||||
type="number"
|
||||
name="rate"
|
||||
min={0.1}
|
||||
max={4}
|
||||
step="0.01"
|
||||
required
|
||||
value={Number(editing?.rate ?? 1)}
|
||||
/></label
|
||||
><label
|
||||
>Map BPM{kind !== 'training' ? ' (optional)' : ''}<Input
|
||||
type="number"
|
||||
name="bpm"
|
||||
min={1}
|
||||
max={2000}
|
||||
required={kind === 'training'}
|
||||
value={editing?.bpm ?? undefined}
|
||||
/></label
|
||||
><label
|
||||
>Duration (seconds)<Input
|
||||
type="number"
|
||||
name="duration"
|
||||
min={1}
|
||||
max={86400}
|
||||
required={kind === 'training'}
|
||||
value={editing?.duration ?? undefined}
|
||||
/></label
|
||||
><label
|
||||
>Accuracy %<Input
|
||||
type="number"
|
||||
name="accuracy"
|
||||
min={0}
|
||||
max={100}
|
||||
step="0.01"
|
||||
value={editing?.accuracy ? Number(editing.accuracy) : undefined}
|
||||
/></label
|
||||
>{#if kind !== 'training'}<label
|
||||
>pp<Input
|
||||
type="number"
|
||||
name="pp"
|
||||
min={0}
|
||||
max={100000}
|
||||
step="0.01"
|
||||
value={editing?.pp ? Number(editing.pp) : undefined}
|
||||
/></label
|
||||
><label
|
||||
>Max combo<Input
|
||||
type="number"
|
||||
name="combo"
|
||||
min={0}
|
||||
max={1000000}
|
||||
value={editing?.combo ?? undefined}
|
||||
/></label
|
||||
><label
|
||||
>Misses<Input
|
||||
type="number"
|
||||
name="misses"
|
||||
min={0}
|
||||
max={1000000}
|
||||
value={editing?.misses ?? undefined}
|
||||
/></label
|
||||
>{/if}
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Enter the actual playback rate, including DT, HT or custom rate changes. BPM is the map’s
|
||||
original tempo.
|
||||
</p>
|
||||
<label class="block"
|
||||
>Notes / target<Textarea
|
||||
name="notes"
|
||||
maxlength={5000}
|
||||
value={editing?.notes ?? ''}
|
||||
placeholder="What felt good? What needs work? What is the next target?"
|
||||
/></label
|
||||
>
|
||||
<Dialog.Footer
|
||||
><Button variant="outline" onclick={() => (open = false)}>Cancel</Button><Button
|
||||
type="submit"
|
||||
disabled={!data.user || saving}>{saving ? 'Saving…' : 'Save entry'}</Button
|
||||
></Dialog.Footer
|
||||
>
|
||||
</form>{/key}
|
||||
{#if editing}<Button
|
||||
variant="ghost"
|
||||
class="justify-self-start text-destructive"
|
||||
onclick={() => (deleteOpen = true)}>Delete entry</Button
|
||||
>{/if}
|
||||
</Dialog.Content></Dialog.Root
|
||||
>
|
||||
<AlertDialog.Root bind:open={deleteOpen}
|
||||
><AlertDialog.Content
|
||||
><AlertDialog.Header
|
||||
><AlertDialog.Title>Delete this entry?</AlertDialog.Title><AlertDialog.Description
|
||||
>This removes “{editing?.title}” from your journal.</AlertDialog.Description
|
||||
></AlertDialog.Header
|
||||
>{#if form?.message}<p role="alert" class="text-destructive">{form.message}</p>{/if}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/delete"
|
||||
use:enhance={() =>
|
||||
async ({ result, update }) => {
|
||||
await update();
|
||||
if (result.type === 'success') {
|
||||
deleteOpen = false;
|
||||
open = false;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="id" value={editing?.id ?? ''} /><AlertDialog.Footer
|
||||
><AlertDialog.Cancel>Keep entry</AlertDialog.Cancel><Button
|
||||
type="submit"
|
||||
variant="destructive">Delete entry</Button
|
||||
></AlertDialog.Footer
|
||||
>
|
||||
</form></AlertDialog.Content
|
||||
></AlertDialog.Root
|
||||
>
|
||||
@@ -1,3 +1,16 @@
|
||||
import { pokemonJournalActions, catchesFor } from '$lib/server/pokemon-journal';
|
||||
import { loadPokemonProgress } from '$lib/server/tracker-progress';
|
||||
import {
|
||||
pokemonCatches,
|
||||
pokemonDexNumbers,
|
||||
pokemonJourneys,
|
||||
pokemonMilestones,
|
||||
pokemonGameResearch,
|
||||
pokemonLocations,
|
||||
pokemonEncounters,
|
||||
pokemonSeen
|
||||
} from '$lib/server/db/schema';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { db } from '$lib/server/db';
|
||||
import {
|
||||
games,
|
||||
@@ -14,13 +27,15 @@ import { requireAdmin } from '$lib/server/authorization';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
export type WireEntry = [id: number, gameId: number, availability: number, note?: string];
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
export const load: PageServerLoad = async ({ locals, url }) => {
|
||||
const [pokemonRows, entryRows, gameRows] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: pokemonTable.id,
|
||||
number: pokemonTable.number,
|
||||
name: pokemonTable.name
|
||||
name: pokemonTable.name,
|
||||
type1: pokemonTable.type1,
|
||||
type2: pokemonTable.type2
|
||||
})
|
||||
.from(pokemonTable)
|
||||
.orderBy(asc(pokemonTable.number)),
|
||||
@@ -34,7 +49,7 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
})
|
||||
.from(pokemonGameEntries),
|
||||
db
|
||||
.select({ id: games.id, name: games.name, generation: games.generation })
|
||||
.select({ id: games.id, name: games.name, slug: games.slug, generation: games.generation })
|
||||
.from(games)
|
||||
.orderBy(asc(games.generation), asc(games.id))
|
||||
]);
|
||||
@@ -69,8 +84,83 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
selectedGameIds = gameRows.map((r) => r.id);
|
||||
}
|
||||
|
||||
const visible =
|
||||
gamesConfigured || selectedGameIds.length
|
||||
? gameRows.filter((g) => selectedGameIds.includes(g.id))
|
||||
: gameRows;
|
||||
const requested = url.searchParams.get('game');
|
||||
const activeGame =
|
||||
visible.find((g) => g.slug === requested || String(g.id) === requested) ??
|
||||
visible.find((g) => g.slug === 'platinum') ??
|
||||
visible[0] ??
|
||||
null;
|
||||
const gameId = activeGame?.id ?? -1;
|
||||
const [
|
||||
dexNumbers,
|
||||
catches,
|
||||
journeyRows,
|
||||
milestones,
|
||||
researchRows,
|
||||
locations,
|
||||
progress,
|
||||
seenRows
|
||||
] = await Promise.all([
|
||||
db.select().from(pokemonDexNumbers).where(eq(pokemonDexNumbers.gameId, gameId)),
|
||||
catchesFor(locals.user?.id, gameId),
|
||||
locals.user
|
||||
? db
|
||||
.select()
|
||||
.from(pokemonJourneys)
|
||||
.where(
|
||||
and(eq(pokemonJourneys.userId, locals.user.id), eq(pokemonJourneys.gameId, gameId))
|
||||
)
|
||||
: [],
|
||||
locals.user
|
||||
? db
|
||||
.select()
|
||||
.from(pokemonMilestones)
|
||||
.where(
|
||||
and(eq(pokemonMilestones.userId, locals.user.id), eq(pokemonMilestones.gameId, gameId))
|
||||
)
|
||||
: [],
|
||||
db.select().from(pokemonGameResearch).where(eq(pokemonGameResearch.gameId, gameId)),
|
||||
db
|
||||
.select({
|
||||
id: pokemonLocations.id,
|
||||
slug: pokemonLocations.slug,
|
||||
name: pokemonLocations.name,
|
||||
region: pokemonLocations.region,
|
||||
count: sql<number>`count(distinct ${pokemonEncounters.pokemonId})`.mapWith(Number)
|
||||
})
|
||||
.from(pokemonLocations)
|
||||
.leftJoin(
|
||||
pokemonEncounters,
|
||||
and(
|
||||
eq(pokemonEncounters.locationId, pokemonLocations.id),
|
||||
eq(pokemonEncounters.gameId, gameId)
|
||||
)
|
||||
)
|
||||
.groupBy(pokemonLocations.id),
|
||||
loadPokemonProgress(locals.user?.id),
|
||||
locals.user
|
||||
? db
|
||||
.select({ pokemonId: pokemonSeen.pokemonId })
|
||||
.from(pokemonSeen)
|
||||
.where(and(eq(pokemonSeen.userId, locals.user.id), eq(pokemonSeen.gameId, gameId)))
|
||||
: []
|
||||
]);
|
||||
|
||||
return {
|
||||
progress,
|
||||
seenIds: seenRows.map((r) => r.pokemonId),
|
||||
pokemon,
|
||||
activeGame,
|
||||
dexNumbers,
|
||||
catches,
|
||||
journey: journeyRows[0] ?? null,
|
||||
milestones,
|
||||
regions: researchRows[0]?.regions ?? [],
|
||||
locations,
|
||||
games: gameRows,
|
||||
caughtIds,
|
||||
selectedGameIds,
|
||||
@@ -84,6 +174,7 @@ function entryId(data: FormData) {
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
...pokemonJournalActions,
|
||||
togglePokemonCaught: async ({ request, locals }) => {
|
||||
if (!locals.user) return fail(401, { message: 'Log in to track your progress' });
|
||||
|
||||
@@ -91,32 +182,58 @@ export const actions = {
|
||||
const id = entryId(data);
|
||||
if (!id) return fail(400, { message: 'Invalid entry' });
|
||||
|
||||
// only obtainable entries can be toggled — the client disables the others
|
||||
// but we can't trust that
|
||||
// Verify the entry and allow historical checkmarks to be cleared even
|
||||
// when source availability has since changed.
|
||||
const [entry] = await db
|
||||
.select({ availability: pokemonGameEntries.availability })
|
||||
.select({
|
||||
availability: pokemonGameEntries.availability,
|
||||
gameId: pokemonGameEntries.gameId,
|
||||
pokemonId: pokemonGameEntries.pokemonId
|
||||
})
|
||||
.from(pokemonGameEntries)
|
||||
.where(eq(pokemonGameEntries.id, id));
|
||||
if (!entry) return fail(404, { message: 'Unknown entry' });
|
||||
if (!obtainableAvailability.has(entry.availability)) {
|
||||
return fail(400, { message: 'This Pokémon has no confirmed way to obtain it in this game' });
|
||||
}
|
||||
|
||||
if (data.has('caught') && data.get('caught') !== 'on')
|
||||
return fail(400, { message: 'Invalid caught state' });
|
||||
const caught = data.get('caught') === 'on';
|
||||
if (caught && !obtainableAvailability.has(entry.availability))
|
||||
return fail(400, { message: 'This Pokémon has no confirmed way to obtain it in this game' });
|
||||
|
||||
if (caught) {
|
||||
await db
|
||||
.insert(userEntries)
|
||||
.values({ pokemonGameEntryId: id, userId: locals.user.id })
|
||||
.onConflictDoNothing();
|
||||
} else {
|
||||
await db
|
||||
.delete(userEntries)
|
||||
.where(and(eq(userEntries.pokemonGameEntryId, id), eq(userEntries.userId, locals.user.id)));
|
||||
}
|
||||
return { saved: true };
|
||||
const userId = locals.user.id;
|
||||
return db.transaction(async (tx) => {
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(hashtext(${userId}), ${entry.gameId})`);
|
||||
if (caught) {
|
||||
await tx
|
||||
.insert(userEntries)
|
||||
.values({ pokemonGameEntryId: id, userId })
|
||||
.onConflictDoNothing();
|
||||
await tx
|
||||
.insert(pokemonSeen)
|
||||
.values({ userId, gameId: entry.gameId, pokemonId: entry.pokemonId })
|
||||
.onConflictDoNothing();
|
||||
} else {
|
||||
const linked = await tx
|
||||
.select({ id: pokemonCatches.id })
|
||||
.from(pokemonCatches)
|
||||
.where(
|
||||
and(
|
||||
eq(pokemonCatches.userId, userId),
|
||||
eq(pokemonCatches.gameId, entry.gameId),
|
||||
eq(pokemonCatches.pokemonId, entry.pokemonId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
if (linked.length)
|
||||
return fail(400, {
|
||||
message: 'Remove the registered catches before clearing the caught checkmark.'
|
||||
});
|
||||
await tx
|
||||
.delete(userEntries)
|
||||
.where(and(eq(userEntries.pokemonGameEntryId, id), eq(userEntries.userId, userId)));
|
||||
}
|
||||
return { saved: true };
|
||||
});
|
||||
},
|
||||
|
||||
setGames: async ({ request, locals }) => {
|
||||
|
||||
@@ -1,474 +1,192 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import { deserialize, enhance } from '$app/forms';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import {
|
||||
availabilityLabels,
|
||||
availabilityValues,
|
||||
obtainableAvailability,
|
||||
type Availability
|
||||
} from '$lib/availability';
|
||||
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';
|
||||
|
||||
type Entry = { id: number; availability: Availability; note: string | null };
|
||||
type Game = { id: number; name: string };
|
||||
let { data, form }: PageProps = $props();
|
||||
// rebuilt whenever the page data changes, so the server render already shows progress
|
||||
const caught = $derived(new SvelteSet(data.caughtIds));
|
||||
const pending = new SvelteSet<number>();
|
||||
let saveError = $state('');
|
||||
// checkboxes stay disabled until hydration, so early clicks can't silently go unsaved
|
||||
let hydrated = $state(false);
|
||||
onMount(() => (hydrated = true));
|
||||
|
||||
async function toggleCaught(entryId: number, checked: boolean) {
|
||||
if (pending.has(entryId)) return;
|
||||
pending.add(entryId);
|
||||
saveError = '';
|
||||
if (checked) caught.add(entryId);
|
||||
else caught.delete(entryId);
|
||||
const body = new FormData();
|
||||
body.set('entryId', String(entryId));
|
||||
if (checked) body.set('caught', 'on');
|
||||
let failed: boolean;
|
||||
try {
|
||||
const response = await fetch('?/togglePokemonCaught', { method: 'POST', body });
|
||||
const result = deserialize(await response.text());
|
||||
failed = result.type !== 'success';
|
||||
} catch {
|
||||
failed = true;
|
||||
}
|
||||
pending.delete(entryId);
|
||||
if (failed) {
|
||||
saveError = 'Could not save your catch. Please try again, or log in again.';
|
||||
if (checked) caught.delete(entryId);
|
||||
else caught.add(entryId);
|
||||
}
|
||||
}
|
||||
|
||||
const entryMap = $derived(
|
||||
new Map(
|
||||
data.pokemon.map((pokemon) => [
|
||||
pokemon.id,
|
||||
new Map(
|
||||
pokemon.entries.map(([id, gameId, code, note]) => [
|
||||
gameId,
|
||||
{ id, availability: availabilityValues[code], note: note ?? null } satisfies Entry
|
||||
])
|
||||
)
|
||||
])
|
||||
onMount(() => {
|
||||
hydrated = true;
|
||||
});
|
||||
import { resolve } from '$app/paths';
|
||||
import { goto } from '$app/navigation';
|
||||
import { enhance } from '$app/forms';
|
||||
import { SvelteSet } from 'svelte/reactivity';
|
||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
|
||||
import { chronologicalPokemon } from '$lib/game-tracker/game-art';
|
||||
import GameLogo from '$lib/game-tracker/GameLogo.svelte';
|
||||
import PokemonJournal from '$lib/game-tracker/PokemonJournal.svelte';
|
||||
import type { PageProps } from './$types';
|
||||
let { data, form }: PageProps = $props();
|
||||
let rulesOpen = $state(false);
|
||||
let stripWidth = $state(1000);
|
||||
const coverRows = $derived(visibleGames.length > Math.floor((stripWidth + 4) / 68) ? 2 : 1);
|
||||
let saving = $state(false);
|
||||
const draft = new SvelteSet<number>();
|
||||
const visibleGames = $derived(
|
||||
chronologicalPokemon(
|
||||
data.gamesConfigured
|
||||
? data.games.filter((g) => data.selectedGameIds.includes(g.id))
|
||||
: data.games
|
||||
)
|
||||
);
|
||||
const selectedGameIds = $derived(new Set(data.selectedGameIds));
|
||||
const visibleGames = $derived(
|
||||
data.gamesConfigured ? data.games.filter((game) => selectedGameIds.has(game.id)) : data.games
|
||||
);
|
||||
let viewGameId = $state<number | null>(null);
|
||||
$effect(() => {
|
||||
if (!visibleGames.some((game) => game.id === viewGameId))
|
||||
viewGameId = visibleGames[0]?.id ?? null;
|
||||
});
|
||||
const viewGame = $derived(visibleGames.find((game) => game.id === viewGameId));
|
||||
|
||||
const visibleGameIds = $derived(new Set(visibleGames.map((game) => game.id)));
|
||||
const progress = $derived(summarizeProgress(data.pokemon, visibleGameIds, caught));
|
||||
const progressBars = $derived([
|
||||
{
|
||||
label: 'seen at least once',
|
||||
value: progress.seenPokemon,
|
||||
total: progress.catchablePokemon,
|
||||
unit: 'Pokémon'
|
||||
},
|
||||
{
|
||||
label: 'caught in every game it’s in',
|
||||
value: progress.completedPokemon,
|
||||
total: progress.catchablePokemon,
|
||||
unit: 'Pokémon'
|
||||
},
|
||||
{
|
||||
label: 'games completed',
|
||||
value: progress.completedGames,
|
||||
total: progress.catchableGames,
|
||||
unit: 'games'
|
||||
}
|
||||
]);
|
||||
function percent(value: number, total: number) {
|
||||
return total > 0 ? ((value / total) * 100).toFixed(1) : '0.0';
|
||||
}
|
||||
|
||||
let search = $state('');
|
||||
let showUnobtainable = $state(false);
|
||||
let pageNumber = $state(1);
|
||||
const pageSize = 25;
|
||||
const filteredPokemon = $derived(
|
||||
data.pokemon.filter((pokemon) => {
|
||||
const availability =
|
||||
viewGameId === null ? undefined : entryMap.get(pokemon.id)?.get(viewGameId)?.availability;
|
||||
if (
|
||||
!showUnobtainable &&
|
||||
viewGameId !== null &&
|
||||
!(availability && obtainableAvailability.has(availability))
|
||||
)
|
||||
return false;
|
||||
return `${pokemon.number} ${pokemon.name}`
|
||||
.toLowerCase()
|
||||
.includes(search.trim().toLowerCase());
|
||||
})
|
||||
);
|
||||
const pageCount = $derived(Math.max(1, Math.ceil(filteredPokemon.length / pageSize)));
|
||||
$effect(() => {
|
||||
if (pageNumber > pageCount) pageNumber = pageCount;
|
||||
});
|
||||
const visiblePokemon = $derived(
|
||||
filteredPokemon.slice((pageNumber - 1) * pageSize, pageNumber * pageSize)
|
||||
);
|
||||
const gamesByGeneration = $derived(
|
||||
[...Map.groupBy(data.games, (game) => game.generation)].sort(([a], [b]) => a - b)
|
||||
);
|
||||
|
||||
let rulesDialog: HTMLDialogElement;
|
||||
const draft = new SvelteSet<number>();
|
||||
const generations = $derived([...Map.groupBy(data.games, (g) => g.generation)]);
|
||||
function openRules() {
|
||||
draft.clear();
|
||||
for (const id of data.gamesConfigured ? selectedGameIds : data.games.map((game) => game.id))
|
||||
draft.add(id);
|
||||
rulesDialog.showModal();
|
||||
}
|
||||
function tooltip(entry: Entry | undefined, game: Game): string | null {
|
||||
if (!entry) return `No catalog record for ${game.name}; availability is not confirmed`;
|
||||
if (obtainableAvailability.has(entry.availability)) return null;
|
||||
const reason = {
|
||||
trade_only: 'Only obtainable by trading in this game',
|
||||
transfer_only: 'Only obtainable by transferring from another game',
|
||||
event_only: 'Only obtainable through an event distribution',
|
||||
unknown: 'Availability has not been confirmed'
|
||||
}[entry.availability as 'trade_only' | 'transfer_only' | 'event_only' | 'unknown'];
|
||||
return entry.note ? `${reason} (${entry.note})` : reason;
|
||||
}
|
||||
|
||||
let detailsDialog: HTMLDialogElement;
|
||||
let detailNumber = $state<number | null>(null);
|
||||
function openDetails(number: number) {
|
||||
detailNumber = number;
|
||||
detailsDialog.showModal();
|
||||
for (const game of data.games)
|
||||
if (!data.gamesConfigured || data.selectedGameIds.includes(game.id)) draft.add(game.id);
|
||||
rulesOpen = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-6xl px-4 py-10 sm:px-6 sm:py-14">
|
||||
<a
|
||||
href={resolve('/projects/game-tracker')}
|
||||
class="text-[var(--site-muted)] hover:text-[var(--site-text)]">← game tracker</a
|
||||
<div class="mx-auto max-w-[1600px] px-3 py-4 sm:px-5">
|
||||
<div
|
||||
class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-2 lg:grid-cols-[auto_minmax(0,1fr)_auto]"
|
||||
>
|
||||
<div class="mt-3 flex flex-wrap items-end justify-between gap-5">
|
||||
<div>
|
||||
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">catchemall</h1>
|
||||
<p class="mt-2 text-[var(--site-muted)]">
|
||||
{data.pokemon.length} Pokémon · {data.games.length} games
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" type="button" onclick={openRules}>set rules</Button>
|
||||
</div>
|
||||
|
||||
{#if !data.user}<p class="mt-6">
|
||||
<a
|
||||
href={resolve('/login?returnTo=/projects/game-tracker/pokemon')}
|
||||
class="underline underline-offset-4">sign in</a
|
||||
> to save your progress.
|
||||
</p>{/if}
|
||||
{#if saveError}<p role="alert" class="mt-5 text-red-500">{saveError}</p>{/if}
|
||||
{#if !data.pokemon.length}<p class="mt-8 text-[var(--site-muted)]">
|
||||
the Pokémon catalog has not been loaded yet.
|
||||
</p>{/if}
|
||||
|
||||
<div class="mt-8 border-y border-[var(--site-border)] py-6">
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<p class="font-medium">overall progress</p>
|
||||
<p class="text-2xl font-semibold tabular-nums">
|
||||
{percent(progress.caughtEntries, progress.catchableEntries)}%
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<ProgressBar
|
||||
value={progress.caughtEntries}
|
||||
max={progress.catchableEntries}
|
||||
label="Overall catch progress"
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-2 text-base text-[var(--site-muted)]">
|
||||
{progress.caughtEntries} of {progress.catchableEntries} obtainable entries caught
|
||||
</p>
|
||||
<div class="mt-8 grid gap-x-10 gap-y-7 lg:grid-cols-3">
|
||||
{#each progressBars as item (item.label)}
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-baseline justify-between gap-2">
|
||||
<p class="font-medium">{item.label}</p>
|
||||
<p class="text-xl font-semibold tabular-nums">{percent(item.value, item.total)}%</p>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<ProgressBar value={item.value} max={item.total} label={item.label} small />
|
||||
</div>
|
||||
<p class="mt-2 text-base text-[var(--site-muted)]">
|
||||
{item.value} of {item.total}
|
||||
{item.unit}
|
||||
</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="mt-6 text-base text-[var(--site-muted)]">
|
||||
counts use your selected games. Pokémon you can evolve or breed there count as obtainable.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 grid gap-4 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)]">
|
||||
<label class="block"
|
||||
>search Pokémon
|
||||
<Input
|
||||
type="search"
|
||||
bind:value={search}
|
||||
oninput={() => (pageNumber = 1)}
|
||||
placeholder="name or number"
|
||||
class="mt-2 h-11"
|
||||
/>
|
||||
</label>
|
||||
<label class="block"
|
||||
>game
|
||||
<select
|
||||
bind:value={viewGameId}
|
||||
onchange={() => (pageNumber = 1)}
|
||||
disabled={!visibleGames.length}
|
||||
class="mt-2 block w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-3"
|
||||
>
|
||||
{#each visibleGames as game (game.id)}<option value={game.id}>{game.name}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-5 flex flex-wrap items-center justify-between gap-4">
|
||||
<label class="flex cursor-pointer items-center gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showUnobtainable}
|
||||
onchange={(event) => {
|
||||
showUnobtainable = event.currentTarget.checked;
|
||||
pageNumber = 1;
|
||||
}}
|
||||
class="h-5 w-5 accent-[var(--site-text)]"
|
||||
/>
|
||||
show Pokémon you can’t get in this game
|
||||
</label>
|
||||
<a
|
||||
href={resolve('/projects/game-tracker/pokemon/locations')}
|
||||
class="underline underline-offset-4">browse locations</a
|
||||
>
|
||||
</div>
|
||||
|
||||
{#if viewGame}
|
||||
<div class="mt-8">
|
||||
<table class="w-full table-fixed border-collapse">
|
||||
<colgroup><col /><col class="w-24 sm:w-36" /></colgroup>
|
||||
<thead
|
||||
><tr class="border-b border-[var(--site-border)] text-left text-[var(--site-muted)]"
|
||||
><th scope="col" class="py-3 font-medium">Pokémon</th><th
|
||||
scope="col"
|
||||
class="py-3 text-center font-medium">caught</th
|
||||
></tr
|
||||
></thead
|
||||
<div class="flex items-center gap-3">
|
||||
{#if data.activeGame}<GameLogo
|
||||
slug={`pokemon/${data.activeGame.slug}`}
|
||||
name={data.activeGame.name}
|
||||
class="h-12 w-12"
|
||||
/>{/if}
|
||||
<div>
|
||||
<a
|
||||
href={resolve('/projects/game-tracker')}
|
||||
class="text-xs text-muted-foreground hover:underline">← Game tracker</a
|
||||
>
|
||||
<tbody>
|
||||
{#each visiblePokemon as pokemon (pokemon.id)}
|
||||
{@const entry = entryMap.get(pokemon.id)?.get(viewGame.id)}
|
||||
{@const reason = tooltip(entry, viewGame)}
|
||||
<tr class="border-b border-[var(--site-border)]">
|
||||
<th scope="row" class="py-2 text-left font-normal">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => openDetails(pokemon.number)}
|
||||
class="flex w-full min-w-0 items-center gap-3 text-left hover:text-[var(--site-muted)]"
|
||||
>
|
||||
<img
|
||||
src="https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/{pokemon.number}.png"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
width="40"
|
||||
height="40"
|
||||
class="h-10 w-10 shrink-0"
|
||||
/>
|
||||
<span class="min-w-0"
|
||||
><span class="mr-2 text-[var(--site-muted)]">#{pokemon.number}</span
|
||||
>{pokemon.name}
|
||||
{#if entry && reason === null}<span
|
||||
class="block truncate text-sm text-[var(--site-muted)]"
|
||||
>{availabilityLabels[entry.availability]}{entry.note
|
||||
? ` · ${entry.note}`
|
||||
: ''}</span
|
||||
>{/if}</span
|
||||
>
|
||||
</button>
|
||||
</th>
|
||||
<td class="py-2 text-center">
|
||||
{#if entry && reason === null}
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-5 w-5 cursor-pointer accent-[var(--site-text)]"
|
||||
checked={caught.has(entry.id)}
|
||||
disabled={!hydrated || !data.user || pending.has(entry.id)}
|
||||
aria-label={`Caught ${pokemon.name} in ${viewGame.name}`}
|
||||
title={data.user ? (entry.note ?? undefined) : 'Sign in to save your progress'}
|
||||
onchange={(event) => toggleCaught(entry.id, event.currentTarget.checked)}
|
||||
/>
|
||||
{:else}
|
||||
<span
|
||||
title={reason ?? undefined}
|
||||
aria-label={`${pokemon.name} in ${viewGame.name}: ${reason}`}
|
||||
class="text-[var(--site-muted)]"
|
||||
>{!entry ? '—' : entry.availability === 'unknown' ? '?' : '·'}</span
|
||||
>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
<tr><td colspan="2" class="py-8 text-[var(--site-muted)]">no Pokémon found</td></tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="mt-5 flex flex-wrap items-center justify-between gap-4">
|
||||
<p class="text-[var(--site-muted)]">
|
||||
{filteredPokemon.length ? (pageNumber - 1) * pageSize + 1 : 0}–{Math.min(
|
||||
pageNumber * pageSize,
|
||||
filteredPokemon.length
|
||||
)} of {filteredPokemon.length}
|
||||
</p>
|
||||
<div class="flex items-center gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
type="button"
|
||||
disabled={pageNumber === 1}
|
||||
onclick={() => (pageNumber -= 1)}>previous</Button
|
||||
<h1 class="text-xl font-semibold">
|
||||
Pokémon journal <span class="text-sm font-normal text-muted-foreground"
|
||||
>· {data.activeGame?.name ?? 'Choose a game'}</span
|
||||
>
|
||||
<span class="text-[var(--site-muted)]">{pageNumber}/{pageCount}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
type="button"
|
||||
disabled={pageNumber === pageCount}
|
||||
onclick={() => (pageNumber += 1)}>next</Button
|
||||
>
|
||||
</div>
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
{:else if data.pokemon.length}
|
||||
<p class="mt-8 text-[var(--site-muted)]">no games selected. use “set rules” to choose one.</p>
|
||||
{/if}
|
||||
<div
|
||||
class={`game-covers order-3 col-span-2 overflow-x-auto pb-1 ${visibleGames.length > 30 ? 'lg:col-span-3' : 'lg:order-none lg:col-span-1'}`}
|
||||
bind:clientWidth={stripWidth}
|
||||
role="group"
|
||||
aria-label="Choose Pokémon game"
|
||||
>
|
||||
<div
|
||||
class="grid gap-1"
|
||||
style:grid-template-columns={`repeat(${Math.max(1, Math.ceil(visibleGames.length / coverRows))}, minmax(40px, 1fr))`}
|
||||
style:max-width={`${Math.ceil(visibleGames.length / coverRows) * 64}px`}
|
||||
>
|
||||
{#each visibleGames as game (game.id)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
disabled={!hydrated}
|
||||
aria-pressed={data.activeGame?.id === game.id}
|
||||
aria-label={`Play ${game.name}`}
|
||||
title={game.name}
|
||||
class={`game-cover-button h-12 min-w-0 rounded-md border p-1 ${data.activeGame?.id === game.id ? 'border-amber-500 bg-amber-500/15 ring-1 ring-amber-500' : 'border-transparent opacity-65 hover:opacity-100'}`}
|
||||
onclick={() =>
|
||||
goto(resolve(`/projects/game-tracker/pokemon?game=${game.slug ?? game.id}`), {
|
||||
noScroll: true,
|
||||
keepFocus: true
|
||||
})}
|
||||
>
|
||||
<GameLogo slug={`pokemon/${game.slug}`} name={game.name} class="h-10 w-full" />
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
class="justify-self-end lg:col-start-3"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={!hydrated}
|
||||
onclick={openRules}>set rules</Button
|
||||
>
|
||||
</div>
|
||||
|
||||
<details class="mt-10 border-t border-[var(--site-border)] pt-5 text-[var(--site-muted)]">
|
||||
<summary class="cursor-pointer">about availability</summary>
|
||||
<p class="mt-3 max-w-3xl">
|
||||
the catalog is owned by this site and built from a pinned PokéAPI snapshot. Checkboxes appear
|
||||
for Pokémon you can catch (wild, gift, static or in-game trade), evolve from something you can
|
||||
get in that game, or breed. “·” marks event-only mythicals, “?” means the Pokémon is in the
|
||||
game but no route is confirmed, and “—” means there is no record. Neither proves it is
|
||||
unobtainable. Newer games (Brilliant Diamond and later) have no encounter data yet.
|
||||
</p>
|
||||
</details>
|
||||
{#if !data.user}<p class="mt-4 text-sm">
|
||||
<a href={resolve('/login?returnTo=/projects/game-tracker/pokemon')} class="underline"
|
||||
>Sign in</a
|
||||
> to save catches, teams and adventures.
|
||||
</p>{/if}
|
||||
{#if form?.message && !rulesOpen}<p role="alert" class="mt-4 text-destructive">
|
||||
{form.message}
|
||||
</p>{/if}
|
||||
{#if data.activeGame}{#key data.activeGame.id}<PokemonJournal {data} />{/key}{:else}<p
|
||||
class="mt-8 text-muted-foreground"
|
||||
>
|
||||
No games selected. Use “set rules” to choose your next adventure.
|
||||
</p>{/if}
|
||||
<p class="mt-4 text-[10px] leading-relaxed text-muted-foreground">
|
||||
Species, regional Pokédexes, moves and encounters from <a
|
||||
href="https://pokeapi.co/"
|
||||
class="underline">PokéAPI</a
|
||||
>. Encounter coverage varies by game; missing records do not prove that a Pokémon is
|
||||
unavailable. Stats and move battle values describe the catalog’s default forms and current
|
||||
snapshot. Maps identify their source and coverage.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<dialog
|
||||
bind:this={detailsDialog}
|
||||
onclose={() => (detailNumber = null)}
|
||||
class="m-auto max-h-[90vh] w-[calc(100%-2rem)] max-w-2xl overflow-y-auto rounded-xl border border-[var(--site-border)] bg-[var(--site-surface)] p-5 text-[var(--site-text)] backdrop:bg-black/70 sm:p-6"
|
||||
>
|
||||
<div class="flex justify-end">
|
||||
<button type="button" onclick={() => detailsDialog.close()} class="text-[var(--site-muted)]"
|
||||
>close</button
|
||||
<Dialog.Root bind:open={rulesOpen}>
|
||||
<Dialog.Content class="max-h-[90dvh] overflow-y-auto sm:max-w-3xl">
|
||||
<Dialog.Header
|
||||
><Dialog.Title>Set rules</Dialog.Title><Dialog.Description
|
||||
>Choose the games in your collection. Your saved catches stay with each game.</Dialog.Description
|
||||
></Dialog.Header
|
||||
>
|
||||
</div>
|
||||
{#if detailNumber !== null}
|
||||
<PokemonDetails
|
||||
number={detailNumber}
|
||||
gameId={viewGameId}
|
||||
isAdmin={data.isAdmin}
|
||||
onsaved={() => invalidateAll()}
|
||||
/>
|
||||
{/if}
|
||||
</dialog>
|
||||
|
||||
<dialog
|
||||
bind:this={rulesDialog}
|
||||
class="m-auto w-[calc(100%-2rem)] max-w-2xl rounded-xl border border-[var(--site-border)] bg-[var(--site-surface)] p-5 text-[var(--site-text)] backdrop:bg-black/70 sm:p-6"
|
||||
>
|
||||
<h2 class="text-2xl font-semibold">set rules</h2>
|
||||
<p class="mt-2 text-[var(--site-muted)]">choose the games that count toward your progress.</p>
|
||||
{#if form?.message}<p role="alert" class="mt-3 text-red-500">{form.message}</p>{/if}
|
||||
<div class="mt-5 flex gap-5">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
draft.clear();
|
||||
for (const game of data.games) draft.add(game.id);
|
||||
{#if form?.message}<p role="alert" class="text-destructive">{form.message}</p>{/if}
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onclick={() => {
|
||||
for (const game of data.games) draft.add(game.id);
|
||||
}}>Select all</Button
|
||||
><Button variant="ghost" size="sm" onclick={() => draft.clear()}>Clear selection</Button>
|
||||
</div>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/setGames"
|
||||
use:enhance={() => {
|
||||
saving = true;
|
||||
return async ({ result, update }) => {
|
||||
await update();
|
||||
saving = false;
|
||||
if (result.type === 'success') rulesOpen = false;
|
||||
};
|
||||
}}
|
||||
class="underline underline-offset-4">all</button
|
||||
>
|
||||
<button type="button" onclick={() => draft.clear()} class="underline underline-offset-4"
|
||||
>none</button
|
||||
>
|
||||
</div>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/setGames"
|
||||
use:enhance={() => {
|
||||
return async ({ result, update }) => {
|
||||
await update();
|
||||
if (result.type === 'success') rulesDialog.close();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<div class="mt-5 grid max-h-[50vh] gap-5 overflow-y-auto pr-2 sm:grid-cols-2">
|
||||
{#each gamesByGeneration as [generation, games] (generation)}
|
||||
<fieldset>
|
||||
<legend class="mb-2 font-semibold"
|
||||
><label class="flex items-center gap-2"
|
||||
><input
|
||||
type="checkbox"
|
||||
checked={games.every((game) => draft.has(game.id))}
|
||||
onchange={(event) => {
|
||||
for (const game of games) {
|
||||
if (event.currentTarget.checked) draft.add(game.id);
|
||||
else draft.delete(game.id);
|
||||
}
|
||||
}}
|
||||
/>generation {generation}</label
|
||||
></legend
|
||||
>
|
||||
{#each games as game (game.id)}<label class="flex items-center gap-2 py-1 pl-5"
|
||||
><input
|
||||
type="checkbox"
|
||||
name="gameIds"
|
||||
value={game.id}
|
||||
checked={draft.has(game.id)}
|
||||
onchange={(event) => {
|
||||
if (event.currentTarget.checked) draft.add(game.id);
|
||||
else draft.delete(game.id);
|
||||
}}
|
||||
/>{game.name}</label
|
||||
>{/each}
|
||||
</fieldset>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="mt-6 flex justify-end gap-5">
|
||||
<button type="button" onclick={() => rulesDialog.close()}>cancel</button>
|
||||
<button
|
||||
disabled={!data.user}
|
||||
title={data.user ? undefined : 'Sign in to save rules'}
|
||||
class="rounded bg-[var(--site-text)] px-4 py-2 font-semibold text-[var(--site-bg)] disabled:opacity-40"
|
||||
>save</button
|
||||
<div class="grid max-h-[55dvh] gap-6 overflow-auto pr-2 sm:grid-cols-2">
|
||||
{#each generations as [generation, games] (generation)}
|
||||
<fieldset>
|
||||
<legend class="mb-3"
|
||||
><label class="flex items-center gap-2 font-semibold"
|
||||
><Checkbox
|
||||
checked={games.every((g) => draft.has(g.id))}
|
||||
indeterminate={games.some((g) => draft.has(g.id)) &&
|
||||
!games.every((g) => draft.has(g.id))}
|
||||
onCheckedChange={(checked) => {
|
||||
for (const g of games)
|
||||
if (checked) draft.add(g.id);
|
||||
else draft.delete(g.id);
|
||||
}}
|
||||
/>Generation {generation}</label
|
||||
></legend
|
||||
>
|
||||
<div class="space-y-2">
|
||||
{#each games as g (g.id)}<label class="flex items-center gap-3 rounded-lg border p-2"
|
||||
><Checkbox
|
||||
name="gameIds"
|
||||
value={String(g.id)}
|
||||
checked={draft.has(g.id)}
|
||||
onCheckedChange={(checked) => (checked ? draft.add(g.id) : draft.delete(g.id))}
|
||||
/><GameLogo slug={`pokemon/${g.slug}`} name={g.name} class="h-8 w-16" /><span
|
||||
class="text-sm">{g.name}</span
|
||||
></label
|
||||
>{/each}
|
||||
</div>
|
||||
</fieldset>{/each}
|
||||
</div>
|
||||
<Dialog.Footer class="mt-6"
|
||||
><Button variant="outline" onclick={() => (rulesOpen = false)}>Cancel</Button><Button
|
||||
type="submit"
|
||||
disabled={!data.user || saving}>{saving ? 'Saving…' : 'Save rules'}</Button
|
||||
></Dialog.Footer
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import { and, asc, eq } from 'drizzle-orm';
|
||||
import { db } from '$lib/server/db';
|
||||
import { pokemon, pokemonEncounters } from '$lib/server/db/schema';
|
||||
|
||||
export async function GET({ url }) {
|
||||
const gameId = Number(url.searchParams.get('game'));
|
||||
const locationId = Number(url.searchParams.get('location'));
|
||||
if (![gameId, locationId].every((n) => Number.isSafeInteger(n) && n > 0 && n <= 2147483647))
|
||||
error(400, 'Invalid game or location');
|
||||
const rows = await db
|
||||
.select({
|
||||
pokemonId: pokemon.id,
|
||||
number: pokemon.number,
|
||||
name: pokemon.name,
|
||||
type1: pokemon.type1,
|
||||
type2: pokemon.type2,
|
||||
area: pokemonEncounters.area,
|
||||
method: pokemonEncounters.method,
|
||||
minLevel: pokemonEncounters.minLevel,
|
||||
maxLevel: pokemonEncounters.maxLevel,
|
||||
rarity: pokemonEncounters.rarity,
|
||||
conditions: pokemonEncounters.conditions,
|
||||
form: pokemonEncounters.form,
|
||||
note: pokemonEncounters.note
|
||||
})
|
||||
.from(pokemonEncounters)
|
||||
.innerJoin(pokemon, eq(pokemon.id, pokemonEncounters.pokemonId))
|
||||
.where(and(eq(pokemonEncounters.gameId, gameId), eq(pokemonEncounters.locationId, locationId)))
|
||||
.orderBy(asc(pokemon.number), asc(pokemonEncounters.method), asc(pokemonEncounters.minLevel));
|
||||
return json(rows, { headers: { 'Cache-Control': 'public, max-age=3600' } });
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { NativeSelect } from '$lib/components/ui/native-select/index.js';
|
||||
import { resolve } from '$app/paths';
|
||||
import { Input } from '$lib/components/ui/input/index.js';
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
@@ -39,13 +40,13 @@
|
||||
</label>
|
||||
<label class="block"
|
||||
>region
|
||||
<select
|
||||
<NativeSelect
|
||||
bind:value={region}
|
||||
class="mt-2 block w-full rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-3"
|
||||
>
|
||||
<option value="">all regions</option>
|
||||
{#each regions as name (name)}<option value={name}>{name}</option>{/each}
|
||||
</select>
|
||||
</NativeSelect>
|
||||
</label>
|
||||
</div>
|
||||
<label class="mt-5 flex cursor-pointer items-center gap-3">
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { pokemonImage } from '$lib/game-tracker/game-art';
|
||||
import { NativeSelect } from '$lib/components/ui/native-select/index.js';
|
||||
import { resolve } from '$app/paths';
|
||||
import { enhance } from '$app/forms';
|
||||
import { page } from '$app/state';
|
||||
@@ -124,12 +126,12 @@
|
||||
{#if games.length}
|
||||
<label class="block"
|
||||
>game
|
||||
<select
|
||||
<NativeSelect
|
||||
bind:value={gameId}
|
||||
class="mt-2 block rounded border border-[var(--site-border)] bg-[var(--site-bg)] px-3 py-2"
|
||||
>
|
||||
{#each games as [id, name] (id)}<option value={id}>{name}</option>{/each}
|
||||
</select>
|
||||
</NativeSelect>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -139,7 +141,7 @@
|
||||
{#each list as encounter, index (index)}
|
||||
<li class="flex items-center gap-3 border-t border-[var(--site-border)] py-2">
|
||||
<img
|
||||
src="https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/{encounter.number}.png"
|
||||
src={pokemonImage(encounter.number)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
width="40"
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
|
||||
let resetTarget = $state<{ code: LangCode; name: string } | null>(null);
|
||||
import { resolve } from '$app/paths';
|
||||
import ArrowRight from 'lucide-svelte/icons/arrow-right';
|
||||
import Flame from 'lucide-svelte/icons/flame';
|
||||
@@ -40,7 +42,7 @@
|
||||
}
|
||||
|
||||
function reset(code: LangCode, name: string) {
|
||||
if (confirm(`Delete all ${name} progress? This can't be undone.`)) progress.reset(code);
|
||||
resetTarget = { code, name };
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -247,3 +249,25 @@
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<AlertDialog.Root
|
||||
open={!!resetTarget}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) resetTarget = null;
|
||||
}}
|
||||
>
|
||||
<AlertDialog.Content
|
||||
><AlertDialog.Header
|
||||
><AlertDialog.Title>Reset {resetTarget?.name}?</AlertDialog.Title><AlertDialog.Description
|
||||
>This deletes all progress for this language and cannot be undone.</AlertDialog.Description
|
||||
></AlertDialog.Header
|
||||
><AlertDialog.Footer
|
||||
><AlertDialog.Cancel>Cancel</AlertDialog.Cancel><AlertDialog.Action
|
||||
onclick={() => {
|
||||
if (resetTarget) progress.reset(resetTarget.code);
|
||||
resetTarget = null;
|
||||
}}>Reset progress</AlertDialog.Action
|
||||
></AlertDialog.Footer
|
||||
></AlertDialog.Content
|
||||
>
|
||||
</AlertDialog.Root>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -486,21 +486,6 @@
|
||||
border-left: 1px solid var(--site-border);
|
||||
}
|
||||
}
|
||||
.money-dialog {
|
||||
width: min(440px, calc(100% - 28px));
|
||||
max-height: min(90vh, 850px);
|
||||
overflow-y: auto;
|
||||
margin: auto;
|
||||
padding: 24px;
|
||||
border: 1px solid var(--site-border);
|
||||
border-radius: 10px;
|
||||
background: var(--site-surface);
|
||||
color: var(--site-text);
|
||||
box-shadow: 0 18px 60px #0006;
|
||||
}
|
||||
.money-dialog::backdrop {
|
||||
background: #0009;
|
||||
}
|
||||
.money-dialog .dialog-header {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { NativeSelect } from '$lib/components/ui/native-select/index.js';
|
||||
import { resolve } from '$app/paths';
|
||||
import { Button } from '$lib/components/ui/button/index.js';
|
||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||
@@ -52,7 +53,7 @@
|
||||
{#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
|
||||
<Label for="topic-id">Add existing topic</Label><NativeSelect
|
||||
id="topic-id"
|
||||
name="topic-id"
|
||||
required
|
||||
@@ -60,7 +61,7 @@
|
||||
><option value="">Choose a topic</option
|
||||
>{#each data.topics as topic (topic.id)}<option value={topic.id}
|
||||
>{topic.name}</option
|
||||
>{/each}</select
|
||||
>{/each}</NativeSelect
|
||||
>
|
||||
</div>
|
||||
<Button type="submit" variant="outline">Add</Button>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { NativeSelect } from '$lib/components/ui/native-select/index.js';
|
||||
import { enhance } from '$app/forms';
|
||||
import type { PageProps } from './$types';
|
||||
import Artwork from '$lib/map-finder/Artwork.svelte';
|
||||
@@ -90,11 +91,14 @@
|
||||
</p>{/if}
|
||||
<label class="mb-6 block text-sm"
|
||||
>Show
|
||||
<select bind:value={filter} class="ml-2 rounded border border-border bg-background p-2">
|
||||
<NativeSelect
|
||||
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>
|
||||
</NativeSelect>
|
||||
</label>
|
||||
{/if}
|
||||
{#if !results.length}<p class="text-muted-foreground">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { NativeSelect } from '$lib/components/ui/native-select/index.js';
|
||||
import './world-opinions.css';
|
||||
import CountryPanel from '$lib/world-opinions/CountryPanel.svelte';
|
||||
import CultureMap from '$lib/world-opinions/CultureMap.svelte';
|
||||
@@ -52,14 +53,14 @@
|
||||
<section id="country" class="scroll-mt-20 pt-10" aria-label="Country">
|
||||
<label class="mb-3 block max-w-xs text-sm">
|
||||
<span class="sr-only">Country</span>
|
||||
<select
|
||||
<NativeSelect
|
||||
bind:value={countryId}
|
||||
class="w-full rounded-md border border-[var(--site-border)] bg-[var(--site-surface)] px-3 py-2"
|
||||
>
|
||||
{#each sorted as country (country.id)}
|
||||
<option value={country.id}>{country.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</NativeSelect>
|
||||
</label>
|
||||
<CountryPanel id={countryId} />
|
||||
</section>
|
||||
|
||||
@@ -13,6 +13,7 @@ const staticPaths = [
|
||||
'/lists',
|
||||
'/projects/game-tracker',
|
||||
'/projects/game-tracker/pokemon',
|
||||
'/projects/game-tracker/osu',
|
||||
'/projects/game-tracker/pokemon/locations',
|
||||
'/projects/animaldex',
|
||||
'/projects/derby',
|
||||
|
||||
Reference in New Issue
Block a user