Files
fabula-ultima-html/src/components/ClassesPage.tsx
Drew Malzahn 2063fffa29 feat: Add skill template picker to Classes page
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>
2026-07-04 12:37:17 -04:00

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>
);
}