Files
sortof/frontend/sortof-app.jsx
indifferentketchup f8b48fbacb refactor: diff panel now compares input wsids → sorted output
Drops the prev-sort snapshot ref. Diff is always available — no "sort once
first" empty state — and surfaces drops (banned/missing/collection IDs that
expanded), additions (collection expansion, branch picks), and reorderings
in one pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 19:10:52 +00:00

2091 lines
86 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
// <thead> in ModTable; expansion-panel <tr colSpan> 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 <span className={className}>{children ?? wsid}</span>;
return (
<a
href={wsidUrl(wsid)}
target="_blank"
rel="noopener noreferrer"
className={className ? className + ' wsid-link' : 'wsid-link'}
title={`open Workshop page for ${wsid}`}
>{children ?? wsid}</a>
);
}
// Compare the wsids the user pasted (input order, deduped) against the wsids
// in WORKSHOP_ITEMS_LINE (sort order). Always available — no "previous sort"
// required. Surfaces drops (banned / missing mod.info / unknown / collection
// IDs that expanded), additions (collection expansion, branch picks), and
// position changes.
function computeDiff(inputWsids, outputWsids) {
if (!inputWsids || !outputWsids) return null;
const inSet = new Set(inputWsids);
const outSet = new Set(outputWsids);
const added = outputWsids.filter(w => !inSet.has(w));
const removed = inputWsids.filter(w => !outSet.has(w));
const inPos = new Map(inputWsids.map((w, i) => [w, i]));
const movers = [];
outputWsids.forEach((w, oi) => {
if (!inSet.has(w)) return;
const ii = inPos.get(w);
if (ii !== oi) movers.push({ wsid: w, from: ii, to: oi, delta: oi - ii });
});
movers.sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta));
return { added, removed, movers };
}
function DiffPanel({ inputWsids, outputWsids, onClose }) {
const diff = computeDiff(inputWsids, outputWsids);
if (!diff) {
return (
<div className="diff-panel">
<div className="diff-head">
<span className="diff-title">diff: input sorted</span>
<button type="button" className="diff-close" onClick={onClose}>close</button>
</div>
<div className="diff-empty">no input or output yet.</div>
</div>
);
}
const { added, removed, movers } = diff;
const empty = !added.length && !removed.length && !movers.length;
return (
<div className="diff-panel">
<div className="diff-head">
<span className="diff-title">diff: input sorted</span>
<span className="diff-summary">
<span className="diff-stat add">+{added.length}</span>
<span className="diff-stat rm">{removed.length}</span>
<span className="diff-stat mv">{movers.length}</span>
</span>
<button type="button" className="diff-close" onClick={onClose}>close</button>
</div>
{empty && <div className="diff-empty">order matches your input. nothing dropped or added.</div>}
{added.length > 0 && (
<div className="diff-section">
<div className="diff-label">added ({added.length}) collection expansion or branch-picker</div>
{added.slice(0, 30).map(w => <div key={w} className="diff-row diff-add">+ {w}</div>)}
{added.length > 30 && <div className="diff-more">and {added.length - 30} more</div>}
</div>
)}
{removed.length > 0 && (
<div className="diff-section">
<div className="diff-label">removed ({removed.length}) dropped, banned, missing mod.info, or a collection ID that expanded</div>
{removed.slice(0, 30).map(w => <div key={w} className="diff-row diff-rm"> {w}</div>)}
{removed.length > 30 && <div className="diff-more">and {removed.length - 30} more</div>}
</div>
)}
{movers.length > 0 && (
<div className="diff-section">
<div className="diff-label">reordered by load order ({movers.length}, top {Math.min(10, movers.length)} shown)</div>
{movers.slice(0, 10).map(({ wsid, from, to, delta }) => (
<div key={wsid} className="diff-row diff-mv">
<span className="diff-arrow">{delta < 0 ? '↑' : '↓'}</span>
{wsid}
<span className="diff-pos">pos {from + 1} {to + 1} ({delta > 0 ? '+' : ''}{delta})</span>
</div>
))}
</div>
)}
</div>
);
}
// ── icons ───────────────────────────────────────────────────────────────────
const IconCopy = () => (
<svg className="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
);
const IconCheck = () => (
<svg className="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12"></polyline>
</svg>
);
const IconGit = () => (
<svg viewBox="0 0 24 24" fill="currentColor" width="14" height="14">
<path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.3.8-.6v-2c-3.2.7-3.9-1.5-3.9-1.5-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.8-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.4-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.2 1.2.9-.3 1.9-.4 2.9-.4 1 0 2 .1 2.9.4 2.2-1.5 3.2-1.2 3.2-1.2.6 1.6.2 2.8.1 3.1.7.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.6 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.7 18.3.5 12 .5z"/>
</svg>
);
// ── 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 (
<button className={'cg-cp' + (copied ? ' copied' : '')} onClick={onClick} title="copy">
{copied ? <IconCheck /> : <IconCopy />}
</button>
);
}
return (
<button className={'copy-btn' + (copied ? ' copied' : '')} onClick={onClick}>
{copied ? <IconCheck /> : <IconCopy />}
{copied ? 'copied' : label}
</button>
);
}
// ── header & footer ─────────────────────────────────────────────────────────
function Header({ tagline, view, onReport, onSort }) {
return (
<header className="app">
<div className="brand">
<a
href="https://indifferentketchup.com"
target="_blank"
rel="noopener noreferrer"
aria-label="indifferent ketchup"
className="brand-mark-link"
>
<img
src="/img/ketchup_bottle.png"
alt=""
className="brand-mark"
width="40"
height="40"
/>
</a>
<span className="wordmark">sortof<span className="dot">.</span></span>
{tagline && <span className="tagline">{tagline}</span>}
</div>
<div className="head-right">
{view === 'reports' ? (
<button type="button" className="icon-btn report" onClick={onSort}>
back to sort
</button>
) : (
<button type="button" className="icon-btn report" onClick={onReport}>
report broken mod
</button>
)}
<a className="icon-btn" href="#" onClick={(e) => e.preventDefault()}>
<IconGit /> github
</a>
<a className="icon-btn" href="#" onClick={(e) => e.preventDefault()}>
docs
</a>
</div>
</header>
);
}
function Footer() {
return (
<footer className="app">
<div className="left">
<span>based on{' '}
<a className="footer-link footer-link-info"
href="https://steamcommunity.com/sharedfiles/filedetails/?id=3423660713"
target="_blank" rel="noopener noreferrer">
REfRigERatoR's mod load order sorter
</a>
</span>
</div>
<div className="right">
<span>
a thing by{' '}
<a className="footer-link footer-link-brand"
href="https://indifferentketchup.com"
target="_blank" rel="noopener noreferrer">
indifferent ketchup <span className="ik-mark" aria-hidden="true">(:|)</span>
</a>
</span>
</div>
</footer>
);
}
// ── empty-state variants ────────────────────────────────────────────────────
function EmptyRight({ variant }) {
if (variant === 'bare') {
return (
<div className="right-empty">
<span className="big">no mods. or maybe loads of them. hard to tell.</span>
<span><span className="arrow"></span> paste workshop ids on the left, hit sort. output lands here.</span>
</div>
);
}
if (variant === 'worked') {
return (
<div className="right-empty">
<span className="big">what you'll get back</span>
<div className="ex">
<div><span style={{ color: 'var(--acc-blue)' }}>WorkshopItems</span>=2169435993;2392709985;
</div>
<div><span style={{ color: 'var(--acc-blue)' }}>Mods</span>=modoptions;tsarslib;</div>
<div><span style={{ color: 'var(--acc-blue)' }}>Map</span>=Muldraugh, KY</div>
<div className="ink-mut" style={{ marginTop: 6, fontSize: 11.5 }}> paste these into your server's .ini</div>
</div>
</div>
);
}
// docs
return (
<div className="docs-panel">
<div className="dp-head">
<span>how this works</span>
<span style={{ color: 'var(--fg-2)', fontSize: 11.5 }}>read once, never again</span>
</div>
<div className="dp-body">
<ol>
<li>paste workshop ids - newlines, semicolons, commas, or urls all work. one mod per id.</li>
<li>optionally provide <code>sorting_rules.txt</code> to pin libs first / maps last / mark incompatibles.</li>
<li>hit sort. we resolve dependencies, apply your rules, and emit three lines for your server's <code>.ini</code>.</li>
<li>copy each line. paste. boot the server. probably works.</li>
</ol>
<div className="hint">we cache mod metadata until the author publishes an update. cold-cache lookups take roughly 30s. you've been warned.</div>
</div>
</div>
);
}
// ── 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) => (
<span className={'status-pill ' + cls} title={title || undefined}>
<span className="status-glyph" aria-hidden="true">{glyph}</span>
<span className="status-label">{label}</span>
</span>
);
// 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 <div className="status-strip">{pill('idle', '', 'idle')}</div>;
if (state === 'success' || state === 'done')
return <div className="status-strip">{pill('done', '', `done. ${counts.cached} mods, ${counts.warnings} warnings`)}</div>;
if (state === 'error') return <div className="status-strip">{pill('failed', '', 'failed. something went sideways')}</div>;
if (state === 'failed') return <div className="status-strip">{pill('failed', '', "failed. that didn't work")}</div>;
if (state === 'cold') return <div className="status-strip">{pill('warning', '⚠', 'cache miss. cold-cache lookups take ~30s')}</div>;
}
if (state === 'expanding') {
return (
<div className="status-strip">
{pill('working', '⏵', 'working. expanding collection…')}
<div className="progress-bar"><i style={{ width: `${progress}%` }}></i></div>
</div>
);
}
// 'queued' / 'draining' / legacy 'partial' / legacy 'loading' - live counts.
return (
<div className="status-strip">
{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.)")}
<div className="progress-bar"><i style={{ width: `${progress}%` }}></i></div>
</div>
);
}
// ── output: three success variants ──────────────────────────────────────────
function SuccessStacked({ data, pzBuild }) {
const modsLine = buildModsLine(D.SORTED_ORDER, pzBuild);
return (
<>
<div className="code-block">
<div className="cb-head">
<span><span className="cb-key">WorkshopItems</span> <span className="cb-meta">· {data.count} ids</span></span>
<CopyBtn text={`WorkshopItems=${D.WORKSHOP_ITEMS_LINE}`} />
</div>
<pre><span className="ink-key">WorkshopItems</span><span className="ink-sep">=</span>{D.WORKSHOP_ITEMS_LINE}</pre>
</div>
<div className="code-block">
<div className="cb-head">
<span><span className="cb-key">Mods</span> <span className="cb-meta">· {data.count} mods · {pzBuild}</span></span>
<CopyBtn text={`Mods=${modsLine}`} />
</div>
<pre><span className="ink-key">Mods</span><span className="ink-sep">=</span>{modsLine}</pre>
</div>
<div className="code-block">
<div className="cb-head">
<span><span className="cb-key">Map</span> <span className="cb-meta">· 1 map detected</span></span>
<CopyBtn text={`Map=${D.MAP_LINE}`} />
</div>
<pre><span className="ink-key">Map</span><span className="ink-sep">=</span>{D.MAP_LINE}</pre>
</div>
</>
);
}
function SuccessCompact({ data, pzBuild }) {
const modsLine = buildModsLine(D.SORTED_ORDER, pzBuild);
return (
<div className="compact-grid">
<div className="cg-row">
<div className="cg-key">WorkshopItems</div>
<div className="cg-val">{D.WORKSHOP_ITEMS_LINE}</div>
<CopyBtn text={`WorkshopItems=${D.WORKSHOP_ITEMS_LINE}`} minimal />
</div>
<div className="cg-row">
<div className="cg-key">Mods <span className="cg-build">· {pzBuild}</span></div>
<div className="cg-val">{modsLine}</div>
<CopyBtn text={`Mods=${modsLine}`} minimal />
</div>
<div className="cg-row">
<div className="cg-key">Map</div>
<div className="cg-val">{D.MAP_LINE}</div>
<CopyBtn text={`Map=${D.MAP_LINE}`} minimal />
</div>
</div>
);
}
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 => (
<div key={b.key} className="numbered-block">
<div className="nb-head">
<span className="nb-num">{b.num}</span>
<span className="nb-key">{b.key}</span>
<span className="nb-meta">{b.meta}</span>
</div>
<pre className="nb-pre">{b.val}</pre>
<div className="nb-cp">
<CopyBtn text={`${b.key}=${b.val}`} />
</div>
</div>
))}
</>
);
}
// ── warnings ─────────────────────────────────────────────────────────────────
// Render a warning message with the leading mod name turned into a Workshop
// hyperlink. Backend warnings start with "<modId> ..." (or "Mod '<modId>' ..."
// 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 <span className="w-msg">{msg}</span>;
// Match "<word>" at start (e.g., "ModId requires X"), or "Mod '<word>'"
// (duplicate-mod_id phrasing). Bare-id form is the common case.
const m = msg.match(/^(Mod ')([^']+)(')(.*)$/) || msg.match(/^([^\s]+)(.*)$/);
if (!m) return <span className="w-msg">{msg}</span>;
if (m.length === 5) {
const [, lead, name, close, rest] = m;
return (
<span className="w-msg">
{lead}
<a href={wsidUrl(wsid)} target="_blank" rel="noopener noreferrer"
className="w-msg-link" title={`open Workshop page for ${wsid}`}>{name}</a>
{close}{rest}
</span>
);
}
const [, name, rest] = m;
return (
<span className="w-msg">
<a href={wsidUrl(wsid)} target="_blank" rel="noopener noreferrer"
className="w-msg-link" title={`open Workshop page for ${wsid}`}>{name}</a>
{rest}
</span>
);
}
// 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 (
<li className={rowStaged ? 'staged' : undefined}>
<span className={'w-tag ' + w.level}>
<span aria-hidden="true" className="w-tag-glyph">{w.level === 'red' ? '!' : '⚠'}</span>
{w.tag}
</span>
<div className="w-content">
<WarnMsg msg={w.msg} wsid={w.wsid} />
{Array.isArray(w.actions) && w.actions.map((a, j) => {
if (a.type === 'add-wsid') {
const staged = actionStaged(a, inputWsids);
return (
<button
key={j}
type="button"
className={'warn-action' + (staged ? ' staged' : '')}
onClick={() => onAddWsid && onAddWsid(a.wsid, a.modId)}
title={staged ? `click to undo (will remove ${a.wsid})` : `add ${a.wsid} to your input — click sort when ready`}
>{staged ? `${a.label}` : a.label}</button>
);
}
if (a.type === 'swap-wsid') {
const staged = actionStaged(a, inputWsids);
return (
<button
key={j}
type="button"
className={'warn-action swap' + (staged ? ' staged' : '')}
onClick={() => onSwapWsid && onSwapWsid(a.from, a.to)}
title={staged ? `click to undo (will swap back to ${a.from})` : `replace ${a.from} with ${a.to} — click sort when ready`}
>{staged ? '✓' : '↔'} {a.label}</button>
);
}
if (a.type === 'remove-wsid') {
const staged = actionStaged(a, inputWsids);
return (
<button
key={j}
type="button"
className={'warn-action remove' + (staged ? ' staged' : '')}
onClick={() => onRemoveWsid && onRemoveWsid(a.wsid)}
title={staged ? `click to undo (will re-add ${a.wsid})` : `remove ${a.wsid} from your input — click sort when ready`}
>{staged ? '✓' : '✕'} {a.label}</button>
);
}
if (a.type === 'search-workshop') {
return (
<a
key={j}
href={a.url}
target="_blank"
rel="noopener noreferrer"
className="warn-action search"
title={`search Steam Workshop for ${a.modId}`}
> {a.label}</a>
);
}
return null;
})}
{hasPicker && (
<>
<button
type="button"
className="warn-action expand"
onClick={() => setBranchOpen(b => !b)}
title={w.tag === 'unmatched-addons' ? 'tick addons to include' : 'pick a different branch'}
>{branchOpen ? '▾ branches' : '▸ branches'}</button>
{branchOpen && (
<div className="warn-branches">
{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 (
<button
key={modId}
type="button"
className={'warn-branch-btn' + (isActive ? ' picked' : '')}
onClick={() => {
if (isAdditive) {
// Primary always stays loaded - no-op on clicking it.
if (isPrimary) return;
onToggleBranch && onToggleBranch(
w.wsid, modId,
w.alternatives.map(id => ({ modId: id })),
);
} else {
if (isActive) return;
onPickBranch && onPickBranch(w.wsid, modId, w.alternatives);
}
}}
title={
isPrimary && isAdditive ? 'main (always loaded)' :
isActive ? (isAdditive ? 'addon enabled - click to remove' : 'currently selected (main)') :
isAdditive ? `enable ${modId}` : `switch to ${modId}`
}
>
{isActive ? '★ ' : ''}{modId}
</button>
);
})}
</div>
)}
</>
)}
</div>
</li>
);
}
// 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 (
<div className="auto-fix-bar" role="group" aria-label="auto-fix actions">
{addCount > 0 && (
<button
type="button"
className="auto-fix-btn add"
onClick={onAddDeps}
title="stage every missing dependency. click sort when ready."
>+ add {addCount} {addCount === 1 ? 'dependency' : 'dependencies'}</button>
)}
{swapCount > 0 && (
<button
type="button"
className="auto-fix-btn swap"
onClick={onFixMismatches}
title="stage every build-mismatch swap. click sort when ready."
> fix {swapCount} mismatched build{swapCount === 1 ? '' : 's'}</button>
)}
{removeCount > 0 && (
<button
type="button"
className="auto-fix-btn remove"
onClick={onRemoveIncorrect}
title="stage removal of build-mismatch mods with no B42 successor. click sort when ready."
> remove {removeCount} incorrect build{removeCount === 1 ? '' : 's'}</button>
)}
</div>
);
}
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 (
<div className="panel warn-section">
<div className="warn-head" onClick={() => setOpen(!open)}>
<span>warnings</span>
{reds > 0 && (
<span className="badge red" title={`${reds} error warning${reds === 1 ? '' : 's'}`}>
<span aria-hidden="true">!</span> {reds}
</span>
)}
{ambers > 0 && (
<span className="badge" title={`${ambers} caution warning${ambers === 1 ? '' : 's'}`}>
<span aria-hidden="true"></span> {ambers}
</span>
)}
<span style={{ color: 'var(--fg-2)', fontSize: 11.5 }}>
{reds > 0 ? 'fix these or your server will sulk' : 'non-fatal but read them'}
</span>
<span className="chev" aria-hidden="true">{open ? '▾' : '▸'}</span>
</div>
{open && (
<ul className="warn-list">
{items.map((w, i) => (
<WarnRow key={i} w={w} inputWsids={inputWsids} onAddWsid={onAddWsid} onPickBranch={onPickBranch} onSwapWsid={onSwapWsid} onRemoveWsid={onRemoveWsid} onToggleBranch={onToggleBranch} />
))}
</ul>
)}
</div>
);
}
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 (
<tr>
<td colSpan={COLUMN_COUNT} className="branch-panel">
<div className="branch-panel-inner">
<div className="branch-panel-meta">
workshop {wsid} · {branches.length} mod_ids · {radio ? 'pick one' : 'multi-select'}
</div>
{branches.map(b => {
const checked = effective.includes(b.modId);
return (
<label key={b.modId} className={'branch-row' + (checked ? ' is-checked' : '')}>
<input
type={inputType}
name={radio ? `branch-${wsid}` : undefined}
className="branch-input"
checked={checked}
onChange={() => onToggle(wsid, b.modId, branches)}
/>
<WsidLink wsid={wsid} className="branch-modid">{b.modId}</WsidLink>
<span className="branch-name">{b.name}</span>
<span className={'cat ' + b.cat}>{b.cat}</span>
<span className="branch-deps">
{b.deps && b.deps.length ? b.deps.join(', ') : '-'}
</span>
<span className="branch-pos">
{b.pos === 'first' ? 'first' : (b.pos === 'last' ? 'last' : '-')}
</span>
{b.hint && <span className="branch-hint">{b.hint}</span>}
</label>
);
})}
</div>
</td>
</tr>
);
}
// ── 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 <tr colSpan={COLUMN_COUNT}> 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 (
<div className="panel table-section">
<div className="tbl-head" onClick={() => setOpen(!open)}>
<span>mod details</span>
<span className="count">· {(D.SORTED_ORDER || []).length} mods</span>
<span style={{ color: 'var(--fg-2)', fontSize: 11.5, marginLeft: 8 }}>
why everything ended up where it did
</span>
<span className="chev">{open ? '▾' : '▸'}</span>
</div>
{open && (
<table className="mods-table">
<thead>
<tr>
<th>#</th>
<th>mod id</th>
<th>workshop id</th>
<th>category</th>
<th>dependencies</th>
<th>load</th>
</tr>
</thead>
<tbody>
{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 (
<tr key={spec.wsid} className={'mod-row mod-row-' + rowState}>
<td className="idx">
<span className="row-state-glyph" aria-hidden="true">{stateGlyph}</span>{idx}
</td>
<td><WsidLink wsid={spec.wsid} className="modid">{m.modId}</WsidLink></td>
<td><span className="wsid">{spec.wsid}</span></td>
<td><span className={'cat ' + m.cat}>{m.cat}</span></td>
<td><span className="deps">{m.deps && m.deps.length ? m.deps.join(', ') : '-'}</span></td>
<td>
{m.pos === 'first' && <span className="pos first">first</span>}
{m.pos === 'last' && <span className="pos last">last</span>}
{!m.pos && <span className="pos">-</span>}
</td>
</tr>
);
}
// 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 (
<React.Fragment key={spec.wsid}>
<tr className={'mod-row mod-row-' + rowState}>
<td className="idx">
<span className="row-state-glyph" aria-hidden="true">{stateGlyph}</span>{idx}
</td>
<td>
<button className="branch-affordance"
aria-expanded={expanded}
onClick={() => onToggleExpansion(spec.wsid)}>
{affordance}
</button>
</td>
<td><span className="wsid">{spec.wsid}</span></td>
<td>{display ? <span className={'cat ' + display.cat}>{display.cat}</span> : '-'}</td>
<td>{display && display.deps && display.deps.length ? display.deps.join(', ') : '-'}</td>
<td>
{display && display.pos === 'first' && <span className="pos first">first</span>}
{display && display.pos === 'last' && <span className="pos last">last</span>}
{(!display || !display.pos) && <span className="pos">-</span>}
</td>
</tr>
{expanded && (
<BranchPicker
wsid={spec.wsid}
branches={spec.branches}
selected={selected}
userTouched={userTouched}
onToggle={onToggleBranch}
/>
)}
</React.Fragment>
);
})}
</tbody>
</table>
)}
</div>
);
}
// ── right column dispatcher ────────────────────────────────────────────────
function BuildToggle({ value, onChange }) {
const opts = ['B41', 'B42'];
return (
<div className="build-toggle" role="radiogroup" aria-label="Project Zomboid build">
<span className="build-toggle-label">build</span>
{opts.map(o => (
<button
key={o}
role="radio"
aria-checked={value === o}
className={'build-toggle-btn' + (value === o ? ' is-active' : '')}
onClick={() => onChange(o)}
>{o}</button>
))}
</div>
);
}
function RightColumn({ state, counts, progress, emptyVariant, successVariant, modTableDefault, pzBuild, setPzBuild, branchSelections, onToggleBranch, expandedWsids, onToggleExpansion, inputWsids, onAddWsid, onPickBranch, onSwapWsid, onRemoveWsid, onAutoFixAddDeps, onAutoFixSwaps, onAutoFixRemoves, onRetry, inputWsidList, 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 && (
<>
<AutoFixBar
items={D.WARNINGS}
inputWsids={inputWsids}
onAddDeps={onAutoFixAddDeps}
onFixMismatches={onAutoFixSwaps}
onRemoveIncorrect={onAutoFixRemoves}
/>
<Warnings
items={D.WARNINGS}
defaultOpen={isTerminalDone}
inputWsids={inputWsids}
onAddWsid={onAddWsid}
onPickBranch={onPickBranch}
onSwapWsid={onSwapWsid}
onRemoveWsid={onRemoveWsid}
onToggleBranch={onToggleBranch}
/>
</>
)}
{/* Cold cache banner */}
{state === 'cold' && (
<div className="cold-banner">
<span className="err-tag"><span aria-hidden="true"></span> cold</span>
<div className="err-msg">
indexing <span style={{ color: 'var(--acc-info)' }}>{counts.queued} {counts.queued === 1 ? 'mod' : 'mods'}</span> we haven't seen before. try again in ~30s. or don't, we're not your boss.
</div>
</div>
)}
{/* 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 (
<div className="err-banner">
<span className="err-tag"><span aria-hidden="true"></span> err</span>
<div className="err-msg">
{reason}{hasCached ? ' · cached results below still valid' : ''}
<div className="err-actions">
<button type="button" className="copy-btn" onClick={() => onRetry && onRetry()}>retry</button>
</div>
</div>
</div>
);
})()}
{/* Output area */}
{state === 'idle' && <EmptyRight variant={emptyVariant} />}
{(state === 'loading' || state === 'expanding') && (
<div className="right-empty">
<span className="big">{state === 'expanding' ? 'expanding your collection…' : 'parsing your collection…'}</span>
<span style={{ color: 'var(--fg-2)' }}>resolving dependencies. applying rules. probably fine.</span>
</div>
)}
{showOutputs && (
<>
{isInflightPartial && (
<div style={{
fontFamily: 'var(--mono)', fontSize: 12, color: 'var(--acc-amber)',
padding: '4px 2px', display: 'flex', gap: 8, alignItems: 'center'
}}>
<span className="dot-led" style={{ background: 'var(--acc-amber)' }}></span>
showing cached subset · {counts.queued + (counts.parsing || 0)} mods still in flight
</div>
)}
<div className="diff-toggle-row">
<button
type="button"
className={'diff-toggle' + (diffOpen ? ' open' : '')}
onClick={() => setDiffOpen(o => !o)}
title="diff your input against the sorted output"
>
{diffOpen ? '▾ diff' : '▸ diff'}
</button>
</div>
{diffOpen && (
<DiffPanel
inputWsids={inputWsidList}
outputWsids={(D.WORKSHOP_ITEMS_LINE || '').replace(/;+$/, '').split(';').filter(Boolean)}
onClose={() => setDiffOpen(false)}
/>
)}
<BuildToggle value={pzBuild} onChange={setPzBuild} />
{successVariant === 'stacked' && <SuccessStacked data={{ count: D.SORTED_ORDER.length }} pzBuild={pzBuild} />}
{successVariant === 'compact' && <SuccessCompact data={{ count: D.SORTED_ORDER.length }} pzBuild={pzBuild} />}
{successVariant === 'numbered' && <SuccessNumbered data={{ count: D.SORTED_ORDER.length }} pzBuild={pzBuild} />}
</>
)}
{/* Mod details - only when we have something to show */}
{(isTerminalDone || isInflightPartial) && (
<ModTable
defaultOpen={modTableDefault}
branchSelections={branchSelections}
onToggleBranch={onToggleBranch}
expandedWsids={expandedWsids}
onToggleExpansion={onToggleExpansion}
/>
)}
</>
);
}
// ── 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 (
<main className="reports-panel">
<div className="reports-head">
<div className="reports-title">Reported broken mods</div>
<div className="reports-blurb">
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.
</div>
<form className="reports-form" onSubmit={onSubmit}>
<input
className="field"
type="text"
placeholder="workshop id (e.g. 2613146550)"
value={wsidIn}
onChange={(e) => setWsidIn(e.target.value)}
inputMode="numeric"
spellCheck={false}
/>
<select
className="field"
value={verIn}
onChange={(e) => setVerIn(e.target.value)}
>
{Object.entries(versions).map(([k, v]) => (
<option key={k} value={k}>{v} ({k})</option>
))}
</select>
<button
type="submit"
className="sort-btn"
disabled={submitting || !wsidIn.trim() || !verIn}
>
{submitting ? '…submitting' : '+ report'}
</button>
</form>
{error && <div className="reports-error">{error}</div>}
<input
className="field reports-search"
type="text"
placeholder="search by workshop id or mod name…"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className="reports-meta">
{filtered.length} {filtered.length === 1 ? 'report' : 'reports'}
{search && reports.length !== filtered.length
? ` (of ${reports.length})` : ''}
</div>
</div>
<ul className="reports-list">
{filtered.length === 0 && (
<li className="reports-empty">no reports yet be the first.</li>
)}
{filtered.map(r => {
const v = voted[r.id]; // 'up' | 'down' | undefined
const verLabel = versions[r.version] || r.version;
return (
<li key={r.id} className="report-row">
<div className="report-name">
<WsidLink wsid={r.workshop_id}>
{r.mod_name || r.workshop_id}
</WsidLink>
</div>
<div className="report-wsid">{r.workshop_id}</div>
<div className="report-ver">
<span className="cat ver-pill" title={r.version}>{verLabel}</span>
</div>
<div className="report-date">{fmtReportDate(r.updated_at)}</div>
<div className="report-votes">
<button
type="button"
className={'vote-btn up' + (v === 'up' ? ' voted' : '')}
onClick={() => onVote(r.id, 'up')}
disabled={!!v}
title={v === 'up' ? 'you upvoted' : 'agree (broken)'}
> {r.upvotes}</button>
<button
type="button"
className={'vote-btn down' + (v === 'down' ? ' voted' : '')}
onClick={() => onVote(r.id, 'down')}
disabled={!!v}
title={v === 'down' ? 'you downvoted' : 'disagree (works for me)'}
> {r.downvotes}</button>
</div>
</li>
);
})}
</ul>
</main>
);
}
// ── 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 toggle. The diff is input-vs-output (textarea wsids vs sorted
// WORKSHOP_ITEMS_LINE), recomputed live every render — no snapshot ref
// needed. Always available, even on the first sort.
const [diffOpen, setDiffOpen] = useState(false);
// Ordered list of wsids currently in the input textarea (deduped, first-seen
// order). The Set wrapper below is for warning rows that need O(1) `.has()`
// lookup when deriving their staged state. Both are memoized off `input` so
// re-renders triggered by unrelated state changes don't churn them.
const inputWsidList = useMemo(() => parseWorkshopInput(input), [input]);
const inputWsids = useMemo(() => new Set(inputWsidList), [inputWsidList]);
// 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) {
// 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;
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 (
<div className="shell">
<Header
tagline={t.showTagline ? tagline : null}
view={view}
onReport={() => setView('reports')}
onSort={() => setView('sort')}
/>
{view === 'reports' && (
<ReportedModsPanel onClose={() => setView('sort')} />
)}
{view === 'sort' && (
<main className="app">
{/* LEFT */}
<section className="col">
<div className="build-prepick">
<BuildToggle value={pzBuild} onChange={setPzBuild} />
<span className="build-prepick-hint">
pick first - we'll flag mods tagged for the other build
</span>
</div>
<div>
<div className="label-row">
<span className="label">workshop ids or collection url</span>
<span className="label-meta">{parseWorkshopInput(input).length} ids</span>
</div>
<textarea
className="field input-main"
placeholder={"2169435993;2392709985;2487022075\nor newline-separated, or paste a steamcommunity.com URL"}
value={input}
onChange={(e) => setInput(e.target.value)}
spellCheck={false}
/>
</div>
<details className="collapsible">
<summary>sorting rules (optional)</summary>
<div className="label-row">
<span className="label">sorting_rules.txt</span>
<span className="label-meta">{rules ? `${rules.split('\n').length} lines` : 'empty - defaults will apply'}</span>
</div>
<textarea
className="field input-rules"
placeholder={D.SAMPLE_RULES}
value={rules}
onChange={(e) => setRules(e.target.value)}
spellCheck={false}
/>
</details>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<button
type="button"
className={'sort-btn' + (sortPending && state !== 'loading' ? ' sort-pending' : '')}
onClick={onSort}
disabled={state === 'loading' || !input.trim()}
title={sortPending ? 'input has staged changes click to apply' : undefined}
>
{state === 'loading'
? <span className="spin" aria-hidden="true"></span>
: <span className="btn-glyph" aria-hidden="true">{(!input.trim()) ? '' : ''}</span>}
{state === 'loading' ? 'sorting' : (sortPending ? 'sort ' : 'sort')}
</button>
{activeJobId && (
<button
type="button"
className="cancel-btn"
style={{ height: 40, padding: '0 14px', width: 'auto', marginTop: 0 }}
onClick={() => {
const jobId = activeJobId;
if (pollAbortRef.current) pollAbortRef.current.abort();
setActiveJobId(null);
setState('idle');
setProgress(0);
setCounts({ cached: 0, queued: 0, parsing: 0, warnings: 0, unknown: 0, nonMod: 0 });
// Fire-and-forget DELETE; server handles idempotency.
fetch(`/api/jobs/${jobId}`, { method: 'DELETE' })
.catch((e) => console.warn('cancel DELETE failed', e));
}}
><span aria-hidden="true">✗</span> cancel</button>
)}
{(input.trim() || state !== 'idle') && (
<button type="button" className="clear-btn" onClick={onClear}>
<span aria-hidden="true">✗</span> clear
</button>
)}
</div>
<StatusStrip state={state} counts={counts} progress={progress} />
{(() => {
// State indicator: color + glyph + label triple per brief mapping.
const stateMap = {
idle: { cls: 'idle', glyph: '' },
loading: { cls: 'working', glyph: '' },
expanding: { cls: 'working', glyph: '' },
queued: { cls: 'queued', glyph: '' },
draining: { cls: 'draining', glyph: '' },
partial: { cls: 'draining', glyph: '' },
done: { cls: 'done', glyph: '' },
success: { cls: 'done', glyph: '' },
cold: { cls: 'warning', glyph: '' },
error: { cls: 'failed', glyph: '' },
failed: { cls: 'failed', glyph: '' },
};
const m = stateMap[state] || stateMap.idle;
return (
<div className={'state-tag state-tag-' + m.cls}>
<span className="state-tag-glyph" aria-hidden="true">{m.glyph}</span>
state: {state}{t.stateOverride !== 'auto' ? ' (forced via tweaks)' : ''}
</div>
);
})()}
</section>
{/* RIGHT */}
<section className="col">
<RightColumn
state={state}
counts={counts}
progress={progress}
emptyVariant={t.emptyVariant}
successVariant={t.successVariant}
modTableDefault={t.modTableDefault}
pzBuild={pzBuild}
setPzBuild={setPzBuild}
branchSelections={branchSelections}
onToggleBranch={onToggleBranch}
expandedWsids={expandedWsids}
onToggleExpansion={onToggleExpansion}
inputWsids={inputWsids}
onAddWsid={onAddWsid}
onPickBranch={onPickBranch}
onSwapWsid={onSwapWsid}
onRemoveWsid={onRemoveWsid}
onAutoFixAddDeps={onAutoFixAddDeps}
onAutoFixSwaps={onAutoFixSwaps}
onAutoFixRemoves={onAutoFixRemoves}
onRetry={onRetry}
inputWsidList={inputWsidList}
diffOpen={diffOpen}
setDiffOpen={setDiffOpen}
/>
</section>
</main>
)}
<Footer />
{(typeof window !== 'undefined' &&
new URLSearchParams(window.location.search).has('tweaks')) && (
<TweaksPanel title="Tweaks">
<TweakSection label="State preview" />
<TweakSelect
label="Force state"
value={t.stateOverride}
options={[
{ value: 'auto', label: 'auto (drive via Sort)' },
{ value: 'idle', label: 'idle / empty' },
{ value: 'loading', label: 'loading' },
{ value: 'partial', label: 'partial (cached + in-flight)' },
{ value: 'success', label: 'success' },
{ value: 'error', label: 'error' },
{ value: 'cold', label: 'cold cache miss' },
]}
onChange={(v) => setTweak('stateOverride', v)}
/>
<TweakButton label="run a fresh sort" onClick={() => { setTweak('stateOverride', 'auto'); setTimeout(onSort, 50); }} />
<TweakSection label="Empty-state variant" />
<TweakRadio
label="Layout"
value={t.emptyVariant}
options={[
{ value: 'bare', label: 'bare' },
{ value: 'worked', label: 'worked' },
{ value: 'docs', label: 'docs' },
]}
onChange={(v) => setTweak('emptyVariant', v)}
/>
<TweakSection label="Success-state variant" />
<TweakRadio
label="Output style"
value={t.successVariant}
options={[
{ value: 'stacked', label: 'stacked' },
{ value: 'compact', label: 'compact' },
{ value: 'numbered', label: 'numbered' },
]}
onChange={(v) => setTweak('successVariant', v)}
/>
<TweakSection label="Chrome" />
<TweakToggle label="Show tagline" value={t.showTagline} onChange={(v) => setTweak('showTagline', v)} />
<TweakToggle label="Mod details open by default" value={t.modTableDefault} onChange={(v) => setTweak('modTableDefault', v)} />
<TweakSlider label="Accent hue (green band)" value={t.accentHue} min={120} max={170} step={1} onChange={(v) => setTweak('accentHue', v)} />
</TweaksPanel>
)}
</div>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App />);