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:
@@ -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
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user