601 lines
25 KiB
TypeScript
601 lines
25 KiB
TypeScript
"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>
|
||
);
|
||
}
|