Vibecode central
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user