import { useEffect, useMemo, useState } from 'react'; import { api, get } from '../lib/api'; import { PageHeader } from '../components/PageHeader'; import { useToast } from '../components/Toast'; import { Plus, Trash, RefreshCw } from '../components/Icons'; // Difficulty values mirror the client CNMMMNJJDMM enum (verified in the // 2023.03.21 il2cpp dump). The Charades slot uses easy/hard/very-hard; the // Icebreakers slot uses its own value so cards group correctly in-game. const DIFFICULTIES = [ { value: 0, label: 'Easy' }, { value: 1, label: 'Hard' }, { value: 10, label: 'Very hard' }, { value: 20, label: 'Icebreaker' }, ] as const; // The three baked client card-source slots. Each is fed by whichever // library list its binding points at. const SLOTS = [ { key: 'charades' as const, title: 'Charades', blurb: 'The main 3D Charades deck players draw from.', }, { key: 'charadesAprilFoolsDay' as const, title: 'April Fools', blurb: 'The seasonal "impossible to explain" deck.', }, { key: 'icebreakers' as const, title: 'Icebreakers', blurb: 'Prompt-style cards for the Icebreakers card box.', }, ]; interface CharadesWord { text: string; difficulty: number; } interface WordList { id: number; name: string; words: CharadesWord[]; isBuiltIn: boolean; updatedAt: string; } interface SlotBindings { charades: number; charadesAprilFoolsDay: number; icebreakers: number; } interface WordListsResponse { lists: WordList[]; bindings: SlotBindings; } export function Charades({ embedded }: { embedded?: boolean } = {}) { const [lists, setLists] = useState(null); const [bindings, setBindings] = useState(null); const [err, setErr] = useState(null); const [busy, setBusy] = useState(false); const toast = useToast(); const load = async () => { try { const data = await get('/charades/wordlists'); setLists(data.lists); setBindings(data.bindings); setErr(null); } catch (e) { setErr((e as Error).message); } }; useEffect(() => { void load(); }, []); const updateLocal = (idx: number, next: WordList) => { setLists(cur => { if (!cur) return cur; const copy = cur.slice(); copy[idx] = next; return copy; }); }; const addNew = async () => { setBusy(true); try { const created = await api('/charades/wordlists', { method: 'POST', body: { Name: 'New list', Words: [] }, }); setLists([...(lists ?? []), created]); toast.push('List created.', 'success'); } catch (e) { toast.push((e as Error).message, 'error'); } finally { setBusy(false); } }; const saveList = async (idx: number) => { if (!lists) return; const l = lists[idx]; try { const saved = await api(`/charades/wordlists/${l.id}`, { method: 'PUT', body: { Name: l.name, Words: l.words }, }); updateLocal(idx, saved); toast.push('Saved.', 'success'); } catch (e) { toast.push((e as Error).message, 'error'); } }; const removeList = async (idx: number) => { if (!lists) return; const l = lists[idx]; const boundSlot = bindings && ( bindings.charades === l.id || bindings.charadesAprilFoolsDay === l.id || bindings.icebreakers === l.id); const warn = boundSlot ? ` It's currently live on a slot — that slot will fall back to its built-in list.` : ''; if (!confirm(`Delete list "${l.name || '(untitled)'}"?${warn}`)) return; try { await api(`/charades/wordlists/${l.id}`, { method: 'DELETE' }); setLists(lists.filter((_, i) => i !== idx)); // Reflect any binding fallback locally. setBindings(cur => cur ? { charades: cur.charades === l.id ? 0 : cur.charades, charadesAprilFoolsDay: cur.charadesAprilFoolsDay === l.id ? 0 : cur.charadesAprilFoolsDay, icebreakers: cur.icebreakers === l.id ? 0 : cur.icebreakers, } : cur); toast.push('Deleted.', 'success'); } catch (e) { toast.push((e as Error).message, 'error'); } }; const importInto = async (idx: number, text: string, defaultDifficulty: number, replace: boolean) => { if (!lists) return; const l = lists[idx]; try { const saved = await api(`/charades/wordlists/${l.id}/import`, { method: 'POST', body: { Text: text, DefaultDifficulty: defaultDifficulty, Replace: replace }, }); updateLocal(idx, saved); toast.push(replace ? 'List replaced from paste.' : 'Cards imported.', 'success'); } catch (e) { toast.push((e as Error).message, 'error'); } }; const saveBindings = async () => { if (!bindings) return; setBusy(true); try { const saved = await api('/charades/bindings', { method: 'PUT', body: { Charades: bindings.charades, CharadesAprilFoolsDay: bindings.charadesAprilFoolsDay, Icebreakers: bindings.icebreakers, }, }); setBindings(saved); toast.push('Live slots updated — effective on next card-box refresh.', 'success'); } catch (e) { toast.push((e as Error).message, 'error'); } finally { setBusy(false); } }; const headerActions = (
); return (
{embedded ? (
{headerActions}
) : ( )} {err && (
{err}
)} {lists === null && !err && (
Loading…
)} {lists && bindings && (

Live card slots

The client only ever requests these three card sources. Point each at any list in your library to switch decks — no redeploy. Changes apply the next time a card box spawns (room rejoin).

{SLOTS.map(slot => ( ))}
)} {lists && lists.length === 0 && (
No lists yet — add one with the button above.
)} {lists && (
{lists.map((l, i) => ( updateLocal(i, next)} onSave={() => saveList(i)} onRemove={() => removeList(i)} onImport={(text, diff, replace) => importInto(i, text, diff, replace)} /> ))}
)}
); } function liveSlotsFor(id: number, bindings: SlotBindings | null): string[] { if (!bindings) return []; const out: string[] = []; if (bindings.charades === id) out.push('Charades'); if (bindings.charadesAprilFoolsDay === id) out.push('April Fools'); if (bindings.icebreakers === id) out.push('Icebreakers'); return out; } function ListEditor({ list, liveSlots, onChange, onSave, onRemove, onImport, }: { list: WordList; liveSlots: string[]; onChange: (next: WordList) => void; onSave: () => void; onRemove: () => void; onImport: (text: string, defaultDifficulty: number, replace: boolean) => void; }) { const [open, setOpen] = useState(false); const [pasteText, setPasteText] = useState(''); const [pasteDifficulty, setPasteDifficulty] = useState(0); const [pasteReplace, setPasteReplace] = useState(false); const counts = useMemo(() => { const by = new Map(); for (const w of list.words) by.set(w.difficulty, (by.get(w.difficulty) ?? 0) + 1); return by; }, [list.words]); const setWord = (idx: number, patch: Partial) => { const words = list.words.slice(); words[idx] = { ...words[idx], ...patch }; onChange({ ...list, words }); }; const removeWord = (idx: number) => { onChange({ ...list, words: list.words.filter((_, i) => i !== idx) }); }; const addWord = () => { onChange({ ...list, words: [...list.words, { text: '', difficulty: 0 }] }); }; const runImport = () => { if (!pasteText.trim()) return; onImport(pasteText, pasteDifficulty, pasteReplace); setPasteText(''); }; return (
onChange({ ...list, name: e.target.value })} className="input text-sm font-semibold flex-1 min-w-0" maxLength={128} /> {list.isBuiltIn && built-in} {liveSlots.map(s => live: {s})} {list.words.length} cards
{DIFFICULTIES.map(d => counts.get(d.value) ? {d.label}: {counts.get(d.value)} : null)}
{open && (
{list.words.map((w, i) => (
setWord(i, { text: e.target.value })} placeholder="Card phrase" className="input text-xs flex-1" maxLength={256} />
))}

Import from paste

One phrase per line. Add | easy,{' '} | hard, | veryhard or{' '} | icebreaker after a line to set its difficulty.