Files
website/src/routes/projects/game-tracker/call-of-duty/[slug]/+page.svelte
Stan Runge abac077541
All checks were successful
Test and deploy staging / staging (push) Successful in 11m52s
ui changes and cs prices
2026-09-25 00:44:06 +00:00

279 lines
9.7 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts">
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 * 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();
// 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>();
let saveError = $state('');
// 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}`);
async function toggle(itemId: number, milestone: string, checked: boolean) {
const key = `${itemId}:${milestone}`;
if (pending.has(key)) return;
pending.add(key);
saveError = '';
if (checked) done.add(key);
else done.delete(key);
const body = new FormData();
body.set('itemId', String(itemId));
body.set('milestone', milestone);
if (checked) body.set('done', 'on');
let failed: boolean;
try {
const response = await fetch('?/toggle', { method: 'POST', body });
failed = deserialize(await response.text()).type !== 'success';
} catch {
failed = true;
}
pending.delete(key);
if (failed) {
saveError = 'Could not save your progress. Please try again, or log in again.';
if (checked) done.delete(key);
else done.add(key);
}
}
const rules = $derived(data.game.rules);
const weapons = $derived(data.items.filter((item) => item.kind === 'weapon'));
const achievements = $derived(data.items.filter((item) => item.kind === 'achievement'));
const summary = $derived(
summarizeGame(
rules,
weapons,
achievements.map((item) => item.id),
isDone
)
);
type Bar = {
label: string;
description?: string;
value: number;
total: number;
achieved: boolean;
};
const bars = $derived<Bar[]>([
{ label: 'achievements', ...summary.achievements },
...summary.milestones,
...summary.tiers.map((tier) => ({
...tier,
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 categories = $derived([...Map.groupBy(achievements, (item) => item.category)]);
const tabs = ['weapons', 'achievements'] 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;
}
</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
>
<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
</p>
{#if !data.user}<p class="mt-6">
<a
href={resolve(`/login?returnTo=/projects/game-tracker/call-of-duty/${data.game.slug}`)}
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}
<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"
>
{#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}
</div>
<div class="mt-8 flex flex-wrap items-center justify-between gap-4">
<Tabs.Root bind:value={tab}
><Tabs.List
>{#each tabs as name (name)}<Tabs.Trigger value={name}>{name}</Tabs.Trigger
>{/each}</Tabs.List
></Tabs.Root
>
<label class="flex cursor-pointer items-center gap-3">
<input type="checkbox" bind:checked={hideDone} class="h-5 w-5 accent-[var(--site-text)]" />
hide finished
</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
>
{/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
>{/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}
{#each categories as [category, list] (category)}
{@const rows = list.filter((item) => !hideDone || !isDone(item.id, 'done'))}
<section class="mt-10">
<div class="flex flex-wrap items-baseline justify-between gap-3">
<h2 class="text-2xl font-semibold">{category}</h2>
<p class="text-[var(--site-muted)]">
{list.filter((item) => isDone(item.id, 'done')).length} of {list.length}
</p>
</div>
<ul class="mt-3">
{#each rows as item (item.id)}
<li class="border-t border-[var(--site-border)]">
<label class="flex cursor-pointer items-center gap-4 py-3">
{#if item.icon}<img
src={item.icon}
alt=""
loading="lazy"
width="48"
height="48"
class="h-12 w-12 shrink-0 rounded"
class:opacity-40={!isDone(item.id, 'done')}
/>{/if}
<span class="min-w-0 flex-1">
<span class="block font-medium">{item.name}</span>
{#if item.description}<span class="block text-[var(--site-muted)]"
>{item.description}</span
>{/if}
</span>
<input
type="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)}
/>
</label>
</li>
{:else}
<li class="border-t border-[var(--site-border)] py-3 text-[var(--site-muted)]">
all unlocked.
</li>
{/each}
</ul>
</section>
{/each}
{/if}
</div>