overhaul
This commit is contained in:
66 files changed
+9227
-3679
No files matched your search
@@ -1,20 +1,97 @@
|
||||
import { useState } from 'react';
|
||||
import { useConfiguration, useSetPerformanceSettings } from '../api/hooks.js';
|
||||
import { Button, Card, Spinner } from './ui.js';
|
||||
import { shortScenario } from './widgets.js';
|
||||
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 DEFAULT_SCENARIO_ID = '{FDE33AFE2ED7875B}Missions/23_Campaign_Montignac.conf';
|
||||
const DEFAULT_SCENARIO_NAME = 'Campaign - Montignac (default)';
|
||||
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 editor. Scenario discovery through the Workshop API is not reliable
|
||||
* enough for every mod, so the primary control is a manual scenario ID input.
|
||||
* 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 { data: config, refetch } = useConfiguration(slug);
|
||||
const toast = useToast();
|
||||
const { data: config, refetch: refetchConfig } = useConfiguration(slug);
|
||||
const {
|
||||
data: missions,
|
||||
isLoading: missionsLoading,
|
||||
refetch: refetchMissions,
|
||||
} = useMissions(slug);
|
||||
const save = useSetPerformanceSettings(slug);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
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 (
|
||||
@@ -24,82 +101,158 @@ export function MissionCard({ slug, canEdit }: { slug: string; canEdit: boolean
|
||||
);
|
||||
}
|
||||
|
||||
const current = config.config.scenarioId;
|
||||
const value = selected ?? current;
|
||||
const dirty = value !== current;
|
||||
|
||||
const submit = (scenarioIdOverride?: string) => {
|
||||
setMessage(null);
|
||||
save.mutate(
|
||||
{ scenarioId: scenarioIdOverride ?? value },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setSelected(null);
|
||||
setMessage('Mission saved to config.json — restart the server to switch.');
|
||||
void refetch();
|
||||
},
|
||||
onError: (error) => setMessage(error.message),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="Mission"
|
||||
action={
|
||||
canEdit &&
|
||||
dirty && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={() => setSelected(null)} disabled={save.isPending}>
|
||||
Discard
|
||||
<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>
|
||||
<Button variant="accent" onClick={() => submit()} disabled={save.isPending}>
|
||||
{save.isPending ? 'Saving…' : 'Save to server'}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-lg font-medium text-zinc-100">{shortScenario(current)}</p>
|
||||
<p className="truncate font-mono text-xs text-slate-dim" title={current}>
|
||||
{current}
|
||||
<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>
|
||||
{canEdit && (
|
||||
<div className="grid gap-2">
|
||||
|
||||
{!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={value}
|
||||
onChange={(event) => {
|
||||
setMessage(null);
|
||||
setSelected(event.target.value);
|
||||
}}
|
||||
value={manual}
|
||||
placeholder="{FDE33AFE2ED7875B}Missions/23_Campaign_Montignac.conf"
|
||||
className="input w-full font-mono text-xs"
|
||||
onChange={(event) => setManual(event.target.value)}
|
||||
className="input font-mono text-xs"
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
onClick={() => {
|
||||
setMessage(null);
|
||||
setSelected(DEFAULT_SCENARIO_ID);
|
||||
}}
|
||||
disabled={save.isPending}
|
||||
>
|
||||
Use {DEFAULT_SCENARIO_NAME}
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => submit(DEFAULT_SCENARIO_ID)}
|
||||
disabled={save.isPending || current === DEFAULT_SCENARIO_ID}
|
||||
>
|
||||
{save.isPending ? 'Saving…' : 'Reset to default'}
|
||||
</Button>
|
||||
</div>
|
||||
<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>
|
||||
{message && <p className="mt-3 text-xs text-accent-400">{message}</p>}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user