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 ; return ; } 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(null); const [message, setMessage] = useState(null); const [resetMissionMessage, setResetMissionMessage] = useState(null); const [selectedModId, setSelectedModId] = useState(null); const [activeTab, setActiveTab] = useState('installed'); const [checkEnabled, setCheckEnabled] = useState(false); const [saveStatus, setSaveStatus] = useState('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 (

Mods

Manage the server mod list and browse the Reforger Workshop. Changes apply on the next server restart.

{(['installed', 'browse'] as ModsTab[]).map((tab) => ( ))}
{activeTab === 'installed' ? (
{ setDraft(null); setMessage(null); setSaveStatus('idle'); }} onPatchVersions={patchVersions} onUpgradeAll={upgradeAllVersions} onCheckDeps={runCheck} onAddMods={addMods} onResetMission={resetMission} />
) : ( void addAllDeps(deps, addMods)} currentScenarioId={currentScenarioId} /> )}
); } 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 => { try { const detail = await api.get(`/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 => r.status === 'fulfilled') .map((r) => r.value); addMods(mods); } async function fetchWorkshopDetailsForMods( mods: ReforgerConfigMod[], ): Promise> { const details = new Map(); 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(`/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) { const queryClient = useQueryClient(); const failedUntilRef = useRef(new Map()); const [loadingIds, setLoadingIds] = useState>(() => 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(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(`/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(); for (const modId of modIds) { const detail = queryClient.getQueryData(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; selectedModId: string | null; fetchedAt?: string; isLoading: boolean; loadError: string | null; message: string | null; resetMissionMessage: string | null; checkResult: ModsCheckResponse | null; isChecking: boolean; installedIds: Set; 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(); 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>(() => 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 ( 0 ? ` (${mods.length})` : ''}`} action={
{canManage && mods.length > 0 && ( )} {saveStatus === 'pending' && ( autosave pending )} {saveStatus === 'saving' && saving…} {saveStatus === 'saved' && !dirty && ( autosaved )} {saveStatus === 'error' && ( autosave failed )} {!dirty && saveStatus !== 'saved' && fetchedAt && ( fetched {formatRelativeTime(fetchedAt)} )} {dirty && ( <> )}
} > {isLoading ? ( ) : loadError ? (

{loadError}

) : mods.length === 0 ? ( ) : (
{mods.map((mod) => { const modIdKey = mod.modId.toUpperCase(); return ( onSelectMod(selectedModId === mod.modId ? null : mod.modId)} onRemove={() => onRemoveMod(mod.modId)} onUpdateVersion={(version) => onUpdateModVersion(mod.modId, version)} onVisibilityChange={handleInstalledModVisibility} /> ); })}
)} {selectedInstalledModId && ( onSelectMod(null)} onAdd={(mod) => onAddMods([mod])} onAddAllDeps={(deps) => void addAllDeps(deps, onAddMods)} /> )} {/* Bulk action toolbar */} {mods.length > 0 && (
{canManage && missingVersionIds.size > 0 && ( )} {checkResult && !isChecking && ( checked {formatRelativeTime(checkResult.checkedAt)} )}
)} {/* Orphaned mission alert from dependency check */} {checkResult && !isChecking && checkResult.orphanedMission && (

Mission will be unavailable

The configured mission{' '} {checkResult.orphanedMission.name ?? checkResult.orphanedMission.scenarioId.split('/').pop()} {' '} is not provided by any installed mod or official content. The server will fail to start.

{canEditConfig ? ( ) : (

Switch the mission on the Configuration page.

)} {resetMissionMessage && (

{resetMissionMessage}

)}
)} {/* Dependency check results summary */} {checkResult && !isChecking && uniqueMissingDeps.length > 0 && (

{checkResult.modsWithMissingDeps.length} mod {checkResult.modsWithMissingDeps.length !== 1 ? 's' : ''} have missing dependencies ( {uniqueMissingDeps.length} unique)

{canManage && ( )}
)} {checkResult && !isChecking && checkResult.modsWithMissingDeps.length === 0 && !checkResult.orphanedMission && (

All dependencies are installed.

)} {message &&

{message}

}

Changes are written to config.json (backup kept) and take effect on restart.

); } 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(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 (

{title}

{mod.modId}

configured v{configuredVersion || '-'} {latestVersion && latestVersion !== configuredVersion ? ` · latest v${latestVersion}` : ''}

{description ?? 'No description available.'}

{noVersion && ( no version )} {depIssue && ( 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 )} {tags.slice(0, 3).map((tag) => ( {tag} ))}
{canManage && (
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" />
)} {canManage && ( )}
); } // ── Workshop Browser ────────────────────────────────────────────────────────── function WorkshopBrowser({ canManage, installedIds, onAdd, selectedModId, setSelectedModId, onAddAllDeps, currentScenarioId, }: { canManage: boolean; installedIds: Set; 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(null); const [sort, setSort] = useState<(typeof WORKSHOP_SORTS)[number]['value']>('popularity'); const [page, setPage] = useState(1); const [addingId, setAddingId] = useState(null); const [visibleModIds, setVisibleModIds] = useState>(() => 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(`/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 (
{ event.preventDefault(); setPage(1); setSelectedModId(null); setQuery(input.trim()); }} > setInput(event.target.value)} placeholder="Search mods… (empty = front page)" className="input min-w-0 flex-1" />
Tags {COMMON_WORKSHOP_TAGS.map((tag) => ( ))} {activeTag && ( )}
{error &&

{error.message}

} {!data && !error && } {data && ( <>

{effectiveQuery ? `${data.meta.totalMods.toLocaleString()} results for "${effectiveQuery}"` : `${data.meta.totalMods.toLocaleString()} Workshop mods`}{' '} · page {data.meta.currentPage} of {data.meta.totalPages}

{data.mods.map((mod) => { const installed = installedIds.has(mod.id.toUpperCase()); return ( setSelectedModId(selectedModId === mod.id ? null : mod.id)} onAdd={() => void addFromWorkshop(mod.id, mod.name)} onTagSelect={(tag) => { setActiveTag(tag); setPage(1); setSelectedModId(null); }} onVisibilityChange={handleBrowseModVisibility} /> ); })}
{selectedModId && ( setSelectedModId(null)} onAdd={onAdd} onAddAllDeps={onAddAllDeps} onTagSelect={(tag) => { setActiveTag(tag); setPage(1); setSelectedModId(null); }} /> )} )}
); } 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(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 (

{title}

by {author} · v{version ?? '-'} · {size ?? '-'}

{description ?? 'No description available.'}

{tags.length > 0 && (
{tags.slice(0, 4).map((tag) => ( ))}
)}
{canManage && !installed && ( )}
); } // ── Mod Detail Modal ────────────────────────────────────────────────────────── function ModDetailModal({ modId, canManage, installedIds, currentScenarioId, onClose, onAdd, onAddAllDeps, onTagSelect, }: { modId: string | null; canManage: boolean; installedIds: Set; 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(null); const [addingAllDeps, setAddingAllDeps] = useState(false); const [copiedScenarioId, setCopiedScenarioId] = useState(null); const [copyFailedScenarioId, setCopyFailedScenarioId] = useState(null); const addDep = async (depId: string, depName: string) => { setAddingDepId(depId); try { const detail = await api.get(`/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 (
{error ? (

Could not load mod details

{error.message}

) : isLoading || !mod ? ( ) : (
{missionWillBeOrphaned && (

Active mission is from this mod

The server is configured to run{' '} {matchingScenario?.name ?? currentScenarioId?.split('/').pop()} . Without this mod installed the server will fail to start.

)}

{mod.name}

by {mod.author}

{mod.id}

{installed && ( Installed )}

{mod.description ?? mod.summary ?? 'No description available.'}

{mod.tags.length > 0 && (

Tags

{mod.tags.map((tag) => onTagSelect ? ( ) : ( {tag} ), )}
)} {mod.scenarios.length > 0 && (

Scenarios ({mod.scenarios.length})

{mod.scenarios.map((scenario, index) => { return ( ); })}
)} {mod.dependencies.length > 0 && (

Dependencies ({mod.dependencies.length})

{canManage && missingDeps.length > 0 && ( )}
    {mod.dependencies.map((dep) => { const depInstalled = dep.id ? installedIds.has(dep.id.toUpperCase()) : false; return (
  • {dep.name} {depInstalled ? ( Installed ) : ( Missing )} {canManage && dep.id && !depInstalled && ( )}
  • ); })}
)}
{canManage && !installed && ( )} {mod.workshopUrl && ( Open in Workshop )}
)}
); } function ScenarioDetailRow({ scenario, copied, copyFailed, onCopy, }: { scenario: WorkshopModDetail['scenarios'][number]; copied: boolean; copyFailed: boolean; onCopy: (scenarioId: string, input: HTMLInputElement | null) => void; }) { const inputRef = useRef(null); return (

{scenario.name}

event.currentTarget.select()} />

{scenario.gamemode ?? 'Scenario'} · players {scenario.playerCount ?? '-'}

{scenario.description && (

{scenario.description}

)}
); } function DetailMetric({ label, value }: { label: string; value: string }) { return (

{label}

{value}

); }