181 lines
6.5 KiB
TypeScript
181 lines
6.5 KiB
TypeScript
"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>
|
||
);
|
||
}
|