6027 lines
234 KiB
TypeScript
6027 lines
234 KiB
TypeScript
import { FormEvent, JSX, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||
|
||
const DEFAULT_PAGE = "/5etools.html";
|
||
const CARD_PAGE_SIZE = 8;
|
||
const APP_CHARACTERS_MENU_ID = "__characters";
|
||
|
||
const CHARACTER_STEPS = [
|
||
"Class",
|
||
"Species",
|
||
"Background",
|
||
"Ability Scores",
|
||
"Feats",
|
||
"Skills",
|
||
"Spells",
|
||
"Equipment",
|
||
"About",
|
||
"Finish"
|
||
] as const;
|
||
|
||
const ABILITY_KEYS = ["str", "dex", "con", "int", "wis", "cha"] as const;
|
||
type AbilityKey = (typeof ABILITY_KEYS)[number];
|
||
|
||
const ABILITY_LABELS: Record<AbilityKey, string> = {
|
||
str: "Strength",
|
||
dex: "Dexterity",
|
||
con: "Constitution",
|
||
int: "Intelligence",
|
||
wis: "Wisdom",
|
||
cha: "Charisma"
|
||
};
|
||
|
||
const STANDARD_ARRAY: [number, number, number, number, number, number] = [15, 14, 13, 12, 10, 8];
|
||
|
||
const SKILL_OPTIONS = [
|
||
"Acrobatics",
|
||
"Animal Handling",
|
||
"Arcana",
|
||
"Athletics",
|
||
"Deception",
|
||
"History",
|
||
"Insight",
|
||
"Intimidation",
|
||
"Investigation",
|
||
"Medicine",
|
||
"Nature",
|
||
"Perception",
|
||
"Performance",
|
||
"Persuasion",
|
||
"Religion",
|
||
"Sleight of Hand",
|
||
"Stealth",
|
||
"Survival"
|
||
] as const;
|
||
|
||
const SOURCE_BUCKET_OPTIONS = [
|
||
{ key: "homebrew", label: "Homebrew (non-WotC)" },
|
||
{ key: "core2024", label: "Core 2024" },
|
||
{ key: "expanded2024", label: "Expanded 2024" },
|
||
{ key: "core2014", label: "Core 2014" },
|
||
{ key: "expanded2014", label: "Expanded 2014" }
|
||
] as const;
|
||
|
||
const FIVEETOOLS_RENDER_CLASSIC_SCRIPT_PATHS = [
|
||
"lib/jquery.js",
|
||
"js/parser.js",
|
||
"js/utils.js",
|
||
"js/utils-ui.js",
|
||
"lib/localforage.js",
|
||
"js/utils-dataloader.js",
|
||
"js/render.js",
|
||
"js/render-dice.js",
|
||
"js/hist.js"
|
||
] as const;
|
||
|
||
const FIVEETOOLS_RENDER_MODULE_SCRIPT_PATHS = [
|
||
"js/utils-brew.js",
|
||
"js/utils-config.js",
|
||
"js/shim-esmodules.js"
|
||
] as const;
|
||
|
||
const FIVEETOOLS_RENDER_STYLESHEET_PATHS = ["css/bootstrap.css", "css/main.css"] as const;
|
||
|
||
type CharacterModalMode = "closed" | "list" | "editor";
|
||
type EntityPopoverKind = "class" | "species" | "background";
|
||
|
||
type RendererHandle = {
|
||
setBaseUrl: (value: string) => RendererHandle;
|
||
setAddHandlers: (value: boolean) => RendererHandle;
|
||
setFirstSection: (value: boolean) => RendererHandle;
|
||
render: (entry: unknown) => string;
|
||
};
|
||
|
||
type RendererGlobal = {
|
||
get: () => RendererHandle;
|
||
hover?: {
|
||
_doInit?: () => void;
|
||
pHandleLinkMouseOver?: (evt: MouseEvent, ele: HTMLElement, opts?: unknown) => Promise<void>;
|
||
handleLinkMouseLeave?: (evt: MouseEvent, ele: HTMLElement) => void;
|
||
handleLinkMouseMove?: (evt: MouseEvent, ele: HTMLElement) => void;
|
||
handleLinkClick?: (evt: MouseEvent, ele: HTMLElement, opts?: { isForceOpenPermanent?: boolean }) => void;
|
||
handleLinkWheel?: (evt: WheelEvent, ele: HTMLElement) => void;
|
||
handleLinkDragStart?: (evt: DragEvent, ele: HTMLElement) => void;
|
||
};
|
||
};
|
||
|
||
type TopbarMenuEntry = {
|
||
id: string;
|
||
type: "link" | "action" | "divider" | "label" | "submenu";
|
||
label?: string;
|
||
datePrefix?: string;
|
||
sourceColor?: string;
|
||
href?: string;
|
||
isExternal?: boolean;
|
||
actionId?: string;
|
||
children?: TopbarMenuEntry[];
|
||
};
|
||
|
||
type TopbarMenuGroup = {
|
||
id: string;
|
||
kind: "dropdown" | "link" | "action";
|
||
label: string;
|
||
href?: string;
|
||
isExternal?: boolean;
|
||
actionId?: string;
|
||
items?: TopbarMenuEntry[];
|
||
};
|
||
|
||
declare global {
|
||
interface Window {
|
||
Renderer?: RendererGlobal;
|
||
JqueryUtil?: {
|
||
initEnhancements?: () => void;
|
||
};
|
||
TemplateUtil?: {
|
||
initVanilla?: () => void;
|
||
};
|
||
}
|
||
}
|
||
|
||
let renderRuntimePromise: Promise<void> | null = null;
|
||
let renderRuntimeBaseUrl = "";
|
||
|
||
function normalizeWikiBaseUrl(value: string): string {
|
||
return value.replace(/\/+$/, "");
|
||
}
|
||
|
||
function cleanTopbarLabel(value: string): string {
|
||
return value
|
||
.replace(/\s+/g, " ")
|
||
.replace(/[▾▼►]+/g, "")
|
||
.trim();
|
||
}
|
||
|
||
function getElementPrimaryText(ele: Element): string {
|
||
for (const node of Array.from(ele.childNodes)) {
|
||
if (node.nodeType === Node.TEXT_NODE) {
|
||
const text = cleanTopbarLabel(node.textContent || "");
|
||
if (text) {
|
||
return text;
|
||
}
|
||
}
|
||
}
|
||
return cleanTopbarLabel(ele.textContent || "");
|
||
}
|
||
|
||
function getElementMenuMeta(ele: Element | null): Pick<TopbarMenuEntry, "datePrefix" | "sourceColor"> {
|
||
if (!ele) {
|
||
return {};
|
||
}
|
||
|
||
const out: Pick<TopbarMenuEntry, "datePrefix" | "sourceColor"> = {};
|
||
|
||
const dateEle = ele.querySelector(":scope > .page__nav-date");
|
||
if (dateEle) {
|
||
const datePrefix = cleanTopbarLabel(dateEle.textContent || "");
|
||
if (datePrefix) {
|
||
out.datePrefix = datePrefix;
|
||
}
|
||
}
|
||
|
||
const sourceEle = ele.querySelector(":scope > .nav2-list__disp-source");
|
||
if (sourceEle instanceof HTMLElement) {
|
||
const computed = getComputedStyle(sourceEle);
|
||
const sourceColor = (computed.backgroundColor || computed.borderLeftColor || "").trim();
|
||
if (
|
||
sourceColor
|
||
&& sourceColor !== "transparent"
|
||
&& !/^rgba?\(\s*0\s*,\s*0\s*,\s*0(?:\s*,\s*0)?\s*\)$/i.test(sourceColor)
|
||
) {
|
||
out.sourceColor = sourceColor;
|
||
}
|
||
}
|
||
|
||
return out;
|
||
}
|
||
|
||
function getPathnameFromHrefLoose(hrefRaw: string): string {
|
||
const href = hrefRaw.trim();
|
||
if (!href) {
|
||
return "";
|
||
}
|
||
try {
|
||
return new URL(href, "http://localhost").pathname.toLowerCase();
|
||
} catch {
|
||
return href.split(/[?#]/)[0].toLowerCase();
|
||
}
|
||
}
|
||
|
||
function areTopbarGroupsEquivalent(a: TopbarMenuGroup[], b: TopbarMenuGroup[]): boolean {
|
||
return JSON.stringify(a) === JSON.stringify(b);
|
||
}
|
||
|
||
function parseNativeTopbarMenuList(
|
||
list: Element,
|
||
registerAction: (ele: HTMLElement) => string,
|
||
idPrefix: string
|
||
): TopbarMenuEntry[] {
|
||
const entries: TopbarMenuEntry[] = [];
|
||
const liElements = Array.from(list.children).filter((it): it is HTMLLIElement => it instanceof HTMLLIElement);
|
||
|
||
liElements.forEach((li, index) => {
|
||
const entryId = `${idPrefix}-${index}`;
|
||
const submenuList = (
|
||
li.querySelector(":scope > ul")
|
||
|| (li.classList.contains("nav2-accord__wrp") ? li.querySelector(":scope > .nav2-accord__body") : null)
|
||
) as Element | null;
|
||
const submenuChildren = submenuList ? parseNativeTopbarMenuList(submenuList, registerAction, `${entryId}-submenu`) : [];
|
||
if (li.classList.contains("ve-dropdown-divider") || li.classList.contains("divider")) {
|
||
entries.push({ id: `${entryId}-divider`, type: "divider" });
|
||
return;
|
||
}
|
||
|
||
if (li.classList.contains("nav2-list__label")) {
|
||
const label = cleanTopbarLabel(li.textContent || "");
|
||
if (label) {
|
||
entries.push({ id: `${entryId}-label`, type: "label", label });
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (li.classList.contains("dropdown--navbar")) {
|
||
const toggle = li.querySelector(":scope > a") as HTMLAnchorElement | null;
|
||
const label = toggle ? getElementPrimaryText(toggle) : "";
|
||
if (!label) {
|
||
return;
|
||
}
|
||
entries.push({
|
||
id: `${entryId}-submenu`,
|
||
type: "submenu",
|
||
label,
|
||
...getElementMenuMeta(toggle),
|
||
children: submenuChildren
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (li.classList.contains("nav2-accord__wrp")) {
|
||
const headPrimary = li.querySelector(":scope > .nav2-accord__head > :first-child");
|
||
const label = headPrimary ? getElementPrimaryText(headPrimary) : "";
|
||
if (!label || !submenuChildren.length) {
|
||
return;
|
||
}
|
||
entries.push({
|
||
id: `${entryId}-accordion`,
|
||
type: "submenu",
|
||
label,
|
||
...getElementMenuMeta(headPrimary),
|
||
children: submenuChildren
|
||
});
|
||
return;
|
||
}
|
||
|
||
const link = li.querySelector(":scope > a") as HTMLAnchorElement | null;
|
||
if (link) {
|
||
const label = getElementPrimaryText(link);
|
||
const href = (link.getAttribute("href") || "").trim();
|
||
const isActionLike = href === "#" || href.toLowerCase().startsWith("javascript:");
|
||
if (submenuChildren.length) {
|
||
entries.push({
|
||
id: `${entryId}-submenu-link`,
|
||
type: "submenu",
|
||
label: label || "Section",
|
||
...getElementMenuMeta(link),
|
||
href: !isActionLike ? href : undefined,
|
||
actionId: isActionLike ? registerAction(link) : undefined,
|
||
isExternal: !isActionLike ? link.target === "_blank" || /^https?:\/\//i.test(href) : false,
|
||
children: submenuChildren
|
||
});
|
||
return;
|
||
}
|
||
if (isActionLike) {
|
||
entries.push({
|
||
id: `${entryId}-action`,
|
||
type: "action",
|
||
label: label || "Action",
|
||
...getElementMenuMeta(link),
|
||
actionId: registerAction(link)
|
||
});
|
||
return;
|
||
}
|
||
|
||
entries.push({
|
||
id: `${entryId}-link`,
|
||
type: "link",
|
||
label: label || href,
|
||
...getElementMenuMeta(link),
|
||
href,
|
||
isExternal: link.target === "_blank" || /^https?:\/\//i.test(href)
|
||
});
|
||
return;
|
||
}
|
||
|
||
const spans = Array.from(li.querySelectorAll(":scope > span")).filter((it): it is HTMLSpanElement => it instanceof HTMLSpanElement);
|
||
if (submenuChildren.length && spans.length) {
|
||
const first = spans[0];
|
||
const label = cleanTopbarLabel(first.textContent || first.title || "");
|
||
entries.push({
|
||
id: `${entryId}-submenu-span`,
|
||
type: "submenu",
|
||
label: label || "Section",
|
||
...getElementMenuMeta(first),
|
||
actionId: registerAction(first),
|
||
children: submenuChildren
|
||
});
|
||
return;
|
||
}
|
||
spans.forEach((span, spanIndex) => {
|
||
const label = cleanTopbarLabel(span.textContent || span.title || "");
|
||
entries.push({
|
||
id: `${entryId}-span-${spanIndex}`,
|
||
type: "action",
|
||
label: label || "Action",
|
||
...getElementMenuMeta(span),
|
||
actionId: registerAction(span)
|
||
});
|
||
});
|
||
});
|
||
|
||
return entries;
|
||
}
|
||
|
||
function parseNativeTopbarGroups(navbar: HTMLElement): {
|
||
groups: TopbarMenuGroup[];
|
||
actionHandlers: Map<string, () => void>;
|
||
} {
|
||
let actionIx = 0;
|
||
const actionHandlers = new Map<string, () => void>();
|
||
const registerAction = (ele: HTMLElement): string => {
|
||
const id = `native-action-${actionIx++}`;
|
||
actionHandlers.set(id, () => {
|
||
ele.click();
|
||
});
|
||
return id;
|
||
};
|
||
|
||
const groups: TopbarMenuGroup[] = [];
|
||
const rootItems = Array.from(navbar.children).filter((it): it is HTMLLIElement => it instanceof HTMLLIElement);
|
||
rootItems.forEach((li, index) => {
|
||
if (li.classList.contains("omni__wrp-input") || li.classList.contains("desktop-nav-characters")) {
|
||
return;
|
||
}
|
||
|
||
if (li.classList.contains("dropdown--navbar")) {
|
||
const toggle = li.querySelector(":scope > a") as HTMLAnchorElement | null;
|
||
const menuList = li.querySelector(":scope > ul") as HTMLUListElement | null;
|
||
const label = toggle ? getElementPrimaryText(toggle) : "";
|
||
if (!label) {
|
||
return;
|
||
}
|
||
groups.push({
|
||
id: `root-${index}-dropdown`,
|
||
kind: "dropdown",
|
||
label,
|
||
items: menuList ? parseNativeTopbarMenuList(menuList, registerAction, `root-${index}-list`) : []
|
||
});
|
||
return;
|
||
}
|
||
|
||
const link = li.querySelector(":scope > a") as HTMLAnchorElement | null;
|
||
if (!link) {
|
||
return;
|
||
}
|
||
|
||
const href = (link.getAttribute("href") || "").trim();
|
||
const label = getElementPrimaryText(link);
|
||
if (!label) {
|
||
return;
|
||
}
|
||
|
||
const isActionLike = href === "#" || href.toLowerCase().startsWith("javascript:");
|
||
if (isActionLike) {
|
||
groups.push({
|
||
id: `root-${index}-action`,
|
||
kind: "action",
|
||
label,
|
||
actionId: registerAction(link)
|
||
});
|
||
return;
|
||
}
|
||
|
||
groups.push({
|
||
id: `root-${index}-link`,
|
||
kind: "link",
|
||
label,
|
||
href,
|
||
isExternal: link.target === "_blank" || /^https?:\/\//i.test(href)
|
||
});
|
||
});
|
||
|
||
return { groups, actionHandlers };
|
||
}
|
||
|
||
function loadExternalScript(src: string, scriptType: "classic" | "module" = "classic"): Promise<void> {
|
||
return new Promise((resolve, reject) => {
|
||
const selector = `script[data-5etd-render-runtime="true"][src="${src}"][data-5etd-script-type="${scriptType}"]`;
|
||
const existing = document.querySelector(selector) as HTMLScriptElement | null;
|
||
if (existing) {
|
||
if (existing.dataset.loaded === "true") {
|
||
resolve();
|
||
return;
|
||
}
|
||
|
||
existing.addEventListener("load", () => resolve(), { once: true });
|
||
existing.addEventListener("error", () => reject(new Error(`Failed to load ${src}`)), { once: true });
|
||
return;
|
||
}
|
||
|
||
const script = document.createElement("script");
|
||
script.src = src;
|
||
script.type = scriptType === "module" ? "module" : "text/javascript";
|
||
script.async = false;
|
||
script.dataset.loaded = "false";
|
||
script.dataset["5etdRenderRuntime"] = "true";
|
||
script.dataset["5etdScriptType"] = scriptType;
|
||
script.addEventListener(
|
||
"load",
|
||
() => {
|
||
script.dataset.loaded = "true";
|
||
resolve();
|
||
},
|
||
{ once: true }
|
||
);
|
||
script.addEventListener(
|
||
"error",
|
||
() => {
|
||
reject(new Error(`Failed to load ${src}`));
|
||
},
|
||
{ once: true }
|
||
);
|
||
document.head.append(script);
|
||
});
|
||
}
|
||
|
||
function loadExternalStylesheet(href: string): Promise<void> {
|
||
return new Promise((resolve, reject) => {
|
||
const selector = `link[data-5etd-render-runtime-style="true"][href="${href}"]`;
|
||
const existing = document.querySelector(selector) as HTMLLinkElement | null;
|
||
if (existing) {
|
||
if (existing.dataset.loaded === "true") {
|
||
resolve();
|
||
return;
|
||
}
|
||
|
||
existing.addEventListener("load", () => resolve(), { once: true });
|
||
existing.addEventListener("error", () => reject(new Error(`Failed to load stylesheet ${href}`)), { once: true });
|
||
return;
|
||
}
|
||
|
||
const link = document.createElement("link");
|
||
link.rel = "stylesheet";
|
||
link.href = href;
|
||
link.dataset.loaded = "false";
|
||
link.dataset["5etdRenderRuntimeStyle"] = "true";
|
||
link.addEventListener(
|
||
"load",
|
||
() => {
|
||
link.dataset.loaded = "true";
|
||
resolve();
|
||
},
|
||
{ once: true }
|
||
);
|
||
link.addEventListener(
|
||
"error",
|
||
() => {
|
||
reject(new Error(`Failed to load stylesheet ${href}`));
|
||
},
|
||
{ once: true }
|
||
);
|
||
|
||
// Insert before Vite-injected app styles where possible, so app-specific CSS keeps precedence.
|
||
const firstStyleOrLink = document.head.querySelector("style, link[rel='stylesheet']");
|
||
if (firstStyleOrLink) {
|
||
document.head.insertBefore(link, firstStyleOrLink);
|
||
} else {
|
||
document.head.append(link);
|
||
}
|
||
});
|
||
}
|
||
|
||
async function ensure5etoolsRenderRuntime(wikiUrl: string): Promise<void> {
|
||
const baseUrl = normalizeWikiBaseUrl(wikiUrl);
|
||
if (!baseUrl) {
|
||
return;
|
||
}
|
||
|
||
if (window.Renderer?.get && renderRuntimeBaseUrl === baseUrl) {
|
||
return;
|
||
}
|
||
|
||
if (renderRuntimePromise && renderRuntimeBaseUrl === baseUrl) {
|
||
await renderRuntimePromise;
|
||
return;
|
||
}
|
||
|
||
renderRuntimeBaseUrl = baseUrl;
|
||
renderRuntimePromise = (async () => {
|
||
(window as unknown as { RENDERER_BASE_URL?: string }).RENDERER_BASE_URL = `${baseUrl}/`;
|
||
|
||
for (const stylePath of FIVEETOOLS_RENDER_STYLESHEET_PATHS) {
|
||
await loadExternalStylesheet(new URL(stylePath, `${baseUrl}/`).toString());
|
||
}
|
||
|
||
for (const scriptPath of FIVEETOOLS_RENDER_CLASSIC_SCRIPT_PATHS) {
|
||
await loadExternalScript(new URL(scriptPath, `${baseUrl}/`).toString(), "classic");
|
||
}
|
||
|
||
for (const scriptPath of FIVEETOOLS_RENDER_MODULE_SCRIPT_PATHS) {
|
||
await loadExternalScript(new URL(scriptPath, `${baseUrl}/`).toString(), "module");
|
||
}
|
||
|
||
if (!window.Renderer?.get) {
|
||
throw new Error("5etools Renderer runtime did not initialize.");
|
||
}
|
||
|
||
// `Renderer.hover` internally uses `ee` template helpers from utils.js.
|
||
// These are initialized by `JqueryUtil.initEnhancements()` in normal 5etools page bootstraps.
|
||
// We must do this manually in the character view runtime.
|
||
if (window.JqueryUtil?.initEnhancements) {
|
||
window.JqueryUtil.initEnhancements();
|
||
} else {
|
||
window.TemplateUtil?.initVanilla?.();
|
||
}
|
||
|
||
window.Renderer.get().setBaseUrl(`${baseUrl}/`).setAddHandlers(true).setFirstSection(true);
|
||
window.Renderer.hover?._doInit?.();
|
||
})();
|
||
|
||
await renderRuntimePromise;
|
||
}
|
||
|
||
function escapeHtml(value: string): string {
|
||
return value
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'");
|
||
}
|
||
|
||
function stripTagsFallback(value: string): string {
|
||
return value
|
||
.replace(/\{@([^}]+)\}/g, (_match: string, innerRaw: string) => {
|
||
const inner = innerRaw.trim();
|
||
if (!inner) {
|
||
return "";
|
||
}
|
||
|
||
const firstSpace = inner.indexOf(" ");
|
||
const payload = firstSpace >= 0 ? inner.slice(firstSpace + 1) : inner;
|
||
const parts = payload.split("|").map((it) => it.trim());
|
||
return parts[2] || parts[3] || parts[0] || parts[1] || "";
|
||
})
|
||
.replace(/\s+/g, " ")
|
||
.trim();
|
||
}
|
||
|
||
function renderEntryText(text: string, isParserReady: boolean): string {
|
||
const value = text.trim();
|
||
if (!value) {
|
||
return "";
|
||
}
|
||
|
||
if (isParserReady && window.Renderer?.get) {
|
||
try {
|
||
const renderer = window.Renderer.get();
|
||
renderer.setFirstSection(true);
|
||
return renderer.render(value);
|
||
} catch {
|
||
// Fall through to basic escaped text below.
|
||
}
|
||
}
|
||
|
||
return escapeHtml(stripTagsFallback(value));
|
||
}
|
||
|
||
function ParsedEntryText(props: {
|
||
text: string;
|
||
isParserReady: boolean;
|
||
className?: string;
|
||
}): JSX.Element | null {
|
||
const { text, isParserReady, className } = props;
|
||
const html = useMemo(() => renderEntryText(text, isParserReady), [text, isParserReady]);
|
||
|
||
if (!html) {
|
||
return null;
|
||
}
|
||
|
||
return <div className={className} dangerouslySetInnerHTML={{ __html: html }} />;
|
||
}
|
||
|
||
function createNewCharacterDraft(): CharacterDocument {
|
||
const now = new Date().toISOString();
|
||
return {
|
||
schema: "5etd.character",
|
||
version: 1,
|
||
id: crypto.randomUUID(),
|
||
createdAt: now,
|
||
updatedAt: now,
|
||
profile: {
|
||
name: "Unnamed Character",
|
||
level: 1,
|
||
alignment: "",
|
||
faith: "",
|
||
lifestyle: "",
|
||
age: "",
|
||
gender: "",
|
||
height: "",
|
||
weight: "",
|
||
eyes: "",
|
||
skin: "",
|
||
hair: ""
|
||
},
|
||
selections: {
|
||
class: null,
|
||
subclass: null,
|
||
background: null,
|
||
species: null
|
||
},
|
||
steps: {
|
||
preferences: {
|
||
sourceBuckets: ["core2024"],
|
||
useOptionalClassFeatures: true,
|
||
customizeOrigin: true,
|
||
advancementType: "milestone",
|
||
hpMode: "average",
|
||
usePrerequisitesFeats: true,
|
||
usePrerequisitesMulticlass: true
|
||
},
|
||
abilityScores: {
|
||
method: "standardArray",
|
||
scores: {
|
||
str: 15,
|
||
dex: 14,
|
||
con: 13,
|
||
int: 12,
|
||
wis: 10,
|
||
cha: 8
|
||
},
|
||
rolledValues: [...STANDARD_ARRAY],
|
||
assignments: {
|
||
str: 0,
|
||
dex: 1,
|
||
con: 2,
|
||
int: 3,
|
||
wis: 4,
|
||
cha: 5
|
||
},
|
||
backgroundBonusModeIndex: 0,
|
||
backgroundBonusSelections: []
|
||
},
|
||
feats: {
|
||
selected: [],
|
||
notes: ""
|
||
},
|
||
skills: {
|
||
selected: [],
|
||
notes: ""
|
||
},
|
||
spells: {
|
||
selected: [],
|
||
notes: ""
|
||
},
|
||
equipment: {
|
||
startingEquipmentText: "",
|
||
inventoryText: "",
|
||
possessionsText: "",
|
||
currency: {
|
||
cp: 0,
|
||
sp: 0,
|
||
ep: 0,
|
||
gp: 0,
|
||
pp: 0
|
||
}
|
||
},
|
||
about: {
|
||
personality: "",
|
||
ideals: "",
|
||
bonds: "",
|
||
flaws: "",
|
||
backstory: "",
|
||
notes: ""
|
||
}
|
||
},
|
||
notes: ""
|
||
};
|
||
}
|
||
|
||
function abilityModifier(score: number): string {
|
||
const mod = Math.floor((score - 10) / 2);
|
||
return mod >= 0 ? `+${mod}` : `${mod}`;
|
||
}
|
||
|
||
function formatIso(value: string): string {
|
||
const date = new Date(value);
|
||
if (Number.isNaN(date.getTime())) {
|
||
return value;
|
||
}
|
||
|
||
return date.toLocaleString();
|
||
}
|
||
|
||
function parseLineList(raw: string): string[] {
|
||
return Array.from(
|
||
new Set(
|
||
raw
|
||
.split(/\r?\n/g)
|
||
.map((it) => it.trim())
|
||
.filter((it) => Boolean(it))
|
||
)
|
||
);
|
||
}
|
||
|
||
function toLineList(items: string[]): string {
|
||
return items.join("\n");
|
||
}
|
||
|
||
function filterOptions(options: CharacterSelectionOption[], query: string): CharacterSelectionOption[] {
|
||
const needle = query.trim().toLowerCase();
|
||
if (!needle) {
|
||
return options;
|
||
}
|
||
|
||
return options.filter((option) => {
|
||
const haystack = `${option.name} ${option.source} ${option.sourceName} ${option.details} ${option.summary}`.toLowerCase();
|
||
return haystack.includes(needle);
|
||
});
|
||
}
|
||
|
||
function filterBySourceBuckets(options: CharacterSelectionOption[], sourceBuckets: string[]): CharacterSelectionOption[] {
|
||
if (!sourceBuckets.length) {
|
||
return [];
|
||
}
|
||
|
||
const selected = new Set(sourceBuckets);
|
||
return options.filter((option) => selected.has(option.sourceBucket));
|
||
}
|
||
|
||
function clampPage(page: number, totalItems: number): number {
|
||
const totalPages = Math.max(1, Math.ceil(totalItems / CARD_PAGE_SIZE));
|
||
return Math.max(1, Math.min(totalPages, page));
|
||
}
|
||
|
||
function paginateOptions(options: CharacterSelectionOption[], page: number): CharacterSelectionOption[] {
|
||
const safePage = clampPage(page, options.length);
|
||
const start = (safePage - 1) * CARD_PAGE_SIZE;
|
||
return options.slice(start, start + CARD_PAGE_SIZE);
|
||
}
|
||
|
||
function toEntityRef(option: CharacterSelectionOption): CharacterEntityRef {
|
||
return {
|
||
entityType: option.entityType,
|
||
name: option.name,
|
||
source: option.source,
|
||
hash: option.hash,
|
||
entityRef: option.entityRef
|
||
};
|
||
}
|
||
|
||
function toSubclassEntityRef(option: CharacterFeatureOption): CharacterEntityRef {
|
||
return {
|
||
entityType: "subclass",
|
||
name: option.name,
|
||
source: option.source,
|
||
hash: option.hash,
|
||
entityRef: option.entityRef
|
||
};
|
||
}
|
||
|
||
function buildAssetUrl(baseUrl: string, relativePath: string | null): string {
|
||
if (!baseUrl || !relativePath) {
|
||
return "";
|
||
}
|
||
|
||
return new URL(relativePath.replace(/^\/+/, ""), `${baseUrl}/`).toString();
|
||
}
|
||
|
||
function roll4d6DropLowest(): number {
|
||
const rolls = [
|
||
Math.floor(Math.random() * 6) + 1,
|
||
Math.floor(Math.random() * 6) + 1,
|
||
Math.floor(Math.random() * 6) + 1,
|
||
Math.floor(Math.random() * 6) + 1
|
||
].sort((a, b) => a - b);
|
||
|
||
return rolls[1] + rolls[2] + rolls[3];
|
||
}
|
||
|
||
function getScoresFromAssignments(
|
||
pool: [number, number, number, number, number, number],
|
||
assignments: Record<AbilityKey, number>
|
||
): Record<AbilityKey, number> {
|
||
return {
|
||
str: pool[assignments.str] ?? 8,
|
||
dex: pool[assignments.dex] ?? 8,
|
||
con: pool[assignments.con] ?? 8,
|
||
int: pool[assignments.int] ?? 8,
|
||
wis: pool[assignments.wis] ?? 8,
|
||
cha: pool[assignments.cha] ?? 8
|
||
};
|
||
}
|
||
|
||
function getPointBuyCost(score: number): number {
|
||
const map: Record<number, number> = {
|
||
8: 0,
|
||
9: 1,
|
||
10: 2,
|
||
11: 3,
|
||
12: 4,
|
||
13: 5,
|
||
14: 7,
|
||
15: 9
|
||
};
|
||
|
||
return map[score] ?? 99;
|
||
}
|
||
|
||
function getPointBuySpent(scores: Record<AbilityKey, number>): number {
|
||
return ABILITY_KEYS.reduce((total, key) => total + getPointBuyCost(scores[key]), 0);
|
||
}
|
||
|
||
interface BackgroundAbilityMode {
|
||
key: string;
|
||
from: AbilityKey[];
|
||
weights: number[];
|
||
label: string;
|
||
}
|
||
|
||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||
return typeof value === "object" && value !== null;
|
||
}
|
||
|
||
function isAbilityKey(value: string): value is AbilityKey {
|
||
return ABILITY_KEYS.includes(value as AbilityKey);
|
||
}
|
||
|
||
function getBackgroundAbilityModes(detail: CharacterSelectionDetail | null): BackgroundAbilityMode[] {
|
||
if (!detail || detail.entityType !== "background") {
|
||
return [];
|
||
}
|
||
|
||
try {
|
||
const parsed = JSON.parse(detail.rawJson) as unknown;
|
||
if (!isRecord(parsed) || !Array.isArray(parsed.ability)) {
|
||
return [];
|
||
}
|
||
|
||
const out: BackgroundAbilityMode[] = [];
|
||
parsed.ability.forEach((partRaw, index) => {
|
||
if (!isRecord(partRaw)) {
|
||
return;
|
||
}
|
||
|
||
const chooseRaw = partRaw.choose;
|
||
if (!isRecord(chooseRaw)) {
|
||
return;
|
||
}
|
||
|
||
const weightedRaw = chooseRaw.weighted;
|
||
if (!isRecord(weightedRaw)) {
|
||
return;
|
||
}
|
||
|
||
if (!Array.isArray(weightedRaw.from) || !Array.isArray(weightedRaw.weights)) {
|
||
return;
|
||
}
|
||
|
||
const from = Array.from(
|
||
new Set(
|
||
weightedRaw.from
|
||
.map((it) => (typeof it === "string" ? it.trim().toLowerCase() : ""))
|
||
.filter((it) => Boolean(it) && isAbilityKey(it))
|
||
)
|
||
) as AbilityKey[];
|
||
|
||
const weights = weightedRaw.weights
|
||
.map((it) => (typeof it === "number" && Number.isFinite(it) ? Math.max(1, Math.trunc(it)) : 0))
|
||
.filter((it) => it > 0);
|
||
|
||
if (!from.length || !weights.length || weights.length > from.length) {
|
||
return;
|
||
}
|
||
|
||
const label = weights.map((it) => `+${it}`).join(" / ");
|
||
out.push({
|
||
key: `bg-ability-mode-${index}`,
|
||
from,
|
||
weights,
|
||
label
|
||
});
|
||
});
|
||
|
||
return out;
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
function areAbilityArraysEqual(a: AbilityKey[], b: AbilityKey[]): boolean {
|
||
if (a.length !== b.length) {
|
||
return false;
|
||
}
|
||
|
||
return a.every((value, index) => value === b[index]);
|
||
}
|
||
|
||
function normalizeBackgroundBonusSelections(mode: BackgroundAbilityMode | null, selectedRaw: AbilityKey[]): AbilityKey[] {
|
||
if (!mode) {
|
||
return [];
|
||
}
|
||
|
||
const selected: AbilityKey[] = [];
|
||
const used = new Set<AbilityKey>();
|
||
|
||
for (const value of selectedRaw) {
|
||
if (!mode.from.includes(value) || used.has(value)) {
|
||
continue;
|
||
}
|
||
selected.push(value);
|
||
used.add(value);
|
||
if (selected.length >= mode.weights.length) {
|
||
break;
|
||
}
|
||
}
|
||
|
||
for (const candidate of mode.from) {
|
||
if (selected.length >= mode.weights.length) {
|
||
break;
|
||
}
|
||
if (used.has(candidate)) {
|
||
continue;
|
||
}
|
||
selected.push(candidate);
|
||
used.add(candidate);
|
||
}
|
||
|
||
return selected;
|
||
}
|
||
|
||
function getBackgroundBonusTotals(mode: BackgroundAbilityMode | null, selected: AbilityKey[]): Record<AbilityKey, number> {
|
||
const totals: Record<AbilityKey, number> = {
|
||
str: 0,
|
||
dex: 0,
|
||
con: 0,
|
||
int: 0,
|
||
wis: 0,
|
||
cha: 0
|
||
};
|
||
|
||
if (!mode) {
|
||
return totals;
|
||
}
|
||
|
||
mode.weights.forEach((weight, index) => {
|
||
const ability = selected[index];
|
||
if (!ability) {
|
||
return;
|
||
}
|
||
totals[ability] += weight;
|
||
});
|
||
|
||
return totals;
|
||
}
|
||
|
||
function getScoresWithBonuses(
|
||
baseScores: Record<AbilityKey, number>,
|
||
bonuses: Record<AbilityKey, number>
|
||
): Record<AbilityKey, number> {
|
||
return {
|
||
str: baseScores.str + bonuses.str,
|
||
dex: baseScores.dex + bonuses.dex,
|
||
con: baseScores.con + bonuses.con,
|
||
int: baseScores.int + bonuses.int,
|
||
wis: baseScores.wis + bonuses.wis,
|
||
cha: baseScores.cha + bonuses.cha
|
||
};
|
||
}
|
||
|
||
const CORE_SOURCE_CODES = new Set(["PHB", "DMG", "XPHB", "XDMG"]);
|
||
|
||
const SKILL_TO_ABILITY: Record<(typeof SKILL_OPTIONS)[number], AbilityKey> = {
|
||
Acrobatics: "dex",
|
||
"Animal Handling": "wis",
|
||
Arcana: "int",
|
||
Athletics: "str",
|
||
Deception: "cha",
|
||
History: "int",
|
||
Insight: "wis",
|
||
Intimidation: "cha",
|
||
Investigation: "int",
|
||
Medicine: "wis",
|
||
Nature: "int",
|
||
Perception: "wis",
|
||
Performance: "cha",
|
||
Persuasion: "cha",
|
||
Religion: "int",
|
||
"Sleight of Hand": "dex",
|
||
Stealth: "dex",
|
||
Survival: "wis"
|
||
};
|
||
|
||
const ABILITY_SCORE_COST_TABLE: Record<number, number> = {
|
||
8: 0,
|
||
9: 1,
|
||
10: 2,
|
||
11: 3,
|
||
12: 4,
|
||
13: 5,
|
||
14: 7,
|
||
15: 9
|
||
};
|
||
|
||
interface SkillChoiceRequirement {
|
||
id: string;
|
||
sourceLabel: string;
|
||
title: string;
|
||
count: number;
|
||
options: string[];
|
||
}
|
||
|
||
interface ParsedSkillData {
|
||
granted: string[];
|
||
choices: SkillChoiceRequirement[];
|
||
}
|
||
|
||
interface FeatCatalogItem {
|
||
key: string;
|
||
name: string;
|
||
source: string;
|
||
sourceBucket: CharacterSourceBucket;
|
||
sourceEdition: "2014" | "2024" | null;
|
||
category: string | null;
|
||
summary: string;
|
||
}
|
||
|
||
interface SpellCatalogItem {
|
||
key: string;
|
||
name: string;
|
||
source: string;
|
||
level: number;
|
||
school: string;
|
||
sourceBucket: CharacterSourceBucket;
|
||
sourceEdition: "2014" | "2024" | null;
|
||
classes: Array<{ name: string; source: string }>;
|
||
}
|
||
|
||
type SpellSourceLookup = Record<
|
||
string,
|
||
Record<
|
||
string,
|
||
{
|
||
class?: Record<string, Record<string, unknown>>;
|
||
classVariant?: Record<string, Record<string, unknown>>;
|
||
}
|
||
>
|
||
>;
|
||
|
||
interface EquipmentGrant {
|
||
id: string;
|
||
label: string;
|
||
cpValue: number;
|
||
}
|
||
|
||
interface EquipmentChoiceOption {
|
||
id: string;
|
||
key: string;
|
||
label: string;
|
||
grants: EquipmentGrant[];
|
||
}
|
||
|
||
interface EquipmentChoiceGroup {
|
||
id: string;
|
||
label: string;
|
||
options: EquipmentChoiceOption[];
|
||
}
|
||
|
||
interface EquipmentSelectionState {
|
||
selections: Record<string, string>;
|
||
addedItems: string[];
|
||
}
|
||
|
||
interface SkillsNotesState {
|
||
text: string;
|
||
picksByChoiceId: Record<string, string[]>;
|
||
extraSkills: string[];
|
||
}
|
||
|
||
function safeJsonParse<T>(value: string): T | null {
|
||
try {
|
||
return JSON.parse(value) as T;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function getSourceBadgeText(source: string, sourceEdition: "2014" | "2024" | null): string {
|
||
const src = source.trim().toUpperCase();
|
||
if (src === "XPHB") {
|
||
return "2024";
|
||
}
|
||
|
||
if (src === "PHB") {
|
||
return "2014";
|
||
}
|
||
|
||
return sourceEdition ?? "";
|
||
}
|
||
|
||
function classifySourceBucket(sourceRaw: string): {
|
||
sourceBucket: CharacterSourceBucket;
|
||
sourceEdition: "2014" | "2024" | null;
|
||
} {
|
||
const source = sourceRaw.trim().toUpperCase();
|
||
if (!source) {
|
||
return {
|
||
sourceBucket: "expanded2014",
|
||
sourceEdition: null
|
||
};
|
||
}
|
||
|
||
const is2024 = source.startsWith("X");
|
||
const isCore = CORE_SOURCE_CODES.has(source);
|
||
return {
|
||
sourceBucket: is2024 ? (isCore ? "core2024" : "expanded2024") : isCore ? "core2014" : "expanded2014",
|
||
sourceEdition: is2024 ? "2024" : "2014"
|
||
};
|
||
}
|
||
|
||
function normalizeSkillName(value: string): string {
|
||
return value
|
||
.trim()
|
||
.toLowerCase()
|
||
.replace(/[^a-z]+/g, " ")
|
||
.replace(/\s+/g, " ")
|
||
.trim();
|
||
}
|
||
|
||
const SKILL_NAME_LOOKUP = (() => {
|
||
const out = new Map<string, string>();
|
||
for (const skill of SKILL_OPTIONS) {
|
||
out.set(normalizeSkillName(skill), skill);
|
||
}
|
||
return out;
|
||
})();
|
||
|
||
function toSkillName(value: string): string | null {
|
||
return SKILL_NAME_LOOKUP.get(normalizeSkillName(value)) || null;
|
||
}
|
||
|
||
function parseSkillProficiencyValue(
|
||
value: unknown,
|
||
sourceLabel: string,
|
||
idPrefix: string
|
||
): ParsedSkillData {
|
||
const granted = new Set<string>();
|
||
const choices: SkillChoiceRequirement[] = [];
|
||
if (!Array.isArray(value)) {
|
||
return { granted: [], choices: [] };
|
||
}
|
||
|
||
value.forEach((entryRaw, entryIndex) => {
|
||
if (!isRecord(entryRaw)) {
|
||
return;
|
||
}
|
||
|
||
const chooseRaw = entryRaw.choose;
|
||
if (isRecord(chooseRaw)) {
|
||
const from = Array.isArray(chooseRaw.from)
|
||
? chooseRaw.from.map((it) => (typeof it === "string" ? toSkillName(it) : null)).filter((it): it is string => Boolean(it))
|
||
: [...SKILL_OPTIONS];
|
||
const count = typeof chooseRaw.count === "number" && Number.isFinite(chooseRaw.count) ? Math.max(1, Math.trunc(chooseRaw.count)) : 1;
|
||
if (from.length) {
|
||
choices.push({
|
||
id: `${idPrefix}-choose-${entryIndex}`,
|
||
sourceLabel,
|
||
title: `${sourceLabel} Skill Choice`,
|
||
count: Math.min(count, from.length),
|
||
options: Array.from(new Set(from))
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
|
||
Object.entries(entryRaw).forEach(([key, val]) => {
|
||
if (key === "any" && typeof val === "number" && val > 0) {
|
||
choices.push({
|
||
id: `${idPrefix}-any-${entryIndex}`,
|
||
sourceLabel,
|
||
title: `${sourceLabel} Skill Choice`,
|
||
count: Math.min(Math.trunc(val), SKILL_OPTIONS.length),
|
||
options: [...SKILL_OPTIONS]
|
||
});
|
||
return;
|
||
}
|
||
|
||
if (val !== true) {
|
||
return;
|
||
}
|
||
|
||
const skill = toSkillName(key);
|
||
if (skill) {
|
||
granted.add(skill);
|
||
}
|
||
});
|
||
});
|
||
|
||
return {
|
||
granted: [...granted],
|
||
choices
|
||
};
|
||
}
|
||
|
||
function parseClassSkills(detail: CharacterSelectionDetail | null): ParsedSkillData {
|
||
if (!detail) {
|
||
return { granted: [], choices: [] };
|
||
}
|
||
|
||
const raw = safeJsonParse<Record<string, unknown>>(detail.rawJson);
|
||
if (!raw || !isRecord(raw.startingProficiencies)) {
|
||
return { granted: [], choices: [] };
|
||
}
|
||
|
||
return parseSkillProficiencyValue(raw.startingProficiencies.skills, "Class", "class");
|
||
}
|
||
|
||
function parseBackgroundSkills(detail: CharacterSelectionDetail | null): ParsedSkillData {
|
||
if (!detail) {
|
||
return { granted: [], choices: [] };
|
||
}
|
||
|
||
const raw = safeJsonParse<Record<string, unknown>>(detail.rawJson);
|
||
return parseSkillProficiencyValue(raw?.skillProficiencies, "Background", "background");
|
||
}
|
||
|
||
function parseSpeciesSkills(detail: CharacterSelectionDetail | null): ParsedSkillData {
|
||
if (!detail) {
|
||
return { granted: [], choices: [] };
|
||
}
|
||
|
||
const raw = safeJsonParse<Record<string, unknown>>(detail.rawJson);
|
||
return parseSkillProficiencyValue(raw?.skillProficiencies, "Species", "species");
|
||
}
|
||
|
||
function getProficiencyBonus(level: number): number {
|
||
return Math.max(2, Math.min(6, 2 + Math.floor((Math.max(1, level) - 1) / 4)));
|
||
}
|
||
|
||
function sanitizeFeatKey(name: string, source: string): string {
|
||
return `${name.trim().toLowerCase()}|${source.trim().toUpperCase()}`;
|
||
}
|
||
|
||
function parseFeatUid(uidRaw: string): { name: string; source: string } | null {
|
||
const uid = uidRaw.trim();
|
||
if (!uid) {
|
||
return null;
|
||
}
|
||
|
||
const [nameRaw, sourceRaw] = uid.split("|");
|
||
const name = (nameRaw || "").trim();
|
||
const source = (sourceRaw || "PHB").trim().toUpperCase();
|
||
if (!name) {
|
||
return null;
|
||
}
|
||
|
||
return { name, source };
|
||
}
|
||
|
||
function getClassFeatSlotCount(detail: CharacterSelectionDetail | null, level: number): number {
|
||
if (!detail) {
|
||
return 0;
|
||
}
|
||
|
||
return detail.featureCards.filter((card) => {
|
||
if (card.level != null && card.level > level) {
|
||
return false;
|
||
}
|
||
|
||
const title = card.title.toLowerCase();
|
||
return /ability score improvement|fighting style feat|epic boon|\bfeat\b/i.test(title);
|
||
}).length;
|
||
}
|
||
|
||
function getFixedFeatUids(detail: CharacterSelectionDetail | null): string[] {
|
||
if (!detail || detail.entityType === "class") {
|
||
return [];
|
||
}
|
||
|
||
const raw = safeJsonParse<Record<string, unknown>>(detail.rawJson);
|
||
if (!raw || !Array.isArray(raw.feats)) {
|
||
return [];
|
||
}
|
||
|
||
const out = new Set<string>();
|
||
raw.feats.forEach((entryRaw) => {
|
||
if (!isRecord(entryRaw)) {
|
||
return;
|
||
}
|
||
|
||
Object.entries(entryRaw).forEach(([key, value]) => {
|
||
if (value !== true) {
|
||
return;
|
||
}
|
||
const parsed = parseFeatUid(key);
|
||
if (!parsed) {
|
||
return;
|
||
}
|
||
out.add(`${parsed.name}|${parsed.source}`);
|
||
});
|
||
});
|
||
|
||
return [...out];
|
||
}
|
||
|
||
function parseClassSpellcasting(detail: CharacterSelectionDetail | null, level: number): {
|
||
cantrips: number;
|
||
prepared: number;
|
||
known: number;
|
||
slots: Array<{ level: number; slots: number; label: string }>;
|
||
hasPactMagic: boolean;
|
||
} {
|
||
const out = {
|
||
cantrips: 0,
|
||
prepared: 0,
|
||
known: 0,
|
||
slots: [] as Array<{ level: number; slots: number; label: string }>,
|
||
hasPactMagic: false
|
||
};
|
||
|
||
if (!detail) {
|
||
return out;
|
||
}
|
||
|
||
const raw = safeJsonParse<Record<string, unknown>>(detail.rawJson);
|
||
if (!raw) {
|
||
return out;
|
||
}
|
||
|
||
const levelIndex = Math.max(0, Math.min(19, level - 1));
|
||
const cantripProgression = Array.isArray(raw.cantripProgression) ? raw.cantripProgression : [];
|
||
const preparedSpellsProgression = Array.isArray(raw.preparedSpellsProgression) ? raw.preparedSpellsProgression : [];
|
||
const spellsKnownProgression = Array.isArray(raw.spellsKnownProgression) ? raw.spellsKnownProgression : [];
|
||
|
||
if (typeof cantripProgression[levelIndex] === "number") {
|
||
out.cantrips = Math.max(0, Math.trunc(cantripProgression[levelIndex]));
|
||
}
|
||
|
||
if (typeof preparedSpellsProgression[levelIndex] === "number") {
|
||
out.prepared = Math.max(0, Math.trunc(preparedSpellsProgression[levelIndex]));
|
||
}
|
||
|
||
if (typeof spellsKnownProgression[levelIndex] === "number") {
|
||
out.known = Math.max(0, Math.trunc(spellsKnownProgression[levelIndex]));
|
||
} else {
|
||
out.known = out.prepared;
|
||
}
|
||
|
||
const groups = Array.isArray(raw.classTableGroups) ? raw.classTableGroups : [];
|
||
groups.forEach((groupRaw) => {
|
||
if (!isRecord(groupRaw)) {
|
||
return;
|
||
}
|
||
|
||
const labels = Array.isArray(groupRaw.colLabels) ? groupRaw.colLabels : [];
|
||
const rows = Array.isArray(groupRaw.rowsSpellProgression) ? groupRaw.rowsSpellProgression : Array.isArray(groupRaw.rows) ? groupRaw.rows : [];
|
||
const levelRow = Array.isArray(rows[levelIndex]) ? rows[levelIndex] : null;
|
||
if (!levelRow) {
|
||
return;
|
||
}
|
||
|
||
labels.forEach((labelRaw, labelIndex) => {
|
||
const label = typeof labelRaw === "string" ? labelRaw : "";
|
||
const lower = label.toLowerCase();
|
||
if (!lower.includes("slot")) {
|
||
return;
|
||
}
|
||
|
||
const value = levelRow[labelIndex];
|
||
const slots = typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0;
|
||
if (!slots) {
|
||
return;
|
||
}
|
||
|
||
const levelMatch = lower.match(/(\d+)(?:st|nd|rd|th)/);
|
||
const spellLevel = levelMatch ? Number.parseInt(levelMatch[1], 10) : 1;
|
||
out.slots.push({
|
||
level: Number.isFinite(spellLevel) ? spellLevel : 1,
|
||
slots,
|
||
label
|
||
});
|
||
|
||
if (lower.includes("pact")) {
|
||
out.hasPactMagic = true;
|
||
}
|
||
});
|
||
});
|
||
|
||
out.slots.sort((a, b) => a.level - b.level || a.label.localeCompare(b.label));
|
||
return out;
|
||
}
|
||
|
||
function readEquipmentTypeLabel(value: string): string {
|
||
const map: Record<string, string> = {
|
||
instrumentMusical: "Musical Instrument",
|
||
toolArtisan: "Artisan's Tools"
|
||
};
|
||
return map[value] || value;
|
||
}
|
||
|
||
function parseEquipmentGrant(raw: unknown, idPrefix: string, index: number): EquipmentGrant[] {
|
||
if (!isRecord(raw)) {
|
||
return [];
|
||
}
|
||
|
||
const grants: EquipmentGrant[] = [];
|
||
|
||
if (typeof raw.item === "string") {
|
||
const parsed = parseFeatUid(raw.item.replace(/^([^|]+)\|/, "$1|"));
|
||
const [nameRaw] = raw.item.split("|");
|
||
const quantity = typeof raw.quantity === "number" ? Math.max(1, Math.trunc(raw.quantity)) : 1;
|
||
grants.push({
|
||
id: `${idPrefix}-item-${index}`,
|
||
label: `${nameRaw.trim()}${quantity > 1 ? ` x${quantity}` : ""}`,
|
||
cpValue: 0
|
||
});
|
||
if (parsed) {
|
||
void parsed;
|
||
}
|
||
}
|
||
|
||
if (typeof raw.value === "number" && Number.isFinite(raw.value)) {
|
||
const cp = Math.max(0, Math.trunc(raw.value));
|
||
const gp = cp / 100;
|
||
grants.push({
|
||
id: `${idPrefix}-gp-${index}`,
|
||
label: `${gp} GP`,
|
||
cpValue: cp
|
||
});
|
||
}
|
||
|
||
if (typeof raw.equipmentType === "string") {
|
||
grants.push({
|
||
id: `${idPrefix}-type-${index}`,
|
||
label: `Choose ${readEquipmentTypeLabel(raw.equipmentType)}`,
|
||
cpValue: 0
|
||
});
|
||
}
|
||
|
||
if (Array.isArray(raw.equipmentTypes) && raw.equipmentTypes.length) {
|
||
const labels = raw.equipmentTypes.filter((it): it is string => typeof it === "string").map((it) => readEquipmentTypeLabel(it));
|
||
if (labels.length) {
|
||
grants.push({
|
||
id: `${idPrefix}-types-${index}`,
|
||
label: `Choose ${labels.join(" or ")}`,
|
||
cpValue: 0
|
||
});
|
||
}
|
||
}
|
||
|
||
if (typeof raw.special === "string" && raw.special.trim()) {
|
||
grants.push({
|
||
id: `${idPrefix}-special-${index}`,
|
||
label: raw.special.trim(),
|
||
cpValue: 0
|
||
});
|
||
}
|
||
|
||
return grants;
|
||
}
|
||
|
||
function parseStartingEquipmentChoices(
|
||
detail: CharacterSelectionDetail | null,
|
||
sourceLabel: "Class" | "Background"
|
||
): EquipmentChoiceGroup[] {
|
||
if (!detail) {
|
||
return [];
|
||
}
|
||
|
||
const raw = safeJsonParse<Record<string, unknown>>(detail.rawJson);
|
||
if (!raw) {
|
||
return [];
|
||
}
|
||
|
||
const startingEquipment = raw.startingEquipment;
|
||
if (!Array.isArray(startingEquipment)) {
|
||
return [];
|
||
}
|
||
|
||
const groups: EquipmentChoiceGroup[] = [];
|
||
startingEquipment.forEach((groupRaw, groupIndex) => {
|
||
if (!isRecord(groupRaw)) {
|
||
return;
|
||
}
|
||
|
||
const options: EquipmentChoiceOption[] = [];
|
||
Object.entries(groupRaw)
|
||
.filter(([_k, value]) => Array.isArray(value))
|
||
.sort(([a], [b]) => a.localeCompare(b))
|
||
.forEach(([key, value]) => {
|
||
const grants = (value as unknown[]).flatMap((it, itemIndex) => parseEquipmentGrant(it, `${sourceLabel}-${groupIndex}-${key}`, itemIndex));
|
||
options.push({
|
||
id: `${sourceLabel}-${groupIndex}-${key}`,
|
||
key,
|
||
label: `${sourceLabel} Option ${key}`,
|
||
grants
|
||
});
|
||
});
|
||
|
||
if (!options.length) {
|
||
return;
|
||
}
|
||
|
||
groups.push({
|
||
id: `${sourceLabel}-${groupIndex}`,
|
||
label: `${sourceLabel} Starting Equipment ${groupIndex + 1}`,
|
||
options
|
||
});
|
||
});
|
||
|
||
return groups;
|
||
}
|
||
|
||
function parseEquipmentSelectionState(raw: string, fallbackInventoryRaw: string): EquipmentSelectionState {
|
||
const parsed = safeJsonParse<{ selections?: Record<string, string>; addedItems?: unknown[] }>(raw);
|
||
if (parsed && isRecord(parsed.selections) && Array.isArray(parsed.addedItems)) {
|
||
const selections: Record<string, string> = {};
|
||
Object.entries(parsed.selections).forEach(([k, v]) => {
|
||
if (typeof v === "string" && v.trim()) {
|
||
selections[k] = v;
|
||
}
|
||
});
|
||
|
||
return {
|
||
selections,
|
||
addedItems: parsed.addedItems
|
||
.filter((it): it is string => typeof it === "string")
|
||
.map((it) => it.trim())
|
||
.filter((it) => Boolean(it))
|
||
};
|
||
}
|
||
|
||
return {
|
||
selections: {},
|
||
addedItems: parseLineList(fallbackInventoryRaw)
|
||
};
|
||
}
|
||
|
||
function stringifyEquipmentSelectionState(state: EquipmentSelectionState): string {
|
||
return JSON.stringify(
|
||
{
|
||
selections: state.selections,
|
||
addedItems: state.addedItems
|
||
},
|
||
null,
|
||
2
|
||
);
|
||
}
|
||
|
||
function parseSkillsNotes(raw: string): SkillsNotesState {
|
||
const parsed = safeJsonParse<{ text?: string; picksByChoiceId?: Record<string, unknown>; extraSkills?: unknown[] }>(raw);
|
||
if (!parsed) {
|
||
return { text: raw, picksByChoiceId: {}, extraSkills: [] };
|
||
}
|
||
|
||
const hasKnownShape = typeof parsed.text === "string" || isRecord(parsed.picksByChoiceId) || Array.isArray(parsed.extraSkills);
|
||
if (!hasKnownShape) {
|
||
return { text: raw, picksByChoiceId: {}, extraSkills: [] };
|
||
}
|
||
|
||
const picksByChoiceId: Record<string, string[]> = {};
|
||
if (isRecord(parsed.picksByChoiceId)) {
|
||
Object.entries(parsed.picksByChoiceId).forEach(([choiceId, picksRaw]) => {
|
||
if (!Array.isArray(picksRaw)) {
|
||
return;
|
||
}
|
||
picksByChoiceId[choiceId] = picksRaw
|
||
.filter((it): it is string => typeof it === "string")
|
||
.map((it) => toSkillName(it))
|
||
.filter((it): it is string => Boolean(it));
|
||
});
|
||
}
|
||
|
||
const extraSkills = Array.isArray(parsed.extraSkills)
|
||
? uniqueStrings(
|
||
parsed.extraSkills
|
||
.filter((it): it is string => typeof it === "string")
|
||
.map((it) => toSkillName(it))
|
||
.filter((it): it is string => Boolean(it))
|
||
)
|
||
: [];
|
||
|
||
return {
|
||
text: typeof parsed.text === "string" ? parsed.text : "",
|
||
picksByChoiceId,
|
||
extraSkills
|
||
};
|
||
}
|
||
|
||
function stringifySkillsNotes(state: SkillsNotesState): string {
|
||
return JSON.stringify(
|
||
{
|
||
text: state.text,
|
||
picksByChoiceId: state.picksByChoiceId,
|
||
extraSkills: state.extraSkills
|
||
},
|
||
null,
|
||
2
|
||
);
|
||
}
|
||
|
||
function getAbilityModifierNumber(score: number): number {
|
||
return Math.floor((score - 10) / 2);
|
||
}
|
||
|
||
function areStringArraysEqual(a: string[], b: string[]): boolean {
|
||
if (a.length !== b.length) {
|
||
return false;
|
||
}
|
||
return a.every((it, ix) => it === b[ix]);
|
||
}
|
||
|
||
function uniqueStrings(values: string[]): string[] {
|
||
return Array.from(new Set(values.filter((it) => Boolean(it))));
|
||
}
|
||
|
||
function toNameSourceToken(name: string, source: string): string {
|
||
return `${name.trim()}|${source.trim().toUpperCase()}`;
|
||
}
|
||
|
||
function parseNameSourceToken(value: string): { name: string; source: string } | null {
|
||
const [nameRaw, sourceRaw] = value.split("|");
|
||
const name = (nameRaw || "").trim();
|
||
const source = (sourceRaw || "").trim().toUpperCase();
|
||
if (!name || !source) {
|
||
return null;
|
||
}
|
||
return { name, source };
|
||
}
|
||
|
||
function cpToCurrency(cpRaw: number): Record<"cp" | "sp" | "ep" | "gp" | "pp", number> {
|
||
let cp = Math.max(0, Math.trunc(cpRaw));
|
||
const pp = Math.floor(cp / 1000);
|
||
cp -= pp * 1000;
|
||
const gp = Math.floor(cp / 100);
|
||
cp -= gp * 100;
|
||
const ep = Math.floor(cp / 50);
|
||
cp -= ep * 50;
|
||
const sp = Math.floor(cp / 10);
|
||
cp -= sp * 10;
|
||
return { cp, sp, ep, gp, pp };
|
||
}
|
||
|
||
function parseInventoryLabel(value: string): { label: string; count: number } {
|
||
const match = value.trim().match(/^(.*?)\s*x\s*(\d+)$/i);
|
||
if (!match) {
|
||
return { label: value.trim(), count: 1 };
|
||
}
|
||
|
||
const label = (match[1] || "").trim();
|
||
const count = Number.parseInt((match[2] || "1").trim(), 10);
|
||
return {
|
||
label: label || value.trim(),
|
||
count: Number.isFinite(count) && count > 0 ? count : 1
|
||
};
|
||
}
|
||
|
||
function collectEntryText(value: unknown, out: string[], limit = 5): void {
|
||
if (out.length >= limit || value == null) {
|
||
return;
|
||
}
|
||
|
||
if (typeof value === "string") {
|
||
const cleaned = stripTagsFallback(value);
|
||
if (cleaned) {
|
||
out.push(cleaned);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (Array.isArray(value)) {
|
||
value.forEach((it) => collectEntryText(it, out, limit));
|
||
return;
|
||
}
|
||
|
||
if (!isRecord(value)) {
|
||
return;
|
||
}
|
||
|
||
if (typeof value.entry === "string") {
|
||
collectEntryText(value.entry, out, limit);
|
||
}
|
||
if (typeof value.text === "string") {
|
||
collectEntryText(value.text, out, limit);
|
||
}
|
||
if (Array.isArray(value.entries)) {
|
||
collectEntryText(value.entries, out, limit);
|
||
}
|
||
if (Array.isArray(value.items)) {
|
||
collectEntryText(value.items, out, limit);
|
||
}
|
||
}
|
||
|
||
function getEntrySummary(value: unknown): string {
|
||
const chunks: string[] = [];
|
||
collectEntryText(value, chunks, 3);
|
||
return chunks.join(" ").trim();
|
||
}
|
||
|
||
function normalizeSpellClasses(classesRaw: Array<{ name: string; source: string }>): Array<{ name: string; source: string }> {
|
||
const seen = new Set<string>();
|
||
const out: Array<{ name: string; source: string }> = [];
|
||
classesRaw.forEach((entry) => {
|
||
const name = entry.name.trim();
|
||
const source = entry.source.trim().toUpperCase();
|
||
if (!name) {
|
||
return;
|
||
}
|
||
const key = `${name.toLowerCase()}|${source}`;
|
||
if (seen.has(key)) {
|
||
return;
|
||
}
|
||
seen.add(key);
|
||
out.push({ name, source });
|
||
});
|
||
return out;
|
||
}
|
||
|
||
function getSpellClassesFromRaw(spellRaw: Record<string, unknown>): Array<{ name: string; source: string }> {
|
||
const out: Array<{ name: string; source: string }> = [];
|
||
|
||
const classesRaw = spellRaw.classes;
|
||
if (!isRecord(classesRaw)) {
|
||
return out;
|
||
}
|
||
|
||
const lists = ["fromClassList", "fromClassListVariant", "fromClassListLegacy"] as const;
|
||
lists.forEach((listKey) => {
|
||
const list = classesRaw[listKey];
|
||
if (!Array.isArray(list)) {
|
||
return;
|
||
}
|
||
|
||
list.forEach((entry) => {
|
||
if (!isRecord(entry)) {
|
||
return;
|
||
}
|
||
const name = typeof entry.name === "string" ? entry.name.trim() : "";
|
||
const source = typeof entry.source === "string" ? entry.source.trim().toUpperCase() : "";
|
||
if (!name) {
|
||
return;
|
||
}
|
||
out.push({ name, source });
|
||
});
|
||
});
|
||
|
||
return out;
|
||
}
|
||
|
||
function getSpellClassesFromLookup(
|
||
spellRaw: Record<string, unknown>,
|
||
sourceLookup: SpellSourceLookup | null
|
||
): Array<{ name: string; source: string }> {
|
||
if (!sourceLookup) {
|
||
return [];
|
||
}
|
||
|
||
const source = typeof spellRaw.source === "string" ? spellRaw.source.trim().toLowerCase() : "";
|
||
const name = typeof spellRaw.name === "string" ? spellRaw.name.trim().toLowerCase() : "";
|
||
if (!source || !name) {
|
||
return [];
|
||
}
|
||
|
||
const record = sourceLookup[source]?.[name];
|
||
if (!record) {
|
||
return [];
|
||
}
|
||
|
||
const out: Array<{ name: string; source: string }> = [];
|
||
const collect = (mapRaw: Record<string, Record<string, unknown>> | undefined) => {
|
||
if (!mapRaw) {
|
||
return;
|
||
}
|
||
Object.entries(mapRaw).forEach(([classSource, classMap]) => {
|
||
if (!isRecord(classMap)) {
|
||
return;
|
||
}
|
||
Object.keys(classMap).forEach((className) => {
|
||
if (!className.trim()) {
|
||
return;
|
||
}
|
||
out.push({ name: className.trim(), source: classSource.trim().toUpperCase() });
|
||
});
|
||
});
|
||
};
|
||
|
||
collect(record.class);
|
||
collect(record.classVariant);
|
||
return out;
|
||
}
|
||
|
||
function SetupView() {
|
||
const [setupState, setSetupState] = useState<SetupState | null>(null);
|
||
const [sourceMode, setSourceMode] = useState<"url" | "zip">("url");
|
||
const [sourceRepoUrl, setSourceRepoUrl] = useState("");
|
||
const [sourceZipPath, setSourceZipPath] = useState("");
|
||
const [imageRepoUrl, setImageRepoUrl] = useState("");
|
||
const [imageBaseUrl, setImageBaseUrl] = useState("");
|
||
const [downloadImages, setDownloadImages] = useState(true);
|
||
const [error, setError] = useState("");
|
||
const [status, setStatus] = useState("Checking local 5etools source...");
|
||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||
|
||
useEffect(() => {
|
||
window.desktopApi
|
||
.getSetupState()
|
||
.then((state) => {
|
||
setSetupState(state);
|
||
setSourceRepoUrl(state.sourceRepoUrl);
|
||
setImageRepoUrl(state.imageRepoUrl);
|
||
setImageBaseUrl(state.imageBaseUrl);
|
||
setStatus(state.needsSetup ? "Local 5etools source not found. Configure first-run setup below." : "Setup already complete.");
|
||
})
|
||
.catch((err: unknown) => {
|
||
const message = err instanceof Error ? err.message : "Unknown setup-state error";
|
||
setError(message);
|
||
setStatus("Failed to load setup state.");
|
||
});
|
||
}, []);
|
||
|
||
const onSubmit = async (event: FormEvent) => {
|
||
event.preventDefault();
|
||
setError("");
|
||
setIsSubmitting(true);
|
||
setStatus(
|
||
sourceMode === "zip"
|
||
? "Extracting local source ZIP and preparing files. This can take a few minutes..."
|
||
: "Downloading and preparing local 5etools source. This can take a few minutes..."
|
||
);
|
||
|
||
try {
|
||
const result = await window.desktopApi.runSetup({
|
||
mode: sourceMode,
|
||
sourceRepoUrl,
|
||
sourceZipPath,
|
||
imageRepoUrl,
|
||
imageBaseUrl,
|
||
downloadImages
|
||
});
|
||
|
||
if (!result.ok) {
|
||
setError(result.error || "Setup failed.");
|
||
setStatus("Setup failed.");
|
||
setIsSubmitting(false);
|
||
return;
|
||
}
|
||
|
||
setStatus("Setup complete. Launching wiki...");
|
||
} catch (err) {
|
||
const message = err instanceof Error ? err.message : "Unknown setup error";
|
||
setError(message);
|
||
setStatus("Setup failed.");
|
||
setIsSubmitting(false);
|
||
}
|
||
};
|
||
|
||
const onClickBrowseZip = async () => {
|
||
const selected = await window.desktopApi.pickSourceZip();
|
||
if (selected) {
|
||
setSourceZipPath(selected);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<main className="status-wrap setup-wrap">
|
||
<section className="status-panel setup-panel">
|
||
<h1>First Run Setup</h1>
|
||
<p>{status}</p>
|
||
{setupState?.sourceDir ? <p className="setup-meta">Detected local source: {setupState.sourceDir}</p> : null}
|
||
{error ? <p className="setup-error">{error}</p> : null}
|
||
|
||
<form className="setup-form" onSubmit={onSubmit}>
|
||
<div className="setup-mode-row" role="radiogroup" aria-label="Source input mode">
|
||
<button
|
||
type="button"
|
||
className={sourceMode === "url" ? "setup-mode-btn is-active" : "setup-mode-btn"}
|
||
onClick={() => setSourceMode("url")}
|
||
disabled={isSubmitting}
|
||
>
|
||
Source URL
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={sourceMode === "zip" ? "setup-mode-btn is-active" : "setup-mode-btn"}
|
||
onClick={() => setSourceMode("zip")}
|
||
disabled={isSubmitting}
|
||
>
|
||
Source ZIP
|
||
</button>
|
||
</div>
|
||
|
||
{sourceMode === "url" ? (
|
||
<label className="setup-field">
|
||
<span>5etools source repository URL</span>
|
||
<input
|
||
type="url"
|
||
value={sourceRepoUrl}
|
||
onChange={(event) => setSourceRepoUrl(event.target.value)}
|
||
required={sourceMode === "url"}
|
||
disabled={isSubmitting}
|
||
placeholder="https://github.com/5etools-mirror-2/5etools-mirror-2.github.io.git"
|
||
/>
|
||
</label>
|
||
) : (
|
||
<label className="setup-field">
|
||
<span>5etools source ZIP file</span>
|
||
<div className="setup-zip-row">
|
||
<input
|
||
type="text"
|
||
value={sourceZipPath}
|
||
onChange={(event) => setSourceZipPath(event.target.value)}
|
||
required={sourceMode === "zip"}
|
||
disabled={isSubmitting}
|
||
placeholder="/path/to/5etools-source.zip"
|
||
/>
|
||
<button type="button" className="setup-zip-pick" onClick={onClickBrowseZip} disabled={isSubmitting}>
|
||
Browse
|
||
</button>
|
||
</div>
|
||
</label>
|
||
)}
|
||
|
||
<label className="setup-field">
|
||
<span>5etools image repository URL</span>
|
||
<input
|
||
type="url"
|
||
value={imageRepoUrl}
|
||
onChange={(event) => setImageRepoUrl(event.target.value)}
|
||
required
|
||
disabled={isSubmitting}
|
||
placeholder="https://github.com/5etools-mirror-2/5etools-img"
|
||
/>
|
||
</label>
|
||
|
||
<label className="setup-field">
|
||
<span>External image base URL fallback</span>
|
||
<input
|
||
type="url"
|
||
value={imageBaseUrl}
|
||
onChange={(event) => setImageBaseUrl(event.target.value)}
|
||
required
|
||
disabled={isSubmitting}
|
||
placeholder="https://5e.tools/img/"
|
||
/>
|
||
</label>
|
||
|
||
<label className="setup-checkbox">
|
||
<input
|
||
type="checkbox"
|
||
checked={downloadImages}
|
||
onChange={(event) => setDownloadImages(event.target.checked)}
|
||
disabled={isSubmitting}
|
||
/>
|
||
<span>Download local image repository (recommended)</span>
|
||
</label>
|
||
|
||
<button className="setup-submit" type="submit" disabled={isSubmitting}>
|
||
{isSubmitting ? "Setting up..." : "Run Setup"}
|
||
</button>
|
||
</form>
|
||
</section>
|
||
</main>
|
||
);
|
||
}
|
||
|
||
function WikiView() {
|
||
const [wikiUrl, setWikiUrl] = useState<string>("");
|
||
const [error, setError] = useState<string>("");
|
||
const [currentPath, setCurrentPath] = useState<string>(DEFAULT_PAGE);
|
||
const [isRenderRuntimeReady, setIsRenderRuntimeReady] = useState(false);
|
||
const [openTopMenuId, setOpenTopMenuId] = useState<string | null>(null);
|
||
const [openTopMenuPath, setOpenTopMenuPath] = useState<string[]>([]);
|
||
const [topbarSearch, setTopbarSearch] = useState("");
|
||
const [topbarGroups, setTopbarGroups] = useState<TopbarMenuGroup[]>([]);
|
||
const frameRef = useRef<HTMLIFrameElement | null>(null);
|
||
const topbarRef = useRef<HTMLElement | null>(null);
|
||
|
||
const [characterModalMode, setCharacterModalMode] = useState<CharacterModalMode>("closed");
|
||
const [characterStep, setCharacterStep] = useState<number>(0);
|
||
const [characterDraft, setCharacterDraft] = useState<CharacterDocument | null>(null);
|
||
const [characterSummaries, setCharacterSummaries] = useState<CharacterSummary[]>([]);
|
||
const [characterOptions, setCharacterOptions] = useState<CharacterSelectionOptionSets | null>(null);
|
||
const [detailCache, setDetailCache] = useState<Record<string, CharacterSelectionDetail>>({});
|
||
const [characterError, setCharacterError] = useState<string>("");
|
||
const [characterNotice, setCharacterNotice] = useState<string>("");
|
||
const [isCharacterBusy, setIsCharacterBusy] = useState(false);
|
||
|
||
const [classSearch, setClassSearch] = useState("");
|
||
const [speciesSearch, setSpeciesSearch] = useState("");
|
||
const [backgroundSearch, setBackgroundSearch] = useState("");
|
||
const [featSearch, setFeatSearch] = useState("");
|
||
const [spellSearch, setSpellSearch] = useState("");
|
||
const [equipmentItemInput, setEquipmentItemInput] = useState("");
|
||
|
||
const [classPage, setClassPage] = useState(1);
|
||
const [speciesPage, setSpeciesPage] = useState(1);
|
||
const [backgroundPage, setBackgroundPage] = useState(1);
|
||
const [entityPopover, setEntityPopover] = useState<{ kind: EntityPopoverKind; entityRef: string } | null>(null);
|
||
const [featCatalog, setFeatCatalog] = useState<FeatCatalogItem[] | null>(null);
|
||
const [spellCatalog, setSpellCatalog] = useState<SpellCatalogItem[] | null>(null);
|
||
const [itemCatalog, setItemCatalog] = useState<string[]>([]);
|
||
|
||
const updateDraft = useCallback((updater: (current: CharacterDocument) => CharacterDocument) => {
|
||
setCharacterDraft((current) => {
|
||
if (!current) {
|
||
return current;
|
||
}
|
||
return updater(current);
|
||
});
|
||
}, []);
|
||
|
||
|
||
const loadCharacterOptions = useCallback(async () => {
|
||
if (characterOptions) {
|
||
return characterOptions;
|
||
}
|
||
|
||
const options = await window.desktopApi.getCharacterOptions();
|
||
setCharacterOptions(options);
|
||
return options;
|
||
}, [characterOptions]);
|
||
|
||
const ensureOptionDetail = useCallback(
|
||
async (entityRef: string) => {
|
||
const trimmed = entityRef.trim();
|
||
if (!trimmed) {
|
||
return null;
|
||
}
|
||
|
||
if (detailCache[trimmed]) {
|
||
return detailCache[trimmed];
|
||
}
|
||
|
||
const detail = await window.desktopApi.getCharacterOptionDetail(trimmed);
|
||
if (!detail) {
|
||
return null;
|
||
}
|
||
|
||
setDetailCache((current) => ({ ...current, [trimmed]: detail }));
|
||
return detail;
|
||
},
|
||
[detailCache]
|
||
);
|
||
|
||
const openEntityPopover = useCallback(
|
||
(kind: EntityPopoverKind, entityRef: string) => {
|
||
const trimmed = entityRef.trim();
|
||
if (!trimmed) {
|
||
return;
|
||
}
|
||
void ensureOptionDetail(trimmed);
|
||
setEntityPopover({ kind, entityRef: trimmed });
|
||
},
|
||
[ensureOptionDetail]
|
||
);
|
||
|
||
const refreshCharacterSummaries = useCallback(async () => {
|
||
const list = await window.desktopApi.listCharacters();
|
||
setCharacterSummaries(list);
|
||
}, []);
|
||
|
||
const resetEditorUiState = useCallback(() => {
|
||
setClassSearch("");
|
||
setSpeciesSearch("");
|
||
setBackgroundSearch("");
|
||
setFeatSearch("");
|
||
setSpellSearch("");
|
||
setEquipmentItemInput("");
|
||
setClassPage(1);
|
||
setSpeciesPage(1);
|
||
setBackgroundPage(1);
|
||
setCharacterStep(0);
|
||
}, []);
|
||
|
||
const openCharacterList = useCallback(async () => {
|
||
setCharacterError("");
|
||
setCharacterNotice("");
|
||
setIsCharacterBusy(true);
|
||
try {
|
||
setCharacterModalMode("list");
|
||
await refreshCharacterSummaries();
|
||
} catch (err) {
|
||
const message = err instanceof Error ? err.message : "Failed to load character list.";
|
||
setCharacterError(message);
|
||
setCharacterModalMode("list");
|
||
} finally {
|
||
setIsCharacterBusy(false);
|
||
}
|
||
}, [refreshCharacterSummaries]);
|
||
|
||
const openNewCharacter = useCallback(async () => {
|
||
setCharacterError("");
|
||
setCharacterNotice("");
|
||
setIsCharacterBusy(true);
|
||
try {
|
||
await loadCharacterOptions();
|
||
setCharacterDraft(createNewCharacterDraft());
|
||
resetEditorUiState();
|
||
setCharacterModalMode("editor");
|
||
} catch (err) {
|
||
const message = err instanceof Error ? err.message : "Failed to start a new character.";
|
||
setCharacterError(message);
|
||
setCharacterModalMode("list");
|
||
} finally {
|
||
setIsCharacterBusy(false);
|
||
}
|
||
}, [loadCharacterOptions, resetEditorUiState]);
|
||
|
||
const openCharacterById = useCallback(
|
||
async (id: string) => {
|
||
setCharacterError("");
|
||
setCharacterNotice("");
|
||
setIsCharacterBusy(true);
|
||
try {
|
||
await loadCharacterOptions();
|
||
const record = await window.desktopApi.getCharacter(id);
|
||
if (!record) {
|
||
setCharacterError("Character file was not found.");
|
||
setCharacterModalMode("list");
|
||
return;
|
||
}
|
||
|
||
setCharacterDraft(record);
|
||
resetEditorUiState();
|
||
setCharacterModalMode("editor");
|
||
} catch (err) {
|
||
const message = err instanceof Error ? err.message : "Failed to open character.";
|
||
setCharacterError(message);
|
||
setCharacterModalMode("list");
|
||
} finally {
|
||
setIsCharacterBusy(false);
|
||
}
|
||
},
|
||
[loadCharacterOptions, resetEditorUiState]
|
||
);
|
||
|
||
const loadCharacterFromJson = useCallback(async () => {
|
||
setCharacterError("");
|
||
setCharacterNotice("");
|
||
setIsCharacterBusy(true);
|
||
try {
|
||
const selectedPath = await window.desktopApi.pickCharacterJson();
|
||
if (!selectedPath) {
|
||
return;
|
||
}
|
||
|
||
await loadCharacterOptions();
|
||
const imported = await window.desktopApi.importCharacterJson(selectedPath);
|
||
await refreshCharacterSummaries();
|
||
setCharacterDraft(imported);
|
||
resetEditorUiState();
|
||
setCharacterNotice(`Imported character: ${imported.profile.name}`);
|
||
setCharacterModalMode("editor");
|
||
} catch (err) {
|
||
const message = err instanceof Error ? err.message : "Failed to import character JSON.";
|
||
setCharacterError(message);
|
||
setCharacterModalMode("list");
|
||
} finally {
|
||
setIsCharacterBusy(false);
|
||
}
|
||
}, [loadCharacterOptions, refreshCharacterSummaries, resetEditorUiState]);
|
||
|
||
const deleteCharacterById = useCallback(
|
||
async (id: string) => {
|
||
if (!window.confirm("Delete this character file? This action cannot be undone.")) {
|
||
return;
|
||
}
|
||
|
||
setCharacterError("");
|
||
setCharacterNotice("");
|
||
setIsCharacterBusy(true);
|
||
try {
|
||
const deleted = await window.desktopApi.deleteCharacter(id);
|
||
if (!deleted) {
|
||
setCharacterError("Character file could not be deleted.");
|
||
return;
|
||
}
|
||
|
||
setCharacterNotice("Character deleted.");
|
||
await refreshCharacterSummaries();
|
||
} catch (err) {
|
||
const message = err instanceof Error ? err.message : "Failed to delete character.";
|
||
setCharacterError(message);
|
||
} finally {
|
||
setIsCharacterBusy(false);
|
||
}
|
||
},
|
||
[refreshCharacterSummaries]
|
||
);
|
||
|
||
const saveCharacterDraft = useCallback(async () => {
|
||
if (!characterDraft) {
|
||
return;
|
||
}
|
||
|
||
setCharacterError("");
|
||
setCharacterNotice("");
|
||
setIsCharacterBusy(true);
|
||
try {
|
||
const saved = await window.desktopApi.saveCharacter({
|
||
id: characterDraft.id,
|
||
createdAt: characterDraft.createdAt,
|
||
profile: characterDraft.profile,
|
||
selections: characterDraft.selections,
|
||
steps: characterDraft.steps,
|
||
notes: characterDraft.notes
|
||
});
|
||
setCharacterDraft(saved);
|
||
setCharacterNotice(`Saved ${saved.profile.name}.`);
|
||
await refreshCharacterSummaries();
|
||
} catch (err) {
|
||
const message = err instanceof Error ? err.message : "Failed to save character.";
|
||
setCharacterError(message);
|
||
} finally {
|
||
setIsCharacterBusy(false);
|
||
}
|
||
}, [characterDraft, refreshCharacterSummaries]);
|
||
|
||
useEffect(() => {
|
||
window.desktopApi
|
||
.getWikiUrl()
|
||
.then((url) => setWikiUrl(url))
|
||
.catch((err: unknown) => {
|
||
const message = err instanceof Error ? err.message : "Unknown error";
|
||
setError(message);
|
||
});
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!wikiUrl) {
|
||
setIsRenderRuntimeReady(false);
|
||
return;
|
||
}
|
||
|
||
let isCancelled = false;
|
||
void ensure5etoolsRenderRuntime(wikiUrl)
|
||
.then(() => {
|
||
if (!isCancelled) {
|
||
setIsRenderRuntimeReady(true);
|
||
}
|
||
})
|
||
.catch((runtimeError: unknown) => {
|
||
console.error("[5ET-D] Failed to initialize 5etools render runtime", runtimeError);
|
||
if (!isCancelled) {
|
||
setIsRenderRuntimeReady(false);
|
||
}
|
||
});
|
||
|
||
return () => {
|
||
isCancelled = true;
|
||
};
|
||
}, [wikiUrl]);
|
||
|
||
useEffect(() => {
|
||
if (characterModalMode !== "editor" || !wikiUrl || !isRenderRuntimeReady) {
|
||
return;
|
||
}
|
||
|
||
let isCancelled = false;
|
||
const baseUrl = `${normalizeWikiBaseUrl(wikiUrl)}/`;
|
||
|
||
const loadFeats = async () => {
|
||
if (featCatalog) {
|
||
return;
|
||
}
|
||
|
||
const response = await fetch(new URL("data/feats.json", baseUrl).toString());
|
||
if (!response.ok) {
|
||
throw new Error(`Failed to load feats (${response.status})`);
|
||
}
|
||
|
||
const payload = (await response.json()) as { feat?: unknown[] };
|
||
const featsRaw = Array.isArray(payload.feat) ? payload.feat : [];
|
||
const next = featsRaw
|
||
.map((featRaw): FeatCatalogItem | null => {
|
||
if (!isRecord(featRaw)) {
|
||
return null;
|
||
}
|
||
|
||
const name = typeof featRaw.name === "string" ? featRaw.name.trim() : "";
|
||
const source = typeof featRaw.source === "string" ? featRaw.source.trim().toUpperCase() : "";
|
||
if (!name || !source) {
|
||
return null;
|
||
}
|
||
|
||
const sourceMeta = classifySourceBucket(source);
|
||
const summary = getEntrySummary(featRaw.entries);
|
||
return {
|
||
key: sanitizeFeatKey(name, source),
|
||
name,
|
||
source,
|
||
sourceBucket: sourceMeta.sourceBucket,
|
||
sourceEdition: sourceMeta.sourceEdition,
|
||
category: typeof featRaw.category === "string" ? featRaw.category : null,
|
||
summary
|
||
};
|
||
})
|
||
.filter((it): it is FeatCatalogItem => Boolean(it))
|
||
.sort((a, b) => a.name.localeCompare(b.name) || a.source.localeCompare(b.source));
|
||
|
||
if (!isCancelled) {
|
||
setFeatCatalog(next);
|
||
}
|
||
};
|
||
|
||
const loadSpells = async () => {
|
||
if (spellCatalog) {
|
||
return;
|
||
}
|
||
|
||
const runtime = window as unknown as {
|
||
DataUtil?: {
|
||
spell?: {
|
||
pLoadAll?: () => Promise<unknown[]>;
|
||
};
|
||
};
|
||
Renderer?: {
|
||
spell?: {
|
||
getCombinedClasses?: (spell: unknown, prop: string) => Array<{ name: string; source: string }>;
|
||
};
|
||
};
|
||
};
|
||
|
||
const pLoadAll = runtime.DataUtil?.spell?.pLoadAll;
|
||
const getCombinedClasses = runtime.Renderer?.spell?.getCombinedClasses;
|
||
if (!pLoadAll) {
|
||
if (!isCancelled) {
|
||
setSpellCatalog([]);
|
||
}
|
||
return;
|
||
}
|
||
|
||
let sourceLookup: SpellSourceLookup | null = null;
|
||
try {
|
||
const lookupResp = await fetch(new URL("data/generated/gendata-spell-source-lookup.json", baseUrl).toString());
|
||
if (lookupResp.ok) {
|
||
sourceLookup = (await lookupResp.json()) as SpellSourceLookup;
|
||
}
|
||
} catch {
|
||
sourceLookup = null;
|
||
}
|
||
|
||
const spellsRaw = await pLoadAll();
|
||
const next = (Array.isArray(spellsRaw) ? spellsRaw : [])
|
||
.map((spellRaw): SpellCatalogItem | null => {
|
||
if (!isRecord(spellRaw)) {
|
||
return null;
|
||
}
|
||
|
||
const name = typeof spellRaw.name === "string" ? spellRaw.name.trim() : "";
|
||
const source = typeof spellRaw.source === "string" ? spellRaw.source.trim().toUpperCase() : "";
|
||
if (!name || !source) {
|
||
return null;
|
||
}
|
||
|
||
const classesFromRenderer = getCombinedClasses
|
||
? (getCombinedClasses(spellRaw, "fromClassList") || [])
|
||
.map((it) => ({
|
||
name: typeof it.name === "string" ? it.name.trim() : "",
|
||
source: typeof it.source === "string" ? it.source.trim().toUpperCase() : ""
|
||
}))
|
||
.filter((it) => Boolean(it.name))
|
||
: [];
|
||
const classesFromRaw = getSpellClassesFromRaw(spellRaw);
|
||
const classesFromLookup = getSpellClassesFromLookup(spellRaw, sourceLookup);
|
||
const classes = normalizeSpellClasses([...classesFromRenderer, ...classesFromRaw, ...classesFromLookup]);
|
||
|
||
const sourceMeta = classifySourceBucket(source);
|
||
const level = typeof spellRaw.level === "number" ? Math.max(0, Math.trunc(spellRaw.level)) : 0;
|
||
const school = typeof spellRaw.school === "string" ? spellRaw.school : "";
|
||
|
||
return {
|
||
key: sanitizeFeatKey(name, source),
|
||
name,
|
||
source,
|
||
level,
|
||
school,
|
||
sourceBucket: sourceMeta.sourceBucket,
|
||
sourceEdition: sourceMeta.sourceEdition,
|
||
classes
|
||
};
|
||
})
|
||
.filter((it): it is SpellCatalogItem => Boolean(it))
|
||
.sort((a, b) => a.level - b.level || a.name.localeCompare(b.name) || a.source.localeCompare(b.source));
|
||
|
||
if (!isCancelled) {
|
||
setSpellCatalog(next);
|
||
}
|
||
};
|
||
|
||
const loadItems = async () => {
|
||
if (itemCatalog.length) {
|
||
return;
|
||
}
|
||
|
||
const response = await fetch(new URL("data/items-base.json", baseUrl).toString());
|
||
if (!response.ok) {
|
||
throw new Error(`Failed to load item catalog (${response.status})`);
|
||
}
|
||
|
||
const payload = (await response.json()) as { baseitem?: unknown[] };
|
||
const itemsRaw = Array.isArray(payload.baseitem) ? payload.baseitem : [];
|
||
const next = Array.from(
|
||
new Set(
|
||
itemsRaw
|
||
.map((it) => (isRecord(it) && typeof it.name === "string" ? it.name.trim() : ""))
|
||
.filter((it) => Boolean(it))
|
||
)
|
||
).sort((a, b) => a.localeCompare(b));
|
||
|
||
if (!isCancelled) {
|
||
setItemCatalog(next);
|
||
}
|
||
};
|
||
|
||
void (async () => {
|
||
try {
|
||
await Promise.all([loadFeats(), loadSpells(), loadItems()]);
|
||
} catch (catalogError) {
|
||
console.error("[5ET-D] Failed to load character builder catalogs", catalogError);
|
||
}
|
||
})();
|
||
|
||
return () => {
|
||
isCancelled = true;
|
||
};
|
||
}, [characterModalMode, wikiUrl, isRenderRuntimeReady, featCatalog, spellCatalog, itemCatalog.length]);
|
||
|
||
useEffect(() => {
|
||
const onMessage = (event: MessageEvent<unknown>) => {
|
||
const frameWindow = frameRef.current?.contentWindow;
|
||
if (!frameWindow || event.source !== frameWindow) {
|
||
return;
|
||
}
|
||
|
||
if (!event.data || typeof event.data !== "object") {
|
||
return;
|
||
}
|
||
|
||
const data = event.data as { type?: string; groups?: TopbarMenuGroup[] };
|
||
|
||
if (data.type === "5etd:native-navbar-data") {
|
||
if (!Array.isArray(data.groups)) {
|
||
return;
|
||
}
|
||
|
||
setTopbarGroups((current) => (areTopbarGroupsEquivalent(current, data.groups as TopbarMenuGroup[]) ? current : (data.groups as TopbarMenuGroup[])));
|
||
return;
|
||
}
|
||
|
||
if (data.type === "5etd:reset-onboarding") {
|
||
void (async () => {
|
||
const result = await window.desktopApi.resetOnboarding();
|
||
if (result.ok) {
|
||
return;
|
||
}
|
||
|
||
frameWindow.postMessage(
|
||
{
|
||
type: "5etd:reset-onboarding-result",
|
||
ok: false,
|
||
error: result.error || "Failed to reset onboarding."
|
||
},
|
||
"*"
|
||
);
|
||
})();
|
||
return;
|
||
}
|
||
|
||
if (data.type === "5etd:characters:new") {
|
||
void openNewCharacter();
|
||
return;
|
||
}
|
||
|
||
if (data.type === "5etd:characters:load-json") {
|
||
void loadCharacterFromJson();
|
||
return;
|
||
}
|
||
|
||
if (data.type === "5etd:characters:list") {
|
||
void openCharacterList();
|
||
}
|
||
};
|
||
|
||
window.addEventListener("message", onMessage);
|
||
return () => window.removeEventListener("message", onMessage);
|
||
}, [loadCharacterFromJson, openCharacterList, openNewCharacter]);
|
||
|
||
useEffect(() => {
|
||
if (!openTopMenuId) {
|
||
return;
|
||
}
|
||
|
||
const onDocumentMouseDown = (event: MouseEvent) => {
|
||
const target = event.target;
|
||
if (!(target instanceof Node)) {
|
||
return;
|
||
}
|
||
if (!topbarRef.current?.contains(target)) {
|
||
setOpenTopMenuId(null);
|
||
}
|
||
};
|
||
|
||
const onWindowKeyDown = (event: KeyboardEvent) => {
|
||
if (event.key === "Escape") {
|
||
setOpenTopMenuId(null);
|
||
}
|
||
};
|
||
|
||
document.addEventListener("mousedown", onDocumentMouseDown);
|
||
window.addEventListener("keydown", onWindowKeyDown);
|
||
return () => {
|
||
document.removeEventListener("mousedown", onDocumentMouseDown);
|
||
window.removeEventListener("keydown", onWindowKeyDown);
|
||
};
|
||
}, [openTopMenuId]);
|
||
|
||
useEffect(() => {
|
||
const frame = frameRef.current;
|
||
if (!frame) {
|
||
return;
|
||
}
|
||
|
||
const requestNativeNavbarModel = () => {
|
||
frame.contentWindow?.postMessage({ type: "5etd:native-navbar-request" }, "*");
|
||
};
|
||
|
||
frame.addEventListener("load", requestNativeNavbarModel);
|
||
requestNativeNavbarModel();
|
||
return () => frame.removeEventListener("load", requestNativeNavbarModel);
|
||
}, [currentPath, characterModalMode]);
|
||
|
||
const frameSrc = useMemo(() => {
|
||
if (!wikiUrl) {
|
||
return "";
|
||
}
|
||
|
||
return new URL(currentPath, `${wikiUrl}/`).toString();
|
||
}, [currentPath, wikiUrl]);
|
||
|
||
const currentPathname = useMemo(() => {
|
||
try {
|
||
return new URL(currentPath, "http://localhost").pathname.toLowerCase();
|
||
} catch {
|
||
return currentPath.split(/[?#]/)[0]?.toLowerCase() || DEFAULT_PAGE;
|
||
}
|
||
}, [currentPath]);
|
||
|
||
const navigateWikiPath = useCallback((path: string) => {
|
||
setCurrentPath(path);
|
||
setCharacterModalMode("closed");
|
||
setOpenTopMenuId(null);
|
||
setOpenTopMenuPath([]);
|
||
}, []);
|
||
|
||
const onSubmitTopbarSearch = useCallback((event: FormEvent<HTMLFormElement>) => {
|
||
event.preventDefault();
|
||
const query = topbarSearch.trim();
|
||
navigateWikiPath(query ? `/search.html?q=${encodeURIComponent(query)}` : "/search.html");
|
||
}, [navigateWikiPath, topbarSearch]);
|
||
|
||
const isSameAppPath = useCallback((leftRaw: string, rightRaw: string): boolean => {
|
||
const left = getPathnameFromHrefLoose(leftRaw);
|
||
const right = getPathnameFromHrefLoose(rightRaw);
|
||
if (!left || !right) {
|
||
return false;
|
||
}
|
||
if (left === right) {
|
||
return true;
|
||
}
|
||
const homeAliases = new Set(["/index.html", "/5etools.html"]);
|
||
return homeAliases.has(left) && homeAliases.has(right);
|
||
}, []);
|
||
|
||
const resolveWikiPathFromHref = useCallback(
|
||
(hrefRaw: string): string | null => {
|
||
const href = hrefRaw.trim();
|
||
if (!href || href.startsWith("#")) {
|
||
return null;
|
||
}
|
||
|
||
const hrefLower = href.toLowerCase();
|
||
if (hrefLower.startsWith("javascript:") || hrefLower.startsWith("mailto:") || hrefLower.startsWith("tel:")) {
|
||
return null;
|
||
}
|
||
|
||
if (!wikiUrl) {
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
const base = new URL(`${normalizeWikiBaseUrl(wikiUrl)}/`);
|
||
const resolved = new URL(href, base);
|
||
if (resolved.origin !== base.origin) {
|
||
return null;
|
||
}
|
||
|
||
if (!resolved.pathname.toLowerCase().endsWith(".html")) {
|
||
return null;
|
||
}
|
||
|
||
const path = resolved.pathname.startsWith("/") ? resolved.pathname : `/${resolved.pathname}`;
|
||
return `${path}${resolved.search}${resolved.hash}`;
|
||
} catch {
|
||
return null;
|
||
}
|
||
},
|
||
[wikiUrl]
|
||
);
|
||
|
||
const runTopbarAction = useCallback((actionId: string | undefined) => {
|
||
if (!actionId) {
|
||
return;
|
||
}
|
||
frameRef.current?.contentWindow?.postMessage({ type: "5etd:native-navbar-run-action", actionId }, "*");
|
||
setOpenTopMenuPath([]);
|
||
}, []);
|
||
|
||
const navigateTopbarHref = useCallback((hrefRaw: string, isExternal?: boolean) => {
|
||
const href = hrefRaw.trim();
|
||
if (!href) {
|
||
return;
|
||
}
|
||
|
||
if (isExternal || /^https?:\/\//i.test(href)) {
|
||
window.open(href, "_blank", "noopener");
|
||
return;
|
||
}
|
||
|
||
const resolved = resolveWikiPathFromHref(href);
|
||
if (resolved) {
|
||
navigateWikiPath(resolved);
|
||
return;
|
||
}
|
||
|
||
if (href.startsWith("/")) {
|
||
navigateWikiPath(href);
|
||
return;
|
||
}
|
||
|
||
navigateWikiPath(`/${href.replace(/^\.?\//, "")}`);
|
||
}, [navigateWikiPath, resolveWikiPathFromHref]);
|
||
|
||
const activateTopbarEntry = useCallback((entry: TopbarMenuEntry) => {
|
||
if (entry.actionId) {
|
||
runTopbarAction(entry.actionId);
|
||
setOpenTopMenuId(null);
|
||
return;
|
||
}
|
||
|
||
if (entry.href) {
|
||
navigateTopbarHref(entry.href, entry.isExternal);
|
||
return;
|
||
}
|
||
}, [navigateTopbarHref, runTopbarAction]);
|
||
|
||
const activateTopbarGroup = useCallback((group: TopbarMenuGroup) => {
|
||
if (group.kind === "dropdown") {
|
||
setOpenTopMenuId((current) => (current === group.id ? null : group.id));
|
||
setOpenTopMenuPath([]);
|
||
return;
|
||
}
|
||
|
||
if (group.kind === "action") {
|
||
runTopbarAction(group.actionId);
|
||
setOpenTopMenuId(null);
|
||
return;
|
||
}
|
||
|
||
if (group.kind === "link" && group.href) {
|
||
navigateTopbarHref(group.href, group.isExternal);
|
||
}
|
||
}, [navigateTopbarHref, runTopbarAction]);
|
||
|
||
useEffect(() => {
|
||
if (!openTopMenuId) {
|
||
setOpenTopMenuPath([]);
|
||
}
|
||
}, [openTopMenuId]);
|
||
|
||
const openTopbarSubmenuAtDepth = useCallback((depth: number, entryId: string) => {
|
||
setOpenTopMenuPath((current) => {
|
||
if (current[depth] === entryId && current.length === depth + 1) {
|
||
return current;
|
||
}
|
||
return [...current.slice(0, depth), entryId];
|
||
});
|
||
}, []);
|
||
|
||
const renderTopbarDropdown = useCallback((entries: TopbarMenuEntry[]): JSX.Element => {
|
||
const renderEntryLabel = (entry: TopbarMenuEntry): JSX.Element => (
|
||
<span className="topbar-item-content">
|
||
{entry.datePrefix ? <span className="topbar-item-date">{entry.datePrefix}</span> : null}
|
||
{entry.sourceColor ? <span className="topbar-item-source" style={{ backgroundColor: entry.sourceColor }} aria-hidden="true" /> : null}
|
||
<span className="topbar-item-text">{entry.label}</span>
|
||
</span>
|
||
);
|
||
|
||
const columns: TopbarMenuEntry[][] = [entries];
|
||
let currentEntries = entries;
|
||
|
||
for (let depth = 0; depth < openTopMenuPath.length; depth += 1) {
|
||
const activeSubmenuId = openTopMenuPath[depth];
|
||
if (!activeSubmenuId) {
|
||
break;
|
||
}
|
||
const submenuEntry = currentEntries.find((entry) => entry.type === "submenu" && entry.id === activeSubmenuId);
|
||
if (!submenuEntry?.children?.length) {
|
||
break;
|
||
}
|
||
columns.push(submenuEntry.children);
|
||
currentEntries = submenuEntry.children;
|
||
}
|
||
|
||
return (
|
||
<div className="topbar-dropdown-columns">
|
||
{columns.map((columnEntries, depth) => (
|
||
<div key={`col-${depth}`} className="topbar-dropdown-column">
|
||
{columnEntries.map((entry) => {
|
||
if (entry.type === "divider") {
|
||
return <div key={entry.id} className="topbar-dropdown-divider" />;
|
||
}
|
||
|
||
if (entry.type === "label") {
|
||
return <div key={entry.id} className="topbar-dropdown-label">{entry.label}</div>;
|
||
}
|
||
|
||
if (entry.type === "submenu") {
|
||
const isSubmenuActive = openTopMenuPath[depth] === entry.id;
|
||
const hasChildren = Boolean(entry.children?.length);
|
||
const className = [
|
||
"topbar-dropdown-item",
|
||
"topbar-dropdown-item--submenu",
|
||
isSubmenuActive ? "is-active" : ""
|
||
]
|
||
.filter(Boolean)
|
||
.join(" ");
|
||
|
||
return (
|
||
<button
|
||
type="button"
|
||
key={entry.id}
|
||
className={className}
|
||
onMouseEnter={() => {
|
||
if (!hasChildren) {
|
||
return;
|
||
}
|
||
openTopbarSubmenuAtDepth(depth, entry.id);
|
||
}}
|
||
onFocus={() => {
|
||
if (!hasChildren) {
|
||
return;
|
||
}
|
||
openTopbarSubmenuAtDepth(depth, entry.id);
|
||
}}
|
||
onClick={() => {
|
||
if (hasChildren) {
|
||
openTopbarSubmenuAtDepth(depth, entry.id);
|
||
return;
|
||
}
|
||
activateTopbarEntry(entry);
|
||
}}
|
||
>
|
||
{renderEntryLabel(entry)}
|
||
{hasChildren ? <span className="topbar-submenu-caret" aria-hidden="true">▸</span> : null}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
const isActive = entry.type === "link" && entry.href ? isSameAppPath(entry.href, currentPathname) : false;
|
||
return (
|
||
<button
|
||
type="button"
|
||
key={entry.id}
|
||
className={isActive ? "topbar-dropdown-item is-active" : "topbar-dropdown-item"}
|
||
onClick={() => activateTopbarEntry(entry)}
|
||
>
|
||
{renderEntryLabel(entry)}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}, [activateTopbarEntry, currentPathname, isSameAppPath, openTopMenuPath, openTopbarSubmenuAtDepth]);
|
||
|
||
const sourceBuckets = characterDraft?.steps.preferences.sourceBuckets ?? [];
|
||
|
||
const filteredClassOptions = useMemo(
|
||
() => filterOptions(filterBySourceBuckets(characterOptions?.classes ?? [], sourceBuckets), classSearch),
|
||
[characterOptions?.classes, sourceBuckets, classSearch]
|
||
);
|
||
|
||
const filteredSpeciesOptions = useMemo(
|
||
() => filterOptions(filterBySourceBuckets(characterOptions?.species ?? [], sourceBuckets), speciesSearch),
|
||
[characterOptions?.species, sourceBuckets, speciesSearch]
|
||
);
|
||
|
||
const filteredBackgroundOptions = useMemo(
|
||
() => filterOptions(filterBySourceBuckets(characterOptions?.backgrounds ?? [], sourceBuckets), backgroundSearch),
|
||
[characterOptions?.backgrounds, sourceBuckets, backgroundSearch]
|
||
);
|
||
|
||
const classTotalPages = Math.max(1, Math.ceil(filteredClassOptions.length / CARD_PAGE_SIZE));
|
||
const speciesTotalPages = Math.max(1, Math.ceil(filteredSpeciesOptions.length / CARD_PAGE_SIZE));
|
||
const backgroundTotalPages = Math.max(1, Math.ceil(filteredBackgroundOptions.length / CARD_PAGE_SIZE));
|
||
|
||
const classPageSafe = clampPage(classPage, filteredClassOptions.length);
|
||
const speciesPageSafe = clampPage(speciesPage, filteredSpeciesOptions.length);
|
||
const backgroundPageSafe = clampPage(backgroundPage, filteredBackgroundOptions.length);
|
||
|
||
const classPageOptions = useMemo(
|
||
() => paginateOptions(filteredClassOptions, classPageSafe),
|
||
[filteredClassOptions, classPageSafe]
|
||
);
|
||
const speciesPageOptions = useMemo(
|
||
() => paginateOptions(filteredSpeciesOptions, speciesPageSafe),
|
||
[filteredSpeciesOptions, speciesPageSafe]
|
||
);
|
||
const backgroundPageOptions = useMemo(
|
||
() => paginateOptions(filteredBackgroundOptions, backgroundPageSafe),
|
||
[filteredBackgroundOptions, backgroundPageSafe]
|
||
);
|
||
|
||
useEffect(() => {
|
||
setClassPage((current) => clampPage(current, filteredClassOptions.length));
|
||
}, [filteredClassOptions.length]);
|
||
|
||
useEffect(() => {
|
||
setSpeciesPage((current) => clampPage(current, filteredSpeciesOptions.length));
|
||
}, [filteredSpeciesOptions.length]);
|
||
|
||
useEffect(() => {
|
||
setBackgroundPage((current) => clampPage(current, filteredBackgroundOptions.length));
|
||
}, [filteredBackgroundOptions.length]);
|
||
|
||
useEffect(() => {
|
||
const selectedRefs = [
|
||
characterDraft?.selections.class?.entityRef,
|
||
characterDraft?.selections.background?.entityRef,
|
||
characterDraft?.selections.species?.entityRef
|
||
]
|
||
.filter((it): it is string => Boolean(it))
|
||
.slice(0, 3);
|
||
|
||
for (const ref of selectedRefs) {
|
||
void ensureOptionDetail(ref);
|
||
}
|
||
}, [characterDraft?.selections.background?.entityRef, characterDraft?.selections.class?.entityRef, characterDraft?.selections.species?.entityRef, ensureOptionDetail]);
|
||
|
||
const selectedClassDetail = characterDraft?.selections.class ? detailCache[characterDraft.selections.class.entityRef] ?? null : null;
|
||
const selectedSpeciesDetail = characterDraft?.selections.species ? detailCache[characterDraft.selections.species.entityRef] ?? null : null;
|
||
const selectedBackgroundDetail = characterDraft?.selections.background
|
||
? detailCache[characterDraft.selections.background.entityRef] ?? null
|
||
: null;
|
||
const entityPopoverDetail = entityPopover ? detailCache[entityPopover.entityRef] ?? null : null;
|
||
|
||
useEffect(() => {
|
||
if (characterModalMode !== "editor" || !entityPopover) {
|
||
return;
|
||
}
|
||
|
||
const onKeyDown = (event: KeyboardEvent) => {
|
||
if (event.key === "Escape") {
|
||
setEntityPopover(null);
|
||
}
|
||
};
|
||
|
||
window.addEventListener("keydown", onKeyDown);
|
||
return () => window.removeEventListener("keydown", onKeyDown);
|
||
}, [characterModalMode, entityPopover]);
|
||
|
||
useEffect(() => {
|
||
if (characterModalMode !== "editor" || !entityPopover) {
|
||
return;
|
||
}
|
||
if (characterStep < 0 || characterStep > 2) {
|
||
setEntityPopover(null);
|
||
}
|
||
}, [characterModalMode, characterStep, entityPopover]);
|
||
|
||
const classSkillData = useMemo(
|
||
() => parseClassSkills(selectedClassDetail),
|
||
[selectedClassDetail?.entityRef, selectedClassDetail?.rawJson]
|
||
);
|
||
const backgroundSkillData = useMemo(
|
||
() => parseBackgroundSkills(selectedBackgroundDetail),
|
||
[selectedBackgroundDetail?.entityRef, selectedBackgroundDetail?.rawJson]
|
||
);
|
||
const speciesSkillData = useMemo(
|
||
() => parseSpeciesSkills(selectedSpeciesDetail),
|
||
[selectedSpeciesDetail?.entityRef, selectedSpeciesDetail?.rawJson]
|
||
);
|
||
|
||
const skillChoiceRequirements = useMemo(
|
||
() => [...classSkillData.choices, ...backgroundSkillData.choices, ...speciesSkillData.choices],
|
||
[classSkillData, backgroundSkillData, speciesSkillData]
|
||
);
|
||
|
||
const grantedSkills = useMemo(
|
||
() => uniqueStrings([...classSkillData.granted, ...backgroundSkillData.granted, ...speciesSkillData.granted]).sort((a, b) => a.localeCompare(b)),
|
||
[classSkillData, backgroundSkillData, speciesSkillData]
|
||
);
|
||
|
||
const skillNotesState = useMemo(
|
||
() => parseSkillsNotes(characterDraft?.steps.skills.notes ?? ""),
|
||
[characterDraft?.steps.skills.notes]
|
||
);
|
||
|
||
const normalizedSkillChoicePicks = useMemo(() => {
|
||
const out: Record<string, string[]> = {};
|
||
for (const choice of skillChoiceRequirements) {
|
||
const picksRaw = skillNotesState.picksByChoiceId[choice.id] || [];
|
||
const picks: string[] = [];
|
||
for (const skill of picksRaw) {
|
||
if (!choice.options.includes(skill) || picks.includes(skill)) {
|
||
continue;
|
||
}
|
||
picks.push(skill);
|
||
if (picks.length >= choice.count) {
|
||
break;
|
||
}
|
||
}
|
||
out[choice.id] = picks;
|
||
}
|
||
return out;
|
||
}, [skillChoiceRequirements, skillNotesState.picksByChoiceId]);
|
||
|
||
const manualSkillSelections = useMemo(
|
||
() =>
|
||
uniqueStrings(skillNotesState.extraSkills.map((it) => toSkillName(it)).filter((it): it is string => Boolean(it))).sort((a, b) =>
|
||
a.localeCompare(b)
|
||
),
|
||
[skillNotesState.extraSkills]
|
||
);
|
||
|
||
const allProficientSkills = useMemo(
|
||
() =>
|
||
uniqueStrings([
|
||
...grantedSkills,
|
||
...Object.values(normalizedSkillChoicePicks).flatMap((picks) => picks),
|
||
...manualSkillSelections
|
||
]).sort((a, b) => a.localeCompare(b)),
|
||
[grantedSkills, manualSkillSelections, normalizedSkillChoicePicks]
|
||
);
|
||
|
||
const fixedFeatTokens = useMemo(
|
||
() => uniqueStrings([...getFixedFeatUids(selectedBackgroundDetail), ...getFixedFeatUids(selectedSpeciesDetail)]).sort((a, b) => a.localeCompare(b)),
|
||
[selectedBackgroundDetail?.rawJson, selectedSpeciesDetail?.rawJson]
|
||
);
|
||
|
||
const classFeatSlotCount = useMemo(
|
||
() => getClassFeatSlotCount(selectedClassDetail, characterDraft?.profile.level ?? 1),
|
||
[selectedClassDetail?.entityRef, selectedClassDetail?.featureCards, characterDraft?.profile.level]
|
||
);
|
||
|
||
const normalizedFeatSelections = useMemo(() => {
|
||
const raw = characterDraft?.steps.feats.selected ?? [];
|
||
return uniqueStrings(
|
||
raw
|
||
.map((token) => {
|
||
const parsed = parseNameSourceToken(token) || parseFeatUid(token);
|
||
return parsed ? toNameSourceToken(parsed.name, parsed.source) : "";
|
||
})
|
||
.filter((it) => Boolean(it))
|
||
);
|
||
}, [characterDraft?.steps.feats.selected]);
|
||
|
||
const variableFeatSelections = useMemo(() => {
|
||
const fixed = new Set(
|
||
fixedFeatTokens
|
||
.map((token) => parseNameSourceToken(token))
|
||
.filter((it): it is { name: string; source: string } => Boolean(it))
|
||
.map((it) => sanitizeFeatKey(it.name, it.source))
|
||
);
|
||
const filtered = normalizedFeatSelections.filter((token) => {
|
||
const parsed = parseNameSourceToken(token);
|
||
if (!parsed) {
|
||
return false;
|
||
}
|
||
return !fixed.has(sanitizeFeatKey(parsed.name, parsed.source));
|
||
});
|
||
return filtered.slice(0, Math.max(0, classFeatSlotCount));
|
||
}, [normalizedFeatSelections, fixedFeatTokens, classFeatSlotCount]);
|
||
|
||
const filteredFeatCatalog = useMemo(() => {
|
||
const needle = featSearch.trim().toLowerCase();
|
||
return (featCatalog ?? [])
|
||
.filter((feat) => sourceBuckets.includes(feat.sourceBucket))
|
||
.filter((feat) => {
|
||
if (!needle) {
|
||
return true;
|
||
}
|
||
return `${feat.name} ${feat.source} ${feat.summary} ${feat.category || ""}`.toLowerCase().includes(needle);
|
||
});
|
||
}, [featCatalog, sourceBuckets, featSearch]);
|
||
|
||
const featByKey = useMemo(() => {
|
||
const out = new Map<string, FeatCatalogItem>();
|
||
(featCatalog ?? []).forEach((feat) => out.set(feat.key, feat));
|
||
return out;
|
||
}, [featCatalog]);
|
||
|
||
const fixedFeatKeySet = useMemo(() => {
|
||
const out = new Set<string>();
|
||
fixedFeatTokens.forEach((token) => {
|
||
const parsed = parseNameSourceToken(token);
|
||
if (!parsed) {
|
||
return;
|
||
}
|
||
out.add(sanitizeFeatKey(parsed.name, parsed.source));
|
||
});
|
||
return out;
|
||
}, [fixedFeatTokens]);
|
||
|
||
const selectedFeatItems = useMemo(
|
||
() =>
|
||
normalizedFeatSelections.map((token) => {
|
||
const parsed = parseNameSourceToken(token);
|
||
if (!parsed) {
|
||
return {
|
||
key: token,
|
||
name: token,
|
||
source: "",
|
||
sourceBucket: "expanded2014" as CharacterSourceBucket,
|
||
sourceEdition: null,
|
||
category: null,
|
||
summary: ""
|
||
};
|
||
}
|
||
return (
|
||
featByKey.get(sanitizeFeatKey(parsed.name, parsed.source)) || {
|
||
key: sanitizeFeatKey(parsed.name, parsed.source),
|
||
name: parsed.name,
|
||
source: parsed.source,
|
||
sourceBucket: classifySourceBucket(parsed.source).sourceBucket,
|
||
sourceEdition: classifySourceBucket(parsed.source).sourceEdition,
|
||
category: null,
|
||
summary: ""
|
||
}
|
||
);
|
||
}),
|
||
[featByKey, normalizedFeatSelections]
|
||
);
|
||
|
||
const classSpellcasting = useMemo(
|
||
() => parseClassSpellcasting(selectedClassDetail, characterDraft?.profile.level ?? 1),
|
||
[selectedClassDetail?.entityRef, selectedClassDetail?.rawJson, characterDraft?.profile.level]
|
||
);
|
||
|
||
const selectedClassSpellPool = useMemo(() => {
|
||
if (!spellCatalog || !characterDraft?.selections.class) {
|
||
return [] as SpellCatalogItem[];
|
||
}
|
||
const className = characterDraft.selections.class.name.trim().toLowerCase();
|
||
const classSource = characterDraft.selections.class.source.trim().toUpperCase();
|
||
const sourceAliases = new Set([classSource]);
|
||
if (classSource === "XPHB") {
|
||
sourceAliases.add("PHB");
|
||
}
|
||
if (classSource === "PHB") {
|
||
sourceAliases.add("XPHB");
|
||
}
|
||
const needle = spellSearch.trim().toLowerCase();
|
||
return spellCatalog
|
||
.filter((spell) => sourceBuckets.includes(spell.sourceBucket))
|
||
.filter((spell) => {
|
||
const matchingByName = spell.classes.filter((cls) => cls.name.trim().toLowerCase() === className);
|
||
if (!matchingByName.length) {
|
||
return false;
|
||
}
|
||
const hasSourceData = matchingByName.some((cls) => Boolean(cls.source?.trim()));
|
||
if (!hasSourceData) {
|
||
return true;
|
||
}
|
||
return matchingByName.some((cls) => sourceAliases.has(cls.source.trim().toUpperCase()));
|
||
})
|
||
.filter((spell) => {
|
||
if (!needle) {
|
||
return true;
|
||
}
|
||
return `${spell.name} ${spell.source} ${spell.level}`.toLowerCase().includes(needle);
|
||
});
|
||
}, [spellCatalog, characterDraft?.selections.class, sourceBuckets, spellSearch]);
|
||
|
||
const selectedCantripPool = useMemo(
|
||
() => selectedClassSpellPool.filter((spell) => spell.level === 0).sort((a, b) => a.name.localeCompare(b.name) || a.source.localeCompare(b.source)),
|
||
[selectedClassSpellPool]
|
||
);
|
||
|
||
const selectedLeveledSpellPoolByLevel = useMemo(() => {
|
||
const groups = new Map<number, SpellCatalogItem[]>();
|
||
selectedClassSpellPool
|
||
.filter((spell) => spell.level > 0)
|
||
.forEach((spell) => {
|
||
const list = groups.get(spell.level) || [];
|
||
list.push(spell);
|
||
groups.set(spell.level, list);
|
||
});
|
||
return Array.from(groups.entries())
|
||
.sort(([a], [b]) => a - b)
|
||
.map(([level, spells]) => ({
|
||
level,
|
||
spells: [...spells].sort((a, b) => a.name.localeCompare(b.name) || a.source.localeCompare(b.source))
|
||
}));
|
||
}, [selectedClassSpellPool]);
|
||
|
||
const selectedSpellTokens = useMemo(
|
||
() =>
|
||
uniqueStrings(
|
||
(characterDraft?.steps.spells.selected ?? [])
|
||
.map((token) => {
|
||
const parsed = parseNameSourceToken(token) || parseFeatUid(token);
|
||
return parsed ? toNameSourceToken(parsed.name, parsed.source) : "";
|
||
})
|
||
.filter((it) => Boolean(it))
|
||
),
|
||
[characterDraft?.steps.spells.selected]
|
||
);
|
||
|
||
const selectedSpellItems = useMemo(() => {
|
||
const map = new Map<string, SpellCatalogItem>();
|
||
(spellCatalog ?? []).forEach((spell) => map.set(spell.key, spell));
|
||
return selectedSpellTokens
|
||
.map((token) => {
|
||
const parsed = parseNameSourceToken(token);
|
||
if (!parsed) {
|
||
return null;
|
||
}
|
||
return map.get(sanitizeFeatKey(parsed.name, parsed.source)) || null;
|
||
})
|
||
.filter((it): it is SpellCatalogItem => Boolean(it));
|
||
}, [spellCatalog, selectedSpellTokens]);
|
||
|
||
const selectedCantripCount = selectedSpellItems.filter((it) => it.level === 0).length;
|
||
const selectedPreparedCount = selectedSpellItems.filter((it) => it.level > 0).length;
|
||
|
||
const spellSlotMatrix = useMemo(() => {
|
||
const byLevel = new Map<number, number>();
|
||
classSpellcasting.slots.forEach((slot) => {
|
||
byLevel.set(slot.level, (byLevel.get(slot.level) || 0) + slot.slots);
|
||
});
|
||
return Array.from(byLevel.entries())
|
||
.sort(([a], [b]) => a - b)
|
||
.map(([level, slots]) => ({ level, slots }));
|
||
}, [classSpellcasting.slots]);
|
||
|
||
const classEquipmentGroups = useMemo(
|
||
() => parseStartingEquipmentChoices(selectedClassDetail, "Class"),
|
||
[selectedClassDetail?.entityRef, selectedClassDetail?.rawJson]
|
||
);
|
||
const backgroundEquipmentGroups = useMemo(
|
||
() => parseStartingEquipmentChoices(selectedBackgroundDetail, "Background"),
|
||
[selectedBackgroundDetail?.entityRef, selectedBackgroundDetail?.rawJson]
|
||
);
|
||
|
||
const equipmentChoiceGroups = useMemo(
|
||
() => [...classEquipmentGroups, ...backgroundEquipmentGroups],
|
||
[classEquipmentGroups, backgroundEquipmentGroups]
|
||
);
|
||
|
||
const equipmentSelectionState = useMemo(
|
||
() =>
|
||
parseEquipmentSelectionState(
|
||
characterDraft?.steps.equipment.startingEquipmentText ?? "",
|
||
characterDraft?.steps.equipment.inventoryText ?? ""
|
||
),
|
||
[characterDraft?.steps.equipment.startingEquipmentText, characterDraft?.steps.equipment.inventoryText]
|
||
);
|
||
|
||
const normalizedEquipmentSelections = useMemo(() => {
|
||
const out: Record<string, string> = {};
|
||
equipmentChoiceGroups.forEach((group) => {
|
||
const selectedOptionId = equipmentSelectionState.selections[group.id];
|
||
const hasSelected = group.options.some((option) => option.id === selectedOptionId);
|
||
out[group.id] = hasSelected ? selectedOptionId : group.options[0]?.id || "";
|
||
});
|
||
return out;
|
||
}, [equipmentChoiceGroups, equipmentSelectionState.selections]);
|
||
|
||
const resolvedEquipmentGrants = useMemo(() => {
|
||
const grants: EquipmentGrant[] = [];
|
||
equipmentChoiceGroups.forEach((group) => {
|
||
const selectedId = normalizedEquipmentSelections[group.id];
|
||
const option = group.options.find((it) => it.id === selectedId);
|
||
if (!option) {
|
||
return;
|
||
}
|
||
grants.push(...option.grants);
|
||
});
|
||
return grants;
|
||
}, [equipmentChoiceGroups, normalizedEquipmentSelections]);
|
||
|
||
const resolvedEquipmentRows = useMemo(() => {
|
||
const counts = new Map<string, number>();
|
||
const sourceLabels = [
|
||
...resolvedEquipmentGrants.map((grant) => grant.label).filter((label) => !/gp$/i.test(label)),
|
||
...equipmentSelectionState.addedItems
|
||
];
|
||
|
||
sourceLabels.forEach((rawLabel) => {
|
||
const parsed = parseInventoryLabel(rawLabel);
|
||
if (!parsed.label) {
|
||
return;
|
||
}
|
||
counts.set(parsed.label, (counts.get(parsed.label) || 0) + parsed.count);
|
||
});
|
||
|
||
return Array.from(counts.entries())
|
||
.map(([label, count]) => ({ label, count }))
|
||
.sort((a, b) => a.label.localeCompare(b.label));
|
||
}, [resolvedEquipmentGrants, equipmentSelectionState.addedItems]);
|
||
|
||
const resolvedEquipmentCurrency = useMemo(
|
||
() => cpToCurrency(resolvedEquipmentGrants.reduce((total, grant) => total + grant.cpValue, 0)),
|
||
[resolvedEquipmentGrants]
|
||
);
|
||
|
||
const backgroundAbilityModes = useMemo(
|
||
() => getBackgroundAbilityModes(selectedBackgroundDetail),
|
||
[selectedBackgroundDetail?.rawJson, selectedBackgroundDetail?.entityRef]
|
||
);
|
||
|
||
const activeBackgroundAbilityMode = useMemo(() => {
|
||
if (!characterDraft || !backgroundAbilityModes.length) {
|
||
return null;
|
||
}
|
||
|
||
const safeIndex = Math.max(0, Math.min(backgroundAbilityModes.length - 1, characterDraft.steps.abilityScores.backgroundBonusModeIndex));
|
||
return backgroundAbilityModes[safeIndex] ?? null;
|
||
}, [backgroundAbilityModes, characterDraft?.steps.abilityScores.backgroundBonusModeIndex, characterDraft]);
|
||
|
||
const normalizedBackgroundBonusSelections = useMemo(() => {
|
||
if (!characterDraft) {
|
||
return [];
|
||
}
|
||
return normalizeBackgroundBonusSelections(activeBackgroundAbilityMode, characterDraft.steps.abilityScores.backgroundBonusSelections);
|
||
}, [activeBackgroundAbilityMode, characterDraft?.steps.abilityScores.backgroundBonusSelections, characterDraft]);
|
||
|
||
const backgroundBonusTotals = useMemo(
|
||
() => getBackgroundBonusTotals(activeBackgroundAbilityMode, normalizedBackgroundBonusSelections),
|
||
[activeBackgroundAbilityMode, normalizedBackgroundBonusSelections]
|
||
);
|
||
|
||
const abilityTotals = useMemo(() => {
|
||
if (!characterDraft) {
|
||
return null;
|
||
}
|
||
return getScoresWithBonuses(characterDraft.steps.abilityScores.scores, backgroundBonusTotals);
|
||
}, [characterDraft?.steps.abilityScores.scores, backgroundBonusTotals, characterDraft]);
|
||
|
||
useEffect(() => {
|
||
if (!characterDraft) {
|
||
return;
|
||
}
|
||
|
||
const current = characterDraft.steps.abilityScores;
|
||
if (!backgroundAbilityModes.length) {
|
||
if (current.backgroundBonusModeIndex === 0 && current.backgroundBonusSelections.length === 0) {
|
||
return;
|
||
}
|
||
|
||
updateDraft((draft) => ({
|
||
...draft,
|
||
steps: {
|
||
...draft.steps,
|
||
abilityScores: {
|
||
...draft.steps.abilityScores,
|
||
backgroundBonusModeIndex: 0,
|
||
backgroundBonusSelections: []
|
||
}
|
||
}
|
||
}));
|
||
return;
|
||
}
|
||
|
||
const clampedModeIndex = Math.max(0, Math.min(backgroundAbilityModes.length - 1, current.backgroundBonusModeIndex));
|
||
const mode = backgroundAbilityModes[clampedModeIndex] ?? null;
|
||
const normalizedSelections = normalizeBackgroundBonusSelections(mode, current.backgroundBonusSelections);
|
||
|
||
const hasModeChanged = clampedModeIndex !== current.backgroundBonusModeIndex;
|
||
const hasSelectionsChanged = !areAbilityArraysEqual(normalizedSelections, current.backgroundBonusSelections);
|
||
if (!hasModeChanged && !hasSelectionsChanged) {
|
||
return;
|
||
}
|
||
|
||
updateDraft((draft) => ({
|
||
...draft,
|
||
steps: {
|
||
...draft.steps,
|
||
abilityScores: {
|
||
...draft.steps.abilityScores,
|
||
backgroundBonusModeIndex: clampedModeIndex,
|
||
backgroundBonusSelections: normalizedSelections
|
||
}
|
||
}
|
||
}));
|
||
}, [
|
||
backgroundAbilityModes,
|
||
characterDraft?.id,
|
||
characterDraft?.steps.abilityScores.backgroundBonusModeIndex,
|
||
characterDraft?.steps.abilityScores.backgroundBonusSelections,
|
||
updateDraft
|
||
]);
|
||
|
||
useEffect(() => {
|
||
if (!characterDraft) {
|
||
return;
|
||
}
|
||
|
||
const desiredNotes =
|
||
skillChoiceRequirements.length > 0 || manualSkillSelections.length > 0
|
||
? stringifySkillsNotes({
|
||
text: skillNotesState.text,
|
||
picksByChoiceId: normalizedSkillChoicePicks,
|
||
extraSkills: manualSkillSelections
|
||
})
|
||
: skillNotesState.text;
|
||
|
||
const currentSelected = [...characterDraft.steps.skills.selected].sort((a, b) => a.localeCompare(b));
|
||
const desiredSelected = [...allProficientSkills].sort((a, b) => a.localeCompare(b));
|
||
const isSelectedSame = areStringArraysEqual(currentSelected, desiredSelected);
|
||
const isNotesSame = characterDraft.steps.skills.notes === desiredNotes;
|
||
if (isSelectedSame && isNotesSame) {
|
||
return;
|
||
}
|
||
|
||
updateDraft((draft) => ({
|
||
...draft,
|
||
steps: {
|
||
...draft.steps,
|
||
skills: {
|
||
...draft.steps.skills,
|
||
selected: desiredSelected,
|
||
notes: desiredNotes
|
||
}
|
||
}
|
||
}));
|
||
}, [
|
||
allProficientSkills,
|
||
characterDraft?.id,
|
||
characterDraft?.steps.skills.notes,
|
||
characterDraft?.steps.skills.selected,
|
||
normalizedSkillChoicePicks,
|
||
manualSkillSelections,
|
||
skillChoiceRequirements.length,
|
||
skillNotesState.text,
|
||
updateDraft
|
||
]);
|
||
|
||
useEffect(() => {
|
||
if (!characterDraft) {
|
||
return;
|
||
}
|
||
|
||
const desired = uniqueStrings([...fixedFeatTokens, ...variableFeatSelections]).sort((a, b) => a.localeCompare(b));
|
||
const current = [...normalizedFeatSelections].sort((a, b) => a.localeCompare(b));
|
||
if (areStringArraysEqual(current, desired)) {
|
||
return;
|
||
}
|
||
|
||
updateDraft((draft) => ({
|
||
...draft,
|
||
steps: {
|
||
...draft.steps,
|
||
feats: {
|
||
...draft.steps.feats,
|
||
selected: desired
|
||
}
|
||
}
|
||
}));
|
||
}, [characterDraft?.id, fixedFeatTokens, normalizedFeatSelections, updateDraft, variableFeatSelections]);
|
||
|
||
useEffect(() => {
|
||
if (!characterDraft || !equipmentChoiceGroups.length) {
|
||
return;
|
||
}
|
||
|
||
const desiredState: EquipmentSelectionState = {
|
||
selections: normalizedEquipmentSelections,
|
||
addedItems: equipmentSelectionState.addedItems
|
||
};
|
||
const desiredStartingEquipment = stringifyEquipmentSelectionState(desiredState);
|
||
const desiredInventory = desiredState.addedItems.join("\n");
|
||
const currentCurrency = characterDraft.steps.equipment.currency;
|
||
const desiredCurrency = resolvedEquipmentCurrency;
|
||
const isCurrencySame =
|
||
currentCurrency.cp === desiredCurrency.cp &&
|
||
currentCurrency.sp === desiredCurrency.sp &&
|
||
currentCurrency.ep === desiredCurrency.ep &&
|
||
currentCurrency.gp === desiredCurrency.gp &&
|
||
currentCurrency.pp === desiredCurrency.pp;
|
||
|
||
if (
|
||
characterDraft.steps.equipment.startingEquipmentText === desiredStartingEquipment &&
|
||
characterDraft.steps.equipment.inventoryText === desiredInventory &&
|
||
isCurrencySame
|
||
) {
|
||
return;
|
||
}
|
||
|
||
updateDraft((draft) => ({
|
||
...draft,
|
||
steps: {
|
||
...draft.steps,
|
||
equipment: {
|
||
...draft.steps.equipment,
|
||
startingEquipmentText: desiredStartingEquipment,
|
||
inventoryText: desiredInventory,
|
||
currency: desiredCurrency
|
||
}
|
||
}
|
||
}));
|
||
}, [
|
||
characterDraft?.id,
|
||
characterDraft?.steps.equipment.currency,
|
||
characterDraft?.steps.equipment.inventoryText,
|
||
characterDraft?.steps.equipment.startingEquipmentText,
|
||
equipmentChoiceGroups.length,
|
||
equipmentSelectionState.addedItems,
|
||
normalizedEquipmentSelections,
|
||
resolvedEquipmentCurrency,
|
||
updateDraft
|
||
]);
|
||
|
||
const isStepComplete = useCallback((stepIndex: number, draft: CharacterDocument | null): boolean => {
|
||
if (!draft) {
|
||
return false;
|
||
}
|
||
|
||
if (stepIndex === 0) {
|
||
return Boolean(draft.selections.class) && draft.steps.preferences.sourceBuckets.length > 0;
|
||
}
|
||
|
||
if (stepIndex === 1) {
|
||
return Boolean(draft.selections.species);
|
||
}
|
||
|
||
if (stepIndex === 2) {
|
||
return Boolean(draft.selections.background);
|
||
}
|
||
|
||
if (stepIndex === 3) {
|
||
const { method, scores, assignments } = draft.steps.abilityScores;
|
||
let isBaseValid = true;
|
||
|
||
if (method === "pointBuy") {
|
||
const valuesOk = ABILITY_KEYS.every((key) => scores[key] >= 8 && scores[key] <= 15);
|
||
isBaseValid = valuesOk && getPointBuySpent(scores) <= 27;
|
||
}
|
||
|
||
if (method === "manual" && isBaseValid) {
|
||
isBaseValid = ABILITY_KEYS.every((key) => Number.isInteger(scores[key]) && scores[key] >= 1 && scores[key] <= 30);
|
||
}
|
||
|
||
if ((method === "standardArray" || method === "rolled") && isBaseValid) {
|
||
const assignmentValues = ABILITY_KEYS.map((key) => assignments[key]);
|
||
const unique = new Set(assignmentValues);
|
||
isBaseValid = assignmentValues.every((it) => Number.isInteger(it) && it >= 0 && it <= 5) && unique.size === 6;
|
||
}
|
||
|
||
if (!isBaseValid) {
|
||
return false;
|
||
}
|
||
|
||
const selectedBackgroundRef = draft.selections.background?.entityRef;
|
||
if (!selectedBackgroundRef) {
|
||
return true;
|
||
}
|
||
|
||
const backgroundDetail = detailCache[selectedBackgroundRef] ?? null;
|
||
const modes = getBackgroundAbilityModes(backgroundDetail);
|
||
if (!modes.length) {
|
||
return true;
|
||
}
|
||
|
||
const safeModeIndex = Math.max(0, Math.min(modes.length - 1, draft.steps.abilityScores.backgroundBonusModeIndex));
|
||
const mode = modes[safeModeIndex] ?? null;
|
||
const selections = normalizeBackgroundBonusSelections(mode, draft.steps.abilityScores.backgroundBonusSelections);
|
||
return mode ? selections.length === mode.weights.length : true;
|
||
}
|
||
|
||
if (stepIndex === 4) {
|
||
const classDetail = draft.selections.class ? detailCache[draft.selections.class.entityRef] ?? null : null;
|
||
const backgroundDetail = draft.selections.background ? detailCache[draft.selections.background.entityRef] ?? null : null;
|
||
const speciesDetail = draft.selections.species ? detailCache[draft.selections.species.entityRef] ?? null : null;
|
||
|
||
const slotCount = getClassFeatSlotCount(classDetail, draft.profile.level);
|
||
if (slotCount <= 0) {
|
||
return true;
|
||
}
|
||
|
||
const fixedFeatKeySet = new Set(
|
||
uniqueStrings([...getFixedFeatUids(backgroundDetail), ...getFixedFeatUids(speciesDetail)])
|
||
.map((token) => parseNameSourceToken(token))
|
||
.filter((it): it is { name: string; source: string } => Boolean(it))
|
||
.map((it) => sanitizeFeatKey(it.name, it.source))
|
||
);
|
||
|
||
const variableCount = uniqueStrings(
|
||
draft.steps.feats.selected
|
||
.map((token) => parseNameSourceToken(token) || parseFeatUid(token))
|
||
.filter((it): it is { name: string; source: string } => Boolean(it))
|
||
.map((it) => sanitizeFeatKey(it.name, it.source))
|
||
).filter((key) => !fixedFeatKeySet.has(key)).length;
|
||
|
||
return variableCount >= slotCount;
|
||
}
|
||
|
||
if (stepIndex === 5) {
|
||
const classDetail = draft.selections.class ? detailCache[draft.selections.class.entityRef] ?? null : null;
|
||
const backgroundDetail = draft.selections.background ? detailCache[draft.selections.background.entityRef] ?? null : null;
|
||
const speciesDetail = draft.selections.species ? detailCache[draft.selections.species.entityRef] ?? null : null;
|
||
|
||
const requirements = [
|
||
...parseClassSkills(classDetail).choices,
|
||
...parseBackgroundSkills(backgroundDetail).choices,
|
||
...parseSpeciesSkills(speciesDetail).choices
|
||
];
|
||
if (!requirements.length) {
|
||
return true;
|
||
}
|
||
|
||
const notesState = parseSkillsNotes(draft.steps.skills.notes);
|
||
return requirements.every((choice) => {
|
||
const picks = uniqueStrings((notesState.picksByChoiceId[choice.id] || []).filter((it) => choice.options.includes(it)));
|
||
return picks.length >= choice.count;
|
||
});
|
||
}
|
||
|
||
if (stepIndex === 6) {
|
||
const classDetail = draft.selections.class ? detailCache[draft.selections.class.entityRef] ?? null : null;
|
||
const spellcasting = parseClassSpellcasting(classDetail, draft.profile.level);
|
||
const requiredCantrips = spellcasting.cantrips;
|
||
const requiredPrepared = spellcasting.known || spellcasting.prepared || 0;
|
||
|
||
if (requiredCantrips <= 0 && requiredPrepared <= 0) {
|
||
return true;
|
||
}
|
||
|
||
if (!spellCatalog?.length) {
|
||
return draft.steps.spells.selected.length > 0;
|
||
}
|
||
|
||
const spellLevelByToken = new Map<string, number>();
|
||
spellCatalog.forEach((spell) => {
|
||
spellLevelByToken.set(toNameSourceToken(spell.name, spell.source), spell.level);
|
||
});
|
||
|
||
let cantripCount = 0;
|
||
let preparedCount = 0;
|
||
uniqueStrings(draft.steps.spells.selected).forEach((token) => {
|
||
const parsed = parseNameSourceToken(token) || parseFeatUid(token);
|
||
if (!parsed) {
|
||
return;
|
||
}
|
||
const level = spellLevelByToken.get(toNameSourceToken(parsed.name, parsed.source));
|
||
if (level == null) {
|
||
return;
|
||
}
|
||
if (level === 0) {
|
||
cantripCount += 1;
|
||
} else {
|
||
preparedCount += 1;
|
||
}
|
||
});
|
||
|
||
return cantripCount >= requiredCantrips && preparedCount >= requiredPrepared;
|
||
}
|
||
|
||
if (stepIndex === 7) {
|
||
const classDetail = draft.selections.class ? detailCache[draft.selections.class.entityRef] ?? null : null;
|
||
const backgroundDetail = draft.selections.background ? detailCache[draft.selections.background.entityRef] ?? null : null;
|
||
const groups = [...parseStartingEquipmentChoices(classDetail, "Class"), ...parseStartingEquipmentChoices(backgroundDetail, "Background")];
|
||
if (!groups.length) {
|
||
return true;
|
||
}
|
||
|
||
const selections = parseEquipmentSelectionState(draft.steps.equipment.startingEquipmentText, draft.steps.equipment.inventoryText).selections;
|
||
return groups.every((group) => {
|
||
const selectedId = selections[group.id];
|
||
return group.options.some((option) => option.id === selectedId);
|
||
});
|
||
}
|
||
|
||
if (stepIndex === 8) {
|
||
return Boolean(draft.profile.name.trim()) && Boolean(draft.steps.about.backstory.trim() || draft.steps.about.notes.trim());
|
||
}
|
||
|
||
if (stepIndex === CHARACTER_STEPS.length - 1) {
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}, [detailCache, spellCatalog]);
|
||
|
||
const stepCompletion = useMemo(
|
||
() => CHARACTER_STEPS.map((_, index) => isStepComplete(index, characterDraft)),
|
||
[characterDraft, isStepComplete]
|
||
);
|
||
|
||
const currentStepLabel = CHARACTER_STEPS[characterStep] ?? CHARACTER_STEPS[0];
|
||
const progressionSteps = CHARACTER_STEPS.slice(0, CHARACTER_STEPS.length - 1);
|
||
const canMoveNext = useMemo(() => {
|
||
if (!characterDraft) {
|
||
return false;
|
||
}
|
||
|
||
if (characterStep >= CHARACTER_STEPS.length - 1) {
|
||
return false;
|
||
}
|
||
|
||
if (characterStep === CHARACTER_STEPS.length - 2) {
|
||
return true;
|
||
}
|
||
|
||
return isStepComplete(characterStep, characterDraft);
|
||
}, [characterDraft, characterStep, isStepComplete]);
|
||
|
||
const nextStep = useCallback(() => {
|
||
setCharacterStep((current) => Math.min(CHARACTER_STEPS.length - 1, current + 1));
|
||
}, []);
|
||
|
||
const prevStep = useCallback(() => {
|
||
setCharacterStep((current) => Math.max(0, current - 1));
|
||
}, []);
|
||
|
||
const renderEntityDetailContent = useCallback(
|
||
(detail: CharacterSelectionDetail | null, kind: EntityPopoverKind) => {
|
||
if (!detail) {
|
||
return <p className="character-empty">Loading details...</p>;
|
||
}
|
||
|
||
return (
|
||
<div className="entity-detail entity-detail--popover">
|
||
<h4>{detail.name}</h4>
|
||
<p className="entity-detail-meta">
|
||
{detail.sourceName} ({detail.source}) • {detail.sourceBucket}
|
||
</p>
|
||
|
||
{detail.summary ? (
|
||
<ParsedEntryText text={detail.summary} isParserReady={isRenderRuntimeReady} className="entity-detail-text" />
|
||
) : null}
|
||
|
||
{detail.featureCards.length ? (
|
||
<div className="entity-feature-list">
|
||
{detail.featureCards.map((feature) => (
|
||
<article key={feature.id} className="entity-feature-card">
|
||
<header>
|
||
<strong>{feature.title}</strong>
|
||
{feature.level != null ? <span>Level {feature.level}</span> : null}
|
||
</header>
|
||
|
||
{feature.summary ? (
|
||
<ParsedEntryText text={feature.summary} isParserReady={isRenderRuntimeReady} className="entity-feature-text" />
|
||
) : null}
|
||
|
||
{kind === "class" && feature.options?.length ? (
|
||
<label className="character-field">
|
||
<span>Choose an option</span>
|
||
<select
|
||
value={characterDraft?.selections.subclass?.entityRef ?? ""}
|
||
onChange={(event) => {
|
||
const next = feature.options?.find((opt) => opt.entityRef === event.target.value) || null;
|
||
updateDraft((draft) => ({
|
||
...draft,
|
||
selections: {
|
||
...draft.selections,
|
||
subclass: next ? toSubclassEntityRef(next) : null
|
||
}
|
||
}));
|
||
}}
|
||
>
|
||
<option value="">Select...</option>
|
||
{feature.options.map((opt) => (
|
||
<option key={opt.entityRef} value={opt.entityRef}>
|
||
{opt.name} ({opt.source})
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
) : null}
|
||
</article>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
|
||
<details className="entity-json">
|
||
<summary>View JSON</summary>
|
||
<pre>{detail.rawJson}</pre>
|
||
</details>
|
||
</div>
|
||
);
|
||
},
|
||
[characterDraft?.selections.subclass?.entityRef, isRenderRuntimeReady, updateDraft]
|
||
);
|
||
|
||
useEffect(() => {
|
||
if (characterModalMode === "closed") {
|
||
return;
|
||
}
|
||
|
||
const onDocumentClickCapture = (event: MouseEvent) => {
|
||
const target = event.target;
|
||
if (!(target instanceof Element)) {
|
||
return;
|
||
}
|
||
|
||
const anchor = target.closest("a[href]") as HTMLAnchorElement | null;
|
||
if (!anchor) {
|
||
return;
|
||
}
|
||
|
||
const isVetLink = anchor.hasAttribute("data-vet-page") && anchor.hasAttribute("data-vet-hash");
|
||
if (isVetLink) {
|
||
// Let native 5etools link handlers drive hover/popover behavior.
|
||
return;
|
||
}
|
||
|
||
const href = anchor.getAttribute("href");
|
||
if (!href) {
|
||
return;
|
||
}
|
||
|
||
const wikiPath = resolveWikiPathFromHref(href);
|
||
if (!wikiPath) {
|
||
return;
|
||
}
|
||
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
event.stopImmediatePropagation();
|
||
setCurrentPath(wikiPath);
|
||
setCharacterModalMode("closed");
|
||
};
|
||
|
||
document.addEventListener("click", onDocumentClickCapture, true);
|
||
return () => document.removeEventListener("click", onDocumentClickCapture, true);
|
||
}, [characterModalMode, resolveWikiPathFromHref]);
|
||
|
||
if (error) {
|
||
return (
|
||
<main className="status-wrap">
|
||
<section className="status-panel">
|
||
<h1>5etools Desktop failed to start</h1>
|
||
<p>{error}</p>
|
||
</section>
|
||
</main>
|
||
);
|
||
}
|
||
|
||
if (!wikiUrl) {
|
||
return (
|
||
<main className="status-wrap">
|
||
<section className="status-panel">
|
||
<h1>Starting 5etools Desktop</h1>
|
||
<p>Loading local 5etools wiki...</p>
|
||
</section>
|
||
</main>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<main className="shell" aria-label="5etools Desktop Wiki Shell">
|
||
<header className="app-topbar" ref={topbarRef}>
|
||
<div className="app-topbar-brand">
|
||
<p className="app-topbar-kicker">5etools Desktop</p>
|
||
<h1 className="app-topbar-title">Compendium</h1>
|
||
</div>
|
||
|
||
<nav className="app-topbar-nav" aria-label="5etools Top Navigation">
|
||
{topbarGroups.map((group) => {
|
||
const isOpen = openTopMenuId === group.id;
|
||
const isActive = group.kind === "link" && group.href ? isSameAppPath(group.href, currentPathname) : false;
|
||
|
||
return (
|
||
<div key={group.id} className={isOpen ? "topbar-menu is-open" : "topbar-menu"}>
|
||
<button
|
||
type="button"
|
||
className={isActive ? "topbar-menu-toggle is-active" : "topbar-menu-toggle"}
|
||
onClick={() => activateTopbarGroup(group)}
|
||
aria-expanded={group.kind === "dropdown" ? isOpen : undefined}
|
||
>
|
||
<span>{group.label}</span>
|
||
{group.kind === "dropdown" ? <span className="topbar-caret" aria-hidden="true">▾</span> : null}
|
||
</button>
|
||
|
||
{group.kind === "dropdown" && isOpen ? (
|
||
<div className="topbar-dropdown-menu" role="menu">
|
||
{renderTopbarDropdown(group.items ?? [])}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
<div className={openTopMenuId === APP_CHARACTERS_MENU_ID ? "topbar-menu is-open" : "topbar-menu"}>
|
||
<button
|
||
type="button"
|
||
className={characterModalMode !== "closed" ? "topbar-menu-toggle is-active" : "topbar-menu-toggle"}
|
||
onClick={() => {
|
||
setOpenTopMenuId((current) => (current === APP_CHARACTERS_MENU_ID ? null : APP_CHARACTERS_MENU_ID));
|
||
setOpenTopMenuPath([]);
|
||
}}
|
||
aria-expanded={openTopMenuId === APP_CHARACTERS_MENU_ID}
|
||
>
|
||
<span>Characters</span>
|
||
<span className="topbar-caret" aria-hidden="true">▾</span>
|
||
</button>
|
||
|
||
{openTopMenuId === APP_CHARACTERS_MENU_ID ? (
|
||
<div className="topbar-dropdown-menu" role="menu">
|
||
<button type="button" className="topbar-dropdown-item" onClick={() => { setOpenTopMenuId(null); void openNewCharacter(); }}>
|
||
New
|
||
</button>
|
||
<button type="button" className="topbar-dropdown-item" onClick={() => { setOpenTopMenuId(null); void loadCharacterFromJson(); }}>
|
||
Load from JSON
|
||
</button>
|
||
<button type="button" className="topbar-dropdown-item" onClick={() => { setOpenTopMenuId(null); void openCharacterList(); }}>
|
||
My Characters
|
||
</button>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</nav>
|
||
|
||
<form className="app-topbar-search" onSubmit={onSubmitTopbarSearch}>
|
||
<input
|
||
type="search"
|
||
value={topbarSearch}
|
||
onChange={(event) => setTopbarSearch(event.target.value)}
|
||
placeholder="Search everywhere..."
|
||
/>
|
||
</form>
|
||
</header>
|
||
|
||
<section className="workspace">
|
||
{characterModalMode === "closed" ? (
|
||
<section className="frame-shell frame-shell--full">
|
||
<iframe ref={frameRef} className="wiki-frame" src={frameSrc} title="5etools Wiki" />
|
||
</section>
|
||
) : (
|
||
<section className="frame-shell frame-shell--full character-shell" role="region" aria-label="Character Creator">
|
||
<div className={characterModalMode === "editor" ? "character-modal is-editor" : "character-modal"}>
|
||
<header className="character-modal-header">
|
||
<h2>Character Creator</h2>
|
||
<div className="character-modal-actions">
|
||
<button type="button" onClick={() => void openCharacterList()} disabled={isCharacterBusy}>
|
||
My Characters
|
||
</button>
|
||
<button type="button" onClick={() => void openNewCharacter()} disabled={isCharacterBusy}>
|
||
New
|
||
</button>
|
||
<button type="button" onClick={() => void loadCharacterFromJson()} disabled={isCharacterBusy}>
|
||
Load JSON
|
||
</button>
|
||
<button type="button" className="is-subtle" onClick={() => setCharacterModalMode("closed")} disabled={isCharacterBusy}>
|
||
Back to Wiki
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
{characterError ? <p className="character-alert character-alert--error">{characterError}</p> : null}
|
||
{characterNotice ? <p className="character-alert character-alert--info">{characterNotice}</p> : null}
|
||
|
||
{characterModalMode === "list" ? (
|
||
<div className="character-list-wrap">
|
||
<div className="character-list-header">
|
||
<h3>Saved Characters</h3>
|
||
<button type="button" onClick={() => void refreshCharacterSummaries()} disabled={isCharacterBusy}>
|
||
Refresh
|
||
</button>
|
||
</div>
|
||
|
||
{characterSummaries.length === 0 ? (
|
||
<p className="character-empty">No characters saved yet.</p>
|
||
) : (
|
||
<ul className="character-list">
|
||
{characterSummaries.map((summary) => (
|
||
<li key={summary.id} className="character-list-item">
|
||
<div>
|
||
<p className="character-list-title">{summary.name}</p>
|
||
<p className="character-list-meta">
|
||
Level {summary.level} • {summary.className || "No class"} • {summary.backgroundName || "No background"} •{" "}
|
||
{summary.speciesName || "No species"}
|
||
</p>
|
||
<p className="character-list-time">Updated {formatIso(summary.updatedAt)}</p>
|
||
</div>
|
||
<div className="character-list-actions">
|
||
<button type="button" onClick={() => void openCharacterById(summary.id)} disabled={isCharacterBusy}>
|
||
Open
|
||
</button>
|
||
<button type="button" className="is-danger" onClick={() => void deleteCharacterById(summary.id)} disabled={isCharacterBusy}>
|
||
Delete
|
||
</button>
|
||
</div>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<div className="character-editor-wrap">
|
||
<header className="character-builder-header">
|
||
<div className="character-builder-step">
|
||
<h3>{currentStepLabel}</h3>
|
||
<span className={stepCompletion[characterStep] ? "character-builder-step-state is-complete" : "character-builder-step-state is-pending"}>
|
||
{stepCompletion[characterStep] ? "Complete" : "Needs Choices"}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="character-builder-actions">
|
||
<button
|
||
type="button"
|
||
className="is-subtle"
|
||
onClick={() => setCharacterStep(CHARACTER_STEPS.length - 1)}
|
||
disabled={!characterDraft || isCharacterBusy}
|
||
>
|
||
Review Character
|
||
</button>
|
||
<button type="button" className="is-subtle" onClick={() => setCharacterModalMode("closed")} disabled={isCharacterBusy}>
|
||
Compendium
|
||
</button>
|
||
<button type="button" className="is-danger" onClick={() => setCharacterModalMode("closed")} disabled={isCharacterBusy}>
|
||
Exit
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
{characterDraft ? (
|
||
<div className="character-step-content">
|
||
{characterStep === 0 ? (
|
||
<section className="character-section">
|
||
<h3>Step 1: Class</h3>
|
||
|
||
<div className="character-grid">
|
||
<label className="character-field">
|
||
<span>Name</span>
|
||
<input
|
||
type="text"
|
||
value={characterDraft.profile.name}
|
||
onChange={(event) =>
|
||
updateDraft((draft) => ({
|
||
...draft,
|
||
profile: { ...draft.profile, name: event.target.value }
|
||
}))
|
||
}
|
||
/>
|
||
</label>
|
||
|
||
<label className="character-field">
|
||
<span>Level</span>
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
max={20}
|
||
value={characterDraft.profile.level}
|
||
onChange={(event) =>
|
||
updateDraft((draft) => ({
|
||
...draft,
|
||
profile: {
|
||
...draft.profile,
|
||
level: Math.max(1, Math.min(20, Number.parseInt(event.target.value || "1", 10) || 1))
|
||
}
|
||
}))
|
||
}
|
||
/>
|
||
</label>
|
||
|
||
<label className="character-field">
|
||
<span>Advancement Type</span>
|
||
<select
|
||
value={characterDraft.steps.preferences.advancementType}
|
||
onChange={(event) =>
|
||
updateDraft((draft) => ({
|
||
...draft,
|
||
steps: {
|
||
...draft.steps,
|
||
preferences: {
|
||
...draft.steps.preferences,
|
||
advancementType: event.target.value === "xp" ? "xp" : "milestone"
|
||
}
|
||
}
|
||
}))
|
||
}
|
||
>
|
||
<option value="milestone">Milestone</option>
|
||
<option value="xp">XP</option>
|
||
</select>
|
||
</label>
|
||
|
||
<label className="character-field">
|
||
<span>Hit Point Type</span>
|
||
<select
|
||
value={characterDraft.steps.preferences.hpMode}
|
||
onChange={(event) =>
|
||
updateDraft((draft) => ({
|
||
...draft,
|
||
steps: {
|
||
...draft.steps,
|
||
preferences: {
|
||
...draft.steps.preferences,
|
||
hpMode:
|
||
event.target.value === "maximum" || event.target.value === "rolled"
|
||
? event.target.value
|
||
: "average"
|
||
}
|
||
}
|
||
}))
|
||
}
|
||
>
|
||
<option value="average">Average</option>
|
||
<option value="maximum">Maximum</option>
|
||
<option value="rolled">Rolled</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
|
||
<div className="character-source-grid">
|
||
{SOURCE_BUCKET_OPTIONS.map((bucket) => {
|
||
const isChecked = characterDraft.steps.preferences.sourceBuckets.includes(bucket.key);
|
||
return (
|
||
<label key={bucket.key} className="character-checkbox">
|
||
<input
|
||
type="checkbox"
|
||
checked={isChecked}
|
||
onChange={(event) =>
|
||
updateDraft((draft) => {
|
||
const current = draft.steps.preferences.sourceBuckets;
|
||
const next = event.target.checked
|
||
? Array.from(new Set([...current, bucket.key]))
|
||
: current.filter((it) => it !== bucket.key);
|
||
|
||
return {
|
||
...draft,
|
||
steps: {
|
||
...draft.steps,
|
||
preferences: {
|
||
...draft.steps.preferences,
|
||
sourceBuckets: next
|
||
}
|
||
}
|
||
};
|
||
})
|
||
}
|
||
/>
|
||
<span>{bucket.label}</span>
|
||
</label>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
<div className="character-toggle-grid">
|
||
<label className="character-checkbox">
|
||
<input
|
||
type="checkbox"
|
||
checked={characterDraft.steps.preferences.useOptionalClassFeatures}
|
||
onChange={(event) =>
|
||
updateDraft((draft) => ({
|
||
...draft,
|
||
steps: {
|
||
...draft.steps,
|
||
preferences: {
|
||
...draft.steps.preferences,
|
||
useOptionalClassFeatures: event.target.checked
|
||
}
|
||
}
|
||
}))
|
||
}
|
||
/>
|
||
<span>Optional Class Features</span>
|
||
</label>
|
||
|
||
<label className="character-checkbox">
|
||
<input
|
||
type="checkbox"
|
||
checked={characterDraft.steps.preferences.customizeOrigin}
|
||
onChange={(event) =>
|
||
updateDraft((draft) => ({
|
||
...draft,
|
||
steps: {
|
||
...draft.steps,
|
||
preferences: {
|
||
...draft.steps.preferences,
|
||
customizeOrigin: event.target.checked
|
||
}
|
||
}
|
||
}))
|
||
}
|
||
/>
|
||
<span>Customize Origin</span>
|
||
</label>
|
||
|
||
<label className="character-checkbox">
|
||
<input
|
||
type="checkbox"
|
||
checked={characterDraft.steps.preferences.usePrerequisitesFeats}
|
||
onChange={(event) =>
|
||
updateDraft((draft) => ({
|
||
...draft,
|
||
steps: {
|
||
...draft.steps,
|
||
preferences: {
|
||
...draft.steps.preferences,
|
||
usePrerequisitesFeats: event.target.checked
|
||
}
|
||
}
|
||
}))
|
||
}
|
||
/>
|
||
<span>Use Feat Prerequisites</span>
|
||
</label>
|
||
|
||
<label className="character-checkbox">
|
||
<input
|
||
type="checkbox"
|
||
checked={characterDraft.steps.preferences.usePrerequisitesMulticlass}
|
||
onChange={(event) =>
|
||
updateDraft((draft) => ({
|
||
...draft,
|
||
steps: {
|
||
...draft.steps,
|
||
preferences: {
|
||
...draft.steps.preferences,
|
||
usePrerequisitesMulticlass: event.target.checked
|
||
}
|
||
}
|
||
}))
|
||
}
|
||
/>
|
||
<span>Use Multiclass Prerequisites</span>
|
||
</label>
|
||
</div>
|
||
|
||
<label className="character-field">
|
||
<span>Search classes</span>
|
||
<input type="search" value={classSearch} onChange={(event) => setClassSearch(event.target.value)} />
|
||
</label>
|
||
|
||
<div className="entity-layout">
|
||
<div className="entity-list-panel entity-list-panel--solo">
|
||
<div className="entity-card-grid">
|
||
{classPageOptions.map((option) => {
|
||
const isSelected = characterDraft.selections.class?.entityRef === option.entityRef;
|
||
const imageUrl = buildAssetUrl(wikiUrl, option.imagePath);
|
||
const sourceBadge = getSourceBadgeText(option.source, option.sourceEdition);
|
||
|
||
return (
|
||
<button
|
||
type="button"
|
||
key={option.entityRef}
|
||
className={isSelected ? "entity-tile is-selected" : "entity-tile"}
|
||
onClick={() => {
|
||
updateDraft((draft) => ({
|
||
...draft,
|
||
selections: {
|
||
...draft.selections,
|
||
class: toEntityRef(option),
|
||
subclass: null
|
||
}
|
||
}));
|
||
openEntityPopover("class", option.entityRef);
|
||
}}
|
||
>
|
||
<span className="entity-tile-media">
|
||
{imageUrl ? <img src={imageUrl} alt={option.name} /> : <span className="entity-tile-fallback">{option.name.slice(0, 1)}</span>}
|
||
{sourceBadge ? <span className="entity-tile-source">{sourceBadge}</span> : null}
|
||
<span className="entity-tile-titlebar">
|
||
<span className="entity-tile-label">{option.name}</span>
|
||
<span className="entity-tile-meta">{option.source}</span>
|
||
</span>
|
||
</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{filteredClassOptions.length === 0 ? <p className="character-empty">No classes match the selected source buckets.</p> : null}
|
||
|
||
<div className="entity-pagination">
|
||
<button type="button" onClick={() => setClassPage((it) => Math.max(1, it - 1))} disabled={classPageSafe <= 1}>
|
||
Previous
|
||
</button>
|
||
<span>
|
||
Page {classPageSafe}/{classTotalPages} ({filteredClassOptions.length} total)
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => setClassPage((it) => Math.min(classTotalPages, it + 1))}
|
||
disabled={classPageSafe >= classTotalPages}
|
||
>
|
||
Next
|
||
</button>
|
||
</div>
|
||
|
||
<div className="entity-list-actions">
|
||
<button
|
||
type="button"
|
||
className="is-subtle"
|
||
onClick={() => {
|
||
if (!characterDraft.selections.class) {
|
||
return;
|
||
}
|
||
openEntityPopover("class", characterDraft.selections.class.entityRef);
|
||
}}
|
||
disabled={!characterDraft.selections.class}
|
||
>
|
||
{characterDraft.selections.class ? `View ${characterDraft.selections.class.name} details` : "Select a class"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
) : null}
|
||
|
||
{characterStep === 1 ? (
|
||
<section className="character-section">
|
||
<h3>Step 2: Species</h3>
|
||
<label className="character-field">
|
||
<span>Search species</span>
|
||
<input type="search" value={speciesSearch} onChange={(event) => setSpeciesSearch(event.target.value)} />
|
||
</label>
|
||
|
||
<div className="entity-layout">
|
||
<div className="entity-list-panel entity-list-panel--solo">
|
||
<div className="entity-card-grid">
|
||
{speciesPageOptions.map((option) => {
|
||
const isSelected = characterDraft.selections.species?.entityRef === option.entityRef;
|
||
const imageUrl = buildAssetUrl(wikiUrl, option.imagePath);
|
||
const sourceBadge = getSourceBadgeText(option.source, option.sourceEdition);
|
||
|
||
return (
|
||
<button
|
||
type="button"
|
||
key={option.entityRef}
|
||
className={isSelected ? "entity-tile is-selected" : "entity-tile"}
|
||
onClick={() => {
|
||
updateDraft((draft) => ({
|
||
...draft,
|
||
selections: {
|
||
...draft.selections,
|
||
species: toEntityRef(option)
|
||
}
|
||
}));
|
||
openEntityPopover("species", option.entityRef);
|
||
}}
|
||
>
|
||
<span className="entity-tile-media">
|
||
{imageUrl ? <img src={imageUrl} alt={option.name} /> : <span className="entity-tile-fallback">{option.name.slice(0, 1)}</span>}
|
||
{sourceBadge ? <span className="entity-tile-source">{sourceBadge}</span> : null}
|
||
<span className="entity-tile-titlebar">
|
||
<span className="entity-tile-label">{option.name}</span>
|
||
<span className="entity-tile-meta">{option.source}</span>
|
||
</span>
|
||
</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{filteredSpeciesOptions.length === 0 ? <p className="character-empty">No species match the selected source buckets.</p> : null}
|
||
|
||
<div className="entity-pagination">
|
||
<button type="button" onClick={() => setSpeciesPage((it) => Math.max(1, it - 1))} disabled={speciesPageSafe <= 1}>
|
||
Previous
|
||
</button>
|
||
<span>
|
||
Page {speciesPageSafe}/{speciesTotalPages} ({filteredSpeciesOptions.length} total)
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => setSpeciesPage((it) => Math.min(speciesTotalPages, it + 1))}
|
||
disabled={speciesPageSafe >= speciesTotalPages}
|
||
>
|
||
Next
|
||
</button>
|
||
</div>
|
||
|
||
<div className="entity-list-actions">
|
||
<button
|
||
type="button"
|
||
className="is-subtle"
|
||
onClick={() => {
|
||
if (!characterDraft.selections.species) {
|
||
return;
|
||
}
|
||
openEntityPopover("species", characterDraft.selections.species.entityRef);
|
||
}}
|
||
disabled={!characterDraft.selections.species}
|
||
>
|
||
{characterDraft.selections.species
|
||
? `View ${characterDraft.selections.species.name} details`
|
||
: "Select a species"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
) : null}
|
||
|
||
{characterStep === 2 ? (
|
||
<section className="character-section">
|
||
<h3>Step 3: Background</h3>
|
||
<label className="character-field">
|
||
<span>Search backgrounds</span>
|
||
<input type="search" value={backgroundSearch} onChange={(event) => setBackgroundSearch(event.target.value)} />
|
||
</label>
|
||
|
||
<div className="entity-layout">
|
||
<div className="entity-list-panel entity-list-panel--solo">
|
||
<div className="entity-card-grid">
|
||
{backgroundPageOptions.map((option) => {
|
||
const isSelected = characterDraft.selections.background?.entityRef === option.entityRef;
|
||
const imageUrl = buildAssetUrl(wikiUrl, option.imagePath);
|
||
const sourceBadge = getSourceBadgeText(option.source, option.sourceEdition);
|
||
|
||
return (
|
||
<button
|
||
type="button"
|
||
key={option.entityRef}
|
||
className={isSelected ? "entity-tile is-selected" : "entity-tile"}
|
||
onClick={() => {
|
||
updateDraft((draft) => ({
|
||
...draft,
|
||
selections: {
|
||
...draft.selections,
|
||
background: toEntityRef(option)
|
||
},
|
||
steps: {
|
||
...draft.steps,
|
||
abilityScores: {
|
||
...draft.steps.abilityScores,
|
||
backgroundBonusModeIndex: 0,
|
||
backgroundBonusSelections: []
|
||
}
|
||
}
|
||
}));
|
||
openEntityPopover("background", option.entityRef);
|
||
}}
|
||
>
|
||
<span className="entity-tile-media">
|
||
{imageUrl ? <img src={imageUrl} alt={option.name} /> : <span className="entity-tile-fallback">{option.name.slice(0, 1)}</span>}
|
||
{sourceBadge ? <span className="entity-tile-source">{sourceBadge}</span> : null}
|
||
<span className="entity-tile-titlebar">
|
||
<span className="entity-tile-label">{option.name}</span>
|
||
<span className="entity-tile-meta">{option.source}</span>
|
||
</span>
|
||
</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{filteredBackgroundOptions.length === 0 ? (
|
||
<p className="character-empty">No backgrounds match the selected source buckets.</p>
|
||
) : null}
|
||
|
||
<div className="entity-pagination">
|
||
<button
|
||
type="button"
|
||
onClick={() => setBackgroundPage((it) => Math.max(1, it - 1))}
|
||
disabled={backgroundPageSafe <= 1}
|
||
>
|
||
Previous
|
||
</button>
|
||
<span>
|
||
Page {backgroundPageSafe}/{backgroundTotalPages} ({filteredBackgroundOptions.length} total)
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => setBackgroundPage((it) => Math.min(backgroundTotalPages, it + 1))}
|
||
disabled={backgroundPageSafe >= backgroundTotalPages}
|
||
>
|
||
Next
|
||
</button>
|
||
</div>
|
||
|
||
<div className="entity-list-actions">
|
||
<button
|
||
type="button"
|
||
className="is-subtle"
|
||
onClick={() => {
|
||
if (!characterDraft.selections.background) {
|
||
return;
|
||
}
|
||
openEntityPopover("background", characterDraft.selections.background.entityRef);
|
||
}}
|
||
disabled={!characterDraft.selections.background}
|
||
>
|
||
{characterDraft.selections.background
|
||
? `View ${characterDraft.selections.background.name} details`
|
||
: "Select a background"}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
) : null}
|
||
|
||
{characterStep === 3 ? (
|
||
<section className="character-section character-section--ability">
|
||
<h3>Step 4: Ability Scores</h3>
|
||
|
||
<div className="ability-method-row">
|
||
{([
|
||
["standardArray", "Standard"],
|
||
["pointBuy", "Point Buy"],
|
||
["rolled", "Roll"],
|
||
["manual", "Manual"]
|
||
] as const).map(([method, label]) => (
|
||
<button
|
||
key={method}
|
||
type="button"
|
||
className={characterDraft.steps.abilityScores.method === method ? "is-active" : ""}
|
||
onClick={() =>
|
||
updateDraft((draft) => {
|
||
if (method === "standardArray") {
|
||
const pool = [...STANDARD_ARRAY] as [number, number, number, number, number, number];
|
||
return {
|
||
...draft,
|
||
steps: {
|
||
...draft.steps,
|
||
abilityScores: {
|
||
...draft.steps.abilityScores,
|
||
method,
|
||
rolledValues: pool,
|
||
assignments: {
|
||
str: 0,
|
||
dex: 1,
|
||
con: 2,
|
||
int: 3,
|
||
wis: 4,
|
||
cha: 5
|
||
},
|
||
scores: {
|
||
str: 15,
|
||
dex: 14,
|
||
con: 13,
|
||
int: 12,
|
||
wis: 10,
|
||
cha: 8
|
||
}
|
||
}
|
||
}
|
||
};
|
||
}
|
||
|
||
if (method === "rolled") {
|
||
const pool = draft.steps.abilityScores.rolledValues;
|
||
const assignments = draft.steps.abilityScores.assignments;
|
||
return {
|
||
...draft,
|
||
steps: {
|
||
...draft.steps,
|
||
abilityScores: {
|
||
...draft.steps.abilityScores,
|
||
method,
|
||
rolledValues: pool,
|
||
assignments,
|
||
scores: getScoresFromAssignments(pool, assignments)
|
||
}
|
||
}
|
||
};
|
||
}
|
||
|
||
if (method === "pointBuy") {
|
||
const normalized: Record<AbilityKey, number> = {
|
||
str: Math.max(8, Math.min(15, draft.steps.abilityScores.scores.str)),
|
||
dex: Math.max(8, Math.min(15, draft.steps.abilityScores.scores.dex)),
|
||
con: Math.max(8, Math.min(15, draft.steps.abilityScores.scores.con)),
|
||
int: Math.max(8, Math.min(15, draft.steps.abilityScores.scores.int)),
|
||
wis: Math.max(8, Math.min(15, draft.steps.abilityScores.scores.wis)),
|
||
cha: Math.max(8, Math.min(15, draft.steps.abilityScores.scores.cha))
|
||
};
|
||
|
||
return {
|
||
...draft,
|
||
steps: {
|
||
...draft.steps,
|
||
abilityScores: {
|
||
...draft.steps.abilityScores,
|
||
method,
|
||
scores: normalized
|
||
}
|
||
}
|
||
};
|
||
}
|
||
|
||
return {
|
||
...draft,
|
||
steps: {
|
||
...draft.steps,
|
||
abilityScores: {
|
||
...draft.steps.abilityScores,
|
||
method
|
||
}
|
||
}
|
||
};
|
||
})
|
||
}
|
||
>
|
||
{label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<div className="ability-layout">
|
||
<aside className="ability-sidebar">
|
||
{characterDraft.steps.abilityScores.method === "rolled" ? (
|
||
<article className="ability-panel">
|
||
<div className="ability-panel-headline">
|
||
<h4>Roll Stats</h4>
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
updateDraft((draft) => {
|
||
const rolled = [
|
||
roll4d6DropLowest(),
|
||
roll4d6DropLowest(),
|
||
roll4d6DropLowest(),
|
||
roll4d6DropLowest(),
|
||
roll4d6DropLowest(),
|
||
roll4d6DropLowest()
|
||
] as [number, number, number, number, number, number];
|
||
|
||
return {
|
||
...draft,
|
||
steps: {
|
||
...draft.steps,
|
||
abilityScores: {
|
||
...draft.steps.abilityScores,
|
||
method: "rolled",
|
||
rolledValues: rolled,
|
||
scores: getScoresFromAssignments(rolled, draft.steps.abilityScores.assignments)
|
||
}
|
||
}
|
||
};
|
||
})
|
||
}
|
||
>
|
||
Roll All
|
||
</button>
|
||
</div>
|
||
|
||
<div className="ability-roll-list">
|
||
{characterDraft.steps.abilityScores.rolledValues.map((value, idx) => (
|
||
<div key={`roll-${idx}`} className="ability-roll-row">
|
||
<span>Roll #{idx + 1}</span>
|
||
<strong>{value}</strong>
|
||
<button
|
||
type="button"
|
||
className="is-subtle"
|
||
onClick={() =>
|
||
updateDraft((draft) => {
|
||
const nextValues = [...draft.steps.abilityScores.rolledValues] as [
|
||
number,
|
||
number,
|
||
number,
|
||
number,
|
||
number,
|
||
number
|
||
];
|
||
nextValues[idx] = roll4d6DropLowest();
|
||
return {
|
||
...draft,
|
||
steps: {
|
||
...draft.steps,
|
||
abilityScores: {
|
||
...draft.steps.abilityScores,
|
||
rolledValues: nextValues,
|
||
scores: getScoresFromAssignments(nextValues, draft.steps.abilityScores.assignments)
|
||
}
|
||
}
|
||
};
|
||
})
|
||
}
|
||
>
|
||
Roll
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
className="is-subtle"
|
||
onClick={() =>
|
||
updateDraft((draft) => {
|
||
const rankOrder = draft.steps.abilityScores.rolledValues
|
||
.map((value, index) => ({ value, index }))
|
||
.sort((a, b) => b.value - a.value)
|
||
.map((it) => it.index);
|
||
const nextAssignments: Record<AbilityKey, number> = {
|
||
str: rankOrder[0] ?? 0,
|
||
dex: rankOrder[1] ?? 1,
|
||
con: rankOrder[2] ?? 2,
|
||
int: rankOrder[3] ?? 3,
|
||
wis: rankOrder[4] ?? 4,
|
||
cha: rankOrder[5] ?? 5
|
||
};
|
||
return {
|
||
...draft,
|
||
steps: {
|
||
...draft.steps,
|
||
abilityScores: {
|
||
...draft.steps.abilityScores,
|
||
assignments: nextAssignments,
|
||
scores: getScoresFromAssignments(draft.steps.abilityScores.rolledValues, nextAssignments)
|
||
}
|
||
}
|
||
};
|
||
})
|
||
}
|
||
>
|
||
Assign All
|
||
</button>
|
||
</article>
|
||
) : null}
|
||
|
||
{characterDraft.steps.abilityScores.method === "standardArray" ? (
|
||
<article className="ability-panel">
|
||
<h4>Standard Array</h4>
|
||
<p className="ability-inline-meta">Assign each slot once: 15, 14, 13, 12, 10, 8.</p>
|
||
</article>
|
||
) : null}
|
||
|
||
{characterDraft.steps.abilityScores.method === "pointBuy" ? (
|
||
<article className="ability-panel">
|
||
<h4>Point Buy</h4>
|
||
<p className="ability-inline-meta">
|
||
Budget 27 • Spent {getPointBuySpent(characterDraft.steps.abilityScores.scores)} • Remaining{" "}
|
||
{27 - getPointBuySpent(characterDraft.steps.abilityScores.scores)}
|
||
</p>
|
||
</article>
|
||
) : null}
|
||
|
||
{characterDraft.steps.abilityScores.method === "manual" ? (
|
||
<article className="ability-panel">
|
||
<h4>Manual Scores</h4>
|
||
<p className="ability-inline-meta">Set each score directly. Allowed range: 1 to 30.</p>
|
||
</article>
|
||
) : null}
|
||
|
||
{selectedBackgroundDetail ? (
|
||
<article className="ability-panel">
|
||
<div className="ability-panel-headline">
|
||
<h4>{selectedBackgroundDetail.name}</h4>
|
||
<span className="ability-panel-meta">
|
||
{selectedBackgroundDetail.sourceName} ({selectedBackgroundDetail.source})
|
||
</span>
|
||
</div>
|
||
|
||
{selectedBackgroundDetail.summary ? (
|
||
<ParsedEntryText
|
||
text={selectedBackgroundDetail.summary}
|
||
isParserReady={isRenderRuntimeReady}
|
||
className="ability-background-summary"
|
||
/>
|
||
) : null}
|
||
|
||
{backgroundAbilityModes.length > 1 ? (
|
||
<label className="character-field">
|
||
<span>Ability Increase Pattern</span>
|
||
<select
|
||
value={characterDraft.steps.abilityScores.backgroundBonusModeIndex}
|
||
onChange={(event) => {
|
||
const nextModeIndex = Math.max(
|
||
0,
|
||
Math.min(backgroundAbilityModes.length - 1, Number.parseInt(event.target.value, 10) || 0)
|
||
);
|
||
const mode = backgroundAbilityModes[nextModeIndex] ?? null;
|
||
updateDraft((draft) => ({
|
||
...draft,
|
||
steps: {
|
||
...draft.steps,
|
||
abilityScores: {
|
||
...draft.steps.abilityScores,
|
||
backgroundBonusModeIndex: nextModeIndex,
|
||
backgroundBonusSelections: normalizeBackgroundBonusSelections(
|
||
mode,
|
||
draft.steps.abilityScores.backgroundBonusSelections
|
||
)
|
||
}
|
||
}
|
||
}));
|
||
}}
|
||
>
|
||
{backgroundAbilityModes.map((mode, index) => (
|
||
<option key={mode.key} value={index}>
|
||
{mode.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
) : null}
|
||
|
||
{activeBackgroundAbilityMode ? (
|
||
<div className="ability-background-selectors">
|
||
{activeBackgroundAbilityMode.weights.map((weight, index) => (
|
||
<label key={`ability-bonus-${index}`} className="character-field">
|
||
<span>Select ability for +{weight}</span>
|
||
<select
|
||
value={normalizedBackgroundBonusSelections[index] ?? activeBackgroundAbilityMode.from[0]}
|
||
onChange={(event) => {
|
||
const selectedRaw = event.target.value.trim().toLowerCase();
|
||
if (!isAbilityKey(selectedRaw)) {
|
||
return;
|
||
}
|
||
updateDraft((draft) => {
|
||
const modeIndex = Math.max(
|
||
0,
|
||
Math.min(
|
||
backgroundAbilityModes.length - 1,
|
||
draft.steps.abilityScores.backgroundBonusModeIndex
|
||
)
|
||
);
|
||
const mode = backgroundAbilityModes[modeIndex] ?? null;
|
||
if (!mode) {
|
||
return draft;
|
||
}
|
||
|
||
const nextSelections = normalizeBackgroundBonusSelections(
|
||
mode,
|
||
draft.steps.abilityScores.backgroundBonusSelections
|
||
);
|
||
nextSelections[index] = selectedRaw;
|
||
const normalized = normalizeBackgroundBonusSelections(mode, nextSelections);
|
||
|
||
return {
|
||
...draft,
|
||
steps: {
|
||
...draft.steps,
|
||
abilityScores: {
|
||
...draft.steps.abilityScores,
|
||
backgroundBonusSelections: normalized
|
||
}
|
||
}
|
||
};
|
||
});
|
||
}}
|
||
>
|
||
{activeBackgroundAbilityMode.from.map((abilityKey) => {
|
||
const usedByOther = normalizedBackgroundBonusSelections.some(
|
||
(selectedAbility, selectedIndex) =>
|
||
selectedIndex !== index && selectedAbility === abilityKey
|
||
);
|
||
return (
|
||
<option key={`ability-choice-${index}-${abilityKey}`} value={abilityKey} disabled={usedByOther}>
|
||
{ABILITY_LABELS[abilityKey]}
|
||
</option>
|
||
);
|
||
})}
|
||
</select>
|
||
</label>
|
||
))}
|
||
</div>
|
||
) : null}
|
||
</article>
|
||
) : null}
|
||
</aside>
|
||
|
||
<div className="ability-main">
|
||
<div className="ability-assignment-grid ability-assignment-grid--ability-step">
|
||
{ABILITY_KEYS.map((key) => {
|
||
const baseScore = characterDraft.steps.abilityScores.scores[key];
|
||
const bonusScore = backgroundBonusTotals[key] ?? 0;
|
||
const totalScore = abilityTotals?.[key] ?? baseScore + bonusScore;
|
||
const method = characterDraft.steps.abilityScores.method;
|
||
|
||
return (
|
||
<article key={key} className="ability-assignment-card ability-assignment-card--detailed">
|
||
<h4>{ABILITY_LABELS[key]}</h4>
|
||
|
||
{method === "standardArray" || method === "rolled" ? (
|
||
<label className="character-field">
|
||
<span>Assign Value</span>
|
||
<select
|
||
value={characterDraft.steps.abilityScores.assignments[key]}
|
||
onChange={(event) => {
|
||
const nextIdx = Math.max(0, Math.min(5, Number.parseInt(event.target.value, 10) || 0));
|
||
updateDraft((draft) => {
|
||
const nextAssignments = {
|
||
...draft.steps.abilityScores.assignments,
|
||
[key]: nextIdx
|
||
};
|
||
|
||
const nextScores = getScoresFromAssignments(draft.steps.abilityScores.rolledValues, nextAssignments);
|
||
return {
|
||
...draft,
|
||
steps: {
|
||
...draft.steps,
|
||
abilityScores: {
|
||
...draft.steps.abilityScores,
|
||
assignments: nextAssignments,
|
||
scores: nextScores
|
||
}
|
||
}
|
||
};
|
||
});
|
||
}}
|
||
>
|
||
{characterDraft.steps.abilityScores.rolledValues.map((_value, idx) => {
|
||
const usedByOther = ABILITY_KEYS.some(
|
||
(other) => other !== key && characterDraft.steps.abilityScores.assignments[other] === idx
|
||
);
|
||
return (
|
||
<option key={`opt-${key}-${idx}`} value={idx} disabled={usedByOther}>
|
||
Slot {idx + 1}: {characterDraft.steps.abilityScores.rolledValues[idx]}
|
||
</option>
|
||
);
|
||
})}
|
||
</select>
|
||
</label>
|
||
) : null}
|
||
|
||
{method === "pointBuy" ? (
|
||
<label className="character-field">
|
||
<span>Score</span>
|
||
<input
|
||
type="number"
|
||
min={8}
|
||
max={15}
|
||
value={baseScore}
|
||
onChange={(event) => {
|
||
const next = Math.max(8, Math.min(15, Number.parseInt(event.target.value || "8", 10) || 8));
|
||
updateDraft((draft) => ({
|
||
...draft,
|
||
steps: {
|
||
...draft.steps,
|
||
abilityScores: {
|
||
...draft.steps.abilityScores,
|
||
scores: {
|
||
...draft.steps.abilityScores.scores,
|
||
[key]: next
|
||
}
|
||
}
|
||
}
|
||
}));
|
||
}}
|
||
/>
|
||
</label>
|
||
) : null}
|
||
|
||
{method === "manual" ? (
|
||
<label className="character-field">
|
||
<span>Score</span>
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
max={30}
|
||
value={baseScore}
|
||
onChange={(event) => {
|
||
const next = Math.max(1, Math.min(30, Number.parseInt(event.target.value || "1", 10) || 1));
|
||
updateDraft((draft) => ({
|
||
...draft,
|
||
steps: {
|
||
...draft.steps,
|
||
abilityScores: {
|
||
...draft.steps.abilityScores,
|
||
scores: {
|
||
...draft.steps.abilityScores.scores,
|
||
[key]: next
|
||
}
|
||
}
|
||
}
|
||
}));
|
||
}}
|
||
/>
|
||
</label>
|
||
) : null}
|
||
|
||
<p className="ability-inline-meta">
|
||
Base {baseScore} • Bonus {bonusScore >= 0 ? `+${bonusScore}` : `${bonusScore}`} • Total {totalScore} • Modifier{" "}
|
||
{abilityModifier(totalScore)}
|
||
{method === "pointBuy" ? ` • Cost ${getPointBuyCost(baseScore)}` : ""}
|
||
</p>
|
||
</article>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
) : null}
|
||
|
||
{characterStep === 4 ? (
|
||
<section className="character-section">
|
||
<h3>Step 5: Feats</h3>
|
||
<div className="chooser-header">
|
||
<p>
|
||
Available picks: <strong>{classFeatSlotCount}</strong> • Chosen: <strong>{variableFeatSelections.length}</strong>
|
||
</p>
|
||
</div>
|
||
|
||
{fixedFeatTokens.length ? (
|
||
<article className="chooser-panel">
|
||
<h4>Granted Feats</h4>
|
||
<div className="chip-row">
|
||
{fixedFeatTokens.map((token) => {
|
||
const parsed = parseNameSourceToken(token);
|
||
if (!parsed) {
|
||
return null;
|
||
}
|
||
return (
|
||
<span key={`fixed-feat-${token}`} className="chip chip--locked">
|
||
{parsed.name} ({parsed.source})
|
||
</span>
|
||
);
|
||
})}
|
||
</div>
|
||
</article>
|
||
) : null}
|
||
|
||
<label className="character-field">
|
||
<span>Search feats</span>
|
||
<input type="search" value={featSearch} onChange={(event) => setFeatSearch(event.target.value)} />
|
||
</label>
|
||
|
||
<div className="chooser-layout">
|
||
<div className="chooser-panel chooser-panel--scroll">
|
||
<div className="feat-list">
|
||
{filteredFeatCatalog.map((feat) => {
|
||
const token = toNameSourceToken(feat.name, feat.source);
|
||
const key = sanitizeFeatKey(feat.name, feat.source);
|
||
const isSelected = normalizedFeatSelections.includes(token);
|
||
const isFixed = fixedFeatKeySet.has(key);
|
||
const isAtCap = !isSelected && !isFixed && variableFeatSelections.length >= classFeatSlotCount;
|
||
return (
|
||
<button
|
||
type="button"
|
||
key={feat.key}
|
||
className={isSelected ? "feat-card is-selected" : "feat-card"}
|
||
onClick={() => {
|
||
if (isFixed) {
|
||
return;
|
||
}
|
||
updateDraft((draft) => {
|
||
const current = uniqueStrings(
|
||
draft.steps.feats.selected
|
||
.map((it) => parseNameSourceToken(it) || parseFeatUid(it))
|
||
.filter((it): it is { name: string; source: string } => Boolean(it))
|
||
.map((it) => toNameSourceToken(it.name, it.source))
|
||
);
|
||
|
||
const has = current.includes(token);
|
||
let next = current;
|
||
if (has) {
|
||
next = current.filter((it) => it !== token);
|
||
} else {
|
||
const fixedKeys = new Set(
|
||
fixedFeatTokens
|
||
.map((it) => parseNameSourceToken(it))
|
||
.filter((it): it is { name: string; source: string } => Boolean(it))
|
||
.map((it) => sanitizeFeatKey(it.name, it.source))
|
||
);
|
||
const variableCount = current.filter((it) => {
|
||
const parsed = parseNameSourceToken(it);
|
||
return parsed ? !fixedKeys.has(sanitizeFeatKey(parsed.name, parsed.source)) : false;
|
||
}).length;
|
||
if (variableCount >= classFeatSlotCount) {
|
||
return draft;
|
||
}
|
||
next = [...current, token];
|
||
}
|
||
|
||
return {
|
||
...draft,
|
||
steps: {
|
||
...draft.steps,
|
||
feats: {
|
||
...draft.steps.feats,
|
||
selected: next
|
||
}
|
||
}
|
||
};
|
||
});
|
||
}}
|
||
disabled={isAtCap}
|
||
>
|
||
<span className="feat-card-title">{feat.name}</span>
|
||
<span className="feat-card-meta">
|
||
{feat.source} {feat.category ? `• ${feat.category}` : ""}
|
||
</span>
|
||
{feat.summary ? <span className="feat-card-summary">{feat.summary}</span> : null}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
{filteredFeatCatalog.length === 0 ? <p className="character-empty">No feats match current sources and search.</p> : null}
|
||
</div>
|
||
|
||
<aside className="chooser-panel chooser-panel--scroll">
|
||
<h4>Selected Feats</h4>
|
||
<div className="feat-list">
|
||
{selectedFeatItems.map((feat) => {
|
||
const isFixed = fixedFeatKeySet.has(sanitizeFeatKey(feat.name, feat.source));
|
||
return (
|
||
<article key={`selected-${feat.key}`} className="feat-card is-selected">
|
||
<span className="feat-card-title">{feat.name}</span>
|
||
<span className="feat-card-meta">
|
||
{feat.source || "Unknown source"}
|
||
{isFixed ? " • granted" : ""}
|
||
</span>
|
||
{feat.summary ? <span className="feat-card-summary">{feat.summary}</span> : null}
|
||
</article>
|
||
);
|
||
})}
|
||
</div>
|
||
{!selectedFeatItems.length ? <p className="character-empty">No feats selected yet.</p> : null}
|
||
|
||
<label className="character-field">
|
||
<span>Feat Notes</span>
|
||
<textarea
|
||
rows={4}
|
||
value={characterDraft.steps.feats.notes}
|
||
onChange={(event) =>
|
||
updateDraft((draft) => ({
|
||
...draft,
|
||
steps: {
|
||
...draft.steps,
|
||
feats: {
|
||
...draft.steps.feats,
|
||
notes: event.target.value
|
||
}
|
||
}
|
||
}))
|
||
}
|
||
/>
|
||
</label>
|
||
</aside>
|
||
</div>
|
||
</section>
|
||
) : null}
|
||
|
||
{characterStep === 5 ? (
|
||
<section className="character-section">
|
||
<h3>Step 6: Skills</h3>
|
||
<div className="chooser-layout">
|
||
<div className="chooser-panel chooser-panel--scroll">
|
||
<article className="feature-choice-card">
|
||
<h4>Granted Proficiencies</h4>
|
||
{grantedSkills.length ? (
|
||
<div className="chip-row">
|
||
{grantedSkills.map((skill) => (
|
||
<span key={`granted-${skill}`} className="chip chip--locked">
|
||
{skill}
|
||
</span>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<p className="character-empty">No fixed skill proficiencies from current class/species/background.</p>
|
||
)}
|
||
</article>
|
||
|
||
{skillChoiceRequirements.map((choice) => {
|
||
const picks = normalizedSkillChoicePicks[choice.id] || [];
|
||
return (
|
||
<article key={choice.id} className="feature-choice-card">
|
||
<h4>
|
||
{choice.title} <span className="feature-choice-count">{picks.length}/{choice.count}</span>
|
||
</h4>
|
||
<div className="feature-choice-grid">
|
||
{Array.from({ length: choice.count }).map((_, ix) => (
|
||
<label key={`${choice.id}-pick-${ix}`} className="character-field">
|
||
<span>{choice.sourceLabel} pick {ix + 1}</span>
|
||
<select
|
||
value={picks[ix] || ""}
|
||
onChange={(event) => {
|
||
const picked = toSkillName(event.target.value);
|
||
updateDraft((draft) => {
|
||
const parsedNotes = parseSkillsNotes(draft.steps.skills.notes);
|
||
const nextByChoice = { ...parsedNotes.picksByChoiceId };
|
||
const nextPicks = [...(nextByChoice[choice.id] || [])];
|
||
nextPicks[ix] = picked || "";
|
||
nextByChoice[choice.id] = uniqueStrings(nextPicks.map((it) => toSkillName(it) || "").filter((it) => Boolean(it)));
|
||
return {
|
||
...draft,
|
||
steps: {
|
||
...draft.steps,
|
||
skills: {
|
||
...draft.steps.skills,
|
||
notes: stringifySkillsNotes({
|
||
text: parsedNotes.text,
|
||
picksByChoiceId: nextByChoice,
|
||
extraSkills: parsedNotes.extraSkills
|
||
})
|
||
}
|
||
}
|
||
};
|
||
});
|
||
}}
|
||
>
|
||
<option value="">Select a skill</option>
|
||
{choice.options.map((option) => {
|
||
const usedElsewhere = picks.some((it, pIx) => pIx !== ix && it === option);
|
||
return (
|
||
<option key={`${choice.id}-${option}`} value={option} disabled={usedElsewhere}>
|
||
{option}
|
||
</option>
|
||
);
|
||
})}
|
||
</select>
|
||
</label>
|
||
))}
|
||
</div>
|
||
</article>
|
||
);
|
||
})}
|
||
|
||
<article className="feature-choice-card">
|
||
<h4>Additional Skill Proficiencies</h4>
|
||
<p className="feature-choice-hint">
|
||
Add selections from class/background/species features which are not automatically parsed yet.
|
||
</p>
|
||
<div className="chip-toggle-grid">
|
||
{SKILL_OPTIONS.map((skill) => {
|
||
const selected = manualSkillSelections.includes(skill);
|
||
return (
|
||
<button
|
||
key={`manual-skill-${skill}`}
|
||
type="button"
|
||
className={selected ? "chip-toggle is-active" : "chip-toggle"}
|
||
onClick={() =>
|
||
updateDraft((draft) => {
|
||
const parsedNotes = parseSkillsNotes(draft.steps.skills.notes);
|
||
const next = new Set(
|
||
parsedNotes.extraSkills
|
||
.map((it) => toSkillName(it))
|
||
.filter((it): it is string => Boolean(it))
|
||
);
|
||
|
||
if (next.has(skill)) {
|
||
next.delete(skill);
|
||
} else {
|
||
next.add(skill);
|
||
}
|
||
|
||
return {
|
||
...draft,
|
||
steps: {
|
||
...draft.steps,
|
||
skills: {
|
||
...draft.steps.skills,
|
||
notes: stringifySkillsNotes({
|
||
text: parsedNotes.text,
|
||
picksByChoiceId: parsedNotes.picksByChoiceId,
|
||
extraSkills: Array.from(next).sort((a, b) => a.localeCompare(b))
|
||
})
|
||
}
|
||
}
|
||
};
|
||
})
|
||
}
|
||
>
|
||
{skill}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</article>
|
||
|
||
<label className="character-field">
|
||
<span>Skill Notes</span>
|
||
<textarea
|
||
rows={4}
|
||
value={skillNotesState.text}
|
||
onChange={(event) =>
|
||
updateDraft((draft) => {
|
||
const parsedNotes = parseSkillsNotes(draft.steps.skills.notes);
|
||
return {
|
||
...draft,
|
||
steps: {
|
||
...draft.steps,
|
||
skills: {
|
||
...draft.steps.skills,
|
||
notes: stringifySkillsNotes({
|
||
text: event.target.value,
|
||
picksByChoiceId: parsedNotes.picksByChoiceId,
|
||
extraSkills: parsedNotes.extraSkills
|
||
})
|
||
}
|
||
}
|
||
};
|
||
})
|
||
}
|
||
/>
|
||
</label>
|
||
</div>
|
||
|
||
<aside className="chooser-panel chooser-panel--scroll">
|
||
<h4>
|
||
Skills Matrix • Proficiency Bonus +{getProficiencyBonus(characterDraft.profile.level)}
|
||
</h4>
|
||
<div className="skill-matrix">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Skill</th>
|
||
<th>Ability</th>
|
||
<th>Mod</th>
|
||
<th>Prof</th>
|
||
<th>Total</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{SKILL_OPTIONS.map((skill) => {
|
||
const ability = SKILL_TO_ABILITY[skill];
|
||
const score = abilityTotals?.[ability] ?? characterDraft.steps.abilityScores.scores[ability];
|
||
const mod = getAbilityModifierNumber(score);
|
||
const isProficient = allProficientSkills.includes(skill);
|
||
const profBonus = getProficiencyBonus(characterDraft.profile.level);
|
||
const total = mod + (isProficient ? profBonus : 0);
|
||
return (
|
||
<tr key={`skill-row-${skill}`}>
|
||
<td>{skill}</td>
|
||
<td>{ABILITY_LABELS[ability]}</td>
|
||
<td>{mod >= 0 ? `+${mod}` : mod}</td>
|
||
<td>{isProficient ? `+${profBonus}` : "—"}</td>
|
||
<td>{total >= 0 ? `+${total}` : total}</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
</section>
|
||
) : null}
|
||
|
||
{characterStep === 6 ? (
|
||
<section className="character-section">
|
||
<h3>Step 7: Spells</h3>
|
||
{!characterDraft.selections.class ? (
|
||
<p className="character-empty">Choose a class first to load its spell list.</p>
|
||
) : (
|
||
<div className="chooser-layout">
|
||
<div className="chooser-panel chooser-panel--scroll">
|
||
<h4>Spell Selection</h4>
|
||
<label className="character-field">
|
||
<span>Search class spells</span>
|
||
<input type="search" value={spellSearch} onChange={(event) => setSpellSearch(event.target.value)} />
|
||
</label>
|
||
|
||
<div className="spell-cap-row">
|
||
<span>Cantrips: {selectedCantripCount}/{classSpellcasting.cantrips || 0}</span>
|
||
<span>Prepared/Known: {selectedPreparedCount}/{classSpellcasting.known || classSpellcasting.prepared || 0}</span>
|
||
</div>
|
||
|
||
<div className="spell-card-list">
|
||
<div className="spell-level-group">
|
||
<h5>Cantrips</h5>
|
||
{selectedCantripPool.map((spell) => {
|
||
const token = toNameSourceToken(spell.name, spell.source);
|
||
const isSelected = selectedSpellTokens.includes(token);
|
||
const canAdd =
|
||
isSelected ||
|
||
((classSpellcasting.cantrips || 0) > 0 && selectedCantripCount < (classSpellcasting.cantrips || 0));
|
||
|
||
return (
|
||
<button
|
||
type="button"
|
||
key={spell.key}
|
||
className={isSelected ? "spell-pick-card is-selected" : "spell-pick-card"}
|
||
onClick={() =>
|
||
updateDraft((draft) => {
|
||
const current = uniqueStrings(
|
||
draft.steps.spells.selected
|
||
.map((it) => parseNameSourceToken(it) || parseFeatUid(it))
|
||
.filter((it): it is { name: string; source: string } => Boolean(it))
|
||
.map((it) => toNameSourceToken(it.name, it.source))
|
||
);
|
||
const has = current.includes(token);
|
||
const next = has ? current.filter((it) => it !== token) : canAdd ? [...current, token] : current;
|
||
return {
|
||
...draft,
|
||
steps: {
|
||
...draft.steps,
|
||
spells: {
|
||
...draft.steps.spells,
|
||
selected: next
|
||
}
|
||
}
|
||
};
|
||
})
|
||
}
|
||
disabled={!canAdd}
|
||
>
|
||
<span className="spell-pick-title">{spell.name}</span>
|
||
<span className="spell-pick-meta">Cantrip • {spell.source}</span>
|
||
</button>
|
||
);
|
||
})}
|
||
{!selectedCantripPool.length ? <p className="character-empty">No cantrips matched your filters.</p> : null}
|
||
</div>
|
||
|
||
{selectedLeveledSpellPoolByLevel.map((group) => (
|
||
<div key={`spell-level-group-${group.level}`} className="spell-level-group">
|
||
<h5>Level {group.level}</h5>
|
||
{group.spells.map((spell) => {
|
||
const token = toNameSourceToken(spell.name, spell.source);
|
||
const isSelected = selectedSpellTokens.includes(token);
|
||
const preparedCap = classSpellcasting.known || classSpellcasting.prepared || 0;
|
||
const canAdd = isSelected || (preparedCap > 0 && selectedPreparedCount < preparedCap);
|
||
return (
|
||
<button
|
||
type="button"
|
||
key={spell.key}
|
||
className={isSelected ? "spell-pick-card is-selected" : "spell-pick-card"}
|
||
onClick={() =>
|
||
updateDraft((draft) => {
|
||
const current = uniqueStrings(
|
||
draft.steps.spells.selected
|
||
.map((it) => parseNameSourceToken(it) || parseFeatUid(it))
|
||
.filter((it): it is { name: string; source: string } => Boolean(it))
|
||
.map((it) => toNameSourceToken(it.name, it.source))
|
||
);
|
||
const has = current.includes(token);
|
||
const next = has ? current.filter((it) => it !== token) : canAdd ? [...current, token] : current;
|
||
return {
|
||
...draft,
|
||
steps: {
|
||
...draft.steps,
|
||
spells: {
|
||
...draft.steps.spells,
|
||
selected: next
|
||
}
|
||
}
|
||
};
|
||
})
|
||
}
|
||
disabled={!canAdd}
|
||
>
|
||
<span className="spell-pick-title">{spell.name}</span>
|
||
<span className="spell-pick-meta">Level {group.level} • {spell.source}</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
))}
|
||
</div>
|
||
{selectedCantripPool.length === 0 && selectedLeveledSpellPoolByLevel.length === 0 ? (
|
||
<p className="character-empty">No class spells matched your filters.</p>
|
||
) : null}
|
||
</div>
|
||
|
||
<aside className="chooser-panel chooser-panel--scroll">
|
||
<h4>Selected Spells</h4>
|
||
{spellSlotMatrix.length ? (
|
||
<>
|
||
<div className="slot-matrix">
|
||
{spellSlotMatrix.map((slot) => (
|
||
<span key={`slot-level-${slot.level}`} className="slot-chip">
|
||
L{slot.level}
|
||
<strong>{slot.slots}</strong>
|
||
</span>
|
||
))}
|
||
</div>
|
||
<div className="slot-detail-list">
|
||
{classSpellcasting.slots.map((slot, index) => (
|
||
<span key={`slot-detail-${slot.label}-${index}`}>{slot.label}</span>
|
||
))}
|
||
</div>
|
||
</>
|
||
) : (
|
||
<p className="character-empty">No spell slots at this level.</p>
|
||
)}
|
||
|
||
<div className="spell-selected-list">
|
||
{selectedSpellItems.map((spell) => (
|
||
<article key={`sel-${spell.key}`} className="spell-selected-card">
|
||
<strong>{spell.name}</strong>
|
||
<span>
|
||
{spell.level === 0 ? "Cantrip" : `Level ${spell.level}`} • {spell.source}
|
||
</span>
|
||
</article>
|
||
))}
|
||
</div>
|
||
{!selectedSpellItems.length ? <p className="character-empty">No spells selected.</p> : null}
|
||
|
||
<label className="character-field">
|
||
<span>Spell Notes</span>
|
||
<textarea
|
||
rows={4}
|
||
value={characterDraft.steps.spells.notes}
|
||
onChange={(event) =>
|
||
updateDraft((draft) => ({
|
||
...draft,
|
||
steps: {
|
||
...draft.steps,
|
||
spells: {
|
||
...draft.steps.spells,
|
||
notes: event.target.value
|
||
}
|
||
}
|
||
}))
|
||
}
|
||
/>
|
||
</label>
|
||
</aside>
|
||
</div>
|
||
)}
|
||
</section>
|
||
) : null}
|
||
|
||
{characterStep === 7 ? (
|
||
<section className="character-section">
|
||
<h3>Step 8: Equipment</h3>
|
||
<div className="chooser-layout">
|
||
<div className="chooser-panel chooser-panel--scroll">
|
||
<h4>Starting Equipment Choices</h4>
|
||
{equipmentChoiceGroups.map((group) => (
|
||
<article key={group.id} className="feature-choice-card">
|
||
<h4>{group.label}</h4>
|
||
<label className="character-field">
|
||
<span>Choose one</span>
|
||
<select
|
||
value={normalizedEquipmentSelections[group.id] || ""}
|
||
onChange={(event) =>
|
||
updateDraft((draft) => {
|
||
const currentState = parseEquipmentSelectionState(
|
||
draft.steps.equipment.startingEquipmentText,
|
||
draft.steps.equipment.inventoryText
|
||
);
|
||
currentState.selections[group.id] = event.target.value;
|
||
return {
|
||
...draft,
|
||
steps: {
|
||
...draft.steps,
|
||
equipment: {
|
||
...draft.steps.equipment,
|
||
startingEquipmentText: stringifyEquipmentSelectionState(currentState)
|
||
}
|
||
}
|
||
};
|
||
})
|
||
}
|
||
>
|
||
{group.options.map((option) => (
|
||
<option key={option.id} value={option.id}>
|
||
{option.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
</article>
|
||
))}
|
||
{equipmentChoiceGroups.length === 0 ? <p className="character-empty">No starting equipment choices for current selections.</p> : null}
|
||
|
||
<article className="feature-choice-card">
|
||
<h4>Add Inventory Item</h4>
|
||
<div className="add-item-row">
|
||
<input
|
||
type="text"
|
||
list="equipment-item-catalog"
|
||
value={equipmentItemInput}
|
||
onChange={(event) => setEquipmentItemInput(event.target.value)}
|
||
placeholder="Add custom or catalog item"
|
||
/>
|
||
<datalist id="equipment-item-catalog">
|
||
{itemCatalog.slice(0, 500).map((item) => (
|
||
<option key={`item-opt-${item}`} value={item} />
|
||
))}
|
||
</datalist>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const trimmed = equipmentItemInput.trim();
|
||
if (!trimmed) {
|
||
return;
|
||
}
|
||
updateDraft((draft) => {
|
||
const currentState = parseEquipmentSelectionState(
|
||
draft.steps.equipment.startingEquipmentText,
|
||
draft.steps.equipment.inventoryText
|
||
);
|
||
if (!currentState.addedItems.includes(trimmed)) {
|
||
currentState.addedItems = [...currentState.addedItems, trimmed];
|
||
}
|
||
return {
|
||
...draft,
|
||
steps: {
|
||
...draft.steps,
|
||
equipment: {
|
||
...draft.steps.equipment,
|
||
startingEquipmentText: stringifyEquipmentSelectionState(currentState),
|
||
inventoryText: currentState.addedItems.join("\n")
|
||
}
|
||
}
|
||
};
|
||
});
|
||
setEquipmentItemInput("");
|
||
}}
|
||
>
|
||
Add
|
||
</button>
|
||
</div>
|
||
<div className="chip-row">
|
||
{equipmentSelectionState.addedItems.map((item) => (
|
||
<button
|
||
key={`added-item-${item}`}
|
||
type="button"
|
||
className="chip chip--removable"
|
||
onClick={() =>
|
||
updateDraft((draft) => {
|
||
const currentState = parseEquipmentSelectionState(
|
||
draft.steps.equipment.startingEquipmentText,
|
||
draft.steps.equipment.inventoryText
|
||
);
|
||
currentState.addedItems = currentState.addedItems.filter((it) => it !== item);
|
||
return {
|
||
...draft,
|
||
steps: {
|
||
...draft.steps,
|
||
equipment: {
|
||
...draft.steps.equipment,
|
||
startingEquipmentText: stringifyEquipmentSelectionState(currentState),
|
||
inventoryText: currentState.addedItems.join("\n")
|
||
}
|
||
}
|
||
};
|
||
})
|
||
}
|
||
>
|
||
{item} ×
|
||
</button>
|
||
))}
|
||
</div>
|
||
</article>
|
||
|
||
<label className="character-field">
|
||
<span>Personal Possessions</span>
|
||
<textarea
|
||
rows={3}
|
||
value={characterDraft.steps.equipment.possessionsText}
|
||
onChange={(event) =>
|
||
updateDraft((draft) => ({
|
||
...draft,
|
||
steps: {
|
||
...draft.steps,
|
||
equipment: {
|
||
...draft.steps.equipment,
|
||
possessionsText: event.target.value
|
||
}
|
||
}
|
||
}))
|
||
}
|
||
/>
|
||
</label>
|
||
</div>
|
||
|
||
<aside className="chooser-panel chooser-panel--scroll">
|
||
<h4>Starting Inventory</h4>
|
||
<div className="inventory-list">
|
||
{resolvedEquipmentRows.map((row) => (
|
||
<div key={`inventory-item-${row.label}`} className="inventory-item">
|
||
<span>{row.label}</span>
|
||
<strong>{row.count}</strong>
|
||
</div>
|
||
))}
|
||
</div>
|
||
{!resolvedEquipmentRows.length ? <p className="character-empty">No equipment selected yet.</p> : null}
|
||
|
||
<div className="currency-grid">
|
||
{(["cp", "sp", "ep", "gp", "pp"] as const).map((coin) => (
|
||
<label key={coin} className="character-field">
|
||
<span>{coin.toUpperCase()}</span>
|
||
<input type="number" min={0} value={characterDraft.steps.equipment.currency[coin]} readOnly />
|
||
</label>
|
||
))}
|
||
</div>
|
||
</aside>
|
||
</div>
|
||
</section>
|
||
) : null}
|
||
|
||
{characterStep === 8 ? (
|
||
<section className="character-section">
|
||
<h3>Step 9: About</h3>
|
||
<div className="character-grid">
|
||
<label className="character-field">
|
||
<span>Alignment</span>
|
||
<input
|
||
type="text"
|
||
value={characterDraft.profile.alignment}
|
||
onChange={(event) =>
|
||
updateDraft((draft) => ({
|
||
...draft,
|
||
profile: { ...draft.profile, alignment: event.target.value }
|
||
}))
|
||
}
|
||
/>
|
||
</label>
|
||
<label className="character-field">
|
||
<span>Faith</span>
|
||
<input
|
||
type="text"
|
||
value={characterDraft.profile.faith}
|
||
onChange={(event) =>
|
||
updateDraft((draft) => ({
|
||
...draft,
|
||
profile: { ...draft.profile, faith: event.target.value }
|
||
}))
|
||
}
|
||
/>
|
||
</label>
|
||
<label className="character-field">
|
||
<span>Lifestyle</span>
|
||
<input
|
||
type="text"
|
||
value={characterDraft.profile.lifestyle}
|
||
onChange={(event) =>
|
||
updateDraft((draft) => ({
|
||
...draft,
|
||
profile: { ...draft.profile, lifestyle: event.target.value }
|
||
}))
|
||
}
|
||
/>
|
||
</label>
|
||
<label className="character-field">
|
||
<span>Age</span>
|
||
<input
|
||
type="text"
|
||
value={characterDraft.profile.age}
|
||
onChange={(event) =>
|
||
updateDraft((draft) => ({
|
||
...draft,
|
||
profile: { ...draft.profile, age: event.target.value }
|
||
}))
|
||
}
|
||
/>
|
||
</label>
|
||
<label className="character-field">
|
||
<span>Gender</span>
|
||
<input
|
||
type="text"
|
||
value={characterDraft.profile.gender}
|
||
onChange={(event) =>
|
||
updateDraft((draft) => ({
|
||
...draft,
|
||
profile: { ...draft.profile, gender: event.target.value }
|
||
}))
|
||
}
|
||
/>
|
||
</label>
|
||
<label className="character-field">
|
||
<span>Height</span>
|
||
<input
|
||
type="text"
|
||
value={characterDraft.profile.height}
|
||
onChange={(event) =>
|
||
updateDraft((draft) => ({
|
||
...draft,
|
||
profile: { ...draft.profile, height: event.target.value }
|
||
}))
|
||
}
|
||
/>
|
||
</label>
|
||
<label className="character-field">
|
||
<span>Weight</span>
|
||
<input
|
||
type="text"
|
||
value={characterDraft.profile.weight}
|
||
onChange={(event) =>
|
||
updateDraft((draft) => ({
|
||
...draft,
|
||
profile: { ...draft.profile, weight: event.target.value }
|
||
}))
|
||
}
|
||
/>
|
||
</label>
|
||
<label className="character-field">
|
||
<span>Eyes</span>
|
||
<input
|
||
type="text"
|
||
value={characterDraft.profile.eyes}
|
||
onChange={(event) =>
|
||
updateDraft((draft) => ({
|
||
...draft,
|
||
profile: { ...draft.profile, eyes: event.target.value }
|
||
}))
|
||
}
|
||
/>
|
||
</label>
|
||
<label className="character-field">
|
||
<span>Skin</span>
|
||
<input
|
||
type="text"
|
||
value={characterDraft.profile.skin}
|
||
onChange={(event) =>
|
||
updateDraft((draft) => ({
|
||
...draft,
|
||
profile: { ...draft.profile, skin: event.target.value }
|
||
}))
|
||
}
|
||
/>
|
||
</label>
|
||
<label className="character-field">
|
||
<span>Hair</span>
|
||
<input
|
||
type="text"
|
||
value={characterDraft.profile.hair}
|
||
onChange={(event) =>
|
||
updateDraft((draft) => ({
|
||
...draft,
|
||
profile: { ...draft.profile, hair: event.target.value }
|
||
}))
|
||
}
|
||
/>
|
||
</label>
|
||
</div>
|
||
|
||
<label className="character-field">
|
||
<span>Personality Traits</span>
|
||
<textarea
|
||
rows={3}
|
||
value={characterDraft.steps.about.personality}
|
||
onChange={(event) =>
|
||
updateDraft((draft) => ({
|
||
...draft,
|
||
steps: {
|
||
...draft.steps,
|
||
about: {
|
||
...draft.steps.about,
|
||
personality: event.target.value
|
||
}
|
||
}
|
||
}))
|
||
}
|
||
/>
|
||
</label>
|
||
|
||
<label className="character-field">
|
||
<span>Ideals</span>
|
||
<textarea
|
||
rows={2}
|
||
value={characterDraft.steps.about.ideals}
|
||
onChange={(event) =>
|
||
updateDraft((draft) => ({
|
||
...draft,
|
||
steps: {
|
||
...draft.steps,
|
||
about: {
|
||
...draft.steps.about,
|
||
ideals: event.target.value
|
||
}
|
||
}
|
||
}))
|
||
}
|
||
/>
|
||
</label>
|
||
|
||
<label className="character-field">
|
||
<span>Bonds</span>
|
||
<textarea
|
||
rows={2}
|
||
value={characterDraft.steps.about.bonds}
|
||
onChange={(event) =>
|
||
updateDraft((draft) => ({
|
||
...draft,
|
||
steps: {
|
||
...draft.steps,
|
||
about: {
|
||
...draft.steps.about,
|
||
bonds: event.target.value
|
||
}
|
||
}
|
||
}))
|
||
}
|
||
/>
|
||
</label>
|
||
|
||
<label className="character-field">
|
||
<span>Flaws</span>
|
||
<textarea
|
||
rows={2}
|
||
value={characterDraft.steps.about.flaws}
|
||
onChange={(event) =>
|
||
updateDraft((draft) => ({
|
||
...draft,
|
||
steps: {
|
||
...draft.steps,
|
||
about: {
|
||
...draft.steps.about,
|
||
flaws: event.target.value
|
||
}
|
||
}
|
||
}))
|
||
}
|
||
/>
|
||
</label>
|
||
|
||
<label className="character-field">
|
||
<span>Backstory</span>
|
||
<textarea
|
||
rows={4}
|
||
value={characterDraft.steps.about.backstory}
|
||
onChange={(event) =>
|
||
updateDraft((draft) => ({
|
||
...draft,
|
||
steps: {
|
||
...draft.steps,
|
||
about: {
|
||
...draft.steps.about,
|
||
backstory: event.target.value
|
||
}
|
||
}
|
||
}))
|
||
}
|
||
/>
|
||
</label>
|
||
|
||
<label className="character-field">
|
||
<span>About Notes</span>
|
||
<textarea
|
||
rows={3}
|
||
value={characterDraft.steps.about.notes}
|
||
onChange={(event) =>
|
||
updateDraft((draft) => ({
|
||
...draft,
|
||
steps: {
|
||
...draft.steps,
|
||
about: {
|
||
...draft.steps.about,
|
||
notes: event.target.value
|
||
}
|
||
}
|
||
}))
|
||
}
|
||
/>
|
||
</label>
|
||
</section>
|
||
) : null}
|
||
|
||
{characterStep === 9 ? (
|
||
<section className="character-section">
|
||
<h3>Finish</h3>
|
||
<div className="character-summary">
|
||
<p>
|
||
<strong>Name:</strong> {characterDraft.profile.name}
|
||
</p>
|
||
<p>
|
||
<strong>Level:</strong> {characterDraft.profile.level}
|
||
</p>
|
||
<p>
|
||
<strong>Class:</strong> {characterDraft.selections.class?.name || "Not set"}
|
||
</p>
|
||
<p>
|
||
<strong>Subclass:</strong> {characterDraft.selections.subclass?.name || "Not set"}
|
||
</p>
|
||
<p>
|
||
<strong>Species:</strong> {characterDraft.selections.species?.name || "Not set"}
|
||
</p>
|
||
<p>
|
||
<strong>Background:</strong> {characterDraft.selections.background?.name || "Not set"}
|
||
</p>
|
||
<p>
|
||
<strong>Source Buckets:</strong> {characterDraft.steps.preferences.sourceBuckets.join(", ") || "None"}
|
||
</p>
|
||
</div>
|
||
|
||
<label className="character-field">
|
||
<span>General Notes</span>
|
||
<textarea
|
||
rows={5}
|
||
value={characterDraft.notes}
|
||
onChange={(event) =>
|
||
updateDraft((draft) => ({
|
||
...draft,
|
||
notes: event.target.value
|
||
}))
|
||
}
|
||
/>
|
||
</label>
|
||
|
||
<div className="character-finish-actions">
|
||
<button type="button" onClick={() => void saveCharacterDraft()} disabled={isCharacterBusy}>
|
||
Save Character
|
||
</button>
|
||
<button type="button" className="is-subtle" onClick={() => void openCharacterList()} disabled={isCharacterBusy}>
|
||
Back to My Characters
|
||
</button>
|
||
</div>
|
||
</section>
|
||
) : null}
|
||
|
||
<footer className="character-flow-footer">
|
||
<button type="button" className="character-flow-nav" onClick={prevStep} disabled={characterStep === 0 || isCharacterBusy}>
|
||
Previous
|
||
</button>
|
||
|
||
<div className="character-flow-rail" role="tablist" aria-label="Character creator steps">
|
||
{progressionSteps.map((stepLabel, index) => {
|
||
const isActive = characterStep === index;
|
||
const isComplete = stepCompletion[index];
|
||
const className = [
|
||
"character-flow-step",
|
||
isActive ? "is-active" : "",
|
||
isComplete ? "is-complete" : "is-pending"
|
||
]
|
||
.filter(Boolean)
|
||
.join(" ");
|
||
|
||
return (
|
||
<button
|
||
key={stepLabel}
|
||
type="button"
|
||
className={className}
|
||
onClick={() => setCharacterStep(index)}
|
||
disabled={!characterDraft}
|
||
role="tab"
|
||
aria-selected={isActive}
|
||
title={stepLabel}
|
||
>
|
||
<span className="character-flow-step-index">{isComplete ? "✓" : index + 1}</span>
|
||
{isActive ? <span className="character-flow-step-label">{stepLabel}</span> : null}
|
||
</button>
|
||
);
|
||
})}
|
||
|
||
<button
|
||
type="button"
|
||
className={characterStep === CHARACTER_STEPS.length - 1 ? "character-flow-step is-active is-review" : "character-flow-step is-review"}
|
||
onClick={() => setCharacterStep(CHARACTER_STEPS.length - 1)}
|
||
disabled={!characterDraft}
|
||
role="tab"
|
||
aria-selected={characterStep === CHARACTER_STEPS.length - 1}
|
||
title="Review Character"
|
||
>
|
||
<span className="character-flow-step-label">Review</span>
|
||
</button>
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
className="character-flow-nav"
|
||
onClick={nextStep}
|
||
disabled={characterStep >= CHARACTER_STEPS.length - 1 || !canMoveNext || isCharacterBusy}
|
||
>
|
||
{characterStep >= CHARACTER_STEPS.length - 2 ? "Review" : "Next"}
|
||
</button>
|
||
</footer>
|
||
|
||
{entityPopover ? (
|
||
<div
|
||
className="entity-popover-backdrop"
|
||
role="presentation"
|
||
onClick={() => setEntityPopover(null)}
|
||
>
|
||
<section
|
||
className="entity-popover"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-label={`${entityPopover.kind} details`}
|
||
onClick={(event) => event.stopPropagation()}
|
||
>
|
||
<header className="entity-popover-header">
|
||
<h4>
|
||
{entityPopover.kind === "class"
|
||
? "Class details"
|
||
: entityPopover.kind === "species"
|
||
? "Species details"
|
||
: "Background details"}
|
||
</h4>
|
||
<button type="button" className="is-subtle" onClick={() => setEntityPopover(null)}>
|
||
Close
|
||
</button>
|
||
</header>
|
||
{renderEntityDetailContent(entityPopoverDetail, entityPopover.kind)}
|
||
</section>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
) : (
|
||
<p className="character-empty">No character loaded.</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
)}
|
||
</section>
|
||
</main>
|
||
);
|
||
}
|
||
|
||
export default function App() {
|
||
const isSetupMode = useMemo(() => {
|
||
const params = new URLSearchParams(window.location.search);
|
||
return params.get("setup") === "1";
|
||
}, []);
|
||
|
||
if (isSetupMode) {
|
||
return <SetupView />;
|
||
}
|
||
|
||
return <WikiView />;
|
||
}
|