Vibecode central
This commit is contained in:
@@ -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'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' tools</code></li>
|
||||
<li><code>disguise kit</code></li>
|
||||
<li><code>herbalism kit</code></li>
|
||||
<li><code>smith'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 "level" 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 "Lv" 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 & 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 "level" 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user