Files
dndextended/src/app/charactercreator/components/SpellProgressionTable.tsx
T
Alexander Harding a3ea76fdff Vibecode central
2026-07-31 19:50:55 -04:00

181 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<HTMLDivElement>(null);
const patchRow = (idx: number, patch: Partial<Row>) => {
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<HTMLInputElement>(`#${cellId(r, c)}`);
el?.focus();
el?.select();
};
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>, 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<HTMLInputElement>, 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 (
<div ref={rootRef} className="flex flex-col gap-2 w-full">
<div className="text-xs text-neutral-500">
Tip: paste tab-separated rows (e.g. from Excel or dndbeyond) into any cell. Arrow keys / Enter to navigate.
</div>
<div className="overflow-x-auto">
<table className="text-xs border-collapse w-full">
<thead>
<tr className="text-neutral-500">
<th className="p-1 text-left">Lv</th>
<th className="p-1 text-left">Cantrips</th>
<th className="p-1 text-left">Spells</th>
{[1,2,3,4,5,6,7,8,9].map((sl) => (
<th key={sl} className="p-1 text-center">{sl}</th>
))}
<th className="p-1"></th>
</tr>
</thead>
<tbody>
{progression.map((row, r) => (
<tr key={r} className="border-t border-neutral-800">
{[
row.level,
row.cantripsKnown,
row.spellsKnown,
...Array.from({ length: 9 }, (_, i) => row.slots[i] ?? 0),
].map((val, c) => (
<td key={c} className="p-1">
<input
id={cellId(r, c)}
type="number"
min={c === 0 ? 1 : 0}
max={c === 0 ? 20 : undefined}
value={val}
onChange={(e) => 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"}`}
/>
</td>
))}
<td className="p-1">
<button
type="button"
onClick={() => removeRow(r)}
className="text-red-400 hover:text-red-300 text-xs px-1"
>×</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<button
type="button"
onClick={addRow}
className="self-start text-xs text-amber-400 border border-amber-400/40 hover:bg-amber-400/10 rounded px-3 py-1"
>
+ Add level row
</button>
</div>
);
}