Mirrors the spell template picker: pack skills.yml into the sheet and let each class insert a formatted skill description from the book, filtered to that class by default. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
347 lines
12 KiB
TypeScript
347 lines
12 KiB
TypeScript
import React, { useState } from "react";
|
|
import skillsFile from "../../data/skills.yml";
|
|
import { ClassEntry } from "../types";
|
|
import { autoResize } from "../utils";
|
|
|
|
type SkillPickerTarget = { list: "primary" | "other"; idx: number };
|
|
|
|
function formatSkillLine(t: SkillTemplate): string {
|
|
return `• ${t.name} (${t.class}, max SL ${t.max_level}): ${t.description}`;
|
|
}
|
|
|
|
interface ClassesPageProps {
|
|
isActive: boolean;
|
|
primaryClasses: ClassEntry[];
|
|
setPrimaryClasses: React.Dispatch<React.SetStateAction<ClassEntry[]>>;
|
|
otherClasses: ClassEntry[];
|
|
setOtherClasses: React.Dispatch<React.SetStateAction<ClassEntry[]>>;
|
|
heroicSkills: string;
|
|
onHeroicSkillsChange: (v: string) => void;
|
|
}
|
|
|
|
export default function ClassesPage({
|
|
isActive,
|
|
primaryClasses,
|
|
setPrimaryClasses,
|
|
otherClasses,
|
|
setOtherClasses,
|
|
heroicSkills,
|
|
onHeroicSkillsChange,
|
|
}: ClassesPageProps) {
|
|
const [skillPickerTarget, setSkillPickerTarget] = useState<SkillPickerTarget | null>(null);
|
|
const [skillCategory, setSkillCategory] = useState<string>("all");
|
|
|
|
const allSkills = (skillsFile as SkillsFile).skills;
|
|
const skillCategories = ["all", ...Array.from(new Set(allSkills.map((s) => s.class))).sort()];
|
|
const visibleSkills =
|
|
skillCategory === "all" ? allSkills : allSkills.filter((s) => s.class === skillCategory);
|
|
|
|
const appendSkill = (target: SkillPickerTarget, line: string) => {
|
|
const setter = target.list === "primary" ? setPrimaryClasses : setOtherClasses;
|
|
setter((prev) =>
|
|
prev.map((c, i) =>
|
|
i === target.idx ? { ...c, skills: c.skills ? c.skills + "\n" + line : line } : c
|
|
)
|
|
);
|
|
};
|
|
|
|
const openSkillPicker = (target: SkillPickerTarget, className: string) => {
|
|
const match = allSkills.find((s) => s.class.toLowerCase() === className.trim().toLowerCase());
|
|
setSkillCategory(match ? match.class : "all");
|
|
setSkillPickerTarget(target);
|
|
};
|
|
|
|
return (
|
|
<div className={`page${isActive ? " active" : ""}`} id="page-classes">
|
|
<div className="grid-2" style={{ marginBottom: 20 }}>
|
|
<div className="section">
|
|
<div className="section-title">
|
|
<span className="icon">✦</span> Primary Classes (up to 3)
|
|
</div>
|
|
{primaryClasses.map((cls, idx) => (
|
|
<div key={idx} className="class-block">
|
|
<div className="class-header">
|
|
<input
|
|
type="text"
|
|
placeholder="Class name…"
|
|
value={cls.name || ""}
|
|
onChange={(e) =>
|
|
setPrimaryClasses((prev) =>
|
|
prev.map((c, i) => (i === idx ? { ...c, name: e.target.value } : c))
|
|
)
|
|
}
|
|
/>
|
|
<input
|
|
type="text"
|
|
placeholder="Free benefits…"
|
|
value={cls.benefits || ""}
|
|
onChange={(e) =>
|
|
setPrimaryClasses((prev) =>
|
|
prev.map((c, i) => (i === idx ? { ...c, benefits: e.target.value } : c))
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="class-skills">
|
|
<textarea
|
|
placeholder="Skill information…"
|
|
value={cls.skills || ""}
|
|
ref={(el) => {
|
|
if (el) autoResize(el);
|
|
}}
|
|
onInput={(e) => autoResize(e.currentTarget)}
|
|
onChange={(e) =>
|
|
setPrimaryClasses((prev) =>
|
|
prev.map((c, i) => (i === idx ? { ...c, skills: e.target.value } : c))
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
<div
|
|
style={{
|
|
padding: "6px 10px",
|
|
borderTop: "1px solid var(--border)",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
}}
|
|
>
|
|
<label
|
|
style={{ display: "flex", alignItems: "center", gap: "6px", fontSize: "0.85em" }}
|
|
>
|
|
Level
|
|
<input
|
|
type="number"
|
|
min={1}
|
|
max={10}
|
|
value={cls.level ?? 1}
|
|
onChange={(e) =>
|
|
setPrimaryClasses((prev) =>
|
|
prev.map((c, i) =>
|
|
i === idx ? { ...c, level: Number(e.target.value) } : c
|
|
)
|
|
)
|
|
}
|
|
style={{ width: "50px" }}
|
|
/>
|
|
</label>
|
|
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
|
<button
|
|
type="button"
|
|
className="add-btn"
|
|
style={{ marginTop: 0 }}
|
|
onClick={() => openSkillPicker({ list: "primary", idx }, cls.name)}
|
|
>
|
|
+ Add Template Skill
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="spell-del-btn"
|
|
onClick={() =>
|
|
setPrimaryClasses((prev) => prev.filter((_, i) => i !== idx))
|
|
}
|
|
>
|
|
✕ Remove
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
<button
|
|
type="button"
|
|
className="add-btn"
|
|
disabled={primaryClasses.length >= 3}
|
|
onClick={() =>
|
|
setPrimaryClasses((prev) => [
|
|
...prev,
|
|
{ name: "", level: 1, benefits: "", skills: "" },
|
|
])
|
|
}
|
|
>
|
|
+ Add Primary Class
|
|
</button>
|
|
</div>
|
|
|
|
<div className="section">
|
|
<div className="section-title">
|
|
<span className="icon">◎</span> Other Classes (max 3 non-mastered)
|
|
</div>
|
|
{otherClasses.map((cls, idx) => (
|
|
<div key={idx} className="class-block">
|
|
<div className="class-header">
|
|
<input
|
|
type="text"
|
|
placeholder="Class name…"
|
|
value={cls.name || ""}
|
|
onChange={(e) =>
|
|
setOtherClasses((prev) =>
|
|
prev.map((c, i) => (i === idx ? { ...c, name: e.target.value } : c))
|
|
)
|
|
}
|
|
/>
|
|
<input
|
|
type="text"
|
|
placeholder="Free benefits…"
|
|
value={cls.benefits || ""}
|
|
onChange={(e) =>
|
|
setOtherClasses((prev) =>
|
|
prev.map((c, i) => (i === idx ? { ...c, benefits: e.target.value } : c))
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="class-skills">
|
|
<textarea
|
|
placeholder="Skill information…"
|
|
value={cls.skills || ""}
|
|
onChange={(e) =>
|
|
setOtherClasses((prev) =>
|
|
prev.map((c, i) => (i === idx ? { ...c, skills: e.target.value } : c))
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
<div
|
|
style={{
|
|
padding: "6px 10px",
|
|
borderTop: "1px solid var(--border)",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
}}
|
|
>
|
|
<label
|
|
style={{ display: "flex", alignItems: "center", gap: "6px", fontSize: "0.85em" }}
|
|
>
|
|
Level
|
|
<input
|
|
type="number"
|
|
min={1}
|
|
max={10}
|
|
value={cls.level ?? 1}
|
|
onChange={(e) =>
|
|
setOtherClasses((prev) =>
|
|
prev.map((c, i) =>
|
|
i === idx ? { ...c, level: Number(e.target.value) } : c
|
|
)
|
|
)
|
|
}
|
|
style={{ width: "50px" }}
|
|
/>
|
|
</label>
|
|
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
|
<button
|
|
type="button"
|
|
className="add-btn"
|
|
style={{ marginTop: 0 }}
|
|
onClick={() => openSkillPicker({ list: "other", idx }, cls.name)}
|
|
>
|
|
+ Add Template Skill
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="spell-del-btn"
|
|
onClick={() =>
|
|
setOtherClasses((prev) => prev.filter((_, i) => i !== idx))
|
|
}
|
|
>
|
|
✕ Remove
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
<button
|
|
type="button"
|
|
className="add-btn"
|
|
onClick={() =>
|
|
setOtherClasses((prev) => [
|
|
...prev,
|
|
{ name: "", level: 1, benefits: "", skills: "" },
|
|
])
|
|
}
|
|
>
|
|
+ Add Class
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="section">
|
|
<div className="section-title">
|
|
<span className="icon">★</span> Heroic Skills
|
|
</div>
|
|
<textarea
|
|
value={heroicSkills}
|
|
onChange={(e) => onHeroicSkillsChange(e.target.value)}
|
|
placeholder="Record your heroic skill abilities here…"
|
|
style={{ minHeight: 100 }}
|
|
/>
|
|
</div>
|
|
|
|
{skillPickerTarget &&
|
|
(() => {
|
|
const target = skillPickerTarget;
|
|
return (
|
|
<div
|
|
className="spell-picker-overlay"
|
|
onClick={() => setSkillPickerTarget(null)}
|
|
>
|
|
<div className="spell-picker-modal" onClick={(e) => e.stopPropagation()}>
|
|
<div className="spell-picker-header">
|
|
<span>Choose a skill</span>
|
|
<button
|
|
type="button"
|
|
className="spell-picker-close"
|
|
onClick={() => setSkillPickerTarget(null)}
|
|
>
|
|
✕
|
|
</button>
|
|
</div>
|
|
<div
|
|
style={{
|
|
display: "flex",
|
|
gap: 6,
|
|
flexWrap: "wrap",
|
|
padding: "8px 14px",
|
|
borderBottom: "1px solid var(--border)",
|
|
}}
|
|
>
|
|
{skillCategories.map((cat) => (
|
|
<button
|
|
type="button"
|
|
key={cat}
|
|
className={`add-btn${skillCategory === cat ? " active-filter" : ""}`}
|
|
style={{
|
|
padding: "2px 8px",
|
|
fontSize: "0.65rem",
|
|
textTransform: "capitalize",
|
|
}}
|
|
onClick={() => setSkillCategory(cat)}
|
|
>
|
|
{cat}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<ul className="spell-picker-list">
|
|
{visibleSkills.map((t, i) => (
|
|
<li
|
|
key={i}
|
|
className="spell-picker-item"
|
|
onClick={() => {
|
|
appendSkill(target, formatSkillLine(t));
|
|
setSkillPickerTarget(null);
|
|
}}
|
|
>
|
|
<span className="spell-picker-name">{t.name}</span>
|
|
<span className="spell-picker-class">{t.class}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
);
|
|
})()}
|
|
</div>
|
|
);
|
|
}
|