// sortof - main React app // Fake state machine: empty → loading → partial → success/error/cold // Three empty variants, three success variants, exposed via Tweaks. const { useState, useEffect, useRef, useMemo } = React; // Live data shim: leaves keep reading D.X; we swap _liveSortData on each // successful /api/sort response. Tweaks-preview mode leaves it null and // falls back to the canned dataset. let _liveSortData = null; const D = new Proxy({}, { get(_, key) { const src = _liveSortData ?? window.SORTOF_DATA; return src ? src[key] : undefined; } }); // Mirrors backend parse.py: accepts newlines, semicolons, commas, spaces, // tabs, ini-style prefixes (WorkshopItems=, Mods=, Map=), and Workshop URLs. // Returns deduped IDs in first-seen order. function parseWorkshopInput(text) { if (!text) return []; const cleaned = String(text).replace(/^\s*(WorkshopItems|Mods|Map)\s*=\s*/gim, ''); const ids = cleaned.match(/\b\d{7,12}\b/g) || []; return Array.from(new Set(ids)); } // Render the Mods= value for a given PZ build. // B41: mod1;mod2;mod3; // B42: \mod1;\mod2;\mod3; // Trailing semicolon kept so the line pastes verbatim into the server's .ini file. function buildModsLine(ids, mode) { if (!ids || !ids.length) return ''; if (mode === 'B42') return '\\' + ids.join(';\\') + ';'; return ids.join(';') + ';'; } // Pure helpers for input-textarea mutations. Used by both the per-warning // toggle handlers (onAddWsid etc.) and the batch auto-fix handlers. Pure so // they can be folded inside a functional setInput(prev => …) call without // stale-closure races. function _applyAppendWsid(input, wsid) { const trimmed = (input || '').replace(/\s+$/, ''); const sep = trimmed ? '\n' : ''; return trimmed + sep + wsid; } function _applyStripWsid(input, wsid) { return (input || '').replace( new RegExp(`(?:^|(?<=[\\s;,]))${wsid}(?=$|[\\s;,])`, 'g'), '' ).replace(/[\s;,]{2,}/g, m => m.includes('\n') ? '\n' : m[0]).trim(); } function _applyEnsureAdded(input, wsid) { const wid = String(wsid); return parseWorkshopInput(input).includes(wid) ? input : _applyAppendWsid(input, wid); } function _applyEnsureRemoved(input, wsid) { const w = String(wsid); return parseWorkshopInput(input).includes(w) ? _applyStripWsid(input, w) : input; } function _applyEnsureSwapped(input, from, to) { const f = String(from); const t = String(to); const existing = parseWorkshopInput(input); if (!existing.includes(f)) return input; // nothing to swap from if (existing.includes(t)) return _applyStripWsid(input, f); // target present, just drop from return input.replace(new RegExp(`(?<=^|[\\s;,])${f}(?=$|[\\s;,])`, 'g'), t); } // Spec B+F: poll a job. Resolves on terminal phase or AbortSignal. const POLL_INTERVAL_MS = 2500; async function pollJobOnce(jobId) { try { const res = await fetch(`/api/jobs/${jobId}`); if (res.status === 404) return { kind: 'expired' }; if (!res.ok) return { kind: 'error', status: res.status }; const json = await res.json(); return { kind: 'ok', body: json }; } catch (e) { console.warn('pollJobOnce: network error', e); return { kind: 'error', status: 0 }; } } function pollJobLoop(jobId, signal, onTick) { return new Promise((resolve) => { let timer = null; async function tick() { if (signal.aborted) { if (timer) clearTimeout(timer); resolve({ kind: 'aborted' }); return; } const r = await pollJobOnce(jobId); if (signal.aborted) { resolve({ kind: 'aborted' }); return; } onTick(r); if (r.kind === 'expired' || r.kind === 'error') { resolve(r); return; } const phase = r.body.phase; if (phase === 'done' || phase === 'failed') { resolve(r); return; } timer = setTimeout(tick, POLL_INTERVAL_MS); } tick(); }); } // The picker's default selection mirrors the backend's auto-pick. After // Spec C, the backend applies build-aware + input-cross-ref + prefix-base // rules and emits SORTED_ORDER reflecting the chosen branches. The picker // reads from SORTED_ORDER (passed as `activeSet`) and ticks any branch // whose mod_id is in that set; falls back to first-only if none match. // Spec A's contract holds - the picker is the source of truth. function isRadioMode(branches) { if (!branches || branches.length < 2) return false; const ids = new Set(branches.map(b => b.modId)); return branches.some(b => (b.conflicts || []).some(c => ids.has(c))); } function defaultSelectionForBranches(branches, activeSet) { if (!branches || !branches.length) return []; if (activeSet && activeSet.size) { const active = branches.filter(b => activeSet.has(b.modId)).map(b => b.modId); if (active.length) return active; } return [branches[0].modId]; } // Shared column count for the Mod Details table. Keep in sync with the // in ModTable; expansion-panel reads this constant. // Spec C will add a 7th column; bump here, do not hardcode the integer. const COLUMN_COUNT = 6; // Steam Workshop deep-link for a wsid. Pattern is the same for mods and // collections (the backend resolves the distinction; users see one URL). function wsidUrl(wsid) { return `https://steamcommunity.com/sharedfiles/filedetails/?id=${wsid}`; } function WsidLink({ wsid, children, className }) { if (!wsid) return {children ?? wsid}; return ( {children ?? wsid} ); } // Snapshot the fields needed to diff against the next sort/resort. Called // before each fetch fires so the snapshot captures "what the user saw // before this action". Polling-mid-flight ticks intentionally don't snapshot // (would erase the prior visible state on every 2.5s update). function snapshotForDiff(src) { if (!src) return null; return { SORTED_ORDER: [...(src.SORTED_ORDER || [])], MOD_DB: (src.MOD_DB || []).map(m => ({ modId: m.modId, wsid: m.wsid, name: m.name })), MODS_LINE: src.MODS_LINE || '', WORKSHOP_ITEMS_LINE: src.WORKSHOP_ITEMS_LINE || '', }; } function computeDiff(prev, curr) { if (!prev || !curr) return null; const prevSorted = prev.SORTED_ORDER || []; const currSorted = curr.SORTED_ORDER || []; const prevSet = new Set(prevSorted); const currSet = new Set(currSorted); const added = currSorted.filter(id => !prevSet.has(id)); const removed = prevSorted.filter(id => !currSet.has(id)); const prevPos = new Map(prevSorted.map((id, i) => [id, i])); const movers = []; currSorted.forEach((id, ci) => { if (!prevSet.has(id)) return; const pi = prevPos.get(id); if (pi !== ci) movers.push({ id, from: pi, to: ci, delta: ci - pi }); }); movers.sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta)); const prevWsl = (prev.WORKSHOP_ITEMS_LINE || '').replace(/;+$/, '').split(';').filter(Boolean); const currWsl = (curr.WORKSHOP_ITEMS_LINE || '').replace(/;+$/, '').split(';').filter(Boolean); const prevWsidPos = new Map(prevWsl.map((w, i) => [w, i])); const wsidMovers = []; currWsl.forEach((w, ci) => { const pi = prevWsidPos.get(w); if (pi !== undefined && pi !== ci) wsidMovers.push({ wsid: w, from: pi, to: ci }); }); wsidMovers.sort((a, b) => Math.abs(b.to - b.from) - Math.abs(a.to - a.from)); return { added, removed, movers, wsidMovers }; } function DiffPanel({ prev, curr, onClose }) { const diff = computeDiff(prev, curr); if (!diff) { return (
diff vs previous sort
no previous sort to compare against - sort once first.
); } const { added, removed, movers, wsidMovers } = diff; const empty = !added.length && !removed.length && !movers.length && !wsidMovers.length; return (
diff vs previous sort +{added.length} −{removed.length} ↕{movers.length}
{empty &&
nothing changed.
} {added.length > 0 && (
added ({added.length})
{added.slice(0, 30).map(id =>
+ {id}
)} {added.length > 30 &&
…and {added.length - 30} more
}
)} {removed.length > 0 && (
removed ({removed.length})
{removed.slice(0, 30).map(id =>
− {id}
)} {removed.length > 30 &&
…and {removed.length - 30} more
}
)} {movers.length > 0 && (
moved by load order ({movers.length}, top {Math.min(10, movers.length)} shown)
{movers.slice(0, 10).map(({ id, from, to, delta }) => (
{delta < 0 ? '↑' : '↓'} {id} pos {from + 1} → {to + 1} ({delta > 0 ? '+' : ''}{delta})
))}
)} {wsidMovers.length > 0 && (
WorkshopItems= reorder ({wsidMovers.length})
{wsidMovers.slice(0, 10).map(({ wsid, from, to }) => (
{to < from ? '↑' : '↓'} {wsid} pos {from + 1} → {to + 1}
))}
)}
); } // ── icons ─────────────────────────────────────────────────────────────────── const IconCopy = () => ( ); const IconCheck = () => ( ); const IconGit = () => ( ); // ── small helpers ─────────────────────────────────────────────────────────── function CopyBtn({ text, label = 'copy', minimal = false }) { const [copied, setCopied] = useState(false); const onClick = async () => { try { await navigator.clipboard.writeText(text); } catch (e) { // fallback const ta = document.createElement('textarea'); ta.value = text; document.body.appendChild(ta); ta.select(); try { document.execCommand('copy'); } catch (_) {} document.body.removeChild(ta); } setCopied(true); setTimeout(() => setCopied(false), 1200); }; if (minimal) { return ( ); } return ( ); } // ── header & footer ───────────────────────────────────────────────────────── function Header({ tagline, view, onReport, onSort }) { return (
sortof. {tagline && {tagline}}
{view === 'reports' ? ( ) : ( )} e.preventDefault()}> github e.preventDefault()}> docs
); } function Footer() { return ( ); } // ── empty-state variants ──────────────────────────────────────────────────── function EmptyRight({ variant }) { if (variant === 'bare') { return (
no mods. or maybe loads of them. hard to tell. paste workshop ids on the left, hit sort. output lands here.
); } if (variant === 'worked') { return (
what you'll get back
WorkshopItems=2169435993;2392709985;…
Mods=modoptions;tsarslib;…
Map=Muldraugh, KY
↑ paste these into your server's .ini
); } // docs return (
how this works read once, never again
  1. paste workshop ids - newlines, semicolons, commas, or urls all work. one mod per id.
  2. optionally provide sorting_rules.txt to pin libs first / maps last / mark incompatibles.
  3. hit sort. we resolve dependencies, apply your rules, and emit three lines for your server's .ini.
  4. copy each line. paste. boot the server. probably works.
we cache mod metadata until the author publishes an update. cold-cache lookups take roughly 30s. you've been warned.
); } // ── status strip ──────────────────────────────────────────────────────────── function StatusStrip({ state, counts, progress }) { // a11y: each pill emits (color, glyph, text) - never color alone. The glyph // is hidden from screen readers (the surrounding text is the actual info) // and the dot-led is replaced by a state-specific block-circle/arrow char. const pill = (cls, glyph, label, title) => ( {label} ); // State→(class, glyph, label) per brief mapping. Class names map to CSS // rules below that pin each state to its semantic color (--success/--info/ // --warning/--error/--fg-muted) - color, glyph, AND label always together. if (state === 'idle' || state === 'success' || state === 'error' || state === 'cold' || state === 'done' || state === 'failed') { if (state === 'idle') return
{pill('idle', '●', 'idle')}
; if (state === 'success' || state === 'done') return
{pill('done', '✓', `done. ${counts.cached} mods, ${counts.warnings} warnings`)}
; if (state === 'error') return
{pill('failed', '✗', 'failed. something went sideways')}
; if (state === 'failed') return
{pill('failed', '✗', "failed. that didn't work")}
; if (state === 'cold') return
{pill('warning', '⚠', 'cache miss. cold-cache lookups take ~30s')}
; } if (state === 'expanding') { return (
{pill('working', '⏵', 'working. expanding collection…')}
); } // 'queued' / 'draining' / legacy 'partial' / legacy 'loading' - live counts. return (
{pill('done', '✓', `${counts.cached} cached`)} {pill('queued', '●', `${counts.queued} queued`)} {pill('draining', '⏵', `${counts.parsing} draining`)} {counts.unknown > 0 && pill('failed', '✗', `${counts.unknown} unknown`, "Steam doesn't recognize these IDs (deleted, typo'd, or private)")} {counts.nonMod > 0 && pill('muted', '−', `${counts.nonMod} non-mod`, "Workshop items that aren't loadable mods (collections, art, etc.)")}
); } // ── output: three success variants ────────────────────────────────────────── function SuccessStacked({ data, pzBuild }) { const modsLine = buildModsLine(D.SORTED_ORDER, pzBuild); return ( <>
WorkshopItems · {data.count} ids
WorkshopItems={D.WORKSHOP_ITEMS_LINE}
Mods · {data.count} mods · {pzBuild}
Mods={modsLine}
Map · 1 map detected
Map={D.MAP_LINE}
); } function SuccessCompact({ data, pzBuild }) { const modsLine = buildModsLine(D.SORTED_ORDER, pzBuild); return (
WorkshopItems
{D.WORKSHOP_ITEMS_LINE}
Mods · {pzBuild}
{modsLine}
Map
{D.MAP_LINE}
); } function SuccessNumbered({ data, pzBuild }) { const modsLine = buildModsLine(D.SORTED_ORDER, pzBuild); const blocks = [ { num: '01', key: 'WorkshopItems', val: D.WORKSHOP_ITEMS_LINE, meta: `${data.count} ids · ${(D.WORKSHOP_ITEMS_LINE.length)} chars` }, { num: '02', key: 'Mods', val: modsLine, meta: `${data.count} mods · ${pzBuild}` }, { num: '03', key: 'Map', val: D.MAP_LINE, meta: '1 map' }, ]; return ( <> {blocks.map(b => (
{b.num} {b.key} {b.meta}
{b.val}
))} ); } // ── warnings ───────────────────────────────────────────────────────────────── // Render a warning message with the leading mod name turned into a Workshop // hyperlink. Backend warnings start with " ..." (or "Mod '' ..." // for duplicate-mod_id). When `wsid` is provided, the first identifier of the // message becomes the link target; the rest renders as plain text. function WarnMsg({ msg, wsid }) { if (!wsid || !msg) return {msg}; // Match "" at start (e.g., "ModId requires X"), or "Mod ''" // (duplicate-mod_id phrasing). Bare-id form is the common case. const m = msg.match(/^(Mod ')([^']+)(')(.*)$/) || msg.match(/^([^\s]+)(.*)$/); if (!m) return {msg}; if (m.length === 5) { const [, lead, name, close, rest] = m; return ( {lead} {name} {close}{rest} ); } const [, name, rest] = m; return ( {name} {rest} ); } // True when this action's intended effect is already present in the current // input — i.e., the user has staged this change but hasn't sorted yet. Drives // the per-button ✓-pending label and the row-level strikethrough. function actionStaged(a, inputWsids) { if (a.type === 'add-wsid') return inputWsids.has(String(a.wsid)); if (a.type === 'remove-wsid') return !inputWsids.has(String(a.wsid)); if (a.type === 'swap-wsid') return !inputWsids.has(String(a.from)) && inputWsids.has(String(a.to)); return false; } function WarnRow({ w, inputWsids, onAddWsid, onPickBranch, onSwapWsid, onRemoveWsid, onToggleBranch }) { // Branch picker collapses by default; click "pick another" chev to expand. const [branchOpen, setBranchOpen] = useState(false); // Any warning that ships an `alternatives` array gets an inline picker. // Covers auto-picked-branch (mutually exclusive primary pick) AND // unmatched-addons (additive addon ticking) — both drive the same UI. const hasPicker = Array.isArray(w.alternatives); // Row is staged when ANY mutating action's effect is satisfied by the // current input. Per-button labels flip independently below. const rowStaged = (Array.isArray(w.actions) ? w.actions : []).some(a => actionStaged(a, inputWsids)); return (
  • {w.tag}
    {Array.isArray(w.actions) && w.actions.map((a, j) => { if (a.type === 'add-wsid') { const staged = actionStaged(a, inputWsids); return ( ); } if (a.type === 'swap-wsid') { const staged = actionStaged(a, inputWsids); return ( ); } if (a.type === 'remove-wsid') { const staged = actionStaged(a, inputWsids); return ( ); } if (a.type === 'search-workshop') { return ( ↗ {a.label} ); } return null; })} {hasPicker && ( <> {branchOpen && (
    {w.alternatives.map(modId => { // Two picker modes drive the same UI: // auto-picked-branch → radio (one of N), `picked` is the // currently-active mod_id; clicking switches. // unmatched-addons → checkbox-additive; the primary // (`picked`) stays loaded; clicking an addon toggles it // in/out of the selection. Active mod_ids come from // SORTED_ORDER so the ★ reflects the live state. const isAdditive = w.tag === 'unmatched-addons'; const activeSet = new Set(D.SORTED_ORDER || []); const isActive = isAdditive ? activeSet.has(modId) : modId === w.picked; const isPrimary = modId === w.picked; return ( ); })}
    )} )}
  • ); } // Auto-fix bar: 3 batched-action buttons that sit ABOVE the warnings panel. // Each button stages every eligible action of its type in a single pass; sort // is NOT fired (consistent with the staged-warning UI). A warning lives in // exactly one bucket: missing-dep → Add, build-mismatch w/ swap → Fix, // build-mismatch w/o swap → Remove. function AutoFixBar({ items, inputWsids, onAddDeps, onFixMismatches, onRemoveIncorrect }) { if (!items || items.length === 0) return null; let addCount = 0, swapCount = 0, removeCount = 0; for (const w of items) { if (!Array.isArray(w.actions)) continue; if (w.tag === 'missing') { // Eligible if any add-wsid target isn't yet in input. if (w.actions.some(a => a.type === 'add-wsid' && a.wsid && !inputWsids.has(String(a.wsid)))) { addCount++; } } else if (w.tag === 'build-mismatch') { const swap = w.actions.find(a => a.type === 'swap-wsid' && a.from && a.to); if (swap) { // Eligible if from is present and to is absent. if (inputWsids.has(String(swap.from)) && !inputWsids.has(String(swap.to))) { swapCount++; } } else { const rem = w.actions.find(a => a.type === 'remove-wsid' && a.wsid); if (rem && inputWsids.has(String(rem.wsid))) { removeCount++; } } } } if (addCount === 0 && swapCount === 0 && removeCount === 0) return null; return (
    {addCount > 0 && ( )} {swapCount > 0 && ( )} {removeCount > 0 && ( )}
    ); } function Warnings({ items, defaultOpen = true, inputWsids, onAddWsid, onPickBranch, onSwapWsid, onRemoveWsid, onToggleBranch }) { const [open, setOpen] = useState(defaultOpen); if (!items || items.length === 0) return null; const reds = items.filter(w => w.level === 'red').length; const ambers = items.length - reds; return (
    setOpen(!open)}> warnings {reds > 0 && ( {reds} )} {ambers > 0 && ( {ambers} )} {reds > 0 ? 'fix these or your server will sulk' : 'non-fatal but read them'}
    {open && (
      {items.map((w, i) => ( ))}
    )}
    ); } function BranchPicker({ wsid, branches, selected, userTouched, onToggle }) { const radio = isRadioMode(branches); const inputType = radio ? 'radio' : 'checkbox'; // Default = whichever branch mod_ids are in SORTED_ORDER (set by backend // rules per Spec C §4). User-touched selections override. const activeSet = new Set(D.SORTED_ORDER || []); const effective = userTouched ? selected : defaultSelectionForBranches(branches, activeSet); return (
    workshop {wsid} · {branches.length} mod_ids · {radio ? 'pick one' : 'multi-select'}
    {branches.map(b => { const checked = effective.includes(b.modId); return ( ); })}
    ); } // ── mod details table ─────────────────────────────────────────────────────── // Classify each mod_id into a row-state for the Mod Details table per Spec // brief 2026-05-01. Pattern-matches the warning messages to derive which // mod_id is the *source* of a warning (the leading token before `requires`, // `marked`, `incompatible`, or in a cycle path). Imperfect but cheap; if the // backend grows a `WARNINGS[].affected: [mod_ids]` field this collapses to // a simple lookup. function rowStateForMod(modId) { const warns = (D.WARNINGS || []); for (const w of warns) { if (!w || !w.msg) continue; const msg = w.msg; const m = msg.match(/^([A-Za-z0-9_+\-]{2,})\s+(requires|marked|and\b)/) || msg.match(/cycle:\s*([A-Za-z0-9_+\-]+)/); if (!m || m[1] !== modId) continue; if (w.level === 'red') return 'error'; if (w.level === 'amber') return 'warning'; } return 'resolved'; } function ModTable({ defaultOpen = false, branchSelections, onToggleBranch, expandedWsids, onToggleExpansion }) { const [open, setOpen] = useState(defaultOpen); // Group MOD_DB rows by wsid. For wsids with >1 mod, render a single parent // row and an expansion with the picker. const groupedByWsid = useMemo(() => { const g = {}; for (const m of (D.MOD_DB || [])) { const w = m.wsid || ''; if (!w) continue; (g[w] = g[w] || []).push(m); } return g; }, [D.MOD_DB]); // Walk SORTED_ORDER and emit one row-spec per wsid (deduped). const rowSpecs = useMemo(() => { const specs = []; const seenWsid = new Set(); for (const modId of (D.SORTED_ORDER || [])) { const m = (D.MOD_DB || []).find(x => x.modId === modId); if (!m || !m.wsid) continue; if (seenWsid.has(m.wsid)) continue; seenWsid.add(m.wsid); const branches = groupedByWsid[m.wsid] || [m]; const isMulti = branches.length >= 2; specs.push({ wsid: m.wsid, primary: m, branches, isMulti }); } // Wsids whose parent had zero selected mods: not in SORTED_ORDER. Append. // NB: Object.keys order for numeric strings - wsids < 2^32 enumerate // ascending, larger ones in insertion order. Spec §6 declares display // position for zero-tick rows implementation-defined. for (const w of Object.keys(groupedByWsid)) { if (!seenWsid.has(w)) { const branches = groupedByWsid[w]; specs.push({ wsid: w, primary: branches[0], branches, isMulti: branches.length >= 2 }); } } return specs; }, [D.SORTED_ORDER, D.MOD_DB, groupedByWsid]); return (
    setOpen(!open)}> mod details · {(D.SORTED_ORDER || []).length} mods why everything ended up where it did {open ? '▾' : '▸'}
    {open && ( {rowSpecs.map((spec, i) => { const idx = String(i + 1).padStart(2, '0'); if (!spec.isMulti) { const m = spec.primary; const rowState = rowStateForMod(m.modId); const stateGlyph = { resolved: '✓', warning: '⚠', error: '✗' }[rowState] || '·'; return ( ); } // Multi-branch wsid: parent row + (optional) expansion panel. const selected = branchSelections[spec.wsid] || []; const N = spec.branches.length; const X = selected.length; const userTouched = (spec.wsid in branchSelections); const affordance = userTouched ? `✓ ${X} of ${N}` : `▾ ${N} branches`; const firstSelected = spec.branches.find(b => selected.includes(b.modId)) || spec.branches[0]; const showAsZero = userTouched && X === 0; const display = showAsZero ? null : firstSelected; const expanded = expandedWsids.has(spec.wsid); const rowState = display ? rowStateForMod(display.modId) : 'resolved'; const stateGlyph = { resolved: '✓', warning: '⚠', error: '✗' }[rowState] || '·'; return ( {expanded && ( )} ); })}
    # mod id workshop id category dependencies load
    {idx} {m.modId} {spec.wsid} {m.cat} {m.deps && m.deps.length ? m.deps.join(', ') : '-'} {m.pos === 'first' && first} {m.pos === 'last' && last} {!m.pos && -}
    {idx} {spec.wsid} {display ? {display.cat} : '-'} {display && display.deps && display.deps.length ? display.deps.join(', ') : '-'} {display && display.pos === 'first' && first} {display && display.pos === 'last' && last} {(!display || !display.pos) && -}
    )}
    ); } // ── right column dispatcher ──────────────────────────────────────────────── function BuildToggle({ value, onChange }) { const opts = ['B41', 'B42']; return (
    build {opts.map(o => ( ))}
    ); } function RightColumn({ state, counts, progress, emptyVariant, successVariant, modTableDefault, pzBuild, setPzBuild, branchSelections, onToggleBranch, expandedWsids, onToggleExpansion, inputWsids, onAddWsid, onPickBranch, onSwapWsid, onRemoveWsid, onAutoFixAddDeps, onAutoFixSwaps, onAutoFixRemoves, onRetry, previousResult, diffOpen, setDiffOpen }) { // Phase-state mapping: legacy `success` ≈ B+F `done`; legacy `partial` ≈ B+F `queued`/`draining`. const isTerminalDone = state === 'success' || state === 'done'; const isInflightPartial = state === 'partial' || state === 'queued' || state === 'draining'; const showWarnings = isTerminalDone || isInflightPartial; const showOutputs = isTerminalDone || isInflightPartial; return ( <> {/* Warnings ABOVE outputs (always-inline treatment). Show the full * list in BOTH terminal-done and inflight-partial: build-mismatch * and other tag-based warnings stabilize as soon as the wsids they * apply to are cached, so truncating to slice(0,1) was hiding load- * order issues from users mid-poll. */} {showWarnings && ( <> )} {/* Cold cache banner */} {state === 'cold' && (
    cold
    indexing {counts.queued} {counts.queued === 1 ? 'mod' : 'mods'} we haven't seen before. try again in ~30s. or don't, we're not your boss.
    )} {/* Error banner. Reason pulled from the most-recent retry/red WARNING when present; otherwise a generic line. The Warnings panel above carries the full detail; this banner is just the headline + retry. */} {state === 'error' && (() => { const warns = (D.WARNINGS || []); const retryWarn = [...warns].reverse().find(w => w.tag === 'retry'); const reason = retryWarn ? retryWarn.msg : 'something failed - check warnings above'; const hasCached = (D.MOD_DB || []).length > 0; return (
    err
    {reason}{hasCached ? ' · cached results below still valid' : ''}
    ); })()} {/* Output area */} {state === 'idle' && } {(state === 'loading' || state === 'expanding') && (
    {state === 'expanding' ? 'expanding your collection…' : 'parsing your collection…'} resolving dependencies. applying rules. probably fine.
    )} {showOutputs && ( <> {isInflightPartial && (
    showing cached subset · {counts.queued + (counts.parsing || 0)} mods still in flight
    )}
    {diffOpen && ( setDiffOpen(false)} /> )} {successVariant === 'stacked' && } {successVariant === 'compact' && } {successVariant === 'numbered' && } )} {/* Mod details - only when we have something to show */} {(isTerminalDone || isInflightPartial) && ( )} ); } // ── default tweak values ──────────────────────────────────────────────────── const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ "emptyVariant": "docs", "successVariant": "stacked", "stateOverride": "auto", "showTagline": true, "modTableDefault": false, "accentHue": 155 }/*EDITMODE-END*/; // ── reported broken mods ──────────────────────────────────────────────────── // Format an ISO timestamp as the literal `HH:MM MM-DD-YYYY` layout the user // asked for. 24-hour, zero-padded, locale-independent (uses UTC components // so timestamps are identical for everyone — there's no "your local time" // disambiguation needed since the value is just a relative recency cue). function fmtReportDate(iso) { if (!iso) return ''; const d = new Date(iso); if (isNaN(d.getTime())) return iso; const pad = (n) => String(n).padStart(2, '0'); const HH = pad(d.getUTCHours()); const MM = pad(d.getUTCMinutes()); const mo = pad(d.getUTCMonth() + 1); const da = pad(d.getUTCDate()); const yy = d.getUTCFullYear(); return `${HH}:${MM} ${mo}-${da}-${yy}`; } const _VOTED_KEY = 'sortof.brokenmod.voted'; function loadVoted() { try { return JSON.parse(localStorage.getItem(_VOTED_KEY) || '{}') || {}; } catch { return {}; } } function saveVoted(v) { try { localStorage.setItem(_VOTED_KEY, JSON.stringify(v)); } catch {} } function ReportedModsPanel({ onClose }) { const [versions, setVersions] = useState({}); const [reports, setReports] = useState([]); const [search, setSearch] = useState(''); const [wsidIn, setWsidIn] = useState(''); const [verIn, setVerIn] = useState(''); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(''); const [voted, setVoted] = useState(loadVoted()); // Initial load: fetch version registry + all reports. useEffect(() => { let cancelled = false; (async () => { try { const [vRes, rRes] = await Promise.all([ fetch('/api/pz-versions').then(r => r.json()), fetch('/api/broken-mods').then(r => r.json()), ]); if (cancelled) return; setVersions(vRes || {}); setReports(Array.isArray(rRes) ? rRes : []); // Default the dropdown to the first key (typically 'stable' or 'unstable'). const firstKey = Object.keys(vRes || {})[0]; if (firstKey) setVerIn(firstKey); } catch (e) { if (!cancelled) setError(`load failed: ${e?.message || e}`); } })(); return () => { cancelled = true; }; }, []); async function onSubmit(e) { e.preventDefault(); setError(''); const wsid = (wsidIn || '').trim(); if (!/^\d+$/.test(wsid)) { setError('workshop id must be digits only'); return; } if (!verIn) { setError('pick a version'); return; } setSubmitting(true); try { const res = await fetch('/api/broken-mods', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ workshop_id: wsid, version: verIn }), }); if (!res.ok) { const txt = await res.text(); setError(`submit failed: ${res.status} ${txt}`); return; } setWsidIn(''); // Refresh list so the upserted entry bubbles to the top. const list = await fetch('/api/broken-mods').then(r => r.json()); setReports(Array.isArray(list) ? list : []); } catch (e) { setError(`network error: ${e?.message || e}`); } finally { setSubmitting(false); } } async function onVote(id, direction) { if (voted[id]) return; // already voted this row in either direction try { const res = await fetch(`/api/broken-mods/${id}/vote`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ direction }), }); if (!res.ok) return; const counts = await res.json(); setReports(prev => prev.map(r => r.id === id ? { ...r, upvotes: counts.upvotes, downvotes: counts.downvotes } : r)); const next = { ...voted, [id]: direction }; setVoted(next); saveVoted(next); } catch {} } const filtered = useMemo(() => { const q = (search || '').trim().toLowerCase(); if (!q) return reports; return reports.filter(r => (r.workshop_id || '').toLowerCase().includes(q) || (r.mod_name || '').toLowerCase().includes(q), ); }, [reports, search]); return (
    Reported broken mods
    add a workshop id + which PZ build it broke on. duplicates of the same (id, version) bubble back to the top with their existing votes.
    setWsidIn(e.target.value)} inputMode="numeric" spellCheck={false} />
    {error &&
    {error}
    } setSearch(e.target.value)} />
    {filtered.length} {filtered.length === 1 ? 'report' : 'reports'} {search && reports.length !== filtered.length ? ` (of ${reports.length})` : ''}
      {filtered.length === 0 && (
    • no reports yet — be the first.
    • )} {filtered.map(r => { const v = voted[r.id]; // 'up' | 'down' | undefined const verLabel = versions[r.version] || r.version; return (
    • {r.mod_name || r.workshop_id}
      {r.workshop_id}
      {verLabel}
      {fmtReportDate(r.updated_at)}
    • ); })}
    ); } // ── main app ──────────────────────────────────────────────────────────────── function App() { const [t, setTweak] = useTweaks(TWEAK_DEFAULTS); const [view, setView] = useState('sort'); // sort | reports const [state, setState] = useState('idle'); // idle | loading | partial | success | error | cold const [counts, setCounts] = useState({ cached: 0, queued: 0, parsing: 0, warnings: 0, unknown: 0, nonMod: 0 }); const [progress, setProgress] = useState(0); const [input, setInput] = useState(D.SAMPLE_INPUT); const [rules, setRules] = useState(''); const [pzBuild, setPzBuild] = useState(() => { try { return localStorage.getItem('sortof.pzBuild') || 'B41'; } catch { return 'B41'; } }); const [branchSelections, setBranchSelections] = useState(() => { try { const raw = localStorage.getItem('sortof.branch.selections'); if (!raw) return {}; const parsed = JSON.parse(raw); return (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {}; } catch { return {}; } }); const latestResortSeqRef = useRef(0); // monotonic; for stale-response drop // Tracks the input string at the moment the most recent /api/sort response // arrived. Drives the sort-button "pending" cue and the per-warning staged // strikethrough: when current `input` !== this ref, the user has staged // edits via warning action buttons (or manual textarea typing) since their // last sort. Stays null until the first successful sort. const lastSortedInputRef = useRef(null); // Contract: WORKSHOP_ITEMS_LINE, counts.queued, unknown[], non_mod[] are owned // by /api/sort's response for the lifetime of that result set. /api/resort // returns them but the client ignores those fields - wsid subscription and // ID-classification are independent of branch selection (spec §8). Captured // here at /api/sort time, applied on every /api/resort response. Do not // "fix" this back to using resort's fields. const sortContextRef = useRef({ workshopItemsLine: '', originalQueued: 0, unknown: [], nonMod: [] }); const pollAbortRef = useRef(null); const [activeJobId, setActiveJobId] = useState(null); const [expandedWsids, setExpandedWsids] = useState(() => new Set()); // Diff: snapshot of the result that's about to be replaced; toggle for the // panel. Snapshot is taken at the START of any sort/resort - polling-mid-flight // ticks don't snapshot so the user's prior visible state stays available. const previousResultRef = useRef(null); const [diffOpen, setDiffOpen] = useState(false); // Set of wsids currently in the input textarea, used by warning rows to // derive their staged state. Memoized off `input` so re-renders triggered // by unrelated state changes don't churn the Set. const inputWsids = useMemo(() => new Set(parseWorkshopInput(input)), [input]); // True when the user has staged edits since the last successful sort. Stays // false until the first sort completes (lastSortedInputRef starts null). const sortPending = lastSortedInputRef.current !== null && input !== lastSortedInputRef.current; const onToggleExpansion = (wsid) => { setExpandedWsids(prev => { const next = new Set(prev); if (next.has(wsid)) next.delete(wsid); else next.add(wsid); return next; }); }; const onToggleBranch = (wsid, modId, branches) => { setBranchSelections(prev => { const radio = isRadioMode(branches); const activeSet = new Set(D.SORTED_ORDER || []); const cur = prev[wsid] !== undefined ? prev[wsid] : defaultSelectionForBranches(branches, activeSet); let next; if (radio) { next = [modId]; } else { next = cur.includes(modId) ? cur.filter(x => x !== modId) : [...cur, modId]; } const updated = { ...prev, [wsid]: next }; // Fire-and-forget: state set + resort dispatch. runResort(updated); return updated; }); }; // Append a wsid to the input textarea. Idempotent toggle: a second click // on the same [add wsid] button removes the wsid (undoes the stage). // Functional setInput so the auto-fix batch handlers can chain mutations // without stale-closure races. Sort is NOT fired here. const onAddWsid = (wsid /*, modId */) => { if (!wsid) return; setInput(prev => { const wid = String(wsid); return parseWorkshopInput(prev).includes(wid) ? _applyStripWsid(prev, wid) : _applyAppendWsid(prev, wid); }); }; // Remove a wsid from the input. Idempotent toggle: a second click re-appends. const onRemoveWsid = (wsid) => { if (!wsid) return; setInput(prev => { const w = String(wsid); return parseWorkshopInput(prev).includes(w) ? _applyStripWsid(prev, w) : _applyAppendWsid(prev, w); }); }; // Replace `from` with `to` in the input. Idempotent toggle: if input is // already in the swapped state (from absent, to present), reverse it. const onSwapWsid = (from, to) => { if (!from || !to) return; setInput(prev => { const f = String(from); const t = String(to); const existing = parseWorkshopInput(prev); if (!existing.includes(f) && existing.includes(t)) { // Reverse a previously-applied swap. return prev.replace(new RegExp(`(?<=^|[\\s;,])${t}(?=$|[\\s;,])`, 'g'), f); } return _applyEnsureSwapped(prev, f, t); }); }; // Auto-fix: stage every `add-wsid` action on `missing` warnings whose target // isn't yet in input. Single batched setInput so all adds land in one pass. // Sort is NOT fired — pending-cue + strikethroughs show the staged edits. const onAutoFixAddDeps = () => { const warns = (_liveSortData && _liveSortData.WARNINGS) || []; setInput(prev => { let working = prev; for (const w of warns) { if (w.tag !== 'missing' || !Array.isArray(w.actions)) continue; for (const a of w.actions) { if (a.type === 'add-wsid' && a.wsid) { working = _applyEnsureAdded(working, a.wsid); } } } return working; }); }; // Auto-fix: stage every `swap-wsid` action on `build-mismatch` warnings. // One swap per warning (warnings ship at most one swap action). Skips swaps // already in their applied state (from absent, to present). const onAutoFixSwaps = () => { const warns = (_liveSortData && _liveSortData.WARNINGS) || []; setInput(prev => { let working = prev; for (const w of warns) { if (w.tag !== 'build-mismatch' || !Array.isArray(w.actions)) continue; const swap = w.actions.find(a => a.type === 'swap-wsid' && a.from && a.to); if (!swap) continue; working = _applyEnsureSwapped(working, swap.from, swap.to); } return working; }); }; // Auto-fix: stage `remove-wsid` only on `build-mismatch` warnings that have // NO `swap-wsid` (i.e., no B42 successor exists — drop is the only option). // Warnings with both swap+remove are handled by onAutoFixSwaps and skipped here. const onAutoFixRemoves = () => { const warns = (_liveSortData && _liveSortData.WARNINGS) || []; setInput(prev => { let working = prev; for (const w of warns) { if (w.tag !== 'build-mismatch' || !Array.isArray(w.actions)) continue; if (w.actions.some(a => a.type === 'swap-wsid')) continue; // handled by Fix const rem = w.actions.find(a => a.type === 'remove-wsid' && a.wsid); if (!rem) continue; working = _applyEnsureRemoved(working, rem.wsid); } return working; }); }; // Branch-warning button: lock the wsid to the chosen mod_id and resort. // Always switches to a single selection (radio-style), since the warning // surfaces only the auto-pick case where one branch is canonical. const onPickBranch = (wsid, modId /*, alternatives */) => { setBranchSelections(prev => { const updated = { ...prev, [wsid]: [modId] }; runResort(updated); return updated; }); }; // Retry: re-fire the current sort with the current input. The error // banner's [retry] button calls this. Also useful from the keyboard // shortcut path if we ever add one. const onRetry = () => onSort(); async function runResort(nextSelections) { // Snapshot for the diff panel: capture state before this resort. previousResultRef.current = snapshotForDiff(_liveSortData); // Compose the flat list of selected mod_ids from MOD_DB + nextSelections. // For wsids not in nextSelections, use the §4 default (all-ticked or // first-only depending on radio mode). For wsids with N=1, include the // sole mod_id unconditionally. const byWsid = {}; const sourceMods = (_liveSortData?.MOD_DB) || (window.SORTOF_DATA?.MOD_DB) || []; for (const m of sourceMods) { const w = m.wsid || ''; if (!w) continue; (byWsid[w] = byWsid[w] || []).push(m); } const ids = []; for (const w of Object.keys(byWsid)) { const branches = byWsid[w]; if (branches.length === 1) { ids.push(branches[0].modId); continue; } const stored = nextSelections[w]; const activeSet = new Set(D.SORTED_ORDER || []); const eff = stored !== undefined ? stored : defaultSelectionForBranches(branches, activeSet); for (const id of eff) ids.push(id); } if (!ids.length) { // Nothing selected; no point hitting the API. Synthesize an empty payload // so the UI shows "0 of N" without dispatching a 400. const ctx = sortContextRef.current; _liveSortData = { ...(_liveSortData || {}), MOD_DB: [], SORTED_ORDER: [], MODS_LINE: '', WORKSHOP_ITEMS_LINE: ctx.workshopItemsLine, MAP_LINE: 'Muldraugh, KY', WARNINGS: [{ tag: 'selection', level: 'amber', msg: 'no mods selected - pick at least one branch' }], pending: [], status: 'success', }; setCounts({ cached: 0, queued: ctx.originalQueued, parsing: 0, warnings: 1, unknown: ctx.unknown.length, nonMod: ctx.nonMod.length, }); return; } const seq = latestResortSeqRef.current + 1; latestResortSeqRef.current = seq; try { // Forward the original /api/sort input wsids so the backend can emit // wsid-keyed warnings (build-mismatch, conflict, required-items) // against the user's subscription set even when a mod_id has been // evicted to a different wsid in the cache (B41↔B42 pairs). const ctxWsids = (sortContextRef.current?.workshopItemsLine || '') .split(';').filter(Boolean); const res = await fetch('/api/resort', { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Sortof-Seq': String(seq) }, body: JSON.stringify({ selected_mod_ids: ids, pz_build: pzBuild, input_wsids: ctxWsids, }), }); // Drop stale: another resort has issued since this one started. if (seq < latestResortSeqRef.current) return; if (!res.ok) { console.error('resort failed', res.status); _liveSortData = { ...(_liveSortData || {}), WARNINGS: [ ...((_liveSortData?.WARNINGS) || []), { tag: 'retry', level: 'red', msg: "couldn't recompute sort - try again" }, ], }; setCounts(c => ({ ...c, warnings: (c.warnings || 0) + 1 })); return; } const json = await res.json(); const ctx = sortContextRef.current; // Spec A §8 ownership: keep the SET of wsids from the original /api/sort // (so toggling branches doesn't change subscription). But REORDER them // by the new sort - first mod's load position drives wsid placement. // Wsids without any selected mods (e.g., all branches deselected) keep // their relative input order at the tail. Same rule for unknown/non_mod. const inputWsids = (ctx.workshopItemsLine || '').split(';').filter(Boolean); const modById = {}; for (const m of (json.MOD_DB || [])) modById[m.modId] = m; const wsidFirstPos = new Map(); (json.SORTED_ORDER || []).forEach((modId, idx) => { const m = modById[modId]; if (m && m.wsid && !wsidFirstPos.has(m.wsid)) wsidFirstPos.set(m.wsid, idx); }); const known = inputWsids.filter(w => wsidFirstPos.has(w)) .sort((a, b) => wsidFirstPos.get(a) - wsidFirstPos.get(b)); const tail = inputWsids.filter(w => !wsidFirstPos.has(w)); const reorderedWsl = inputWsids.length ? [...known, ...tail].join(';') + ';' : ''; _liveSortData = { ...json, WORKSHOP_ITEMS_LINE: reorderedWsl }; const cached = (json.MOD_DB || []).length; const warns = (json.WARNINGS || []).length; setCounts({ cached, queued: ctx.originalQueued, parsing: 0, warnings: warns, unknown: ctx.unknown.length, nonMod: ctx.nonMod.length, }); setState(json.status || 'success'); } catch (e) { console.error('resort threw', e); _liveSortData = { ...(_liveSortData || {}), WARNINGS: [ ...((_liveSortData?.WARNINGS) || []), { tag: 'retry', level: 'red', msg: "couldn't recompute sort - try again" }, ], }; setCounts(c => ({ ...c, warnings: (c.warnings || 0) + 1 })); } } useEffect(() => { try { localStorage.setItem('sortof.pzBuild', pzBuild); } catch {} }, [pzBuild]); useEffect(() => { try { localStorage.setItem('sortof.branch.selections', JSON.stringify(branchSelections)); } catch {} }, [branchSelections]); useEffect(() => { function onStorage(e) { if (e.key !== 'sortof.branch.selections' || e.newValue === null) return; try { const parsed = JSON.parse(e.newValue); if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { setBranchSelections(parsed); runResort(parsed); } } catch {} } window.addEventListener('storage', onStorage); return () => window.removeEventListener('storage', onStorage); }, []); useEffect(() => { // Hydrate-driven resort: after the user submits and gets MOD_DB, // if branchSelections has entries, re-run sort against them. if (state === 'success' || state === 'partial' || state === 'done') { if (Object.keys(branchSelections).length > 0) { runResort(branchSelections); } } // eslint-disable-next-line }, [state]); const tagline = useMemo(() => { const taglines = [ 'sorted. sort of.', 'your mods, sort of in order.', 'a server-admin\u2019s tiny dignity.', 'mostly correct, mostly the time.', ]; return taglines[0]; }, []); // accent override useEffect(() => { const root = document.documentElement; root.style.setProperty('--acc-green', `oklch(0.74 0.10 ${t.accentHue})`); root.style.setProperty('--acc-green-bg', `oklch(0.74 0.10 ${t.accentHue} / 0.12)`); }, [t.accentHue]); // state override (preview from Tweaks) useEffect(() => { if (t.stateOverride && t.stateOverride !== 'auto') { runState(t.stateOverride); } // eslint-disable-next-line }, [t.stateOverride]); const timersRef = useRef([]); const clearTimers = () => { timersRef.current.forEach(clearTimeout); timersRef.current = []; }; function runState(target) { clearTimers(); if (target === 'idle') { setState('idle'); setProgress(0); setCounts({ cached: 0, queued: 0, parsing: 0, warnings: 0 }); return; } if (target === 'error') { setState('error'); setProgress(100); setCounts({ cached: 7, queued: 0, parsing: 0, warnings: 1 }); return; } if (target === 'cold') { setState('cold'); setProgress(45); setCounts({ cached: 0, queued: 3, parsing: 0, warnings: 0 }); return; } if (target === 'loading') { setState('loading'); setProgress(20); setCounts({ cached: 0, queued: 11, parsing: 1, warnings: 0 }); return; } if (target === 'partial') { setState('partial'); setProgress(70); setCounts({ cached: 7, queued: 4, parsing: 1, warnings: 1 }); return; } if (target === 'success') { setState('success'); setProgress(100); setCounts({ cached: 11, queued: 0, parsing: 0, warnings: D.WARNINGS.length }); return; } } async function onSort(inputOverride) { if (t.stateOverride !== 'auto') { // user is in preview mode; ignore real flow return; } // Allow callers (e.g., the missing-dep [add] button) to submit a freshly // computed input without waiting for setInput's async state update. // Type-check defensively: `onClick={onSort}` passes a React synthetic // event as arg 0 - reject anything that isn't a string and fall back // to the state input. const submitInput = typeof inputOverride === 'string' ? inputOverride : input; // Snapshot the about-to-be-replaced result so the [diff] button can // surface what changed. Skip if there's nothing meaningful yet. previousResultRef.current = snapshotForDiff(_liveSortData); clearTimers(); setState('loading'); setProgress(15); setCounts({ cached: 0, queued: 0, parsing: 0, warnings: 0 }); try { const res = await fetch('/api/sort', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ input: submitInput, rules, pz_build: pzBuild }) }); if (!res.ok) { const txt = await res.text(); console.error('sort failed', res.status, txt); const detail = (() => { try { return JSON.parse(txt).detail || txt; } catch { return txt || `status ${res.status}`; } })(); _liveSortData = { WARNINGS: [{ tag: 'retry', level: 'red', msg: `sort failed (${res.status}): ${detail}` }], }; setState('error'); setProgress(100); setCounts({ cached: 0, queued: 0, parsing: 0, warnings: 1 }); return; } const json = await res.json(); // Server accepted the input — clear the staged-edit pending cue. Both // sync and async paths fall through here; failure paths early-return // above and DO NOT update this ref (so the cue persists on retry). lastSortedInputRef.current = submitInput; if (json.job_id) { // Async path - start polling and let the loop drive state. if (pollAbortRef.current) { pollAbortRef.current.abort(); } const ctrl = new AbortController(); pollAbortRef.current = ctrl; setActiveJobId(json.job_id); pollJobLoop(json.job_id, ctrl.signal, (r) => { if (r.kind !== 'ok') return; const b = r.body; if (b.result) { _liveSortData = b.result; sortContextRef.current = { workshopItemsLine: (b.result.WORKSHOP_ITEMS_LINE) || '', originalQueued: (b.result.pending || []).length, unknown: b.result.unknown || [], nonMod: b.result.non_mod || [], }; } const total = b.counts.cached + b.counts.queued + b.counts.draining; setProgress( b.phase === 'expanding' ? 5 : 5 + Math.round(90 * b.counts.cached / Math.max(1, total)) ); // The legacy UI label is "parsing"; backend phase is "draining". Same thing. setCounts({ cached: b.counts.cached, queued: b.counts.queued, parsing: b.counts.draining, warnings: ((b.result && b.result.WARNINGS) || []).length, unknown: ((b.result && b.result.unknown) || []).length, nonMod: ((b.result && b.result.non_mod) || []).length, }); setState(b.phase); }).then((final) => { setActiveJobId(null); pollAbortRef.current = null; if (final.kind === 'expired') { setState('error'); _liveSortData = { ...(_liveSortData || {}), WARNINGS: [ ...((_liveSortData?.WARNINGS) || []), { tag: 'retry', level: 'red', msg: 'this job expired - re-submit' }, ], }; } else if (final.kind === 'error') { setState('error'); _liveSortData = { ...(_liveSortData || {}), WARNINGS: [ ...((_liveSortData?.WARNINGS) || []), { tag: 'retry', level: 'red', msg: `polling failed (status ${final.status}) - re-submit` }, ], }; } }); return; } // Sync fast path - existing code follows. _liveSortData = json; const unknownArr = json.unknown || []; const nonModArr = json.non_mod || []; sortContextRef.current = { workshopItemsLine: json.WORKSHOP_ITEMS_LINE || '', originalQueued: (json.pending || []).length, unknown: unknownArr, nonMod: nonModArr, }; const cached = (json.MOD_DB || []).length; const queued = (json.pending || []).length; const warns = (json.WARNINGS || []).length; setProgress(100); setCounts({ cached, queued, parsing: 0, warnings: warns, unknown: unknownArr.length, nonMod: nonModArr.length, }); setState(json.status || 'success'); } catch (e) { console.error('sort threw', e); _liveSortData = { WARNINGS: [{ tag: 'retry', level: 'red', msg: `network error: ${e && e.message ? e.message : String(e)}` }], }; setState('error'); setProgress(100); setCounts({ cached: 0, queued: 0, parsing: 0, warnings: 1 }); } } function onClear() { clearTimers(); setInput(''); setRules(''); setState('idle'); setProgress(0); setCounts({ cached: 0, queued: 0, parsing: 0, warnings: 0 }); } return (
    setView('reports')} onSort={() => setView('sort')} /> {view === 'reports' && ( setView('sort')} /> )} {view === 'sort' && (
    {/* LEFT */}
    pick first - we'll flag mods tagged for the other build
    workshop ids or collection url {parseWorkshopInput(input).length} ids