// 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;
}
});
// ── icons ───────────────────────────────────────────────────────────────────
const IconCopy = () => (
);
const IconCheck = () => (
);
const IconGit = () => (
);
// ── small helpers ───────────────────────────────────────────────────────────
function CopyBtn({ text, label = 'copy', minimal = false }) {
const [copied, setCopied] = useState(false);
const onClick = async () => {
try {
await navigator.clipboard.writeText(text);
} catch (e) {
// fallback
const ta = document.createElement('textarea');
ta.value = text; document.body.appendChild(ta); ta.select();
try { document.execCommand('copy'); } catch (_) {}
document.body.removeChild(ta);
}
setCopied(true);
setTimeout(() => setCopied(false), 1200);
};
if (minimal) {
return (
);
}
return (
);
}
// ── header & footer ─────────────────────────────────────────────────────────
function Header({ tagline }) {
return (
);
}
function Footer() {
return (
);
}
// ── empty-state variants ────────────────────────────────────────────────────
function EmptyRight({ variant }) {
if (variant === 'bare') {
return (
paste workshop ids on the left, then hit sort.
› output appears here. one line per servertest field.
);
}
if (variant === 'worked') {
return (
what you'll get back
WorkshopItems=2169435993;2392709985;…
Mods=modoptions;tsarslib;…
Map=Muldraugh, KY
↑ paste these into your servertest.ini
);
}
// docs
return (
how this works
read once, never again
- paste numeric workshop ids (one per line) or a single
steamcommunity.com collection url.
- optionally provide
sorting_rules.txt to pin libs first / maps last / mark incompatibles.
- hit sort. we resolve dependencies, apply your rules, and emit three lines for
servertest.ini.
- copy each line. paste. boot the server. probably works.
we cache mod metadata for ~24h. cold-cache lookups take roughly 30s. you've been warned.
);
}
// ── status strip ────────────────────────────────────────────────────────────
function StatusStrip({ state, counts, progress }) {
if (state === 'idle' || state === 'success' || state === 'error' || state === 'cold') {
return (
{state === 'idle' && 'ready when you are'}
{state === 'success' && `done. ${counts.cached} mods, ${counts.warnings} warnings`}
{state === 'error' && 'something went sideways'}
{state === 'cold' && 'cache miss — be patient'}
);
}
return (
{counts.cached} cached
{counts.queued} queued
{counts.parsing} parsing
);
}
// ── output: three success variants ──────────────────────────────────────────
function SuccessStacked({ data }) {
return (
<>
WorkshopItems · {data.count} ids
WorkshopItems={D.WORKSHOP_ITEMS_LINE}
Mods · {data.count} mods
Mods={D.MODS_LINE}
Map · 1 map detected
Map={D.MAP_LINE}
>
);
}
function SuccessCompact({ data }) {
return (
WorkshopItems
{D.WORKSHOP_ITEMS_LINE}
);
}
function SuccessNumbered({ data }) {
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: D.MODS_LINE, meta: `${data.count} mods` },
{ num: '03', key: 'Map', val: D.MAP_LINE, meta: '1 map' },
];
return (
<>
{blocks.map(b => (
{b.num}
{b.key}
{b.meta}
{b.val}
))}
>
);
}
// ── warnings ─────────────────────────────────────────────────────────────────
function Warnings({ items, defaultOpen = true }) {
const [open, setOpen] = useState(defaultOpen);
if (!items || items.length === 0) return null;
const reds = items.filter(w => w.level === 'red').length;
const ambers = items.length - reds;
return (
setOpen(!open)}>
warnings
{reds > 0 && {reds}}
{ambers > 0 && {ambers}}
{reds > 0 ? 'fix these or your server will sulk' : 'non-fatal but read them'}
{open ? '▾' : '▸'}
{open && (
{items.map((w, i) => (
-
{w.tag}
{w.msg}
))}
)}
);
}
// ── mod details table ───────────────────────────────────────────────────────
function ModTable({ defaultOpen = false }) {
const [open, setOpen] = useState(defaultOpen);
const ordered = D.SORTED_ORDER.map(id => D.MOD_DB.find(m => m.modId === id));
return (
setOpen(!open)}>
mod details
· {ordered.length} mods
why everything ended up where it did
{open ? '▾' : '▸'}
{open && (
| # |
mod id |
workshop id |
category |
dependencies |
load |
{ordered.map((m, i) => (
| {String(i + 1).padStart(2, '0')} |
{m.modId} |
{m.wsid} |
{m.cat} |
{m.deps && m.deps.length ? m.deps.join(', ') : '—'} |
{m.pos === 'first' && first}
{m.pos === 'last' && last}
{!m.pos && —}
|
))}
)}
);
}
// ── right column dispatcher ────────────────────────────────────────────────
function RightColumn({ state, counts, progress, emptyVariant, successVariant, modTableDefault }) {
// Right-column content depending on state
const showWarnings = state === 'success' || state === 'partial';
const showOutputs = state === 'partial' || state === 'success';
return (
<>
{/* Warnings ABOVE outputs (always-inline treatment) */}
{showWarnings && (
)}
{/* Cold cache banner */}
{state === 'cold' && (
cold
indexing 3 mods we haven't seen before. try again in ~30s. or don't, we're not your boss.
)}
{/* Error banner */}
{state === 'error' && (
err
steam workshop returned a 503. classic. cached results below are still valid.
)}
{/* Output area */}
{state === 'idle' && }
{state === 'loading' && (
parsing your collection…
resolving dependencies. applying rules. probably fine.
)}
{showOutputs && (
<>
{state === 'partial' && (
showing cached subset · {counts.queued} mods still in flight
)}
{successVariant === 'stacked' && }
{successVariant === 'compact' && }
{successVariant === 'numbered' && }
>
)}
{/* Mod details — only when we have something to show */}
{(state === 'success' || state === 'partial') && (
)}
>
);
}
// ── default tweak values ────────────────────────────────────────────────────
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"emptyVariant": "docs",
"successVariant": "stacked",
"stateOverride": "auto",
"showTagline": true,
"modTableDefault": false,
"accentHue": 155
}/*EDITMODE-END*/;
// ── main app ────────────────────────────────────────────────────────────────
function App() {
const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
const [state, setState] = useState('idle'); // idle | loading | partial | success | error | cold
const [counts, setCounts] = useState({ cached: 0, queued: 0, parsing: 0, warnings: 0 });
const [progress, setProgress] = useState(0);
const [input, setInput] = useState(D.SAMPLE_INPUT);
const [rules, setRules] = useState('');
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() {
if (t.stateOverride !== 'auto') {
// user is in preview mode; ignore real flow
return;
}
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, rules })
});
if (!res.ok) {
const txt = await res.text();
console.error('sort failed', res.status, txt);
_liveSortData = null;
setState('error');
setProgress(100);
setCounts({ cached: 0, queued: 0, parsing: 0, warnings: 1 });
return;
}
const json = await res.json();
_liveSortData = json;
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 });
setState(json.status || 'success');
} catch (e) {
console.error('sort threw', e);
_liveSortData = null;
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 (
{/* LEFT */}
workshop ids or collection url
{input.split('\n').filter(Boolean).length} lines
sorting rules (optional)
sorting_rules.txt
{rules ? `${rules.split('\n').length} lines` : 'empty — defaults will apply'}
{(input.trim() || state !== 'idle') && (
)}
state: {state}{t.stateOverride !== 'auto' ? ' (forced via tweaks)' : ''}
{/* RIGHT */}
{(typeof window !== 'undefined' &&
new URLSearchParams(window.location.search).has('tweaks')) && (
setTweak('stateOverride', v)}
/>
{ setTweak('stateOverride', 'auto'); setTimeout(onSort, 50); }} />
setTweak('emptyVariant', v)}
/>
setTweak('successVariant', v)}
/>
setTweak('showTagline', v)} />
setTweak('modTableDefault', v)} />
setTweak('accentHue', v)} />
)}
);
}
ReactDOM.createRoot(document.getElementById('root')).render();