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>
2091 lines
86 KiB
JavaScript
2091 lines
86 KiB
JavaScript
// 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 />);
|