Vibecode central

This commit is contained in:
Alexander Harding
2026-07-31 19:50:55 -04:00
parent 1362f28585
commit a3ea76fdff
67 changed files with 6287 additions and 202 deletions
@@ -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>
);
}