// 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 (
sortof. {tagline && {tagline}}
e.preventDefault()}> github e.preventDefault()}> docs
); } 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
  1. paste numeric workshop ids (one per line) or a single steamcommunity.com collection url.
  2. optionally provide sorting_rules.txt to pin libs first / maps last / mark incompatibles.
  3. hit sort. we resolve dependencies, apply your rules, and emit three lines for servertest.ini.
  4. 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}
Mods
{D.MODS_LINE}
Map
{D.MAP_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 && ( )}
); } // ── 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 && ( {ordered.map((m, i) => ( ))}
# mod id workshop id category dependencies load
{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