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,600 @@
"use client";
import { useEffect, useState } from "react";
import { ABILITIES, Ability, Skill, SKILL_ABILITY } from "@/types/content";
import type { AdvantageScope, Effect, EffectTarget } from "@/types/core";
import SpellProgressionTable from "./SpellProgressionTable";
/**
* Comma-list input with a local string buffer so typing spaces and multi-word items
* (like "thieves' tools") doesn't get eaten while typing.
*/
function CommaListInput({
value,
onCommit,
placeholder,
className,
}: {
value: string[];
onCommit: (next: string[]) => void;
placeholder?: string;
className?: string;
}) {
const [buf, setBuf] = useState(value.join(", "));
useEffect(() => {
setBuf(value.join(", "));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value.join("\u0001")]);
const commit = () => {
const parsed = buf.split(",").map((s) => s.trim()).filter(Boolean);
onCommit(parsed);
};
return (
<input
type="text"
value={buf}
onChange={(e) => setBuf(e.target.value)}
onBlur={commit}
placeholder={placeholder}
className={className}
/>
);
}
const SKILLS = Object.keys(SKILL_ABILITY) as Skill[];
const TARGET_KINDS: EffectTarget["kind"][] = [
"ability",
"abilityScoreChoice",
"speed",
"hpMax",
"armorClass",
"initiative",
"savingThrowProficiency",
"skillProficiency",
"skillProficiencyChoice",
"skillExpertise",
"toolExpertise",
"expertiseChoice",
"armorProficiency",
"weaponProficiency",
"toolProficiency",
"language",
"resistance",
"immunity",
"vulnerability",
"grantSpell",
"grantSpellcasting",
"grantFeat",
"extraAttack",
"grantSubclassChoice",
"advantage",
"disadvantage",
"action",
"bonusAction",
"reaction",
"custom",
];
const KIND_LABELS: Record<EffectTarget["kind"], string> = {
ability: "Ability score",
abilityScoreChoice: "Ability score improvement (choose)",
speed: "Speed",
hpMax: "HP Max",
armorClass: "Armor class",
initiative: "Initiative",
savingThrowProficiency: "Saving throw prof.",
skillProficiency: "Skill proficiency",
skillProficiencyChoice: "Skill proficiency (choose N)",
skillExpertise: "Skill expertise",
toolExpertise: "Tool expertise",
expertiseChoice: "Expertise (choose N)",
armorProficiency: "Armor proficiency",
weaponProficiency: "Weapon proficiency",
toolProficiency: "Tool proficiency",
language: "Language",
resistance: "Damage resistance",
immunity: "Damage immunity",
vulnerability: "Damage vulnerability",
grantSpell: "Grant spell",
grantSpellcasting: "Grant spellcasting",
grantFeat: "Grant feat",
extraAttack: "Extra attack",
grantSubclassChoice: "Grant subclass choice",
advantage: "Advantage",
disadvantage: "Disadvantage",
action: "Action",
bonusAction: "Bonus Action",
reaction: "Reaction",
custom: "Custom note",
};
const ADV_SCOPES = ["attack", "save", "skill", "check"] as const;
const SCOPE_LABELS: Record<(typeof ADV_SCOPES)[number], string> = {
attack: "attack rolls",
save: "saving throws",
skill: "skill check",
check: "ability checks",
};
function defaultTargetFor(kind: EffectTarget["kind"]): EffectTarget {
switch (kind) {
case "ability": return { kind, ability: "strength" };
case "abilityScoreChoice": return { kind, points: 2, maxPerAbility: 2, cap: 20 };
case "speed":
case "hpMax":
case "armorClass":
case "initiative": return { kind };
case "savingThrowProficiency": return { kind, ability: "strength" };
case "skillProficiency":
case "skillExpertise": return { kind, skill: "athletics" };
case "skillProficiencyChoice": return { kind, count: 2, from: [] };
case "toolExpertise": return { kind, tool: "" };
case "expertiseChoice": return { kind, count: 2, skillPool: "characterSkills", toolPool: [] };
case "armorProficiency":
case "weaponProficiency":
case "toolProficiency":
case "language": return { kind, value: "" };
case "resistance":
case "immunity":
case "vulnerability": return { kind, damageType: "fire" };
case "grantSpell": return { kind, spellId: "" };
case "grantSpellcasting": return {
kind,
ability: "intelligence",
progression: [{ level: 1, cantripsKnown: 0, spellsKnown: 0, slots: [] }],
};
case "grantFeat": return { kind, featId: "" };
case "extraAttack": return { kind, count: 1 };
case "grantSubclassChoice": return { kind, label: "" };
case "advantage":
case "disadvantage": return { kind, scope: { on: "attack" }, condition: "" };
case "action":
case "bonusAction":
case "reaction": return { kind, note: "" };
case "custom": return { kind, note: "" };
}
}
function defaultScope(on: AdvantageScope["on"]): AdvantageScope {
switch (on) {
case "attack": return { on };
case "save": return { on, ability: undefined };
case "skill": return { on, skill: "athletics" };
case "check": return { on, ability: undefined };
}
}
interface Props {
effects: Effect[];
onChange: (next: Effect[]) => void;
}
export default function EffectEditor({ effects, onChange }: Props) {
const update = (idx: number, patch: Partial<Effect>) => {
onChange(effects.map((e, i) => (i === idx ? { ...e, ...patch } : e)));
};
const updateTarget = (idx: number, target: EffectTarget) => update(idx, { target });
const remove = (idx: number) => onChange(effects.filter((_, i) => i !== idx));
const add = () =>
onChange([...effects, { target: { kind: "ability", ability: "strength" }, operation: "increase", value: 1 }]);
const needsValue = (kind: EffectTarget["kind"]) =>
["ability", "speed", "hpMax", "armorClass", "initiative"].includes(kind);
return (
<div className="space-y-3">
{effects.length === 0 && (
<p className="text-sm text-neutral-500 italic">No effects. Click below to add one.</p>
)}
{effects.map((eff, i) => {
const kind = eff.target.kind;
return (
<div key={i} className="flex flex-wrap items-center gap-2 p-3 border border-neutral-700 rounded bg-neutral-950/50">
<select
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700"
value={kind}
onChange={(e) => updateTarget(i, defaultTargetFor(e.target.value as EffectTarget["kind"]))}
>
{TARGET_KINDS.map((k) => (
<option key={k} value={k}>{KIND_LABELS[k]}</option>
))}
</select>
{eff.target.kind === "ability" && (
<select
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700"
value={eff.target.ability}
onChange={(e) => updateTarget(i, { kind: "ability", ability: e.target.value as Ability })}
>
{ABILITIES.map((a) => <option key={a} value={a}>{a}</option>)}
</select>
)}
{eff.target.kind === "abilityScoreChoice" && (() => {
const t = eff.target;
const setPatch = (patch: Partial<typeof t>) =>
updateTarget(i, { ...t, ...patch } as EffectTarget);
return (
<div className="flex items-center gap-2">
<span className="text-xs text-neutral-500">distribute</span>
<input
type="number"
min={1}
value={t.points}
onChange={(e) => setPatch({ points: Math.max(1, Number(e.target.value) || 1) })}
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700 w-16"
/>
<span className="text-xs text-neutral-500">points, max</span>
<input
type="number"
min={1}
value={t.maxPerAbility}
onChange={(e) => setPatch({ maxPerAbility: Math.max(1, Number(e.target.value) || 1) })}
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700 w-16"
/>
<span className="text-xs text-neutral-500">per ability, cap</span>
<input
type="number"
min={1}
placeholder="none"
value={t.cap ?? ""}
onChange={(e) =>
setPatch({ cap: e.target.value ? Math.max(1, Number(e.target.value)) : undefined })
}
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700 w-16"
/>
</div>
);
})()}
{eff.target.kind === "savingThrowProficiency" && (
<select
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700"
value={eff.target.ability}
onChange={(e) => updateTarget(i, { kind: "savingThrowProficiency", ability: e.target.value as Ability })}
>
{ABILITIES.map((a) => <option key={a} value={a}>{a}</option>)}
</select>
)}
{(eff.target.kind === "skillProficiency" || eff.target.kind === "skillExpertise") && (
<select
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700"
value={eff.target.skill}
onChange={(e) => updateTarget(i, { kind: eff.target.kind as "skillProficiency" | "skillExpertise", skill: e.target.value as Skill })}
>
{SKILLS.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
)}
{eff.target.kind === "toolExpertise" && (
<input
type="text"
placeholder="tool name (e.g. thieves' tools)"
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700 w-56"
value={eff.target.tool}
onChange={(e) => updateTarget(i, { kind: "toolExpertise", tool: e.target.value })}
/>
)}
{eff.target.kind === "expertiseChoice" && (() => {
const t = eff.target;
const setPatch = (patch: Partial<typeof t>) =>
updateTarget(i, { ...t, ...patch } as EffectTarget);
const skillPoolMode = t.skillPool === "characterSkills" ? "characterSkills" : "specific";
return (
<div className="flex flex-col gap-2 w-full">
<div className="flex items-center gap-2">
<span className="text-xs text-neutral-500">choose</span>
<input
type="number"
min={1}
value={t.count}
onChange={(e) => setPatch({ count: Math.max(1, Number(e.target.value) || 1) })}
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700 w-16"
/>
<span className="text-xs text-neutral-500">total from:</span>
</div>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-neutral-400">Skill pool:</span>
<select
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700"
value={skillPoolMode}
onChange={(e) =>
setPatch({
skillPool: e.target.value === "characterSkills" ? "characterSkills" : [],
})
}
>
<option value="characterSkills">Any skill character is proficient in</option>
<option value="specific">Specific skills</option>
</select>
</div>
{Array.isArray(t.skillPool) && (
<div className="flex flex-wrap gap-1">
{SKILLS.map((s) => {
const arr = t.skillPool as Skill[];
const on = arr.includes(s);
return (
<label
key={s}
className={`px-2 py-0.5 text-xs rounded border cursor-pointer ${on ? "bg-amber-600/20 border-amber-500" : "border-neutral-700"}`}
>
<input
type="checkbox"
className="hidden"
checked={on}
onChange={() =>
setPatch({
skillPool: on ? arr.filter((x) => x !== s) : [...arr, s],
})
}
/>
{s}
</label>
);
})}
</div>
)}
<div className="flex items-center gap-2">
<span className="text-xs text-neutral-400">Also eligible tools (comma):</span>
<CommaListInput
value={t.toolPool}
onCommit={(next) => setPatch({ toolPool: next })}
placeholder="thieves' tools"
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700 flex-1"
/>
</div>
</div>
);
})()}
{eff.target.kind === "skillProficiencyChoice" && (() => {
const t = eff.target;
const setCount = (n: number) =>
updateTarget(i, { kind: "skillProficiencyChoice", count: n, from: t.from });
const toggleFrom = (s: Skill) => {
const next = t.from.includes(s) ? t.from.filter((x) => x !== s) : [...t.from, s];
updateTarget(i, { kind: "skillProficiencyChoice", count: t.count, from: next });
};
return (
<div className="flex flex-col gap-2 w-full">
<div className="flex items-center gap-2">
<span className="text-xs text-neutral-500">choose</span>
<input
type="number"
min={1}
max={t.from.length || 18}
value={t.count}
onChange={(e) => setCount(Math.max(1, Number(e.target.value) || 1))}
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700 w-16"
/>
<span className="text-xs text-neutral-500">of {t.from.length} option(s)</span>
</div>
<div className="flex flex-wrap gap-1">
{SKILLS.map((s) => {
const on = t.from.includes(s);
return (
<label
key={s}
className={`px-2 py-0.5 text-xs rounded border cursor-pointer ${on ? "bg-amber-600/20 border-amber-500" : "border-neutral-700"}`}
>
<input type="checkbox" className="hidden" checked={on} onChange={() => toggleFrom(s)} />
{s}
</label>
);
})}
</div>
</div>
);
})()}
{(eff.target.kind === "armorProficiency" ||
eff.target.kind === "weaponProficiency" ||
eff.target.kind === "toolProficiency" ||
eff.target.kind === "language") && (
<input
type="text"
placeholder="value"
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700 w-40"
value={eff.target.value}
onChange={(e) => updateTarget(i, { kind: eff.target.kind as "armorProficiency" | "weaponProficiency" | "toolProficiency" | "language", value: e.target.value })}
/>
)}
{(eff.target.kind === "resistance" ||
eff.target.kind === "immunity" ||
eff.target.kind === "vulnerability") && (
<input
type="text"
placeholder="damage type"
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700 w-32"
value={eff.target.damageType}
onChange={(e) => updateTarget(i, { kind: eff.target.kind as "resistance" | "immunity" | "vulnerability", damageType: e.target.value })}
/>
)}
{eff.target.kind === "grantSpell" && (
<input
type="text"
placeholder="spell id"
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700 w-40"
value={eff.target.spellId}
onChange={(e) => updateTarget(i, { kind: "grantSpell", spellId: e.target.value })}
/>
)}
{eff.target.kind === "grantSpellcasting" && (() => {
const t = eff.target;
const setPatch = (patch: Partial<typeof t>) =>
updateTarget(i, { ...t, ...patch } as EffectTarget);
return (
<div className="flex flex-col gap-2 w-full">
<div className="flex items-center gap-2">
<span className="text-xs text-neutral-500">Ability:</span>
<select
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700"
value={t.ability}
onChange={(e) => setPatch({ ability: e.target.value as Ability })}
>
{ABILITIES.map((a) => <option key={a} value={a}>{a}</option>)}
</select>
<label className="text-xs text-neutral-500 flex items-center gap-1">
<input
type="checkbox"
checked={t.ritual === true}
onChange={(e) => setPatch({ ritual: e.target.checked })}
/>
Ritual casting
</label>
</div>
<SpellProgressionTable
progression={t.progression}
onChange={(rows) => setPatch({ progression: rows })}
/>
</div>
);
})()}
{eff.target.kind === "grantFeat" && (
<input
type="text"
placeholder="feat id"
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700 w-40"
value={eff.target.featId}
onChange={(e) => updateTarget(i, { kind: "grantFeat", featId: e.target.value })}
/>
)}
{eff.target.kind === "grantSubclassChoice" && (
<input
type="text"
placeholder="label (e.g. Roguish Archetype)"
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700 flex-1"
value={eff.target.label ?? ""}
onChange={(e) => updateTarget(i, { kind: "grantSubclassChoice", label: e.target.value })}
/>
)}
{eff.target.kind === "extraAttack" && (
<input
type="number"
min={1}
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700 w-20"
value={eff.target.count}
onChange={(e) => updateTarget(i, { kind: "extraAttack", count: Number(e.target.value) || 1 })}
/>
)}
{(eff.target.kind === "advantage" || eff.target.kind === "disadvantage") && (() => {
const kindLocal = eff.target.kind;
const scope = eff.target.scope;
const cond = eff.target.condition ?? "";
const setScope = (next: AdvantageScope) =>
updateTarget(i, { kind: kindLocal, scope: next, condition: cond });
const setCond = (c: string) =>
updateTarget(i, { kind: kindLocal, scope, condition: c });
return (
<>
<span className="text-xs text-neutral-500">on</span>
<select
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700"
value={scope.on}
onChange={(e) => setScope(defaultScope(e.target.value as AdvantageScope["on"]))}
>
{ADV_SCOPES.map((s) => <option key={s} value={s}>{SCOPE_LABELS[s]}</option>)}
</select>
{scope.on === "skill" && (
<select
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700"
value={scope.skill}
onChange={(e) => setScope({ on: "skill", skill: e.target.value as Skill })}
>
{SKILLS.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
)}
{(scope.on === "save" || scope.on === "check") && (
<select
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700"
value={scope.ability ?? ""}
onChange={(e) => {
const val = e.target.value;
setScope({ on: scope.on, ability: val ? (val as Ability) : undefined } as AdvantageScope);
}}
>
<option value="">all</option>
{ABILITIES.map((a) => <option key={a} value={a}>{a}</option>)}
</select>
)}
<input
type="text"
placeholder="condition (optional, e.g. 'vs poison')"
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700 flex-1 min-w-[10rem]"
value={cond}
onChange={(e) => setCond(e.target.value)}
/>
</>
);
})()}
{(eff.target.kind === "action" || eff.target.kind === "bonusAction" || eff.target.kind === "reaction") && (
<input
type="text"
placeholder="note (e.g. 'Dash, Disengage, or Hide')"
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700 flex-1"
value={eff.target.note}
onChange={(e) => updateTarget(i, { kind: eff.target.kind as "action" | "bonusAction" | "reaction", note: e.target.value })}
/>
)}
{eff.target.kind === "custom" && (
<input
type="text"
placeholder="note"
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700 flex-1"
value={eff.target.note}
onChange={(e) => updateTarget(i, { kind: "custom", note: e.target.value })}
/>
)}
{needsValue(kind) && (
<>
<select
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700"
value={eff.operation}
onChange={(e) => update(i, { operation: e.target.value as Effect["operation"] })}
>
<option value="increase">+</option>
<option value="decrease"></option>
<option value="set">=</option>
</select>
<input
type="number"
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700 w-20"
value={eff.value ?? 0}
onChange={(e) => update(i, { value: Number(e.target.value) })}
/>
</>
)}
<div className="ml-auto flex items-center gap-1">
<label className="text-[10px] text-neutral-500 uppercase">Lv</label>
<input
type="number"
min={1}
max={20}
placeholder="1"
value={eff.level ?? ""}
onChange={(e) => update(i, { level: e.target.value ? Number(e.target.value) : undefined })}
className="bg-neutral-800 text-sm rounded px-2 py-1 border border-neutral-700 w-14 text-center"
title="Minimum level for this effect to apply"
/>
<button
onClick={() => remove(i)}
className="text-red-400 hover:text-red-300 text-sm px-2"
type="button"
>
Remove
</button>
</div>
</div>
);
})}
<button
onClick={add}
type="button"
className="text-sm text-amber-400 border border-amber-400/40 hover:bg-amber-400/10 rounded px-3 py-1"
>
+ Add effect
</button>
</div>
);
}