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,131 @@
"use client";
import { useState } from "react";
import type { Feature } from "@/types/content";
import Modal from "./Modal";
import FeatureBuilder from "./FeatureBuilder";
interface Props {
source: Feature["source"];
parentType?: "species" | "class" | "background" | "subclass";
parentId?: string;
available: Feature[];
attachedIds: string[];
onChange: (ids: string[]) => void;
onFeatureCreated: (f: Feature) => void;
}
export default function AttachedFeatures({
source,
parentType,
parentId,
available,
attachedIds,
onChange,
onFeatureCreated,
}: Props) {
const [pickerOpen, setPickerOpen] = useState(false);
const [builderOpen, setBuilderOpen] = useState(false);
const [search, setSearch] = useState("");
const attached = attachedIds
.map((id) => available.find((f) => f.id === id))
.filter((f): f is Feature => !!f);
const filtered = available
.filter((f) => f.source === source || source === "custom")
.filter((f) => !attachedIds.includes(f.id))
.filter((f) => {
const isOrphan = !f.parentType && !f.parentId;
const ownedByThisParent =
parentType && parentId && f.parentType === parentType && f.parentId === parentId;
return isOrphan || ownedByThisParent;
})
.filter((f) => f.name.toLowerCase().includes(search.toLowerCase()));
const remove = (id: string) => onChange(attachedIds.filter((x) => x !== id));
const add = (id: string) => onChange([...attachedIds, id]);
return (
<div className="space-y-2">
{attached.length === 0 && (
<p className="text-sm text-neutral-500 italic">No features attached.</p>
)}
{attached.map((f) => (
<div key={f.id} className="flex items-start justify-between p-2 bg-neutral-950/50 border border-neutral-700 rounded">
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-neutral-100">
{f.name}
{f.level != null && <span className="ml-2 text-xs text-neutral-500">Lv {f.level}</span>}
{f.effects?.length > 0 && (
<span className="ml-2 text-xs text-amber-400">{f.effects.length} effect(s)</span>
)}
</div>
{f.description && <div className="text-xs text-neutral-400 line-clamp-2">{f.description}</div>}
</div>
<button
onClick={() => remove(f.id)}
className="text-red-400 hover:text-red-300 text-xs px-2"
type="button"
>
Remove
</button>
</div>
))}
<div className="flex gap-2">
<button
type="button"
onClick={() => setPickerOpen(true)}
className="text-sm text-neutral-300 border border-neutral-600 hover:border-neutral-400 rounded px-3 py-1"
>
Attach existing
</button>
<button
type="button"
onClick={() => setBuilderOpen(true)}
className="text-sm text-amber-400 border border-amber-400/40 hover:bg-amber-400/10 rounded px-3 py-1"
>
+ Create feature
</button>
</div>
<Modal open={pickerOpen} onClose={() => setPickerOpen(false)} title="Attach existing feature">
<input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search..."
className="w-full mb-3 bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm"
/>
<div className="max-h-96 overflow-y-auto space-y-2">
{filtered.length === 0 && <p className="text-sm text-neutral-500 italic">No matches.</p>}
{filtered.map((f) => (
<button
key={f.id}
onClick={() => { add(f.id); setPickerOpen(false); }}
className="w-full text-left p-2 border border-neutral-700 rounded hover:border-amber-500"
type="button"
>
<div className="text-sm font-medium">{f.name}</div>
{f.description && <div className="text-xs text-neutral-400 line-clamp-1">{f.description}</div>}
</button>
))}
</div>
</Modal>
<Modal open={builderOpen} onClose={() => setBuilderOpen(false)} title="Create feature" wide confirmClose>
<FeatureBuilder
source={source}
parentType={parentType}
parentId={parentId}
onCancel={() => setBuilderOpen(false)}
onSave={(f) => {
onFeatureCreated(f);
add(f.id);
setBuilderOpen(false);
}}
/>
</Modal>
</div>
);
}
@@ -0,0 +1,136 @@
"use client";
import { useState } from "react";
import { Background, Feature, RULESETS, RULESET_LABELS, Ruleset, Skill, SKILL_ABILITY } from "@/types/content";
import { useCreator } from "../CreatorContext";
import { useIsAdmin } from "@/hooks/useIsAdmin";
import AttachedFeatures from "./AttachedFeatures";
import { claimFeaturesForParent } from "@/lib/claimFeatures";
const SKILLS = Object.keys(SKILL_ABILITY) as Skill[];
interface Props {
onSave: (b: Background) => void;
onCancel: () => void;
}
export default function BackgroundBuilder({ onSave, onCancel }: Props) {
const isAdmin = useIsAdmin();
const { features, refreshFeatures } = useCreator();
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [skills, setSkills] = useState<Skill[]>([]);
const [toolsStr, setToolsStr] = useState("");
const [languagesStr, setLanguagesStr] = useState("");
const [featureIds, setFeatureIds] = useState<string[]>([]);
const [visibility, setVisibility] = useState<"private" | "public">("private");
const [ruleset, setRuleset] = useState<Ruleset>("5e");
const [busy, setBusy] = useState(false);
const available = Object.values(features);
const toggle = <T,>(arr: T[], v: T): T[] =>
arr.includes(v) ? arr.filter((x) => x !== v) : [...arr, v];
const handleFeatureCreated = async (_f: Feature) => {
await refreshFeatures();
};
const submit = async () => {
if (!name.trim()) return;
setBusy(true);
const payload = {
name: name.trim(),
description: description.trim(),
skillProficiencies: skills,
toolProficiencies: toolsStr.split(",").map((s) => s.trim()).filter(Boolean),
languages: languagesStr.split(",").map((s) => s.trim()).filter(Boolean),
featureIds,
visibility,
ruleset,
};
const res = await fetch("/api/backgrounds", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
setBusy(false);
return;
}
const { result } = await res.json();
await claimFeaturesForParent({
parentType: "background",
parentId: (result as Background).id,
featureIds,
featureDict: features,
});
await refreshFeatures();
setBusy(false);
onSave(result as Background);
};
return (
<div className="space-y-4">
<div>
<label className="block text-sm text-neutral-400 mb-1">Name *</label>
<input value={name} onChange={(e) => setName(e.target.value)} className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm" />
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1">Description</label>
<textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={2} className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm" />
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1">Skill proficiencies</label>
<div className="flex flex-wrap gap-2">
{SKILLS.map((s) => (
<label key={s} className={`px-2 py-1 rounded border text-xs cursor-pointer ${skills.includes(s) ? "bg-amber-600/20 border-amber-500" : "border-neutral-700"}`}>
<input type="checkbox" className="hidden" checked={skills.includes(s)} onChange={() => setSkills(toggle(skills, s))} />
{s}
</label>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm text-neutral-400 mb-1">Tools (comma)</label>
<input value={toolsStr} onChange={(e) => setToolsStr(e.target.value)} className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm" />
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1">Languages (comma)</label>
<input value={languagesStr} onChange={(e) => setLanguagesStr(e.target.value)} className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm" />
</div>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-2">Features</label>
<AttachedFeatures source="background" parentType="background" available={available} attachedIds={featureIds} onChange={setFeatureIds} onFeatureCreated={handleFeatureCreated} />
</div>
{isAdmin && (
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<label className="text-sm text-neutral-400">Visibility:</label>
<select value={visibility} onChange={(e) => setVisibility(e.target.value as "private" | "public")} className="bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-sm">
<option value="private">Private</option>
<option value="public">Public (admin)</option>
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-sm text-neutral-400">Ruleset:</label>
<select value={ruleset} onChange={(e) => setRuleset(e.target.value as Ruleset)} className="bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-sm">
{RULESETS.map((r) => <option key={r} value={r}>{RULESET_LABELS[r]}</option>)}
</select>
</div>
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<button onClick={onCancel} className="px-4 py-2 text-sm text-neutral-300 hover:text-neutral-100">Cancel</button>
<button
onClick={submit}
disabled={busy || !name.trim()}
className="px-4 py-2 text-sm bg-amber-600 hover:bg-amber-500 disabled:opacity-40 rounded text-neutral-900 font-semibold"
>
{busy ? "Saving..." : "Save Background"}
</button>
</div>
</div>
);
}
@@ -0,0 +1,323 @@
"use client";
import { useState } from "react";
import { ABILITIES, Ability, ClassDef, Feature, RULESETS, RULESET_LABELS, Ruleset, Skill, SKILL_ABILITY } from "@/types/content";
import { useCreator } from "../CreatorContext";
import { useIsAdmin } from "@/hooks/useIsAdmin";
import AttachedFeatures from "./AttachedFeatures";
import { claimFeaturesForParent } from "@/lib/claimFeatures";
import InfoTip from "./InfoTip";
const PRIMARY_ABILITY_TABLE: [string, string][] = [
["Barbarian", "Str"],
["Bard", "Cha"],
["Cleric", "Wis"],
["Druid", "Wis"],
["Fighter", "Str or Dex"],
["Monk", "Dex + Wis"],
["Paladin", "Str + Cha"],
["Ranger", "Dex + Wis"],
["Rogue", "Dex"],
["Sorcerer", "Cha"],
["Warlock", "Cha"],
["Wizard", "Int"],
];
const SKILLS = Object.keys(SKILL_ABILITY) as Skill[];
interface Props {
onSave: (c: ClassDef) => void;
onCancel: () => void;
}
export default function ClassBuilder({ onSave, onCancel }: Props) {
const isAdmin = useIsAdmin();
const { features, refreshFeatures } = useCreator();
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [hitSides, setHitSides] = useState(8);
const [primary, setPrimary] = useState<Ability[]>(["strength"]);
const [saves, setSaves] = useState<Ability[]>(["strength", "constitution"]);
const [skillCount, setSkillCount] = useState(2);
const [skillOptions, setSkillOptions] = useState<Skill[]>([]);
const [armorStr, setArmorStr] = useState("");
const [weaponStr, setWeaponStr] = useState("");
const [toolStr, setToolStr] = useState("");
const [featureIds, setFeatureIds] = useState<string[]>([]);
const [hasSpellcasting, setHasSpellcasting] = useState(false);
const [spellAbility, setSpellAbility] = useState<Ability>("intelligence");
const [visibility, setVisibility] = useState<"private" | "public">("private");
const [ruleset, setRuleset] = useState<Ruleset>("5e");
const [busy, setBusy] = useState(false);
const available = Object.values(features);
const toggle = <T,>(arr: T[], v: T): T[] =>
arr.includes(v) ? arr.filter((x) => x !== v) : [...arr, v];
const handleFeatureCreated = async (_f: Feature) => {
await refreshFeatures();
};
const submit = async () => {
if (!name.trim()) return;
setBusy(true);
const attachedFeatures = featureIds
.map((id) => features[id])
.filter((f): f is Feature => !!f);
const levelFeatures: Record<number, string[]> = {};
for (const f of attachedFeatures) {
const lv = f.level ?? 1;
(levelFeatures[lv] ||= []).push(f.id);
}
const payload: Omit<ClassDef, "id"> = {
name: name.trim(),
description: description.trim(),
hitDice: { sides: hitSides, count: 1 },
primaryAbility: primary,
savingThrowProficiencies: saves,
skillProficiencyOptions: { count: skillCount, from: skillOptions },
armorProficiencies: armorStr.split(",").map((s) => s.trim()).filter(Boolean),
weaponProficiencies: weaponStr.split(",").map((s) => s.trim()).filter(Boolean),
toolProficiencies: toolStr.split(",").map((s) => s.trim()).filter(Boolean),
levelFeatures: Object.entries(levelFeatures).map(([lv, ids]) => ({
level: Number(lv),
featureIds: ids,
})),
...(hasSpellcasting ? { spellcasting: { ability: spellAbility } } : {}),
visibility,
ruleset,
};
const res = await fetch("/api/classes", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
setBusy(false);
return;
}
const { result } = await res.json();
await claimFeaturesForParent({
parentType: "class",
parentId: (result as ClassDef).id,
featureIds,
featureDict: features,
});
await refreshFeatures();
setBusy(false);
onSave(result as ClassDef);
};
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2">
<label className="block text-sm text-neutral-400 mb-1">Name *</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm"
/>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1">Hit die (d?)</label>
<select
value={hitSides}
onChange={(e) => setHitSides(Number(e.target.value))}
className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm"
>
{[6, 8, 10, 12].map((n) => <option key={n} value={n}>d{n}</option>)}
</select>
</div>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1">Description</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm"
/>
</div>
<div>
<label className="text-sm text-neutral-400 mb-1 flex items-center gap-2">
<span>Primary ability</span>
<InfoTip>
<div className="mb-2 text-neutral-400">
Not in 2014 PHB tables narrative in each class&apos;s Quick Build. 2024 PHB formalized it. Common values:
</div>
<table className="w-full text-[11px] border-collapse">
<tbody>
{PRIMARY_ABILITY_TABLE.map(([cls, ab]) => (
<tr key={cls} className="border-b border-neutral-800 last:border-0">
<td className="py-0.5 pr-3 text-neutral-300">{cls}</td>
<td className="py-0.5 text-amber-300">{ab}</td>
</tr>
))}
</tbody>
</table>
</InfoTip>
</label>
<div className="flex flex-wrap gap-2">
{ABILITIES.map((a) => (
<label key={a} className={`px-2 py-1 rounded border text-xs cursor-pointer ${primary.includes(a) ? "bg-amber-600/20 border-amber-500" : "border-neutral-700"}`}>
<input type="checkbox" className="hidden" checked={primary.includes(a)} onChange={() => setPrimary(toggle(primary, a))} />
{a}
</label>
))}
</div>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1">Saving throw proficiencies</label>
<div className="flex flex-wrap gap-2">
{ABILITIES.map((a) => (
<label key={a} className={`px-2 py-1 rounded border text-xs cursor-pointer ${saves.includes(a) ? "bg-amber-600/20 border-amber-500" : "border-neutral-700"}`}>
<input type="checkbox" className="hidden" checked={saves.includes(a)} onChange={() => setSaves(toggle(saves, a))} />
{a}
</label>
))}
</div>
</div>
<div className="p-3 border border-neutral-700 rounded space-y-3">
<div className="text-sm font-medium text-neutral-300">Starting Skill Proficiencies</div>
<div className="flex items-center gap-2">
<label className="text-xs text-neutral-400">Player picks</label>
<input
type="number"
min={0}
max={6}
value={skillCount}
onChange={(e) => setSkillCount(Number(e.target.value) || 0)}
className="w-16 bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-sm text-center"
/>
<label className="text-xs text-neutral-400">skill(s) from the pool below</label>
</div>
<div>
<div className="text-xs text-neutral-500 mb-1">Eligible skill pool</div>
<div className="flex flex-wrap gap-2">
{SKILLS.map((s) => (
<label key={s} className={`px-2 py-1 rounded border text-xs cursor-pointer ${skillOptions.includes(s) ? "bg-amber-600/20 border-amber-500" : "border-neutral-700"}`}>
<input type="checkbox" className="hidden" checked={skillOptions.includes(s)} onChange={() => setSkillOptions(toggle(skillOptions, s))} />
{s}
</label>
))}
</div>
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="text-sm text-neutral-400 mb-1 flex items-center gap-2">
<span>Armor proficiencies</span>
<InfoTip>
<div className="space-y-1">
<div className="text-neutral-400">Comma-separated. Use lowercase.</div>
<div><span className="text-amber-300">Categories:</span> <code>light</code>, <code>medium</code>, <code>heavy</code>, <code>shields</code></div>
<div className="text-neutral-500">Example: <code>light, medium, shields</code></div>
</div>
</InfoTip>
</label>
<input value={armorStr} onChange={(e) => setArmorStr(e.target.value)} className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm" placeholder="light, medium, shields" />
</div>
<div>
<label className="text-sm text-neutral-400 mb-1 flex items-center gap-2">
<span>Weapon proficiencies</span>
<InfoTip>
<div className="space-y-1">
<div className="text-neutral-400">Comma-separated. Use lowercase, singular PHB names.</div>
<div><span className="text-amber-300">Categories:</span> <code>simple</code>, <code>martial</code></div>
<div><span className="text-amber-300">Specific weapons:</span> use PHB singular name <code>hand crossbow</code>, <code>longsword</code>, <code>rapier</code>, <code>shortbow</code></div>
<div className="text-neutral-500">Example (Rogue): <code>simple, hand crossbow, longsword, rapier, shortsword</code></div>
</div>
</InfoTip>
</label>
<input value={weaponStr} onChange={(e) => setWeaponStr(e.target.value)} className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm" placeholder="simple, hand crossbow, longsword" />
</div>
<div>
<label className="text-sm text-neutral-400 mb-1 flex items-center gap-2">
<span>Tool proficiencies</span>
<InfoTip>
<div className="space-y-1">
<div className="text-neutral-400">Comma-separated. Full PHB name, lowercase.</div>
<div><span className="text-amber-300">Examples:</span></div>
<ul className="list-disc list-inside text-neutral-300">
<li><code>thieves&apos; tools</code></li>
<li><code>disguise kit</code></li>
<li><code>herbalism kit</code></li>
<li><code>smith&apos;s tools</code></li>
<li><code>dragonchess set</code></li>
<li><code>vehicles (land)</code></li>
</ul>
</div>
</InfoTip>
</label>
<input value={toolStr} onChange={(e) => setToolStr(e.target.value)} className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm" placeholder="thieves' tools, disguise kit" />
</div>
</div>
<div className="p-3 border border-neutral-700 rounded">
<label className="flex items-center gap-2 text-sm text-neutral-300">
<input type="checkbox" checked={hasSpellcasting} onChange={(e) => setHasSpellcasting(e.target.checked)} />
Spellcasting
</label>
{hasSpellcasting && (
<div className="mt-2">
<label className="block text-xs text-neutral-400 mb-1">Spellcasting ability</label>
<select value={spellAbility} onChange={(e) => setSpellAbility(e.target.value as Ability)} className="bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-sm">
{ABILITIES.map((a) => <option key={a} value={a}>{a}</option>)}
</select>
</div>
)}
</div>
<div>
<label className="block text-sm text-neutral-400 mb-2">Class Features (set the &quot;level&quot; on each)</label>
<AttachedFeatures
source="class"
parentType="class"
available={available}
attachedIds={featureIds}
onChange={setFeatureIds}
onFeatureCreated={handleFeatureCreated}
/>
</div>
{isAdmin && (
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<label className="text-sm text-neutral-400">Visibility:</label>
<select value={visibility} onChange={(e) => setVisibility(e.target.value as "private" | "public")} className="bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-sm">
<option value="private">Private</option>
<option value="public">Public (admin)</option>
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-sm text-neutral-400">Ruleset:</label>
<select value={ruleset} onChange={(e) => setRuleset(e.target.value as Ruleset)} className="bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-sm">
{RULESETS.map((r) => <option key={r} value={r}>{RULESET_LABELS[r]}</option>)}
</select>
</div>
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<button onClick={onCancel} className="px-4 py-2 text-sm text-neutral-300 hover:text-neutral-100">Cancel</button>
<button
onClick={submit}
disabled={busy || !name.trim()}
className="px-4 py-2 text-sm bg-amber-600 hover:bg-amber-500 disabled:opacity-40 rounded text-neutral-900 font-semibold"
>
{busy ? "Saving..." : "Save Class"}
</button>
</div>
</div>
);
}
@@ -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>
);
}
@@ -0,0 +1,136 @@
"use client";
import { useState } from "react";
import { RULESETS, RULESET_LABELS, type Feature, type Ruleset } from "@/types/content";
import type { Effect } from "@/types/core";
import { useIsAdmin } from "@/hooks/useIsAdmin";
import EffectEditor from "./EffectEditor";
interface Props {
source: Feature["source"];
parentType?: "species" | "class" | "background" | "subclass";
parentId?: string;
onSave: (feature: Feature) => void;
onCancel: () => void;
initial?: Partial<Feature>;
}
export default function FeatureBuilder({ source, parentType, parentId, onSave, onCancel, initial }: Props) {
const isAdmin = useIsAdmin();
const [name, setName] = useState(initial?.name ?? "");
const [description, setDescription] = useState(initial?.description ?? "");
const [level, setLevel] = useState<number | undefined>(initial?.level);
const [effects, setEffects] = useState<Effect[]>(initial?.effects ?? []);
const [visibility, setVisibility] = useState<"private" | "public">(
(initial?.visibility as "private" | "public") ?? "private"
);
const [ruleset, setRuleset] = useState<Ruleset>((initial?.ruleset as Ruleset) ?? "5e");
const [busy, setBusy] = useState(false);
const submit = async () => {
if (!name.trim()) return;
setBusy(true);
const payload = {
name: name.trim(),
description: description.trim(),
source,
level,
effects,
visibility,
ruleset,
parentType: parentType ?? null,
parentId: parentId ?? null,
};
const res = await fetch("/api/features", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
});
setBusy(false);
if (!res.ok) return;
const { result } = await res.json();
onSave(result as Feature);
};
return (
<div className="space-y-4">
<div>
<label className="block text-sm text-neutral-400 mb-1">Name *</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm"
/>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1">Description</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm"
/>
</div>
{(source === "class" || source === "subclass") && (
<div>
<label className="block text-sm text-neutral-400 mb-1">
Class level feature first appears
</label>
<input
type="number"
min={1}
max={20}
value={level ?? ""}
onChange={(e) => setLevel(e.target.value ? Number(e.target.value) : undefined)}
className="w-24 bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm"
/>
<p className="text-xs text-neutral-500 mt-1">
For scaling features (e.g. Sneak Attack, Extra Attack), leave this at the earliest level
and set per-effect &quot;Lv&quot; on individual effects below.
</p>
</div>
)}
<div>
<label className="block text-sm text-neutral-400 mb-1">Effects</label>
<EffectEditor effects={effects} onChange={setEffects} />
</div>
{isAdmin && (
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<label className="text-sm text-neutral-400">Visibility:</label>
<select
value={visibility}
onChange={(e) => setVisibility(e.target.value as "private" | "public")}
className="bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-sm"
>
<option value="private">Private</option>
<option value="public">Public (admin)</option>
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-sm text-neutral-400">Ruleset:</label>
<select
value={ruleset}
onChange={(e) => setRuleset(e.target.value as Ruleset)}
className="bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-sm"
>
{RULESETS.map((r) => <option key={r} value={r}>{RULESET_LABELS[r]}</option>)}
</select>
</div>
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<button onClick={onCancel} className="px-4 py-2 text-sm text-neutral-300 hover:text-neutral-100">
Cancel
</button>
<button
onClick={submit}
disabled={busy || !name.trim()}
className="px-4 py-2 text-sm bg-amber-600 hover:bg-amber-500 disabled:opacity-40 rounded text-neutral-900 font-semibold"
>
{busy ? "Saving..." : "Save Feature"}
</button>
</div>
</div>
);
}
@@ -0,0 +1,53 @@
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
interface Props {
children: ReactNode;
width?: number;
}
export default function InfoTip({ children, width = 320 }: Props) {
const iconRef = useRef<HTMLSpanElement>(null);
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const show = () => {
const el = iconRef.current;
if (!el) return;
const r = el.getBoundingClientRect();
const vw = window.innerWidth;
const centered = r.left + r.width / 2 - width / 2;
const clampedLeft = Math.max(8, Math.min(centered, vw - width - 8));
setPos({ top: r.bottom + 8, left: clampedLeft });
};
const hide = () => setPos(null);
return (
<span
ref={iconRef}
onMouseEnter={show}
onMouseLeave={hide}
onFocus={show}
onBlur={hide}
tabIndex={0}
className="inline-flex items-center justify-center w-4 h-4 rounded-full border border-neutral-500 text-[10px] text-neutral-400 cursor-help hover:border-amber-400 hover:text-amber-400 transition align-middle"
>
?
{mounted && pos &&
createPortal(
<div
style={{ position: "fixed", top: pos.top, left: pos.left, width, zIndex: 1000 }}
className="p-3 bg-neutral-900 border border-neutral-700 rounded shadow-xl text-xs text-neutral-200 pointer-events-none"
>
{children}
</div>,
document.body
)}
</span>
);
}
@@ -0,0 +1,64 @@
"use client";
import { type ReactNode, useEffect } from "react";
interface ModalProps {
open: boolean;
onClose: () => void;
title: string;
children: ReactNode;
wide?: boolean;
/**
* When true, X + Esc prompt for confirmation before closing.
* Backdrop clicks never close — user must use X.
*/
confirmClose?: boolean;
confirmMessage?: string;
}
export default function Modal({
open,
onClose,
title,
children,
wide,
confirmClose,
confirmMessage = "Discard changes? Any unsaved data will be lost.",
}: ModalProps) {
const requestClose = () => {
if (confirmClose && !window.confirm(confirmMessage)) return;
onClose();
};
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") requestClose();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, confirmClose]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
<div
onClick={(e) => e.stopPropagation()}
className={`bg-neutral-900 border border-neutral-700 rounded-lg shadow-2xl w-full ${wide ? "max-w-4xl" : "max-w-lg"} max-h-[90vh] overflow-y-auto`}
>
<div className="flex items-center justify-between px-6 py-4 border-b border-neutral-700">
<h2 className="text-lg font-semibold text-neutral-100">{title}</h2>
<button
onClick={requestClose}
className="text-neutral-400 hover:text-neutral-100 text-2xl leading-none"
aria-label="Close"
>
×
</button>
</div>
<div className="p-6">{children}</div>
</div>
</div>
);
}
@@ -0,0 +1,77 @@
"use client";
import type { ReactNode } from "react";
interface Item {
id: string;
name: string;
description?: string;
creatorId?: string;
visibility?: string;
ruleset?: string;
}
interface SelectorGridProps<T extends Item> {
items: T[];
selectedId?: string;
onSelect: (item: T) => void;
onAddCustom: () => void;
addLabel: string;
renderMeta?: (item: T) => ReactNode;
}
export default function SelectorGrid<T extends Item>({
items,
selectedId,
onSelect,
onAddCustom,
addLabel,
renderMeta,
}: SelectorGridProps<T>) {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{items.map((item) => {
const isSelected = item.id === selectedId;
return (
<button
key={item.id}
onClick={() => onSelect(item)}
className={`text-left p-4 rounded-lg border transition ${
isSelected
? "border-amber-500 bg-amber-500/10"
: "border-neutral-700 bg-neutral-800/60 hover:bg-neutral-800 hover:border-neutral-500"
}`}
>
<div className="flex items-start justify-between gap-2">
<span className="font-semibold text-neutral-100">{item.name}</span>
<div className="flex flex-wrap gap-1">
{item.ruleset && (
<span className="text-[10px] uppercase tracking-wide text-neutral-400 border border-neutral-600 px-1.5 py-0.5 rounded">
{item.ruleset}
</span>
)}
{item.visibility === "private" && (
<span className="text-[10px] uppercase tracking-wide text-amber-400 border border-amber-400/40 px-1.5 py-0.5 rounded">
Custom
</span>
)}
</div>
</div>
{item.description && (
<p className="mt-2 text-sm text-neutral-400 line-clamp-3">{item.description}</p>
)}
{renderMeta && <div className="mt-2 text-xs text-neutral-500">{renderMeta(item)}</div>}
</button>
);
})}
<button
onClick={onAddCustom}
className="p-4 rounded-lg border-2 border-dashed border-neutral-600 text-neutral-400 hover:border-amber-500 hover:text-amber-400 transition flex items-center justify-center gap-2"
>
<span className="text-2xl leading-none">+</span>
<span className="font-medium">{addLabel}</span>
</button>
</div>
);
}
@@ -0,0 +1,196 @@
"use client";
import { useState } from "react";
import { RULESET_LABELS, RULESETS, type CreatureType, type Feature, type Ruleset, type Size, type Species } from "@/types/content";
import { useCreator } from "../CreatorContext";
import { useIsAdmin } from "@/hooks/useIsAdmin";
import AttachedFeatures from "./AttachedFeatures";
import { claimFeaturesForParent } from "@/lib/claimFeatures";
const SIZES: Size[] = ["tiny", "small", "medium", "large", "huge", "gargantuan"];
const CREATURE_TYPES: CreatureType[] = [
"aberration", "beast", "celestial", "construct", "dragon", "elemental",
"fey", "fiend", "giant", "humanoid", "monstrosity", "ooze", "plant", "undead",
];
interface Props {
onSave: (s: Species) => void;
onCancel: () => void;
}
export default function SpeciesBuilder({ onSave, onCancel }: Props) {
const isAdmin = useIsAdmin();
const { features, refreshFeatures } = useCreator();
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [sizes, setSizes] = useState<Size[]>(["medium"]);
const [creatureType, setCreatureType] = useState<CreatureType>("humanoid");
const [speed, setSpeed] = useState(30);
const [languagesStr, setLanguagesStr] = useState("Common");
const [featureIds, setFeatureIds] = useState<string[]>([]);
const [visibility, setVisibility] = useState<"private" | "public">("private");
const [ruleset, setRuleset] = useState<Ruleset>("5e");
const [busy, setBusy] = useState(false);
const available = Object.values(features);
const handleFeatureCreated = async (_f: Feature) => {
await refreshFeatures();
};
const submit = async () => {
if (!name.trim()) return;
setBusy(true);
const payload = {
name: name.trim(),
description: description.trim(),
creatureType,
sizes,
speed,
featureIds,
languages: languagesStr.split(",").map((s) => s.trim()).filter(Boolean),
visibility,
ruleset,
};
const res = await fetch("/api/species", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
setBusy(false);
return;
}
const { result } = await res.json();
await claimFeaturesForParent({
parentType: "species",
parentId: (result as Species).id,
featureIds,
featureDict: features,
});
await refreshFeatures();
setBusy(false);
onSave(result as Species);
};
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm text-neutral-400 mb-1">Name *</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm"
/>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1">Size(s) pick one or more</label>
<div className="flex flex-wrap gap-2">
{SIZES.map((s) => {
const on = sizes.includes(s);
return (
<label
key={s}
className={`px-2 py-1 rounded border text-xs cursor-pointer capitalize ${on ? "bg-amber-600/20 border-amber-500" : "border-neutral-700"}`}
>
<input
type="checkbox"
className="hidden"
checked={on}
onChange={() =>
setSizes(on ? sizes.filter((x) => x !== s) : [...sizes, s])
}
/>
{s}
</label>
);
})}
</div>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1">Speed (ft)</label>
<input
type="number"
value={speed}
onChange={(e) => setSpeed(Number(e.target.value) || 0)}
className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm"
/>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1">Languages (comma-separated)</label>
<input
value={languagesStr}
onChange={(e) => setLanguagesStr(e.target.value)}
className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm"
/>
</div>
<div className="col-span-2">
<label className="block text-sm text-neutral-400 mb-1">Creature Type</label>
<select
value={creatureType}
onChange={(e) => setCreatureType(e.target.value as CreatureType)}
className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm capitalize"
>
{CREATURE_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
</div>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1">Description</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm"
/>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-2">Traits &amp; Features</label>
<AttachedFeatures
source="species"
parentType="species"
available={available}
attachedIds={featureIds}
onChange={setFeatureIds}
onFeatureCreated={handleFeatureCreated}
/>
</div>
{isAdmin && (
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<label className="text-sm text-neutral-400">Visibility:</label>
<select
value={visibility}
onChange={(e) => setVisibility(e.target.value as "private" | "public")}
className="bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-sm"
>
<option value="private">Private</option>
<option value="public">Public (admin)</option>
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-sm text-neutral-400">Ruleset:</label>
<select
value={ruleset}
onChange={(e) => setRuleset(e.target.value as Ruleset)}
className="bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-sm"
>
{RULESETS.map((r) => <option key={r} value={r}>{RULESET_LABELS[r]}</option>)}
</select>
</div>
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<button onClick={onCancel} className="px-4 py-2 text-sm text-neutral-300 hover:text-neutral-100">Cancel</button>
<button
onClick={submit}
disabled={busy || !name.trim()}
className="px-4 py-2 text-sm bg-amber-600 hover:bg-amber-500 disabled:opacity-40 rounded text-neutral-900 font-semibold"
>
{busy ? "Saving..." : "Save Species"}
</button>
</div>
</div>
);
}
@@ -0,0 +1,124 @@
"use client";
import { useState } from "react";
import { RULESETS, RULESET_LABELS, type Ruleset, type Spell } from "@/types/content";
import { useIsAdmin } from "@/hooks/useIsAdmin";
const SCHOOLS = [
"abjuration", "conjuration", "divination", "enchantment",
"evocation", "illusion", "necromancy", "transmutation",
];
interface Props {
onSave: (s: Spell) => void;
onCancel: () => void;
}
export default function SpellBuilder({ onSave, onCancel }: Props) {
const isAdmin = useIsAdmin();
const [name, setName] = useState("");
const [level, setLevel] = useState(0);
const [school, setSchool] = useState("evocation");
const [castingTime, setCastingTime] = useState("1 action");
const [range, setRange] = useState("30 feet");
const [components, setComponents] = useState("V, S");
const [duration, setDuration] = useState("Instantaneous");
const [description, setDescription] = useState("");
const [ruleset, setRuleset] = useState<Ruleset>("5e");
const [visibility, setVisibility] = useState<"private" | "public">("private");
const [busy, setBusy] = useState(false);
const submit = async () => {
if (!name.trim()) return;
setBusy(true);
const payload = {
name: name.trim(),
level,
school,
castingTime,
range,
components,
duration,
description,
ruleset,
visibility,
};
const res = await fetch("/api/spells", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
});
setBusy(false);
if (!res.ok) return;
const { result } = await res.json();
onSave(result as Spell);
};
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2">
<label className="block text-sm text-neutral-400 mb-1">Name *</label>
<input value={name} onChange={(e) => setName(e.target.value)} className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm" />
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1">Level (0 = cantrip)</label>
<input type="number" min={0} max={9} value={level} onChange={(e) => setLevel(Math.max(0, Math.min(9, Number(e.target.value) || 0)))} className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm" />
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1">School</label>
<select value={school} onChange={(e) => setSchool(e.target.value)} className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm capitalize">
{SCHOOLS.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1">Casting time</label>
<input value={castingTime} onChange={(e) => setCastingTime(e.target.value)} className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm" />
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1">Range</label>
<input value={range} onChange={(e) => setRange(e.target.value)} className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm" />
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1">Components</label>
<input value={components} onChange={(e) => setComponents(e.target.value)} className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm" placeholder="V, S, M (a pinch of salt)" />
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1">Duration</label>
<input value={duration} onChange={(e) => setDuration(e.target.value)} className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm" />
</div>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1">Description</label>
<textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={4} className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm" />
</div>
{isAdmin && (
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<label className="text-sm text-neutral-400">Visibility:</label>
<select value={visibility} onChange={(e) => setVisibility(e.target.value as "private" | "public")} className="bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-sm">
<option value="private">Private</option>
<option value="public">Public (admin)</option>
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-sm text-neutral-400">Ruleset:</label>
<select value={ruleset} onChange={(e) => setRuleset(e.target.value as Ruleset)} className="bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-sm">
{RULESETS.map((r) => <option key={r} value={r}>{RULESET_LABELS[r]}</option>)}
</select>
</div>
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<button onClick={onCancel} className="px-4 py-2 text-sm text-neutral-300 hover:text-neutral-100">Cancel</button>
<button
onClick={submit}
disabled={busy || !name.trim()}
className="px-4 py-2 text-sm bg-amber-600 hover:bg-amber-500 disabled:opacity-40 rounded text-neutral-900 font-semibold"
>
{busy ? "Saving..." : "Save Spell"}
</button>
</div>
</div>
);
}
@@ -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>
);
}
@@ -0,0 +1,139 @@
"use client";
import { useState } from "react";
import { RULESETS, RULESET_LABELS, type Feature, type Ruleset, type Subclass } from "@/types/content";
import { useCreator } from "../CreatorContext";
import { useIsAdmin } from "@/hooks/useIsAdmin";
import AttachedFeatures from "./AttachedFeatures";
import { claimFeaturesForParent } from "@/lib/claimFeatures";
interface Props {
classId: string;
className: string;
onSave: (s: Subclass) => void;
onCancel: () => void;
}
export default function SubclassBuilder({ classId, className, onSave, onCancel }: Props) {
const isAdmin = useIsAdmin();
const { features, refreshFeatures } = useCreator();
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [featureIds, setFeatureIds] = useState<string[]>([]);
const [visibility, setVisibility] = useState<"private" | "public">("private");
const [ruleset, setRuleset] = useState<Ruleset>("5e");
const [busy, setBusy] = useState(false);
const available = Object.values(features);
const handleFeatureCreated = async (_f: Feature) => {
await refreshFeatures();
};
const submit = async () => {
if (!name.trim()) return;
setBusy(true);
const attachedFeatures = featureIds
.map((id) => features[id])
.filter((f): f is Feature => !!f);
const grouped: Record<number, string[]> = {};
for (const f of attachedFeatures) {
const lv = f.level ?? 1;
(grouped[lv] ||= []).push(f.id);
}
const payload = {
classId,
name: name.trim(),
description: description.trim(),
levelFeatures: Object.entries(grouped).map(([lv, ids]) => ({
level: Number(lv),
featureIds: ids,
})),
visibility,
ruleset,
};
const res = await fetch("/api/subclasses", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
setBusy(false);
return;
}
const { result } = await res.json();
await claimFeaturesForParent({
parentType: "subclass",
parentId: (result as Subclass).id,
featureIds,
featureDict: features,
});
await refreshFeatures();
setBusy(false);
onSave(result as Subclass);
};
return (
<div className="space-y-4">
<div className="text-xs text-neutral-500">Parent class: <span className="text-neutral-300">{className}</span></div>
<div>
<label className="block text-sm text-neutral-400 mb-1">Name *</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Thief, Assassin, Champion"
className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm"
/>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1">Description</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={2}
className="w-full bg-neutral-800 border border-neutral-700 rounded px-3 py-2 text-sm"
/>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-2">Subclass Features (set the &quot;level&quot; on each)</label>
<AttachedFeatures
source="subclass"
parentType="subclass"
available={available}
attachedIds={featureIds}
onChange={setFeatureIds}
onFeatureCreated={handleFeatureCreated}
/>
</div>
{isAdmin && (
<div className="flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<label className="text-sm text-neutral-400">Visibility:</label>
<select value={visibility} onChange={(e) => setVisibility(e.target.value as "private" | "public")} className="bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-sm">
<option value="private">Private</option>
<option value="public">Public (admin)</option>
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-sm text-neutral-400">Ruleset:</label>
<select value={ruleset} onChange={(e) => setRuleset(e.target.value as Ruleset)} className="bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-sm">
{RULESETS.map((r) => <option key={r} value={r}>{RULESET_LABELS[r]}</option>)}
</select>
</div>
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<button onClick={onCancel} className="px-4 py-2 text-sm text-neutral-300 hover:text-neutral-100">Cancel</button>
<button
onClick={submit}
disabled={busy || !name.trim()}
className="px-4 py-2 text-sm bg-amber-600 hover:bg-amber-500 disabled:opacity-40 rounded text-neutral-900 font-semibold"
>
{busy ? "Saving..." : "Save Subclass"}
</button>
</div>
</div>
);
}