"use client"; import { useRef } from "react"; interface Row { level: number; cantripsKnown: number; spellsKnown: number; slots: number[]; } interface Props { progression: Row[]; onChange: (rows: Row[]) => void; } const COLUMNS = 12; // level, cantrips, spells, slots 1-9 function cellId(r: number, c: number) { return `sp-cell-${r}-${c}`; } export default function SpellProgressionTable({ progression, onChange }: Props) { const rootRef = useRef(null); const patchRow = (idx: number, patch: Partial) => { onChange(progression.map((r, k) => (k === idx ? { ...r, ...patch } : r))); }; const patchSlot = (idx: number, spellLv: number, value: number) => { const row = progression[idx]; const nextSlots = [...(row.slots ?? [])]; while (nextSlots.length <= spellLv - 1) nextSlots.push(0); nextSlots[spellLv - 1] = value; patchRow(idx, { slots: nextSlots }); }; const addRow = () => { const last = progression[progression.length - 1]; onChange([ ...progression, { level: (last?.level ?? 0) + 1, cantripsKnown: last?.cantripsKnown ?? 0, spellsKnown: last?.spellsKnown ?? 0, slots: [...(last?.slots ?? [])] }, ]); }; const removeRow = (k: number) => onChange(progression.filter((_, i) => i !== k)); const setCell = (r: number, c: number, raw: string) => { const v = Number(raw) || 0; if (c === 0) patchRow(r, { level: v || 1 }); else if (c === 1) patchRow(r, { cantripsKnown: Math.max(0, v) }); else if (c === 2) patchRow(r, { spellsKnown: Math.max(0, v) }); else patchSlot(r, c - 2, Math.max(0, v)); }; const focusCell = (r: number, c: number) => { const el = rootRef.current?.querySelector(`#${cellId(r, c)}`); el?.focus(); el?.select(); }; const onKeyDown = (e: React.KeyboardEvent, r: number, c: number) => { const maxR = progression.length - 1; if (e.key === "ArrowRight" || (e.key === "Enter" && e.shiftKey === false && e.altKey === false && !e.ctrlKey && !e.metaKey && c < COLUMNS - 1)) { e.preventDefault(); focusCell(r, Math.min(COLUMNS - 1, c + 1)); } else if (e.key === "ArrowLeft") { e.preventDefault(); focusCell(r, Math.max(0, c - 1)); } else if (e.key === "ArrowDown" || (e.key === "Enter" && c === COLUMNS - 1)) { e.preventDefault(); if (r < maxR) focusCell(r + 1, c === COLUMNS - 1 ? 0 : c); } else if (e.key === "ArrowUp") { e.preventDefault(); focusCell(Math.max(0, r - 1), c); } }; const onPaste = (e: React.ClipboardEvent, startR: number, startC: number) => { const text = e.clipboardData.getData("text"); if (!text || (!text.includes("\t") && !text.includes("\n"))) return; // single value: let default happen e.preventDefault(); const rows = text.replace(/\r/g, "").split("\n").filter((line) => line.length > 0); const grid = rows.map((line) => line.split("\t")); const next: Row[] = progression.map((r) => ({ ...r, slots: [...r.slots] })); for (let dr = 0; dr < grid.length; dr++) { const targetRow = startR + dr; while (next.length <= targetRow) { const last = next[next.length - 1]; next.push({ level: (last?.level ?? 0) + 1, cantripsKnown: 0, spellsKnown: 0, slots: [], }); } for (let dc = 0; dc < grid[dr].length; dc++) { const targetC = startC + dc; if (targetC >= COLUMNS) break; const raw = grid[dr][dc].trim(); // Skip empty cells so partial pastes don't wipe existing data if (raw === "") continue; const v = Number(raw); if (Number.isNaN(v)) continue; const row = next[targetRow]; if (targetC === 0) row.level = v || 1; else if (targetC === 1) row.cantripsKnown = Math.max(0, v); else if (targetC === 2) row.spellsKnown = Math.max(0, v); else { const spellLv = targetC - 2; while (row.slots.length <= spellLv - 1) row.slots.push(0); row.slots[spellLv - 1] = Math.max(0, v); } } } onChange(next); }; return (
Tip: paste tab-separated rows (e.g. from Excel or dndbeyond) into any cell. Arrow keys / Enter to navigate.
{[1,2,3,4,5,6,7,8,9].map((sl) => ( ))} {progression.map((row, r) => ( {[ row.level, row.cantripsKnown, row.spellsKnown, ...Array.from({ length: 9 }, (_, i) => row.slots[i] ?? 0), ].map((val, c) => ( ))} ))}
Lv Cantrips Spells{sl}
setCell(r, c, e.target.value)} onKeyDown={(e) => onKeyDown(e, r, c)} onPaste={(e) => onPaste(e, r, c)} className={`bg-neutral-800 border border-neutral-700 rounded px-1 py-0.5 ${c >= 3 ? "w-10 text-center" : "w-12"}`} />
); }