Some checks failed
Test and deploy staging / staging (push) Failing after 2m50s
1018 lines
31 KiB
Svelte
1018 lines
31 KiB
Svelte
<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 PlayerRankBadge from '$lib/game-tracker/PlayerRankBadge.svelte';
|
||
import {
|
||
gameRanksProgress,
|
||
rankProgress as countRank,
|
||
weaponLevelsProgress,
|
||
weaponFinished,
|
||
hasReachedMaxLevel
|
||
} from '$lib/game-tracker/rank-progress';
|
||
import { fraction } from '$lib/game-tracker/progress';
|
||
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, 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, 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>();
|
||
let saveError = $state('');
|
||
// checkboxes stay disabled until hydration, so early clicks can't silently go unsaved
|
||
let hydrated = $state(false);
|
||
onMount(() => (hydrated = true));
|
||
const weaponRanks = $derived(new SvelteMap(data.weaponRanks.map((r) => [r.itemId, r])));
|
||
const isDone = (id: number, milestone: string) =>
|
||
done.has(`${id}:${milestone}`) ||
|
||
(milestone === 'maxLevel' &&
|
||
!!weapons.find((w) => w.id === id && hasReachedMaxLevel(rules, w, weaponRanks.get(id))));
|
||
|
||
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
|
||
)
|
||
);
|
||
const masteryProgress = $derived(sumProgress(summary.milestones));
|
||
const unlockProgress = $derived(challengeProgress(data.challenges, data.challengeProgress));
|
||
const rankProgress = $derived(gameRanksProgress(rules, weapons, weaponRanks, data.rank));
|
||
const allWeaponLevels = $derived(weaponLevelsProgress(rules, weapons, weaponRanks));
|
||
const maxedWeapons = $derived(
|
||
weapons.filter((w) => {
|
||
const p = countRank(weaponRankLimits(rules, w), weaponRanks.get(w.id));
|
||
return p.total > 0 && p.value === p.total;
|
||
}).length
|
||
);
|
||
|
||
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;
|
||
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 weaponRows = $derived(
|
||
weapons.filter(
|
||
(w) =>
|
||
(weaponClass === 'all' || w.category === weaponClass) &&
|
||
w.name.toLowerCase().includes(weaponSearch.toLowerCase().trim()) &&
|
||
(!hideDone || !weaponFinished(rules, w, weaponRanks.get(w.id), isDone))
|
||
)
|
||
);
|
||
let weaponPage = $state(1);
|
||
const weaponPageSize = $derived(data.game.slug === 'black-ops-2' ? 40 : 32);
|
||
const weaponPages = $derived(Math.max(1, Math.ceil(weaponRows.length / weaponPageSize)));
|
||
$effect(() => {
|
||
if (weaponPage > weaponPages) weaponPage = 1;
|
||
});
|
||
const weaponGroups = $derived(
|
||
[
|
||
...Map.groupBy(
|
||
weaponRows.slice((weaponPage - 1) * weaponPageSize, weaponPage * weaponPageSize),
|
||
(w) => w.category
|
||
)
|
||
].flatMap(([name, members]) =>
|
||
Array.from({ length: Math.ceil(members.length / 10) }, (_, part) => ({
|
||
name,
|
||
key: `${name}:${part}`,
|
||
members: members.slice(part * 10, (part + 1) * 10)
|
||
}))
|
||
)
|
||
);
|
||
// Place whole classes in the shortest column; never split a class across columns.
|
||
const weaponColumns = $derived.by(() => {
|
||
const columns: (typeof weaponGroups)[] = [[], [], [], []];
|
||
const heights = [0, 0, 0, 0];
|
||
for (const group of [...weaponGroups].sort((a, b) => b.members.length - a.members.length)) {
|
||
const index = heights.indexOf(Math.min(...heights));
|
||
columns[index].push(group);
|
||
heights[index] += group.members.length + 0.7;
|
||
}
|
||
return columns;
|
||
});
|
||
let detailWeaponId = $state<number | null>(null);
|
||
const detailWeapon = $derived(weapons.find((w) => w.id === detailWeaponId));
|
||
function weaponCompletion(weapon: (typeof weapons)[number]) {
|
||
const milestones = rules.milestones.filter((m) => appliesTo(m, weapon));
|
||
return combinedProgress([
|
||
countRank(weaponRankLimits(rules, weapon), weaponRanks.get(weapon.id)),
|
||
{ value: milestones.filter((m) => isDone(weapon.id, m.key)).length, total: milestones.length }
|
||
]);
|
||
}
|
||
const categories = $derived([...Map.groupBy(achievements, (item) => item.category)]);
|
||
|
||
const tabs = ['weapons', 'achievements', 'challenges'] as const;
|
||
let tab = $state<(typeof tabs)[number]>('weapons');
|
||
let hideDone = $state(false);
|
||
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="cod-companion">
|
||
<div class="companion-header">
|
||
<a href={resolve('/projects/game-tracker')} class="back-link">← game tracker</a>
|
||
<GameLogo slug={`cod/${data.game.slug}`} name={data.game.shortName} class="h-10 w-20" />
|
||
<div class="game-heading">
|
||
<p class="eyebrow">THE GRIND / MULTIPLAYER</p>
|
||
<h1>{data.game.shortName}</h1>
|
||
</div>
|
||
<div class="player-rank">
|
||
<PlayerRankBadge
|
||
game={data.game.slug}
|
||
level={data.rank?.level ?? 1}
|
||
prestige={data.rank?.prestige ?? 0}
|
||
/>
|
||
{#if data.user}<Button
|
||
variant="outline"
|
||
size="sm"
|
||
disabled={!hydrated || !data.user}
|
||
onclick={() => {
|
||
rankWeapon = null;
|
||
rankOpen = true;
|
||
}}>Update my rank</Button
|
||
>{:else}<Button
|
||
variant="outline"
|
||
size="sm"
|
||
href={resolve(`/login?returnTo=/projects/game-tracker/call-of-duty/${data.game.slug}`)}
|
||
>Sign in to save</Button
|
||
>{/if}
|
||
</div>
|
||
</div>
|
||
{#if form?.message}<p role="alert" class="text-xs text-destructive">{form.message}</p>{/if}
|
||
{#if saveError}<p role="alert" class="text-xs text-red-500">{saveError}</p>{/if}
|
||
<div class="completion-ribbon" aria-label="Game completion">
|
||
<ProgressMeter
|
||
label="Game completion"
|
||
progress={overall}
|
||
color="violet"
|
||
compact
|
||
hint="Every recorded level across all prestige runs counts. Ranks, camo milestones, achievements and partial challenges contribute to completion."
|
||
/>
|
||
<ProgressMeter
|
||
label="Weapon levels"
|
||
progress={allWeaponLevels}
|
||
color="amber"
|
||
compact
|
||
detail={`${allWeaponLevels.value} / ${allWeaponLevels.total} levels`}
|
||
/>
|
||
<ProgressMeter
|
||
label="Player & weapon ranks"
|
||
progress={rankProgress}
|
||
color="pink"
|
||
compact
|
||
hint="Current level + prestige × level cap, across all player and weapon prestige runs. Unknown caps are excluded."
|
||
/>
|
||
<ProgressMeter label="Achievements" progress={summary.achievements} color="sky" compact />
|
||
<ProgressMeter label="Challenges & unlocks" progress={unlockProgress} compact />
|
||
</div>
|
||
|
||
<div class="companion-toolbar">
|
||
<Tabs.Root bind:value={tab}
|
||
><Tabs.List class="max-w-full"
|
||
>{#each tabs as name (name)}<Tabs.Trigger
|
||
class="px-2 text-xs sm:px-3 sm:text-sm"
|
||
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
|
||
>
|
||
<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'}
|
||
<div class="weapon-filter">
|
||
{#if weaponPages > 1}<Button
|
||
size="sm"
|
||
variant="outline"
|
||
aria-label="Previous weapons"
|
||
disabled={weaponPage === 1}
|
||
onclick={() => weaponPage--}>←</Button
|
||
><span class="text-xs">{weaponPage}/{weaponPages}</span><Button
|
||
size="sm"
|
||
variant="outline"
|
||
aria-label="Next weapons"
|
||
disabled={weaponPage === weaponPages}
|
||
onclick={() => weaponPage++}>→</Button
|
||
>{/if}
|
||
<Input
|
||
class="h-8 max-w-64"
|
||
aria-label="Find a weapon"
|
||
type="search"
|
||
placeholder="Find your next grind…"
|
||
bind:value={weaponSearch}
|
||
/>
|
||
<NativeSelect class="h-8" aria-label="Weapon class" bind:value={weaponClass}
|
||
><option value="all">All classes</option>{#each rules.classes as name (name)}<option
|
||
value={name}>{name}</option
|
||
>{/each}</NativeSelect
|
||
>
|
||
<p>
|
||
<strong>{maxedWeapons}</strong> maxed · <strong>{allWeaponLevels.value}</strong> / {allWeaponLevels.total ||
|
||
'—'} weapon levels
|
||
</p>
|
||
</div>
|
||
<div class="armoury-board" aria-label="Weapon progress">
|
||
{#each weaponColumns as column, columnIndex (columnIndex)}
|
||
<div class="armoury-column">
|
||
{#each column as group (group.key)}
|
||
{@const groupLevels = weaponLevelsProgress(rules, group.members, weaponRanks)}
|
||
<section
|
||
class="weapon-class"
|
||
aria-label={group.name}
|
||
style:flex-grow={group.members.length + 0.7}
|
||
>
|
||
<div class="class-heading">
|
||
<h2>{group.name}</h2>
|
||
<span>{group.members.length}</span>
|
||
<div
|
||
class="class-meter"
|
||
role="progressbar"
|
||
aria-label={`${group.name} levels`}
|
||
aria-valuemin={0}
|
||
aria-valuemax={100}
|
||
aria-valuenow={fraction(groupLevels) * 100}
|
||
>
|
||
<i style:width={`${fraction(groupLevels) * 100}%`}></i>
|
||
</div>
|
||
</div>
|
||
<div class="class-weapons">
|
||
{#each group.members as weapon (weapon.id)}
|
||
{@const current = weaponRanks.get(weapon.id)}
|
||
{@const limits = weaponRankLimits(rules, weapon)}
|
||
{@const levels = countRank(limits, current)}
|
||
{@const complete = weaponFinished(rules, weapon, current, isDone)}
|
||
<article class="weapon-card" class:complete aria-label={weapon.name}>
|
||
<WeaponArtwork
|
||
game={data.game.slug}
|
||
name={weapon.name}
|
||
icon={weapon.icon}
|
||
compact
|
||
/>
|
||
<button
|
||
type="button"
|
||
class="weapon-info"
|
||
disabled={!hydrated}
|
||
aria-label={`${weapon.name} camos and milestones`}
|
||
onclick={() => (detailWeaponId = weapon.id)}
|
||
>
|
||
<span class="weapon-name"
|
||
>{weapon.name}{weapon.postLaunch ? ' · DLC' : ''}</span
|
||
>
|
||
<span class="weapon-meta"
|
||
>{#if limits.rankable}P{current?.prestige ?? 0}{limits.maxLevel
|
||
? ` / ${limits.maxPrestige}`
|
||
: ''}<span>
|
||
· {levels.total ? `${levels.value}/${levels.total}` : 'Rank'}
|
||
</span>{/if}{isDone(weapon.id, 'gold')
|
||
? '◆ Gold'
|
||
: complete
|
||
? '✓ Done'
|
||
: ''}</span
|
||
>
|
||
<span
|
||
class="weapon-meter"
|
||
role="progressbar"
|
||
aria-label={`${weapon.name} mastery`}
|
||
aria-valuemin={0}
|
||
aria-valuemax={100}
|
||
aria-valuenow={fraction(weaponCompletion(weapon)) * 100}
|
||
><i style:width={`${fraction(weaponCompletion(weapon)) * 100}%`}></i></span
|
||
>
|
||
</button>
|
||
{#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}
|
||
dense
|
||
disabled={!hydrated || !data.user}
|
||
onupdate={(r) => weaponRanks.set(r.itemId, r)}
|
||
onedit={() => {
|
||
rankWeapon = weapon.id;
|
||
rankOpen = true;
|
||
}}
|
||
/>
|
||
{:else}<button
|
||
type="button"
|
||
class="text-[10px] text-muted-foreground"
|
||
onclick={() => (detailWeaponId = weapon.id)}>Camos ↗</button
|
||
>{/if}
|
||
</article>
|
||
{/each}
|
||
</div>
|
||
</section>
|
||
{/each}
|
||
</div>
|
||
{/each}
|
||
{#if !weaponRows.length}<p class="col-span-full p-6 text-muted-foreground">
|
||
No weapons match these filters.
|
||
</p>{/if}
|
||
</div>
|
||
<p class="companion-hint">
|
||
{weaponRows.length} weapons · + to level up · rank to edit / prestige · weapon name for camos ·
|
||
saved as you go
|
||
</p>
|
||
{:else if tab === 'achievements'}
|
||
<div class="detail-panel">
|
||
{#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>
|
||
<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)]">
|
||
<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>
|
||
<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}`}
|
||
onCheckedChange={(checked) => toggle(item.id, 'done', 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}
|
||
</div>
|
||
{:else}
|
||
<div class="detail-panel">
|
||
<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>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
|
||
<Dialog.Root
|
||
open={!!detailWeapon}
|
||
onOpenChange={(open) => {
|
||
if (!open) detailWeaponId = null;
|
||
}}
|
||
>
|
||
<Dialog.Content>
|
||
{#if detailWeapon}
|
||
{@const w = detailWeapon}
|
||
{@const limits = weaponRankLimits(rules, w)}
|
||
{@const current = weaponRanks.get(w.id)}
|
||
<Dialog.Header
|
||
><Dialog.Title>{w.name}</Dialog.Title><Dialog.Description
|
||
>{w.category} · Track every level, prestige and camo.</Dialog.Description
|
||
></Dialog.Header
|
||
>
|
||
<WeaponArtwork game={data.game.slug} name={w.name} icon={w.icon} />
|
||
{#if limits.rankable}<WeaponRankControls
|
||
itemId={w.id}
|
||
name={w.name}
|
||
level={current?.level ?? 1}
|
||
prestige={current?.prestige ?? 0}
|
||
levelCap={limits.maxLevel}
|
||
prestigeCap={limits.maxPrestige}
|
||
prestigeRequiresMaxLevel={limits.canPrestigeOnlyAtMaxLevel}
|
||
disabled={!hydrated || !data.user}
|
||
onupdate={(r) => weaponRanks.set(r.itemId, r)}
|
||
onedit={() => {
|
||
detailWeaponId = null;
|
||
rankWeapon = w.id;
|
||
rankOpen = true;
|
||
}}
|
||
/>{/if}
|
||
<ProgressMeter
|
||
label={`${w.name} total levels`}
|
||
progress={countRank(limits, current)}
|
||
color="amber"
|
||
/>
|
||
{#each rules.milestones.filter((m) => appliesTo(m, w)) as milestone (milestone.key)}
|
||
{#if milestone.key === 'maxLevel' && hasReachedMaxLevel(rules, w, current)}<p
|
||
class="text-sm text-emerald-500"
|
||
>
|
||
✓ Max level reached
|
||
</p>
|
||
{:else}<label class="flex items-center gap-3 text-sm"
|
||
><Checkbox
|
||
checked={isDone(w.id, milestone.key)}
|
||
disabled={!hydrated || !data.user || pending.has(`${w.id}:${milestone.key}`)}
|
||
aria-label={`${w.name}: ${milestone.label}`}
|
||
onCheckedChange={(checked) => toggle(w.id, milestone.key, checked)}
|
||
/><span
|
||
>{milestone.label}<small class="block text-muted-foreground"
|
||
>{milestone.description ?? ''}</small
|
||
></span
|
||
></label
|
||
>{/if}
|
||
{/each}
|
||
{#if saveError}<p role="alert" class="text-destructive">{saveError}</p>{/if}
|
||
{/if}
|
||
</Dialog.Content>
|
||
</Dialog.Root>
|
||
|
||
<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
|
||
>
|
||
|
||
<style>
|
||
.cod-companion {
|
||
max-width: 1920px;
|
||
margin: auto;
|
||
padding: 8px 18px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
.companion-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
}
|
||
.back-link {
|
||
font-size: 11px;
|
||
color: var(--site-muted);
|
||
}
|
||
.game-heading {
|
||
flex: 1;
|
||
}
|
||
.game-heading h1 {
|
||
font-size: 21px;
|
||
font-weight: 700;
|
||
letter-spacing: -0.04em;
|
||
}
|
||
.eyebrow {
|
||
font-size: 9px;
|
||
letter-spacing: 0.17em;
|
||
color: #f59e0b;
|
||
}
|
||
.player-rank {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 14px;
|
||
}
|
||
.completion-ribbon {
|
||
display: grid;
|
||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||
gap: 20px;
|
||
padding: 7px 12px;
|
||
border: 1px solid var(--site-border);
|
||
border-radius: 8px;
|
||
background: var(--card);
|
||
}
|
||
.companion-toolbar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
}
|
||
.weapon-filter {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
.weapon-filter p {
|
||
margin-left: auto;
|
||
font-size: 11px;
|
||
color: var(--site-muted);
|
||
}
|
||
.weapon-filter strong {
|
||
color: var(--site-text);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.armoury-board {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
gap: 10px;
|
||
flex: 1;
|
||
min-height: 0;
|
||
}
|
||
.armoury-column {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
min-width: 0;
|
||
min-height: 0;
|
||
}
|
||
.weapon-class {
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 0;
|
||
flex-basis: 0;
|
||
border: 1px solid var(--site-border);
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
background: var(--card);
|
||
}
|
||
.class-heading {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 4px 8px;
|
||
line-height: 12px;
|
||
font-size: 10px;
|
||
color: var(--site-muted);
|
||
background: color-mix(in srgb, #f59e0b 5%, var(--card));
|
||
}
|
||
.class-heading h2 {
|
||
color: var(--site-text);
|
||
font-weight: 600;
|
||
}
|
||
.class-meter {
|
||
margin-left: auto;
|
||
width: 42px;
|
||
height: 3px;
|
||
background: var(--site-border);
|
||
border-radius: 3px;
|
||
}
|
||
.class-meter i,
|
||
.weapon-meter i {
|
||
display: block;
|
||
height: 100%;
|
||
background: #f59e0b;
|
||
border-radius: inherit;
|
||
transition: width 0.35s;
|
||
}
|
||
.class-weapons {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
.weapon-card {
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 7px;
|
||
min-height: 30px;
|
||
padding: 2px 5px;
|
||
border-top: 1px solid var(--site-border);
|
||
}
|
||
.weapon-card:hover {
|
||
background: color-mix(in srgb, #f59e0b 6%, var(--card));
|
||
}
|
||
.weapon-card.complete {
|
||
background: color-mix(in srgb, #10b981 8%, var(--card));
|
||
}
|
||
.weapon-info {
|
||
min-width: 0;
|
||
flex: 1;
|
||
text-align: left;
|
||
cursor: pointer;
|
||
padding: 0;
|
||
}
|
||
.weapon-name {
|
||
display: block;
|
||
font-size: 11px;
|
||
line-height: 13px;
|
||
font-weight: 600;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.weapon-meta {
|
||
display: block;
|
||
font-size: 9px;
|
||
color: var(--site-muted);
|
||
line-height: 10px;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.weapon-meter {
|
||
display: block;
|
||
height: 2px;
|
||
background: var(--site-border);
|
||
border-radius: 2px;
|
||
margin-top: 2px;
|
||
}
|
||
.companion-hint {
|
||
font-size: 10px;
|
||
color: var(--site-muted);
|
||
}
|
||
.detail-panel {
|
||
min-height: 0;
|
||
overflow-y: auto;
|
||
flex: 1;
|
||
padding-right: 4px;
|
||
}
|
||
@media (min-width: 1100px) and (min-height: 700px) {
|
||
.cod-companion {
|
||
height: calc(100dvh - 65px);
|
||
}
|
||
}
|
||
@media (max-width: 1099px), (max-height: 699px) {
|
||
.weapon-class {
|
||
flex-basis: auto;
|
||
}
|
||
.armoury-board {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
}
|
||
.weapon-card {
|
||
min-height: 52px;
|
||
}
|
||
.companion-header {
|
||
flex-wrap: wrap;
|
||
}
|
||
.player-rank {
|
||
margin-left: auto;
|
||
}
|
||
.back-link {
|
||
width: 100%;
|
||
}
|
||
}
|
||
@media (max-width: 600px) {
|
||
.cod-companion {
|
||
padding: 10px;
|
||
}
|
||
.armoury-board {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.completion-ribbon {
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 10px;
|
||
}
|
||
.companion-toolbar,
|
||
.weapon-filter {
|
||
flex-wrap: wrap;
|
||
}
|
||
.weapon-filter p {
|
||
width: 100%;
|
||
margin: 0;
|
||
}
|
||
.player-rank {
|
||
width: 100%;
|
||
flex-wrap: wrap;
|
||
justify-content: space-between;
|
||
}
|
||
}
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.weapon-meter i,
|
||
.class-meter i {
|
||
transition: none;
|
||
}
|
||
}
|
||
</style>
|