Files
personal-website/src/routes/projects/game-tracker/call-of-duty/[slug]/+page.svelte
Stan Runge c2bc9050c2
Some checks failed
Test and deploy staging / staging (push) Failing after 2m50s
feat: build compact BO2 grind companion with cumulative ranks
2026-10-08 21:06:19 +00:00

1018 lines
31 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 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>