upgrade all mods + autosave

This commit is contained in:
SowinskiBraeden committed 2026-07-10 22:43:07 -07:00
1 parent ce3008451d
commit 17135c2efd
2 files changed
+158 -23

No files matched your search

+2 -1
View File
@@ -285,7 +285,8 @@ export function useSetServerMods(slug: string) {
return useMutation({ return useMutation({
mutationFn: (mods: ReforgerConfigMod[]) => mutationFn: (mods: ReforgerConfigMod[]) =>
api.put<UpdateModsResult>(`/api/servers/${slug}/mods`, { mods }), api.put<UpdateModsResult>(`/api/servers/${slug}/mods`, { mods }),
onSuccess: () => { onSuccess: (result) => {
queryClient.setQueryData(['servers', slug, 'mods'], result);
void queryClient.invalidateQueries({ queryKey: ['servers', slug] }); void queryClient.invalidateQueries({ queryKey: ['servers', slug] });
}, },
}); });
+156 -22
View File
@@ -5,6 +5,7 @@ import type {
ModDependencyIssue, ModDependencyIssue,
ModsCheckResponse, ModsCheckResponse,
ReforgerConfigMod, ReforgerConfigMod,
UpdateModsResult,
WorkshopModDetail, WorkshopModDetail,
WorkshopModPreview, WorkshopModPreview,
} from '@reforger-panel/shared'; } from '@reforger-panel/shared';
@@ -48,6 +49,8 @@ const WORKSHOP_DETAIL_BATCH_SIZE = 4;
const WORKSHOP_DETAIL_BURST_CAPACITY = 20; const WORKSHOP_DETAIL_BURST_CAPACITY = 20;
const WORKSHOP_DETAIL_FAILURE_COOLDOWN_MS = 60_000; const WORKSHOP_DETAIL_FAILURE_COOLDOWN_MS = 60_000;
const WORKSHOP_DETAIL_PREFETCH_MARGIN = '500px'; const WORKSHOP_DETAIL_PREFETCH_MARGIN = '500px';
const MODS_AUTOSAVE_DEBOUNCE_MS = 1_500;
const UPGRADE_ALL_DETAIL_BATCH_SIZE = 8;
const workshopDetailLimiter = { const workshopDetailLimiter = {
inFlight: false, inFlight: false,
@@ -67,6 +70,7 @@ function sameMods(a: ReforgerConfigMod[], b: ReforgerConfigMod[]): boolean {
} }
type ModsTab = 'installed' | 'browse'; type ModsTab = 'installed' | 'browse';
type SaveStatus = 'idle' | 'pending' | 'saving' | 'saved' | 'error';
const DEFAULT_SCENARIO_ID = '{FDE33AFE2ED7875B}Missions/23_Campaign_Montignac.conf'; const DEFAULT_SCENARIO_ID = '{FDE33AFE2ED7875B}Missions/23_Campaign_Montignac.conf';
@@ -76,7 +80,7 @@ function ModsBody({ slug, user }: { slug: string; user: CurrentUser }) {
const { data, isLoading, error, refetch } = useServerMods(slug); const { data, isLoading, error, refetch } = useServerMods(slug);
const { data: configData } = useConfiguration(slug); const { data: configData } = useConfiguration(slug);
const currentScenarioId = configData?.config.scenarioId ?? null; const currentScenarioId = configData?.config.scenarioId ?? null;
const save = useSetServerMods(slug); const { mutate: saveMutate, isPending: isSavingMods } = useSetServerMods(slug);
const savePerf = useSetPerformanceSettings(slug); const savePerf = useSetPerformanceSettings(slug);
const [draft, setDraft] = useState<ReforgerConfigMod[] | null>(null); const [draft, setDraft] = useState<ReforgerConfigMod[] | null>(null);
const [message, setMessage] = useState<string | null>(null); const [message, setMessage] = useState<string | null>(null);
@@ -84,15 +88,64 @@ function ModsBody({ slug, user }: { slug: string; user: CurrentUser }) {
const [selectedModId, setSelectedModId] = useState<string | null>(null); const [selectedModId, setSelectedModId] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<ModsTab>('installed'); const [activeTab, setActiveTab] = useState<ModsTab>('installed');
const [checkEnabled, setCheckEnabled] = useState(false); const [checkEnabled, setCheckEnabled] = useState(false);
const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle');
const [isUpgradingAll, setIsUpgradingAll] = useState(false);
const checkQuery = useServerModsCheck(slug, checkEnabled); const checkQuery = useServerModsCheck(slug, checkEnabled);
const serverMods = data?.mods ?? []; const serverMods = data?.mods ?? [];
const mods = draft ?? serverMods; 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 dirty = draft !== null && !sameMods(draft, serverMods);
const installedIds = new Set(mods.map((mod) => mod.modId.toUpperCase())); const installedIds = new Set(mods.map((mod) => mod.modId.toUpperCase()));
const missingVersionIds = new Set(mods.filter((m) => !m.version).map((m) => m.modId)); 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) => { const addMod = (mod: ReforgerConfigMod) => {
if (installedIds.has(mod.modId.toUpperCase())) return; if (installedIds.has(mod.modId.toUpperCase())) return;
setMessage(null); setMessage(null);
@@ -128,28 +181,60 @@ function ModsBody({ slug, user }: { slug: string; user: CurrentUser }) {
}; };
const saveMods = () => { const saveMods = () => {
setMessage(null); submitMods(mods, (result, currentSaved) =>
save.mutate(mods, { currentSaved
onSuccess: (result) => { ? `Saved — ${result.added} added, ${result.removed} removed. Restart to apply.`
setDraft(null); : 'Saved. More changes pending.',
setMessage(`Saved — ${result.added} added, ${result.removed} removed. Restart to apply.`); );
setCheckEnabled(false);
void refetch();
},
onError: (saveError) => setMessage(saveError.message),
});
}; };
const patchVersions = () => { 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); setMessage(null);
save.mutate(mods, { try {
onSuccess: () => { const details = await fetchWorkshopDetailsForMods(mods);
setDraft(null); let changed = 0;
setMessage('Version info patched. Restart to apply.'); let foundVersions = 0;
void refetch(); const upgraded = mods.map((mod) => {
}, const detail = details.get(mod.modId.toUpperCase());
onError: (saveError) => setMessage(saveError.message), 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 = () => { const resetMission = () => {
@@ -210,7 +295,9 @@ function ModsBody({ slug, user }: { slug: string; user: CurrentUser }) {
canManage={canManage} canManage={canManage}
canEditConfig={canEditConfig} canEditConfig={canEditConfig}
dirty={dirty} dirty={dirty}
isSaving={save.isPending} isSaving={isSavingMods}
saveStatus={saveStatus}
isUpgradingAll={isUpgradingAll}
isResettingMission={savePerf.isPending} isResettingMission={savePerf.isPending}
missingVersionIds={missingVersionIds} missingVersionIds={missingVersionIds}
selectedModId={selectedModId} selectedModId={selectedModId}
@@ -230,8 +317,10 @@ function ModsBody({ slug, user }: { slug: string; user: CurrentUser }) {
onDiscard={() => { onDiscard={() => {
setDraft(null); setDraft(null);
setMessage(null); setMessage(null);
setSaveStatus('idle');
}} }}
onPatchVersions={patchVersions} onPatchVersions={patchVersions}
onUpgradeAll={upgradeAllVersions}
onCheckDeps={runCheck} onCheckDeps={runCheck}
onAddMods={addMods} onAddMods={addMods}
onResetMission={resetMission} onResetMission={resetMission}
@@ -278,6 +367,26 @@ async function addAllDeps(
addMods(mods); 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) { function workshopModQueryKey(modId: string) {
return ['workshop', 'mod', modId] as const; return ['workshop', 'mod', modId] as const;
} }
@@ -379,6 +488,8 @@ function InstalledModsPanel({
canEditConfig, canEditConfig,
dirty, dirty,
isSaving, isSaving,
saveStatus,
isUpgradingAll,
isResettingMission, isResettingMission,
missingVersionIds, missingVersionIds,
selectedModId, selectedModId,
@@ -397,6 +508,7 @@ function InstalledModsPanel({
onSave, onSave,
onDiscard, onDiscard,
onPatchVersions, onPatchVersions,
onUpgradeAll,
onCheckDeps, onCheckDeps,
onAddMods, onAddMods,
onResetMission, onResetMission,
@@ -406,6 +518,8 @@ function InstalledModsPanel({
canEditConfig: boolean; canEditConfig: boolean;
dirty: boolean; dirty: boolean;
isSaving: boolean; isSaving: boolean;
saveStatus: SaveStatus;
isUpgradingAll: boolean;
isResettingMission: boolean; isResettingMission: boolean;
missingVersionIds: Set<string>; missingVersionIds: Set<string>;
selectedModId: string | null; selectedModId: string | null;
@@ -424,6 +538,7 @@ function InstalledModsPanel({
onSave: () => void; onSave: () => void;
onDiscard: () => void; onDiscard: () => void;
onPatchVersions: () => void; onPatchVersions: () => void;
onUpgradeAll: () => void;
onCheckDeps: () => void; onCheckDeps: () => void;
onAddMods: (mods: ReforgerConfigMod[]) => void; onAddMods: (mods: ReforgerConfigMod[]) => void;
onResetMission: () => void; onResetMission: () => void;
@@ -467,17 +582,36 @@ function InstalledModsPanel({
title={`Installed Mods${mods.length > 0 ? ` (${mods.length})` : ''}`} title={`Installed Mods${mods.length > 0 ? ` (${mods.length})` : ''}`}
action={ action={
<div className="flex flex-wrap items-center justify-end gap-2"> <div className="flex flex-wrap items-center justify-end gap-2">
{!dirty && fetchedAt && ( {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> <span className="text-xs text-slate-dim">fetched {formatRelativeTime(fetchedAt)}</span>
)} )}
{dirty && ( {dirty && (
<> <>
<span className="text-xs text-warn-400">unsaved changes</span>
<Button onClick={onDiscard} disabled={isSaving}> <Button onClick={onDiscard} disabled={isSaving}>
Discard Discard
</Button> </Button>
<Button variant="accent" onClick={onSave} disabled={isSaving}> <Button variant="accent" onClick={onSave} disabled={isSaving}>
{isSaving ? 'Saving…' : 'Save'} {isSaving ? 'Saving…' : 'Save now'}
</Button> </Button>
</> </>
)} )}