Files
ptero-reforger-panel/apps/web/src/components/mission-card.tsx
T
2026-09-05 13:10:36 -07:00

259 lines
8.6 KiB
TypeScript

import { useMemo, useState } from 'react';
import type { MissionInfo } from '@reforger-panel/shared';
import { useConfiguration, useMissions, useSetPerformanceSettings } from '../api/hooks.js';
import { Badge, Button, Card, EmptyState, Notice, SearchInput, Spinner, useToast } from './ui.js';
import { Icon } from './icons.js';
const SCENARIO_PATTERN = /^\{[0-9A-Fa-f]{16}\}[^\0\r\n]+\.conf$/;
/** Display form of a scenario id: just the file name, e.g. "23_Campaign.conf". */
export function shortScenario(scenarioId: string): string {
const slash = scenarioId.lastIndexOf('/');
return slash >= 0 ? scenarioId.slice(slash + 1) : scenarioId;
}
/**
* Mission picker.
*
* Scenario discovery is now reliable: the vanilla list is bundled and merged
* with whatever the server prints at boot, and modded scenarios come from the
* Workshop v2 `scenarios[].gameId` field rather than being scraped out of prose.
* The raw id input is kept, but demoted to a fallback.
*/
export function MissionCard({ slug, canEdit }: { slug: string; canEdit: boolean }) {
const toast = useToast();
const { data: config, refetch: refetchConfig } = useConfiguration(slug);
const {
data: missions,
isLoading: missionsLoading,
refetch: refetchMissions,
} = useMissions(slug);
const save = useSetPerformanceSettings(slug);
const [query, setQuery] = useState('');
const [manual, setManual] = useState('');
const [showManual, setShowManual] = useState(false);
const current = config?.config.scenarioId ?? '';
const groups = useMemo(() => {
if (!missions) return [];
const needle = query.trim().toLowerCase();
if (!needle) return missions.groups;
return missions.groups
.map((group) => ({
...group,
missions: group.missions.filter(
(mission) =>
mission.name.toLowerCase().includes(needle) ||
mission.scenarioId.toLowerCase().includes(needle) ||
(mission.gameMode ?? '').toLowerCase().includes(needle),
),
}))
.filter((group) => group.missions.length > 0);
}, [missions, query]);
const known = useMemo(
() =>
new Set(
(missions?.groups ?? []).flatMap((group) =>
group.missions.map((mission) => mission.scenarioId),
),
),
[missions],
);
const currentMission = useMemo(() => {
for (const group of missions?.groups ?? []) {
const match = group.missions.find((mission) => mission.scenarioId === current);
if (match) return { mission: match, groupLabel: group.label };
}
return null;
}, [missions, current]);
const apply = (scenarioId: string) => {
if (!SCENARIO_PATTERN.test(scenarioId)) {
toast('That does not look like a scenario id ({16 hex}Missions/….conf).', 'danger');
return;
}
save.mutate(
{
settings: { scenarioId },
expectedRevision: config?.revision,
writeStartupVars: true,
},
{
onSuccess: () => {
setManual('');
void refetchConfig();
toast('Mission saved to config.json. Restart the server to switch.', 'ok');
},
onError: (error) => toast(error.message, 'danger'),
},
);
};
if (!config) {
return (
<Card title="Mission">
<Spinner />
</Card>
);
}
return (
<Card
title="Mission"
action={
<div className="flex items-center gap-2">
<Button
size="sm"
variant="ghost"
icon="refresh"
onClick={() => void refetchMissions()}
title="Re-scan available missions"
/>
{canEdit && (
<Button size="sm" variant="ghost" onClick={() => setShowManual((open) => !open)}>
{showManual ? 'Hide manual entry' : 'Enter an ID manually'}
</Button>
)}
</div>
}
>
<div className="space-y-4">
<div className="rounded-sm border border-graphite-700 bg-graphite-950 px-3 py-2.5">
<p className="eyebrow">Currently configured</p>
<p className="mt-1 flex flex-wrap items-center gap-2 text-base text-zinc-50">
{currentMission?.mission.name ?? shortScenario(current)}
{currentMission && <Badge tone="accent">{currentMission.groupLabel}</Badge>}
{currentMission?.mission.gameMode && <Badge>{currentMission.mission.gameMode}</Badge>}
</p>
<p className="mt-0.5 truncate font-mono text-2xs text-slate-faint" title={current}>
{current || '(none set)'}
</p>
</div>
{!missionsLoading && current && !known.has(current) && (
<Notice tone="warn" title="Nothing installed provides this mission">
The server is configured for a scenario the base game does not ship and no installed mod
offers. It will fail to load it on the next restart pick one below, or re-add the mod
that provided it.
</Notice>
)}
{(missions?.incompleteModIds.length ?? 0) > 0 && (
<Notice tone="info">
{missions!.incompleteModIds.length} installed mod
{missions!.incompleteModIds.length === 1 ? "'s" : "s'"} scenarios could not be read from
the Workshop, so this list may be incomplete.
</Notice>
)}
{canEdit && showManual && (
<div className="flex gap-2">
<input
value={manual}
placeholder="{FDE33AFE2ED7875B}Missions/23_Campaign_Montignac.conf"
onChange={(event) => setManual(event.target.value)}
className="input font-mono text-xs"
/>
<Button
variant="accent"
disabled={!manual.trim() || save.isPending}
onClick={() => apply(manual.trim())}
>
Set
</Button>
</div>
)}
<SearchInput value={query} onChange={setQuery} placeholder="Search missions…" />
{missionsLoading ? (
<Spinner label="Reading available missions…" />
) : groups.length === 0 ? (
<EmptyState
icon="map"
title={query ? 'No missions match that search' : 'No missions found'}
hint={
query
? undefined
: 'Vanilla scenarios are always listed; modded scenarios come from the mods installed on this server.'
}
/>
) : (
<div className="space-y-4">
{groups.map((group) => (
<section key={group.id}>
<p className="eyebrow mb-1.5 flex items-center gap-2">
<Icon
name={group.kind === 'official' ? 'map' : 'package'}
className="h-3.5 w-3.5"
/>
{group.label}
<span className="numeric text-slate-faint">{group.missions.length}</span>
</p>
<ul className="divide-y divide-graphite-800 overflow-hidden rounded-sm border border-graphite-700">
{group.missions.map((mission) => (
<MissionRow
key={mission.scenarioId}
mission={mission}
active={mission.scenarioId === current}
canEdit={canEdit}
saving={save.isPending}
onSelect={() => apply(mission.scenarioId)}
/>
))}
</ul>
</section>
))}
</div>
)}
</div>
</Card>
);
}
function MissionRow({
mission,
active,
canEdit,
saving,
onSelect,
}: {
mission: MissionInfo;
active: boolean;
canEdit: boolean;
saving: boolean;
onSelect: () => void;
}) {
return (
<li
className={`flex flex-wrap items-center gap-3 px-3 py-2 ${active ? 'bg-accent-600/[0.09]' : 'hover:bg-graphite-850/50'}`}
>
<div className="min-w-0 flex-1">
<p className="flex items-center gap-2 truncate text-sm text-zinc-100">
{mission.name}
{active && <Badge tone="accent">running</Badge>}
</p>
<p className="truncate font-mono text-2xs text-slate-faint">{mission.scenarioId}</p>
</div>
{mission.gameMode && <Badge>{mission.gameMode}</Badge>}
{mission.playerCount ? (
<span className="numeric text-2xs text-slate-dim">{mission.playerCount}p</span>
) : null}
{canEdit && (
<Button
size="sm"
variant={active ? 'subtle' : 'accent'}
disabled={active || saving}
onClick={onSelect}
>
{active ? 'Current' : 'Use'}
</Button>
)}
</li>
);
}