Files
ptero-reforger-panel/apps/web/src/pages/mods.tsx
T

1602 lines
57 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import type {
CurrentUser,
ModDependencyIssue,
ModsCheckResponse,
ReforgerConfigMod,
UpdateModsResult,
WorkshopModDetail,
WorkshopModPreview,
} from '@reforger-panel/shared';
import { api } from '../api/client.js';
import {
useConfiguration,
useServerMods,
useServerModsCheck,
useServers,
useSetPerformanceSettings,
useSetServerMods,
useWorkshopMod,
useWorkshopSearch,
} from '../api/hooks.js';
import { formatRelativeTime } from '../lib/format.js';
import { Button, Card, EmptyState, ModImage, Spinner } from '../components/ui.js';
const COMMON_WORKSHOP_TAGS = [
'SCENARIO',
'SCENARIOS_MP',
'SCENARIOS_SP',
'WEAPONS',
'VEHICLES',
'MISSIONS',
'EQUIPMENT',
'GAMEPLAY',
'MISC',
'QUALITY OF LIFE',
] as const;
const WORKSHOP_SORTS = [
{ value: 'popularity', label: 'Popular' },
{ value: 'newest', label: 'Newest' },
{ value: 'subscribers', label: 'Subscribers' },
{ value: 'version_size', label: 'Size' },
] as const;
const WORKSHOP_MOD_DETAIL_STALE_MS = 5 * 60_000;
const WORKSHOP_DETAIL_BATCH_INTERVAL_MS = 1_000;
const WORKSHOP_DETAIL_BATCH_SIZE = 4;
const WORKSHOP_DETAIL_BURST_CAPACITY = 20;
const WORKSHOP_DETAIL_FAILURE_COOLDOWN_MS = 60_000;
const WORKSHOP_DETAIL_PREFETCH_MARGIN = '500px';
const MODS_AUTOSAVE_DEBOUNCE_MS = 1_500;
const UPGRADE_ALL_DETAIL_BATCH_SIZE = 8;
const workshopDetailLimiter = {
inFlight: false,
burstTokens: WORKSHOP_DETAIL_BURST_CAPACITY,
lastTokenRefillAt: Date.now(),
};
export function ModsPage({ user }: { user: CurrentUser }) {
const { data: serversData } = useServers();
const slug = serversData?.servers[0]?.slug;
if (!slug) return <Spinner />;
return <ModsBody slug={slug} user={user} />;
}
function sameMods(a: ReforgerConfigMod[], b: ReforgerConfigMod[]): boolean {
return JSON.stringify(a) === JSON.stringify(b);
}
type ModsTab = 'installed' | 'browse';
type SaveStatus = 'idle' | 'pending' | 'saving' | 'saved' | 'error';
const DEFAULT_SCENARIO_ID = '{FDE33AFE2ED7875B}Missions/23_Campaign_Montignac.conf';
function ModsBody({ slug, user }: { slug: string; user: CurrentUser }) {
const canManage = user.capabilities.includes('mods.manage');
const canEditConfig = user.capabilities.includes('config.edit');
const { data, isLoading, error, refetch } = useServerMods(slug);
const { data: configData } = useConfiguration(slug);
const currentScenarioId = configData?.config.scenarioId ?? null;
const { mutate: saveMutate, isPending: isSavingMods } = useSetServerMods(slug);
const savePerf = useSetPerformanceSettings(slug);
const [draft, setDraft] = useState<ReforgerConfigMod[] | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [resetMissionMessage, setResetMissionMessage] = useState<string | null>(null);
const [selectedModId, setSelectedModId] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<ModsTab>('installed');
const [checkEnabled, setCheckEnabled] = useState(false);
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle');
const [isUpgradingAll, setIsUpgradingAll] = useState(false);
const checkQuery = useServerModsCheck(slug, checkEnabled);
const serverMods = data?.mods ?? [];
const mods = draft ?? serverMods;
const modsKey = useMemo(() => JSON.stringify(mods), [mods]);
const modsRef = useRef(mods);
modsRef.current = mods;
const dirty = draft !== null && !sameMods(draft, serverMods);
const installedIds = new Set(mods.map((mod) => mod.modId.toUpperCase()));
const missingVersionIds = new Set(mods.filter((m) => !m.version).map((m) => m.modId));
const submitMods = useCallback(
(
submittedMods: ReforgerConfigMod[],
getSuccessMessage: (result: UpdateModsResult, currentSaved: boolean) => string,
source: 'manual' | 'auto' = 'manual',
) => {
setMessage(null);
setSaveStatus('saving');
saveMutate(submittedMods, {
onSuccess: (result) => {
const currentSaved = sameMods(modsRef.current, submittedMods);
if (currentSaved) setDraft(null);
setSaveStatus(currentSaved ? 'saved' : 'pending');
setMessage(getSuccessMessage(result, currentSaved));
setCheckEnabled(false);
void refetch();
},
onError: (saveError) => {
setSaveStatus('error');
setMessage(
source === 'auto' ? `Autosave failed: ${saveError.message}` : saveError.message,
);
},
});
},
[refetch, saveMutate],
);
useEffect(() => {
if (!canManage || !dirty || isSavingMods || isLoading) return;
setSaveStatus('pending');
const timer = window.setTimeout(() => {
submitMods(
modsRef.current,
(result, currentSaved) =>
currentSaved
? `Autosaved — ${result.added} added, ${result.removed} removed. Restart to apply.`
: 'Autosaved. More changes pending.',
'auto',
);
}, MODS_AUTOSAVE_DEBOUNCE_MS);
return () => window.clearTimeout(timer);
}, [canManage, dirty, isLoading, isSavingMods, modsKey, submitMods]);
const addMod = (mod: ReforgerConfigMod) => {
if (installedIds.has(mod.modId.toUpperCase())) return;
setMessage(null);
setDraft([...mods, mod]);
};
const addMods = (newMods: ReforgerConfigMod[]) => {
const toAdd = newMods.filter((m) => !installedIds.has(m.modId.toUpperCase()));
if (toAdd.length === 0) return;
setMessage(null);
setDraft([...mods, ...toAdd]);
};
const removeMod = (modId: string) => {
setMessage(null);
if (selectedModId === modId) setSelectedModId(null);
setDraft(mods.filter((mod) => mod.modId !== modId));
};
const updateModVersion = (modId: string, version: string) => {
setMessage(null);
const normalized = version.trim();
setDraft(
mods.map((mod) =>
mod.modId === modId
? {
...mod,
...(normalized ? { version: normalized } : { version: undefined }),
}
: mod,
),
);
};
const saveMods = () => {
submitMods(mods, (result, currentSaved) =>
currentSaved
? `Saved — ${result.added} added, ${result.removed} removed. Restart to apply.`
: 'Saved. More changes pending.',
);
};
const patchVersions = () => {
submitMods(mods, (_result, currentSaved) =>
currentSaved
? 'Version info patched. Restart to apply.'
: 'Version info patched. More changes pending.',
);
};
const upgradeAllVersions = async () => {
if (!canManage || isUpgradingAll || isSavingMods || mods.length === 0) return;
setIsUpgradingAll(true);
setMessage(null);
try {
const details = await fetchWorkshopDetailsForMods(mods);
let changed = 0;
let foundVersions = 0;
const upgraded = mods.map((mod) => {
const detail = details.get(mod.modId.toUpperCase());
if (!detail?.version) return mod;
foundVersions += 1;
if (mod.version === detail.version) return mod;
changed += 1;
return {
...mod,
name: mod.name ?? detail.name,
version: detail.version,
};
});
if (changed === 0) {
setMessage(
foundVersions === 0
? 'No workshop version info was available for the installed mods.'
: 'All installed mods are already on the latest known versions.',
);
return;
}
setDraft(upgraded);
setSaveStatus('pending');
setMessage(
`Updated ${changed} version number${changed === 1 ? '' : 's'}. Autosave will write the changes shortly.`,
);
} finally {
setIsUpgradingAll(false);
}
};
const resetMission = () => {
setResetMissionMessage(null);
savePerf.mutate(
{ scenarioId: DEFAULT_SCENARIO_ID },
{
onSuccess: () =>
setResetMissionMessage('Mission reset to Campaign - Montignac. Restart to apply.'),
onError: (err) => setResetMissionMessage(err.message),
},
);
};
const runCheck = () => {
if (dirty) {
setMessage('Save your changes before checking dependencies.');
return;
}
setCheckEnabled(true);
if (checkQuery.isFetching) return;
void checkQuery.refetch();
};
return (
<div className="w-full space-y-4">
<div>
<h1 className="page-title">Mods</h1>
<p className="page-kicker">
Manage the server mod list and browse the Reforger Workshop. Changes apply on the next
server restart.
</p>
</div>
<div className="flex overflow-x-auto border-b border-graphite-700">
{(['installed', 'browse'] as ModsTab[]).map((tab) => (
<button
key={tab}
type="button"
onClick={() => setActiveTab(tab)}
className={`whitespace-nowrap px-4 py-2.5 text-sm font-semibold transition-colors ${
activeTab === tab
? 'border-b-2 border-accent-500 text-accent-400'
: 'text-slate-dim hover:text-zinc-200'
}`}
>
{tab === 'installed'
? `Installed${mods.length > 0 ? ` (${mods.length})` : ''}`
: 'Browse Workshop'}
</button>
))}
</div>
{activeTab === 'installed' ? (
<div>
<InstalledModsPanel
mods={mods}
canManage={canManage}
canEditConfig={canEditConfig}
dirty={dirty}
isSaving={isSavingMods}
saveStatus={saveStatus}
isUpgradingAll={isUpgradingAll}
isResettingMission={savePerf.isPending}
missingVersionIds={missingVersionIds}
selectedModId={selectedModId}
fetchedAt={data?.fetchedAt}
isLoading={isLoading}
loadError={error?.message ?? null}
message={message}
resetMissionMessage={resetMissionMessage}
checkResult={checkEnabled ? (checkQuery.data ?? null) : null}
isChecking={checkQuery.isFetching}
installedIds={installedIds}
currentScenarioId={currentScenarioId}
onSelectMod={setSelectedModId}
onRemoveMod={removeMod}
onUpdateModVersion={updateModVersion}
onSave={saveMods}
onDiscard={() => {
setDraft(null);
setMessage(null);
setSaveStatus('idle');
}}
onPatchVersions={patchVersions}
onUpgradeAll={upgradeAllVersions}
onCheckDeps={runCheck}
onAddMods={addMods}
onResetMission={resetMission}
/>
</div>
) : (
<WorkshopBrowser
canManage={canManage}
installedIds={installedIds}
onAdd={addMod}
selectedModId={selectedModId}
setSelectedModId={setSelectedModId}
onAddAllDeps={(deps) => void addAllDeps(deps, addMods)}
currentScenarioId={currentScenarioId}
/>
)}
</div>
);
}
async function addAllDeps(
deps: Array<{ id: string | null; name: string }>,
addMods: (mods: ReforgerConfigMod[]) => void,
) {
const resolved = await Promise.allSettled(
deps
.filter((d) => d.id)
.map(async (d): Promise<ReforgerConfigMod> => {
try {
const detail = await api.get<WorkshopModDetail>(`/api/workshop/mods/${d.id}`);
return {
modId: detail.id,
name: detail.name,
...(detail.version ? { version: detail.version } : {}),
};
} catch {
return { modId: d.id!, name: d.name };
}
}),
);
const mods = resolved
.filter((r): r is PromiseFulfilledResult<ReforgerConfigMod> => r.status === 'fulfilled')
.map((r) => r.value);
addMods(mods);
}
async function fetchWorkshopDetailsForMods(
mods: ReforgerConfigMod[],
): Promise<Map<string, WorkshopModDetail>> {
const details = new Map<string, WorkshopModDetail>();
for (let i = 0; i < mods.length; i += UPGRADE_ALL_DETAIL_BATCH_SIZE) {
const batch = mods.slice(i, i + UPGRADE_ALL_DETAIL_BATCH_SIZE);
const results = await Promise.allSettled(
batch.map((mod) => api.get<WorkshopModDetail>(`/api/workshop/mods/${mod.modId}`)),
);
for (const result of results) {
if (result.status === 'fulfilled') {
details.set(result.value.id.toUpperCase(), result.value);
}
}
}
return details;
}
function workshopModQueryKey(modId: string) {
return ['workshop', 'mod', modId] as const;
}
function useQueuedWorkshopModDetails(modIds: string[], visibleModIds: Set<string>) {
const queryClient = useQueryClient();
const failedUntilRef = useRef(new Map<string, number>());
const [loadingIds, setLoadingIds] = useState<Set<string>>(() => new Set());
const [cacheVersion, setCacheVersion] = useState(0);
const modIdsKey = useMemo(() => modIds.join('|'), [modIds]);
const visibleKey = useMemo(
() => [...visibleModIds].sort((a, b) => a.localeCompare(b)).join('|'),
[visibleModIds],
);
useEffect(() => {
if (workshopDetailLimiter.inFlight) return;
const now = Date.now();
const elapsedMs = now - workshopDetailLimiter.lastTokenRefillAt;
if (elapsedMs > 0) {
workshopDetailLimiter.burstTokens = Math.min(
WORKSHOP_DETAIL_BURST_CAPACITY,
workshopDetailLimiter.burstTokens + elapsedMs / 1000,
);
workshopDetailLimiter.lastTokenRefillAt = now;
}
const tokenCount = Math.floor(workshopDetailLimiter.burstTokens);
const batch = modIds
.filter((modId) => {
if (!visibleModIds.has(modId)) return false;
if ((failedUntilRef.current.get(modId) ?? 0) > now) return false;
return !queryClient.getQueryData<WorkshopModDetail>(workshopModQueryKey(modId));
})
.slice(0, Math.min(WORKSHOP_DETAIL_BATCH_SIZE, tokenCount));
if (batch.length === 0) {
const timer = window.setTimeout(
() => setCacheVersion((version) => version + 1),
WORKSHOP_DETAIL_BATCH_INTERVAL_MS,
);
return () => window.clearTimeout(timer);
}
const timer = window.setTimeout(() => {
workshopDetailLimiter.burstTokens = Math.max(
0,
workshopDetailLimiter.burstTokens - batch.length,
);
workshopDetailLimiter.inFlight = true;
setLoadingIds(new Set(batch.map((modId) => modId.toUpperCase())));
Promise.allSettled(
batch.map((modId) =>
queryClient.fetchQuery({
queryKey: workshopModQueryKey(modId),
queryFn: () => api.get<WorkshopModDetail>(`/api/workshop/mods/${modId}`),
staleTime: WORKSHOP_MOD_DETAIL_STALE_MS,
retry: false,
}),
),
)
.then((results) => {
for (let i = 0; i < results.length; i++) {
if (results[i]?.status === 'rejected') {
failedUntilRef.current.set(
batch[i]!,
Date.now() + WORKSHOP_DETAIL_FAILURE_COOLDOWN_MS,
);
}
}
})
.finally(() => {
workshopDetailLimiter.inFlight = false;
setLoadingIds(new Set());
setCacheVersion((version) => version + 1);
});
}, WORKSHOP_DETAIL_BATCH_INTERVAL_MS);
return () => window.clearTimeout(timer);
}, [cacheVersion, modIds, modIdsKey, queryClient, visibleKey, visibleModIds]);
const detailByModId = useMemo(() => {
const details = new Map<string, WorkshopModDetail>();
for (const modId of modIds) {
const detail = queryClient.getQueryData<WorkshopModDetail>(workshopModQueryKey(modId));
if (detail) details.set(modId.toUpperCase(), detail);
}
return details;
}, [cacheVersion, modIds, queryClient]);
return { detailByModId, loadingDetailIds: loadingIds };
}
// ── Installed Mods Panel ──────────────────────────────────────────────────────
function InstalledModsPanel({
mods,
canManage,
canEditConfig,
dirty,
isSaving,
saveStatus,
isUpgradingAll,
isResettingMission,
missingVersionIds,
selectedModId,
fetchedAt,
isLoading,
loadError,
message,
resetMissionMessage,
checkResult,
isChecking,
installedIds,
currentScenarioId,
onSelectMod,
onRemoveMod,
onUpdateModVersion,
onSave,
onDiscard,
onPatchVersions,
onUpgradeAll,
onCheckDeps,
onAddMods,
onResetMission,
}: {
mods: ReforgerConfigMod[];
canManage: boolean;
canEditConfig: boolean;
dirty: boolean;
isSaving: boolean;
saveStatus: SaveStatus;
isUpgradingAll: boolean;
isResettingMission: boolean;
missingVersionIds: Set<string>;
selectedModId: string | null;
fetchedAt?: string;
isLoading: boolean;
loadError: string | null;
message: string | null;
resetMissionMessage: string | null;
checkResult: ModsCheckResponse | null;
isChecking: boolean;
installedIds: Set<string>;
currentScenarioId: string | null;
onSelectMod: (id: string | null) => void;
onRemoveMod: (id: string) => void;
onUpdateModVersion: (id: string, version: string) => void;
onSave: () => void;
onDiscard: () => void;
onPatchVersions: () => void;
onUpgradeAll: () => void;
onCheckDeps: () => void;
onAddMods: (mods: ReforgerConfigMod[]) => void;
onResetMission: () => void;
}) {
const missingDepsByModId = new Map<string, ModDependencyIssue>();
if (checkResult) {
for (const issue of checkResult.modsWithMissingDeps) {
missingDepsByModId.set(issue.modId.toUpperCase(), issue);
}
}
const allMissingDeps = checkResult
? checkResult.modsWithMissingDeps.flatMap((issue) => issue.missing)
: [];
const uniqueMissingDeps = [
...new Map(allMissingDeps.filter((d) => d.id).map((d) => [d.id, d])).values(),
];
const [visibleModIds, setVisibleModIds] = useState<Set<string>>(() => new Set());
const installedModIds = useMemo(() => mods.map((mod) => mod.modId), [mods]);
const { detailByModId, loadingDetailIds } = useQueuedWorkshopModDetails(
installedModIds,
visibleModIds,
);
const handleInstalledModVisibility = useCallback((modId: string, visible: boolean) => {
setVisibleModIds((current) => {
const next = new Set(current);
if (visible) {
next.add(modId);
} else {
next.delete(modId);
}
return next;
});
}, []);
const selectedInstalledModId = mods.some((mod) => mod.modId === selectedModId)
? selectedModId
: null;
return (
<Card
title={`Installed Mods${mods.length > 0 ? ` (${mods.length})` : ''}`}
action={
<div className="flex flex-wrap items-center justify-end gap-2">
{canManage && mods.length > 0 && (
<Button
variant="accent"
onClick={onUpgradeAll}
disabled={isSaving || isUpgradingAll}
title="Fetch the latest Workshop version for every installed mod"
>
{isUpgradingAll ? 'Upgrading…' : 'Upgrade all'}
</Button>
)}
{saveStatus === 'pending' && (
<span className="text-xs text-warn-400">autosave pending</span>
)}
{saveStatus === 'saving' && <span className="text-xs text-slate-dim">saving</span>}
{saveStatus === 'saved' && !dirty && (
<span className="text-xs text-accent-400">autosaved</span>
)}
{saveStatus === 'error' && (
<span className="text-xs text-danger-400">autosave failed</span>
)}
{!dirty && saveStatus !== 'saved' && fetchedAt && (
<span className="text-xs text-slate-dim">fetched {formatRelativeTime(fetchedAt)}</span>
)}
{dirty && (
<>
<Button onClick={onDiscard} disabled={isSaving}>
Discard
</Button>
<Button variant="accent" onClick={onSave} disabled={isSaving}>
{isSaving ? 'Saving…' : 'Save now'}
</Button>
</>
)}
</div>
}
>
{isLoading ? (
<Spinner label="Downloading config.json…" />
) : loadError ? (
<p className="text-sm text-danger-400">{loadError}</p>
) : mods.length === 0 ? (
<EmptyState
title="No mods installed"
hint={canManage ? 'Add mods from the Workshop.' : 'The server runs vanilla.'}
/>
) : (
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{mods.map((mod) => {
const modIdKey = mod.modId.toUpperCase();
return (
<InstalledModCard
key={mod.modId}
mod={mod}
detail={detailByModId.get(modIdKey) ?? null}
isLoadingDetail={loadingDetailIds.has(modIdKey)}
depIssue={missingDepsByModId.get(modIdKey) ?? null}
noVersion={missingVersionIds.has(mod.modId)}
selected={selectedModId === mod.modId}
canManage={canManage}
onView={() => onSelectMod(selectedModId === mod.modId ? null : mod.modId)}
onRemove={() => onRemoveMod(mod.modId)}
onUpdateVersion={(version) => onUpdateModVersion(mod.modId, version)}
onVisibilityChange={handleInstalledModVisibility}
/>
);
})}
</div>
</div>
)}
{selectedInstalledModId && (
<ModDetailModal
modId={selectedInstalledModId}
canManage={canManage}
installedIds={installedIds}
currentScenarioId={currentScenarioId}
onClose={() => onSelectMod(null)}
onAdd={(mod) => onAddMods([mod])}
onAddAllDeps={(deps) => void addAllDeps(deps, onAddMods)}
/>
)}
{/* Bulk action toolbar */}
{mods.length > 0 && (
<div className="mt-3 flex flex-wrap items-center gap-2 border-t border-graphite-800 pt-3">
{canManage && missingVersionIds.size > 0 && (
<Button onClick={onPatchVersions} disabled={isSaving} variant="accent">
{isSaving
? 'Patching…'
: `Patch ${missingVersionIds.size} missing version${missingVersionIds.size !== 1 ? 's' : ''}`}
</Button>
)}
<Button onClick={onCheckDeps} disabled={isChecking || dirty}>
{isChecking ? 'Checking…' : 'Check Dependencies'}
</Button>
{checkResult && !isChecking && (
<span className="text-xs text-slate-dim">
checked {formatRelativeTime(checkResult.checkedAt)}
</span>
)}
</div>
)}
{/* Orphaned mission alert from dependency check */}
{checkResult && !isChecking && checkResult.orphanedMission && (
<div className="mt-3 rounded-md border border-danger-400/40 bg-danger-400/5 p-3">
<p className="mb-1 text-sm font-semibold text-danger-400">Mission will be unavailable</p>
<p className="mb-2 text-xs text-zinc-400">
The configured mission{' '}
<span className="font-mono text-zinc-300">
{checkResult.orphanedMission.name ??
checkResult.orphanedMission.scenarioId.split('/').pop()}
</span>{' '}
is not provided by any installed mod or official content. The server will fail to start.
</p>
{canEditConfig ? (
<Button variant="danger" onClick={onResetMission} disabled={isResettingMission}>
{isResettingMission ? 'Saving…' : 'Reset to Campaign - Montignac (default)'}
</Button>
) : (
<p className="text-xs text-zinc-400">Switch the mission on the Configuration page.</p>
)}
{resetMissionMessage && (
<p className="mt-2 text-xs text-accent-400">{resetMissionMessage}</p>
)}
</div>
)}
{/* Dependency check results summary */}
{checkResult && !isChecking && uniqueMissingDeps.length > 0 && (
<div className="mt-3 rounded-md border border-danger-400/30 bg-danger-400/5 p-3">
<p className="mb-2 text-sm font-semibold text-danger-400">
{checkResult.modsWithMissingDeps.length} mod
{checkResult.modsWithMissingDeps.length !== 1 ? 's' : ''} have missing dependencies (
{uniqueMissingDeps.length} unique)
</p>
{canManage && (
<Button variant="accent" onClick={() => void addAllDeps(uniqueMissingDeps, onAddMods)}>
Add all {uniqueMissingDeps.length} missing
</Button>
)}
</div>
)}
{checkResult &&
!isChecking &&
checkResult.modsWithMissingDeps.length === 0 &&
!checkResult.orphanedMission && (
<p className="mt-3 text-xs text-accent-400">All dependencies are installed.</p>
)}
{message && <p className="mt-3 text-xs text-accent-400">{message}</p>}
<p className="mt-3 text-xs text-slate-dim">
Changes are written to config.json (backup kept) and take effect on restart.
</p>
</Card>
);
}
function InstalledModCard({
mod,
detail,
isLoadingDetail,
depIssue,
noVersion,
selected,
canManage,
onView,
onRemove,
onUpdateVersion,
onVisibilityChange,
}: {
mod: ReforgerConfigMod;
detail: WorkshopModDetail | null;
isLoadingDetail: boolean;
depIssue: ModDependencyIssue | null;
noVersion: boolean;
selected: boolean;
canManage: boolean;
onView: () => void;
onRemove: () => void;
onUpdateVersion: (version: string) => void;
onVisibilityChange: (modId: string, visible: boolean) => void;
}) {
const cardRef = useRef<HTMLElement | null>(null);
const title = detail?.name ?? mod.name ?? mod.modId;
const configuredVersion = mod.version ?? '';
const latestVersion = detail?.version ?? null;
const updateAvailable =
latestVersion !== null && configuredVersion !== '' && latestVersion !== configuredVersion;
const description = detail?.summary ?? detail?.description ?? null;
const tags = detail?.tags ?? [];
useEffect(() => {
const node = cardRef.current;
if (!node) return;
if (!('IntersectionObserver' in window)) {
onVisibilityChange(mod.modId, true);
return () => onVisibilityChange(mod.modId, false);
}
const observer = new IntersectionObserver(
([entry]) => onVisibilityChange(mod.modId, entry?.isIntersecting ?? false),
{ rootMargin: WORKSHOP_DETAIL_PREFETCH_MARGIN },
);
observer.observe(node);
return () => {
observer.disconnect();
onVisibilityChange(mod.modId, false);
};
}, [mod.modId, onVisibilityChange]);
return (
<article
ref={cardRef}
className={`flex min-h-[390px] flex-col overflow-hidden rounded-md border bg-graphite-900 shadow-sm shadow-black/20 transition-colors ${
selected ? 'border-accent-600/70' : 'border-graphite-700/70 hover:border-graphite-600'
}`}
>
<button type="button" onClick={onView} className="relative aspect-[16/9] text-left">
<ModImage src={detail?.imageUrl ?? null} className="h-full w-full rounded-none border-0" />
<span className="absolute right-2 top-2 rounded border border-emerald-400/40 bg-emerald-500/20 px-2 py-1 text-[10px] font-semibold uppercase text-emerald-300">
Installed
</span>
{isLoadingDetail && (
<span className="absolute bottom-2 left-2 rounded border border-graphite-600 bg-graphite-950/80 px-2 py-1 text-[10px] font-semibold uppercase text-slate-ink">
Loading
</span>
)}
</button>
<div className="flex flex-1 flex-col gap-3 p-4">
<div className="min-w-0">
<h3 className="text-base font-semibold leading-6 text-zinc-100">{title}</h3>
<p className="mt-0.5 truncate font-mono text-xs text-slate-dim">{mod.modId}</p>
<p className="mt-1 text-xs text-slate-dim">
configured v{configuredVersion || '-'}
{latestVersion && latestVersion !== configuredVersion
? ` · latest v${latestVersion}`
: ''}
</p>
</div>
<p className="min-h-16 overflow-hidden text-sm leading-5 text-slate-ink">
{description ?? 'No description available.'}
</p>
<div className="flex flex-wrap gap-1.5">
{noVersion && (
<span
title="No version info - click Patch Versions"
className="rounded border border-warn-400/40 bg-warn-400/10 px-2 py-0.5 text-[11px] font-semibold text-warn-400"
>
no version
</span>
)}
{depIssue && (
<span
title={`Missing deps: ${depIssue.missing.map((d) => d.name).join(', ')}`}
className="rounded border border-danger-400/40 bg-danger-400/10 px-2 py-0.5 text-[11px] font-semibold text-danger-400"
>
{depIssue.missing.length} dep{depIssue.missing.length !== 1 ? 's' : ''} missing
</span>
)}
{tags.slice(0, 3).map((tag) => (
<span
key={tag}
className="rounded-full border border-graphite-700 bg-graphite-950/35 px-2 py-0.5 text-[11px] font-medium text-slate-ink"
>
{tag}
</span>
))}
</div>
<div className="mt-auto flex flex-wrap items-center gap-2 pt-1">
{canManage && (
<div className="grid w-full gap-1">
<label className="text-[10px] font-semibold uppercase tracking-wider text-slate-dim">
Version
</label>
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_auto]">
<input
value={configuredVersion}
onChange={(event) => onUpdateVersion(event.target.value)}
placeholder={latestVersion ?? 'manual version'}
maxLength={32}
className="input min-h-9 px-2.5 py-1.5 font-mono text-xs"
/>
<Button
variant={updateAvailable ? 'accent' : 'default'}
disabled={!latestVersion || configuredVersion === latestVersion}
onClick={() => latestVersion && onUpdateVersion(latestVersion)}
title={
latestVersion
? `Use latest version ${latestVersion}`
: 'Latest version not loaded'
}
>
Use latest
</Button>
</div>
</div>
)}
<Button onClick={onView}>{selected ? 'Hide details' : 'View'}</Button>
{canManage && (
<Button variant="danger" onClick={onRemove}>
Remove
</Button>
)}
</div>
</div>
</article>
);
}
// ── Workshop Browser ──────────────────────────────────────────────────────────
function WorkshopBrowser({
canManage,
installedIds,
onAdd,
selectedModId,
setSelectedModId,
onAddAllDeps,
currentScenarioId,
}: {
canManage: boolean;
installedIds: Set<string>;
onAdd: (mod: ReforgerConfigMod) => void;
selectedModId: string | null;
setSelectedModId: (id: string | null) => void;
onAddAllDeps: (deps: Array<{ id: string | null; name: string }>) => void;
currentScenarioId: string | null;
}) {
const [input, setInput] = useState('');
const [query, setQuery] = useState('');
const [activeTag, setActiveTag] = useState<string | null>(null);
const [sort, setSort] = useState<(typeof WORKSHOP_SORTS)[number]['value']>('popularity');
const [page, setPage] = useState(1);
const [addingId, setAddingId] = useState<string | null>(null);
const [visibleModIds, setVisibleModIds] = useState<Set<string>>(() => new Set());
const effectiveQuery = [query, activeTag].filter(Boolean).join(' ');
const { data, isFetching, error } = useWorkshopSearch(effectiveQuery, page, sort);
const browseModIds = useMemo(() => data?.mods.map((mod) => mod.id) ?? [], [data?.mods]);
const { detailByModId, loadingDetailIds } = useQueuedWorkshopModDetails(
browseModIds,
visibleModIds,
);
const handleBrowseModVisibility = useCallback((modId: string, visible: boolean) => {
setVisibleModIds((current) => {
const next = new Set(current);
if (visible) {
next.add(modId);
} else {
next.delete(modId);
}
return next;
});
}, []);
const addFromWorkshop = async (modId: string, fallbackName: string) => {
setAddingId(modId);
try {
const detail = await api.get<WorkshopModDetail>(`/api/workshop/mods/${modId}`);
onAdd({
modId: detail.id,
name: detail.name || fallbackName,
...(detail.version ? { version: detail.version } : {}),
});
} catch {
onAdd({ modId, name: fallbackName });
} finally {
setAddingId(null);
}
};
return (
<section className="space-y-4">
<div className="rounded-md border border-graphite-700/70 bg-graphite-900 p-4 shadow-sm shadow-black/20">
<form
className="mb-3 grid gap-2 sm:grid-cols-[minmax(0,1fr)_160px_auto]"
onSubmit={(event) => {
event.preventDefault();
setPage(1);
setSelectedModId(null);
setQuery(input.trim());
}}
>
<input
value={input}
onChange={(event) => setInput(event.target.value)}
placeholder="Search mods… (empty = front page)"
className="input min-w-0 flex-1"
/>
<select
value={sort}
onChange={(event) => {
setPage(1);
setSort(event.target.value as (typeof WORKSHOP_SORTS)[number]['value']);
}}
className="input"
>
{WORKSHOP_SORTS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<Button variant="accent" type="submit" disabled={isFetching}>
{isFetching ? 'Loading…' : 'Search'}
</Button>
</form>
<div className="mb-4 flex flex-wrap items-center gap-2">
<span className="text-xs uppercase tracking-wider text-slate-dim">Tags</span>
{COMMON_WORKSHOP_TAGS.map((tag) => (
<button
key={tag}
type="button"
onClick={() => {
setPage(1);
setSelectedModId(null);
setActiveTag(activeTag === tag ? null : tag);
}}
className={`rounded-full border px-2.5 py-1 text-xs font-medium transition-colors ${
activeTag === tag
? 'border-accent-500/50 bg-accent-500/15 text-accent-400'
: 'border-graphite-700 bg-graphite-950/20 text-slate-ink hover:border-graphite-600 hover:text-zinc-200'
}`}
>
{tag}
</button>
))}
{activeTag && (
<button
type="button"
onClick={() => {
setActiveTag(null);
setPage(1);
}}
className="text-xs text-slate-dim hover:text-zinc-200"
>
Clear
</button>
)}
</div>
</div>
{error && <p className="text-sm text-danger-400">{error.message}</p>}
{!data && !error && <Spinner label="Loading Workshop mods…" />}
{data && (
<>
<div className={`min-w-0 ${isFetching ? 'opacity-60' : ''} transition-opacity`}>
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<p className="text-xs text-slate-dim">
{effectiveQuery
? `${data.meta.totalMods.toLocaleString()} results for "${effectiveQuery}"`
: `${data.meta.totalMods.toLocaleString()} Workshop mods`}{' '}
· page {data.meta.currentPage} of {data.meta.totalPages}
</p>
<div className="flex items-center gap-2">
<Button disabled={page <= 1 || isFetching} onClick={() => setPage((p) => p - 1)}>
Previous
</Button>
<Button
disabled={page >= data.meta.totalPages || isFetching}
onClick={() => setPage((p) => p + 1)}
>
Next
</Button>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
{data.mods.map((mod) => {
const installed = installedIds.has(mod.id.toUpperCase());
return (
<WorkshopModCard
key={mod.id}
mod={mod}
detail={detailByModId.get(mod.id.toUpperCase()) ?? null}
installed={installed}
selected={selectedModId === mod.id}
canManage={canManage}
isLoadingDetail={loadingDetailIds.has(mod.id.toUpperCase())}
isAdding={addingId === mod.id}
onSelect={() => setSelectedModId(selectedModId === mod.id ? null : mod.id)}
onAdd={() => void addFromWorkshop(mod.id, mod.name)}
onTagSelect={(tag) => {
setActiveTag(tag);
setPage(1);
setSelectedModId(null);
}}
onVisibilityChange={handleBrowseModVisibility}
/>
);
})}
</div>
<div className="mt-3 flex items-center gap-2">
<Button disabled={page <= 1 || isFetching} onClick={() => setPage((p) => p - 1)}>
Previous
</Button>
<Button
disabled={page >= data.meta.totalPages || isFetching}
onClick={() => setPage((p) => p + 1)}
>
Next
</Button>
</div>
</div>
{selectedModId && (
<ModDetailModal
modId={selectedModId}
canManage={canManage}
installedIds={installedIds}
currentScenarioId={currentScenarioId}
onClose={() => setSelectedModId(null)}
onAdd={onAdd}
onAddAllDeps={onAddAllDeps}
onTagSelect={(tag) => {
setActiveTag(tag);
setPage(1);
setSelectedModId(null);
}}
/>
)}
</>
)}
</section>
);
}
function WorkshopModCard({
mod,
detail,
installed,
selected,
canManage,
isLoadingDetail,
isAdding,
onSelect,
onAdd,
onTagSelect,
onVisibilityChange,
}: {
mod: WorkshopModPreview;
detail: WorkshopModDetail | null;
installed: boolean;
selected: boolean;
canManage: boolean;
isLoadingDetail: boolean;
isAdding: boolean;
onSelect: () => void;
onAdd: () => void;
onTagSelect: (tag: string) => void;
onVisibilityChange: (modId: string, visible: boolean) => void;
}) {
const cardRef = useRef<HTMLElement | null>(null);
const title = detail?.name ?? mod.name;
const author = detail?.author ?? mod.author;
const version = detail?.version ?? mod.version;
const size = detail?.size ?? mod.size;
const description = detail?.summary ?? detail?.description ?? mod.summary;
const tags = detail?.tags.length ? detail.tags : mod.tags;
const imageUrl = detail?.imageUrl ?? mod.imageUrl;
useEffect(() => {
const node = cardRef.current;
if (!node) return;
if (!('IntersectionObserver' in window)) {
onVisibilityChange(mod.id, true);
return () => onVisibilityChange(mod.id, false);
}
const observer = new IntersectionObserver(
([entry]) => onVisibilityChange(mod.id, entry?.isIntersecting ?? false),
{ rootMargin: WORKSHOP_DETAIL_PREFETCH_MARGIN },
);
observer.observe(node);
return () => {
observer.disconnect();
onVisibilityChange(mod.id, false);
};
}, [mod.id, onVisibilityChange]);
return (
<article
ref={cardRef}
className={`flex min-h-[390px] flex-col overflow-hidden rounded-md border bg-graphite-900 shadow-sm shadow-black/20 transition-colors ${
selected ? 'border-accent-600/70' : 'border-graphite-700/70 hover:border-graphite-600'
}`}
>
<button type="button" onClick={onSelect} className="relative aspect-[16/9] text-left">
<ModImage src={imageUrl} className="h-full w-full rounded-none border-0" />
{installed && (
<span className="absolute right-2 top-2 rounded border border-emerald-400/40 bg-emerald-500/20 px-2 py-1 text-[10px] font-semibold uppercase text-emerald-300">
Installed
</span>
)}
{isLoadingDetail && (
<span className="absolute bottom-2 left-2 rounded border border-graphite-600 bg-graphite-950/80 px-2 py-1 text-[10px] font-semibold uppercase text-slate-ink">
Loading
</span>
)}
</button>
<div className="flex flex-1 flex-col gap-3 p-4">
<div className="min-w-0">
<h3 className="text-base font-semibold leading-6 text-zinc-100">{title}</h3>
<p className="mt-0.5 text-xs text-slate-dim">
by {author} · v{version ?? '-'} · {size ?? '-'}
</p>
</div>
<p className="min-h-16 overflow-hidden text-sm leading-5 text-slate-ink">
{description ?? 'No description available.'}
</p>
{tags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{tags.slice(0, 4).map((tag) => (
<button
key={tag}
type="button"
onClick={() => onTagSelect(tag)}
className="rounded-full border border-graphite-700 bg-graphite-950/35 px-2 py-0.5 text-[11px] font-medium text-slate-ink hover:border-accent-500/50 hover:text-accent-400"
>
{tag}
</button>
))}
</div>
)}
<div className="mt-auto flex flex-wrap items-center gap-2 pt-1">
<Button onClick={onSelect}>{selected ? 'Hide details' : 'Details'}</Button>
{canManage && !installed && (
<Button variant="accent" disabled={isAdding} onClick={onAdd}>
{isAdding ? 'Adding...' : 'Add'}
</Button>
)}
</div>
</div>
</article>
);
}
// ── Mod Detail Modal ──────────────────────────────────────────────────────────
function ModDetailModal({
modId,
canManage,
installedIds,
currentScenarioId,
onClose,
onAdd,
onAddAllDeps,
onTagSelect,
}: {
modId: string | null;
canManage: boolean;
installedIds: Set<string>;
currentScenarioId: string | null;
onClose: () => void;
onAdd: (mod: ReforgerConfigMod) => void;
onAddAllDeps: (deps: Array<{ id: string | null; name: string }>) => void;
onTagSelect?: (tag: string) => void;
}) {
const { data: mod, isLoading, error } = useWorkshopMod(modId);
const [addingDepId, setAddingDepId] = useState<string | null>(null);
const [addingAllDeps, setAddingAllDeps] = useState(false);
const [copiedScenarioId, setCopiedScenarioId] = useState<string | null>(null);
const [copyFailedScenarioId, setCopyFailedScenarioId] = useState<string | null>(null);
const addDep = async (depId: string, depName: string) => {
setAddingDepId(depId);
try {
const detail = await api.get<WorkshopModDetail>(`/api/workshop/mods/${depId}`);
onAdd({
modId: detail.id,
name: detail.name || depName,
...(detail.version ? { version: detail.version } : {}),
});
} catch {
onAdd({ modId: depId, name: depName });
} finally {
setAddingDepId(null);
}
};
const copyScenarioId = async (scenarioId: string, input: HTMLInputElement | null) => {
const value = scenarioId.trim();
if (!value) return;
const markCopied = () => {
setCopyFailedScenarioId(null);
setCopiedScenarioId(value);
window.setTimeout(() => setCopiedScenarioId(null), 1500);
};
if (navigator.clipboard) {
try {
await navigator.clipboard.writeText(value);
markCopied();
return;
} catch {
// Fall back to selecting the readonly input for browsers that block Clipboard API in modals.
}
}
let copied = false;
if (input) {
input.focus();
input.select();
input.setSelectionRange(0, value.length);
copied = document.execCommand('copy');
}
if (copied) {
markCopied();
return;
}
setCopiedScenarioId(null);
setCopyFailedScenarioId(value);
window.setTimeout(() => setCopyFailedScenarioId(null), 1800);
};
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [onClose]);
if (!modId) return null;
const installed = mod ? installedIds.has(mod.id.toUpperCase()) : false;
const missingDeps = (mod?.dependencies ?? []).filter(
(dep) => dep.id && !installedIds.has(dep.id.toUpperCase()),
);
const providesCurrentMission =
mod !== undefined &&
currentScenarioId !== null &&
mod.scenarios.some((s) => s.scenarioId === currentScenarioId);
const missionWillBeOrphaned = providesCurrentMission && !installed;
const matchingScenario = mod?.scenarios.find((s) => s.scenarioId === currentScenarioId);
return (
<div className="fixed inset-0 z-50 flex items-end justify-center bg-black/70 p-0 sm:items-center sm:p-4">
<button
type="button"
aria-label="Close mod details"
className="absolute inset-0 cursor-default"
onClick={onClose}
/>
<section className="relative flex max-h-[94vh] w-full max-w-5xl flex-col overflow-hidden rounded-t-lg border border-graphite-700 bg-graphite-900 shadow-2xl shadow-black/50 sm:rounded-lg">
<div className="flex items-center justify-between gap-3 border-b border-graphite-700 px-4 py-3">
<h2 className="truncate text-sm font-semibold uppercase tracking-[0.14em] text-slate-ink">
Mod Details
</h2>
<Button onClick={onClose}>Close</Button>
</div>
<div className="overflow-y-auto">
{error ? (
<div className="p-6">
<p className="text-sm font-semibold text-danger-400">Could not load mod details</p>
<p className="mt-1 text-sm text-slate-ink">{error.message}</p>
</div>
) : isLoading || !mod ? (
<Spinner label="Loading mod details..." />
) : (
<div className="grid gap-5 p-4 lg:grid-cols-[minmax(0,360px)_minmax(0,1fr)] lg:p-5">
<div className="space-y-3">
<ModImage src={mod.imageUrl} className="aspect-[16/9] h-auto w-full" />
<div className="grid grid-cols-2 gap-2 text-xs">
<DetailMetric label="Version" value={mod.version ?? '-'} />
<DetailMetric label="Rating" value={mod.rating ?? '-'} />
<DetailMetric label="Downloads" value={mod.downloads?.toLocaleString() ?? '-'} />
<DetailMetric label="Size" value={mod.size ?? '-'} />
<DetailMetric label="Game" value={mod.gameVersion ?? '-'} />
<DetailMetric
label="Subscribers"
value={mod.subscribers?.toLocaleString() ?? '-'}
/>
</div>
</div>
<div className="min-w-0 space-y-4">
{missionWillBeOrphaned && (
<div className="rounded-md border border-danger-400/40 bg-danger-400/8 px-3 py-2.5">
<p className="text-sm font-semibold text-danger-400">
Active mission is from this mod
</p>
<p className="mt-0.5 text-xs text-zinc-400">
The server is configured to run{' '}
<span className="font-mono text-zinc-300">
{matchingScenario?.name ?? currentScenarioId?.split('/').pop()}
</span>
. Without this mod installed the server will fail to start.
</p>
</div>
)}
<div>
<div className="mb-2 flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="text-2xl font-semibold leading-tight text-zinc-100">
{mod.name}
</h3>
<p className="mt-1 text-sm text-slate-ink">by {mod.author}</p>
<p className="mt-1 break-all font-mono text-xs text-slate-dim">{mod.id}</p>
</div>
{installed && (
<span className="shrink-0 rounded border border-emerald-400/40 bg-emerald-500/15 px-2 py-1 text-[11px] font-semibold uppercase text-emerald-300">
Installed
</span>
)}
</div>
<p className="text-sm leading-6 text-zinc-300">
{mod.description ?? mod.summary ?? 'No description available.'}
</p>
</div>
{mod.tags.length > 0 && (
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-zinc-400">
Tags
</p>
<div className="flex flex-wrap gap-1.5">
{mod.tags.map((tag) =>
onTagSelect ? (
<button
key={tag}
type="button"
onClick={() => onTagSelect(tag)}
className="rounded-full border border-graphite-700 bg-graphite-950/35 px-2 py-0.5 text-xs text-slate-ink hover:border-accent-500/50 hover:text-accent-400"
>
{tag}
</button>
) : (
<span
key={tag}
className="rounded-full border border-graphite-700 bg-graphite-950/35 px-2 py-0.5 text-xs text-slate-ink"
>
{tag}
</span>
),
)}
</div>
</div>
)}
{mod.scenarios.length > 0 && (
<div>
<p className="mb-2 text-xs font-semibold uppercase tracking-wider text-zinc-400">
Scenarios ({mod.scenarios.length})
</p>
<div className="space-y-2">
{mod.scenarios.map((scenario, index) => {
return (
<ScenarioDetailRow
key={`${scenario.name}-${scenario.scenarioId}-${index}`}
scenario={scenario}
copied={copiedScenarioId === scenario.scenarioId}
copyFailed={copyFailedScenarioId === scenario.scenarioId}
onCopy={copyScenarioId}
/>
);
})}
</div>
</div>
)}
{mod.dependencies.length > 0 && (
<div>
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<p className="text-xs font-semibold uppercase tracking-wider text-zinc-400">
Dependencies ({mod.dependencies.length})
</p>
{canManage && missingDeps.length > 0 && (
<Button
variant="accent"
disabled={addingAllDeps}
onClick={() => {
setAddingAllDeps(true);
onAddAllDeps(missingDeps);
setTimeout(() => setAddingAllDeps(false), 1000);
}}
>
{addingAllDeps ? 'Adding...' : `Add all missing (${missingDeps.length})`}
</Button>
)}
</div>
<ul className="space-y-1">
{mod.dependencies.map((dep) => {
const depInstalled = dep.id
? installedIds.has(dep.id.toUpperCase())
: false;
return (
<li
key={dep.id ?? dep.name}
className="flex items-center justify-between gap-2 rounded border border-graphite-800 bg-graphite-950/25 px-2.5 py-1.5"
>
<span className="flex min-w-0 items-center gap-1.5">
<span className="truncate text-sm text-zinc-300">{dep.name}</span>
{depInstalled ? (
<span className="shrink-0 text-xs text-accent-400">Installed</span>
) : (
<span className="shrink-0 text-xs text-warn-400">Missing</span>
)}
</span>
{canManage && dep.id && !depInstalled && (
<Button
variant="accent"
disabled={addingDepId === dep.id}
onClick={() => dep.id && void addDep(dep.id, dep.name)}
>
{addingDepId === dep.id ? 'Adding...' : 'Add'}
</Button>
)}
</li>
);
})}
</ul>
</div>
)}
<div className="flex flex-wrap items-center gap-2 border-t border-graphite-800 pt-4">
{canManage && !installed && (
<Button
variant="accent"
onClick={() =>
onAdd({
modId: mod.id,
name: mod.name,
...(mod.version ? { version: mod.version } : {}),
})
}
>
Add to server
</Button>
)}
{mod.workshopUrl && (
<a
href={mod.workshopUrl}
target="_blank"
rel="noreferrer"
className="text-xs text-accent-400 hover:underline"
>
Open in Workshop
</a>
)}
</div>
</div>
</div>
)}
</div>
</section>
</div>
);
}
function ScenarioDetailRow({
scenario,
copied,
copyFailed,
onCopy,
}: {
scenario: WorkshopModDetail['scenarios'][number];
copied: boolean;
copyFailed: boolean;
onCopy: (scenarioId: string, input: HTMLInputElement | null) => void;
}) {
const inputRef = useRef<HTMLInputElement | null>(null);
return (
<div className="rounded border border-graphite-800 bg-graphite-950/25 p-3">
<div className="space-y-2">
<p className="text-sm font-medium text-zinc-200">{scenario.name}</p>
<div className="grid gap-2">
<label className="text-[10px] font-semibold uppercase tracking-wider text-slate-dim">
Scenario ID
</label>
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_auto]">
<input
ref={inputRef}
readOnly
value={scenario.scenarioId}
placeholder="No scenario ID found"
className="input min-h-9 w-full font-mono text-xs"
onFocus={(event) => event.currentTarget.select()}
/>
<Button
disabled={!scenario.scenarioId}
onClick={() => onCopy(scenario.scenarioId, inputRef.current)}
title="Copy scenario ID"
>
{copyFailed ? 'Copy failed' : copied ? 'Copied' : 'Copy ID'}
</Button>
</div>
</div>
</div>
<p className="mt-2 text-xs text-slate-ink">
{scenario.gamemode ?? 'Scenario'} · players {scenario.playerCount ?? '-'}
</p>
{scenario.description && (
<p className="mt-2 text-xs leading-5 text-slate-ink">{scenario.description}</p>
)}
</div>
);
}
function DetailMetric({ label, value }: { label: string; value: string }) {
return (
<div className="rounded border border-graphite-800 bg-graphite-950/25 px-3 py-2">
<p className="text-[10px] font-semibold uppercase tracking-wider text-slate-dim">{label}</p>
<p className="mt-1 truncate text-sm text-zinc-200" title={value}>
{value}
</p>
</div>
);
}