Add Pathfinder 2e game system mode
Implements PF2e as an alternative game system alongside D&D 5e/5.5e. Settings modal "Game System" selector switches conditions, bestiary, stat block layout, and initiative calculation between systems. - Valued conditions with increment/decrement UX (Clumsy 2, Frightened 3) - 2,502 PF2e creatures from bundled search index (77 sources) - PF2e stat block: level, traits, Perception, Fort/Ref/Will, ability mods - Perception-based initiative rolling - System-scoped source cache (D&D and PF2e sources don't collide) - Backwards-compatible condition rehydration (ConditionId[] → ConditionEntry[]) - Difficulty indicator hidden in PF2e mode (excluded from MVP) Closes dostulata/initiative#19 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
||||
import { AdapterProvider } from "../../contexts/adapter-context.js";
|
||||
import { RulesEditionProvider } from "../../contexts/rules-edition-context.js";
|
||||
import { BulkImportPrompt } from "../bulk-import-prompt.js";
|
||||
|
||||
const THREE_SOURCES_REGEX = /3 sources/;
|
||||
@@ -68,9 +69,11 @@ function createAdaptersWithSources() {
|
||||
function renderWithAdapters() {
|
||||
const adapters = createAdaptersWithSources();
|
||||
return render(
|
||||
<AdapterProvider adapters={adapters}>
|
||||
<BulkImportPrompt />
|
||||
</AdapterProvider>,
|
||||
<RulesEditionProvider>
|
||||
<AdapterProvider adapters={adapters}>
|
||||
<BulkImportPrompt />
|
||||
</AdapterProvider>
|
||||
</RulesEditionProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
// @vitest-environment jsdom
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
|
||||
import { CONDITION_DEFINITIONS, type ConditionId } from "@initiative/domain";
|
||||
import {
|
||||
type ConditionEntry,
|
||||
type ConditionId,
|
||||
getConditionsForEdition,
|
||||
} from "@initiative/domain";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { createRef, type RefObject } from "react";
|
||||
@@ -13,12 +17,14 @@ afterEach(cleanup);
|
||||
|
||||
function renderPicker(
|
||||
overrides: Partial<{
|
||||
activeConditions: readonly ConditionId[];
|
||||
activeConditions: readonly ConditionEntry[];
|
||||
onToggle: (conditionId: ConditionId) => void;
|
||||
onSetValue: (conditionId: ConditionId, value: number) => void;
|
||||
onClose: () => void;
|
||||
}> = {},
|
||||
) {
|
||||
const onToggle = overrides.onToggle ?? vi.fn();
|
||||
const onSetValue = overrides.onSetValue ?? vi.fn();
|
||||
const onClose = overrides.onClose ?? vi.fn();
|
||||
const anchorRef = createRef<HTMLElement>() as RefObject<HTMLElement>;
|
||||
const anchor = document.createElement("div");
|
||||
@@ -30,25 +36,27 @@ function renderPicker(
|
||||
anchorRef={anchorRef}
|
||||
activeConditions={overrides.activeConditions ?? []}
|
||||
onToggle={onToggle}
|
||||
onSetValue={onSetValue}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</RulesEditionProvider>,
|
||||
);
|
||||
return { ...result, onToggle, onClose };
|
||||
return { ...result, onToggle, onSetValue, onClose };
|
||||
}
|
||||
|
||||
describe("ConditionPicker", () => {
|
||||
it("renders all condition definitions from domain", () => {
|
||||
it("renders edition-specific conditions from domain", () => {
|
||||
renderPicker();
|
||||
for (const def of CONDITION_DEFINITIONS) {
|
||||
const editionConditions = getConditionsForEdition("5.5e");
|
||||
for (const def of editionConditions) {
|
||||
expect(screen.getByText(def.label)).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("active conditions are visually distinguished", () => {
|
||||
renderPicker({ activeConditions: ["blinded"] });
|
||||
const blindedButton = screen.getByText("Blinded").closest("button");
|
||||
expect(blindedButton?.className).toContain("bg-card/50");
|
||||
renderPicker({ activeConditions: [{ id: "blinded" }] });
|
||||
const row = screen.getByText("Blinded").closest("div[class]");
|
||||
expect(row?.className).toContain("bg-card/50");
|
||||
});
|
||||
|
||||
it("clicking a condition calls onToggle with that condition's ID", async () => {
|
||||
@@ -65,7 +73,7 @@ describe("ConditionPicker", () => {
|
||||
});
|
||||
|
||||
it("active condition labels use foreground color", () => {
|
||||
renderPicker({ activeConditions: ["charmed"] });
|
||||
renderPicker({ activeConditions: [{ id: "charmed" }] });
|
||||
const label = screen.getByText("Charmed");
|
||||
expect(label.className).toContain("text-foreground");
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @vitest-environment jsdom
|
||||
import type { ConditionId } from "@initiative/domain";
|
||||
import type { ConditionEntry } from "@initiative/domain";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { userEvent } from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -14,6 +14,7 @@ function renderTags(props: Partial<Parameters<typeof ConditionTags>[0]> = {}) {
|
||||
<ConditionTags
|
||||
conditions={props.conditions}
|
||||
onRemove={props.onRemove ?? (() => {})}
|
||||
onDecrement={props.onDecrement ?? (() => {})}
|
||||
onOpenPicker={props.onOpenPicker ?? (() => {})}
|
||||
/>
|
||||
</RulesEditionProvider>,
|
||||
@@ -28,7 +29,7 @@ describe("ConditionTags", () => {
|
||||
});
|
||||
|
||||
it("renders a button per condition", () => {
|
||||
const conditions: ConditionId[] = ["blinded", "prone"];
|
||||
const conditions: ConditionEntry[] = [{ id: "blinded" }, { id: "prone" }];
|
||||
renderTags({ conditions });
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Remove Blinded" }),
|
||||
@@ -39,7 +40,7 @@ describe("ConditionTags", () => {
|
||||
it("calls onRemove with condition id when clicked", async () => {
|
||||
const onRemove = vi.fn();
|
||||
renderTags({
|
||||
conditions: ["blinded"] as ConditionId[],
|
||||
conditions: [{ id: "blinded" }] as ConditionEntry[],
|
||||
onRemove,
|
||||
});
|
||||
|
||||
@@ -66,4 +67,37 @@ describe("ConditionTags", () => {
|
||||
// Only add button
|
||||
expect(screen.getByRole("button", { name: "Add condition" })).toBeDefined();
|
||||
});
|
||||
|
||||
it("displays value badge for valued conditions", () => {
|
||||
renderTags({ conditions: [{ id: "frightened", value: 3 }] });
|
||||
expect(screen.getByText("3")).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls onDecrement for valued condition click", async () => {
|
||||
const onDecrement = vi.fn();
|
||||
renderTags({
|
||||
conditions: [{ id: "frightened", value: 2 }],
|
||||
onDecrement,
|
||||
});
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Remove Frightened" }),
|
||||
);
|
||||
|
||||
expect(onDecrement).toHaveBeenCalledWith("frightened");
|
||||
});
|
||||
|
||||
it("calls onRemove for non-valued condition click", async () => {
|
||||
const onRemove = vi.fn();
|
||||
renderTags({
|
||||
conditions: [{ id: "blinded" }],
|
||||
onRemove,
|
||||
});
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Remove Blinded" }),
|
||||
);
|
||||
|
||||
expect(onRemove).toHaveBeenCalledWith("blinded");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,15 +37,18 @@ function renderModal(open = true) {
|
||||
}
|
||||
|
||||
describe("SettingsModal", () => {
|
||||
it("renders edition section with 'Rules Edition' label", () => {
|
||||
it("renders game system section with all three options", () => {
|
||||
renderModal();
|
||||
expect(screen.getByText("Rules Edition")).toBeInTheDocument();
|
||||
expect(screen.getByText("Game System")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "5e (2014)" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "5.5e (2024)" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Pathfinder 2e" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders theme toggle buttons", () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createTestAdapters } from "../../__tests__/adapters/in-memory-adapters.js";
|
||||
import { AdapterProvider } from "../../contexts/adapter-context.js";
|
||||
import { RulesEditionProvider } from "../../contexts/rules-edition-context.js";
|
||||
import { SourceFetchPrompt } from "../source-fetch-prompt.js";
|
||||
|
||||
const MONSTER_MANUAL_REGEX = /Monster Manual/;
|
||||
@@ -36,12 +37,14 @@ function renderPrompt(sourceCode = "MM") {
|
||||
code === "MM" ? "Monster Manual" : code,
|
||||
};
|
||||
const result = render(
|
||||
<AdapterProvider adapters={adapters}>
|
||||
<SourceFetchPrompt
|
||||
sourceCode={sourceCode}
|
||||
onSourceLoaded={onSourceLoaded}
|
||||
/>
|
||||
</AdapterProvider>,
|
||||
<RulesEditionProvider>
|
||||
<AdapterProvider adapters={adapters}>
|
||||
<SourceFetchPrompt
|
||||
sourceCode={sourceCode}
|
||||
onSourceLoaded={onSourceLoaded}
|
||||
/>
|
||||
</AdapterProvider>
|
||||
</RulesEditionProvider>,
|
||||
);
|
||||
return { ...result, onSourceLoaded };
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ function renderWithSources(sources: CachedSourceInfo[] = []) {
|
||||
adapters.bestiaryCache = {
|
||||
...adapters.bestiaryCache,
|
||||
getCachedSources: () => Promise.resolve(currentSources),
|
||||
clearSource(sourceCode) {
|
||||
clearSource(_system, sourceCode) {
|
||||
currentSources = currentSources.filter(
|
||||
(s) => s.sourceCode !== sourceCode,
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { Creature } from "@initiative/domain";
|
||||
import { creatureId } from "@initiative/domain";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { StatBlock } from "../stat-block.js";
|
||||
import { DndStatBlock as StatBlock } from "../dnd-stat-block.js";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
|
||||
@@ -3,23 +3,30 @@ import { useId, useState } from "react";
|
||||
import { useAdapters } from "../contexts/adapter-context.js";
|
||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||
import { useBulkImportContext } from "../contexts/bulk-import-context.js";
|
||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
import { Input } from "./ui/input.js";
|
||||
|
||||
const DEFAULT_BASE_URL =
|
||||
const DND_BASE_URL =
|
||||
"https://raw.githubusercontent.com/5etools-mirror-3/5etools-src/main/data/bestiary/";
|
||||
|
||||
const PF2E_BASE_URL =
|
||||
"https://raw.githubusercontent.com/Pf2eToolsOrg/Pf2eTools/dev/data/bestiary/";
|
||||
|
||||
export function BulkImportPrompt() {
|
||||
const { bestiaryIndex } = useAdapters();
|
||||
const { bestiaryIndex, pf2eBestiaryIndex } = useAdapters();
|
||||
const { fetchAndCacheSource, isSourceCached, refreshCache } =
|
||||
useBestiaryContext();
|
||||
const { state: importState, startImport, reset } = useBulkImportContext();
|
||||
const { dismissPanel } = useSidePanelContext();
|
||||
const { edition } = useRulesEditionContext();
|
||||
|
||||
const [baseUrl, setBaseUrl] = useState(DEFAULT_BASE_URL);
|
||||
const indexPort = edition === "pf2e" ? pf2eBestiaryIndex : bestiaryIndex;
|
||||
const defaultUrl = edition === "pf2e" ? PF2E_BASE_URL : DND_BASE_URL;
|
||||
const [baseUrl, setBaseUrl] = useState(defaultUrl);
|
||||
const baseUrlId = useId();
|
||||
const totalSources = bestiaryIndex.getAllSourceCodes().length;
|
||||
const totalSources = indexPort.getAllSourceCodes().length;
|
||||
|
||||
const handleStart = (url: string) => {
|
||||
startImport(url, fetchAndCacheSource, isSourceCached, refreshCache);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
type CombatantId,
|
||||
type ConditionId,
|
||||
type ConditionEntry,
|
||||
type CreatureId,
|
||||
deriveHpStatus,
|
||||
type PlayerIcon,
|
||||
@@ -31,7 +31,7 @@ interface Combatant {
|
||||
readonly currentHp?: number;
|
||||
readonly tempHp?: number;
|
||||
readonly ac?: number;
|
||||
readonly conditions?: readonly ConditionId[];
|
||||
readonly conditions?: readonly ConditionEntry[];
|
||||
readonly isConcentrating?: boolean;
|
||||
readonly color?: string;
|
||||
readonly icon?: string;
|
||||
@@ -448,6 +448,8 @@ export function CombatantRow({
|
||||
setTempHp,
|
||||
setAc,
|
||||
toggleCondition,
|
||||
setConditionValue,
|
||||
decrementCondition,
|
||||
toggleConcentration,
|
||||
} = useEncounterContext();
|
||||
const { selectedCreatureId, showCreature, toggleCollapse } =
|
||||
@@ -585,6 +587,7 @@ export function CombatantRow({
|
||||
<ConditionTags
|
||||
conditions={combatant.conditions}
|
||||
onRemove={(conditionId) => toggleCondition(id, conditionId)}
|
||||
onDecrement={(conditionId) => decrementCondition(id, conditionId)}
|
||||
onOpenPicker={() => setPickerOpen((prev) => !prev)}
|
||||
/>
|
||||
</div>
|
||||
@@ -593,6 +596,9 @@ export function CombatantRow({
|
||||
anchorRef={conditionAnchorRef}
|
||||
activeConditions={combatant.conditions}
|
||||
onToggle={(conditionId) => toggleCondition(id, conditionId)}
|
||||
onSetValue={(conditionId, value) =>
|
||||
setConditionValue(id, conditionId, value)
|
||||
}
|
||||
onClose={() => setPickerOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
type ConditionEntry,
|
||||
type ConditionId,
|
||||
getConditionDescription,
|
||||
getConditionsForEdition,
|
||||
} from "@initiative/domain";
|
||||
import { Check, Minus, Plus } from "lucide-react";
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||
@@ -16,8 +18,9 @@ import { Tooltip } from "./ui/tooltip.js";
|
||||
|
||||
interface ConditionPickerProps {
|
||||
anchorRef: React.RefObject<HTMLElement | null>;
|
||||
activeConditions: readonly ConditionId[] | undefined;
|
||||
activeConditions: readonly ConditionEntry[] | undefined;
|
||||
onToggle: (conditionId: ConditionId) => void;
|
||||
onSetValue: (conditionId: ConditionId, value: number) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
@@ -25,6 +28,7 @@ export function ConditionPicker({
|
||||
anchorRef,
|
||||
activeConditions,
|
||||
onToggle,
|
||||
onSetValue,
|
||||
onClose,
|
||||
}: Readonly<ConditionPickerProps>) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
@@ -34,6 +38,11 @@ export function ConditionPicker({
|
||||
maxHeight: number;
|
||||
} | null>(null);
|
||||
|
||||
const [editing, setEditing] = useState<{
|
||||
id: ConditionId;
|
||||
value: number;
|
||||
} | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const anchor = anchorRef.current;
|
||||
const el = ref.current;
|
||||
@@ -59,7 +68,9 @@ export function ConditionPicker({
|
||||
|
||||
const { edition } = useRulesEditionContext();
|
||||
const conditions = getConditionsForEdition(edition);
|
||||
const active = new Set(activeConditions ?? []);
|
||||
const activeMap = new Map(
|
||||
(activeConditions ?? []).map((e) => [e.id, e.value]),
|
||||
);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
@@ -74,35 +85,112 @@ export function ConditionPicker({
|
||||
{conditions.map((def) => {
|
||||
const Icon = CONDITION_ICON_MAP[def.iconName];
|
||||
if (!Icon) return null;
|
||||
const isActive = active.has(def.id);
|
||||
const isActive = activeMap.has(def.id);
|
||||
const activeValue = activeMap.get(def.id);
|
||||
const isEditing = editing?.id === def.id;
|
||||
const colorClass =
|
||||
CONDITION_COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
||||
|
||||
const handleClick = () => {
|
||||
if (def.valued && edition === "pf2e") {
|
||||
const current = activeMap.get(def.id);
|
||||
setEditing({
|
||||
id: def.id,
|
||||
value: current ?? 1,
|
||||
});
|
||||
} else {
|
||||
onToggle(def.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
key={def.id}
|
||||
content={getConditionDescription(def, edition)}
|
||||
className="block"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded px-2 py-1 text-sm transition-colors hover:bg-hover-neutral-bg",
|
||||
isActive && "bg-card/50",
|
||||
"flex w-full items-center gap-2 rounded px-2 py-1 text-sm transition-colors",
|
||||
(isActive || isEditing) && "bg-card/50",
|
||||
)}
|
||||
onClick={() => onToggle(def.id)}
|
||||
>
|
||||
<Icon
|
||||
size={14}
|
||||
className={isActive ? colorClass : "text-muted-foreground"}
|
||||
/>
|
||||
<span
|
||||
className={
|
||||
isActive ? "text-foreground" : "text-muted-foreground"
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
className="flex flex-1 items-center gap-2"
|
||||
onClick={handleClick}
|
||||
>
|
||||
{def.label}
|
||||
</span>
|
||||
</button>
|
||||
<Icon
|
||||
size={14}
|
||||
className={
|
||||
isActive || isEditing ? colorClass : "text-muted-foreground"
|
||||
}
|
||||
/>
|
||||
<span
|
||||
className={
|
||||
isActive || isEditing
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground"
|
||||
}
|
||||
>
|
||||
{def.label}
|
||||
</span>
|
||||
</button>
|
||||
{isActive && def.valued && edition === "pf2e" && !isEditing && (
|
||||
<span className="min-w-5 rounded-full bg-accent px-1.5 py-0.5 text-center font-medium text-primary-foreground text-xs">
|
||||
{activeValue}
|
||||
</span>
|
||||
)}
|
||||
{isEditing && (
|
||||
<div className="flex items-center gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-0.5 text-foreground hover:bg-accent/40"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (editing.value > 1) {
|
||||
setEditing({
|
||||
...editing,
|
||||
value: editing.value - 1,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Minus className="h-3 w-3" />
|
||||
</button>
|
||||
<span className="min-w-5 rounded-full bg-accent px-1.5 py-0.5 text-center font-medium text-primary-foreground text-xs">
|
||||
{editing.value}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-0.5 text-foreground hover:bg-accent/40"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditing({
|
||||
...editing,
|
||||
value: editing.value + 1,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-0.5 rounded p-0.5 text-foreground hover:bg-accent/40"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSetValue(editing.id, editing.value);
|
||||
setEditing(null);
|
||||
}}
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,42 +1,74 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
Anchor,
|
||||
ArrowDown,
|
||||
Ban,
|
||||
BatteryLow,
|
||||
BrainCog,
|
||||
CircleHelp,
|
||||
CloudFog,
|
||||
Drama,
|
||||
Droplet,
|
||||
Droplets,
|
||||
EarOff,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Footprints,
|
||||
Gem,
|
||||
Ghost,
|
||||
Hand,
|
||||
Heart,
|
||||
HeartCrack,
|
||||
HeartPulse,
|
||||
Link,
|
||||
Moon,
|
||||
PersonStanding,
|
||||
ShieldMinus,
|
||||
ShieldOff,
|
||||
Siren,
|
||||
Skull,
|
||||
Snail,
|
||||
Sparkles,
|
||||
Sun,
|
||||
TrendingDown,
|
||||
Zap,
|
||||
ZapOff,
|
||||
} from "lucide-react";
|
||||
|
||||
export const CONDITION_ICON_MAP: Record<string, LucideIcon> = {
|
||||
EyeOff,
|
||||
Heart,
|
||||
EarOff,
|
||||
BatteryLow,
|
||||
Siren,
|
||||
Hand,
|
||||
Ban,
|
||||
Ghost,
|
||||
ZapOff,
|
||||
Gem,
|
||||
Droplet,
|
||||
Anchor,
|
||||
ArrowDown,
|
||||
Ban,
|
||||
BatteryLow,
|
||||
BrainCog,
|
||||
CircleHelp,
|
||||
CloudFog,
|
||||
Drama,
|
||||
Droplet,
|
||||
Droplets,
|
||||
EarOff,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Footprints,
|
||||
Gem,
|
||||
Ghost,
|
||||
Hand,
|
||||
Heart,
|
||||
HeartCrack,
|
||||
HeartPulse,
|
||||
Link,
|
||||
Moon,
|
||||
PersonStanding,
|
||||
ShieldMinus,
|
||||
ShieldOff,
|
||||
Siren,
|
||||
Skull,
|
||||
Snail,
|
||||
Sparkles,
|
||||
Moon,
|
||||
Sun,
|
||||
TrendingDown,
|
||||
Zap,
|
||||
ZapOff,
|
||||
};
|
||||
|
||||
export const CONDITION_COLOR_CLASSES: Record<string, string> = {
|
||||
@@ -51,4 +83,5 @@ export const CONDITION_COLOR_CLASSES: Record<string, string> = {
|
||||
green: "text-green-400",
|
||||
indigo: "text-indigo-400",
|
||||
sky: "text-sky-400",
|
||||
red: "text-red-400",
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
CONDITION_DEFINITIONS,
|
||||
type ConditionEntry,
|
||||
type ConditionId,
|
||||
getConditionDescription,
|
||||
} from "@initiative/domain";
|
||||
@@ -13,44 +14,57 @@ import {
|
||||
import { Tooltip } from "./ui/tooltip.js";
|
||||
|
||||
interface ConditionTagsProps {
|
||||
conditions: readonly ConditionId[] | undefined;
|
||||
conditions: readonly ConditionEntry[] | undefined;
|
||||
onRemove: (conditionId: ConditionId) => void;
|
||||
onDecrement: (conditionId: ConditionId) => void;
|
||||
onOpenPicker: () => void;
|
||||
}
|
||||
|
||||
export function ConditionTags({
|
||||
conditions,
|
||||
onRemove,
|
||||
onDecrement,
|
||||
onOpenPicker,
|
||||
}: Readonly<ConditionTagsProps>) {
|
||||
const { edition } = useRulesEditionContext();
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-0.5">
|
||||
{conditions?.map((condId) => {
|
||||
const def = CONDITION_DEFINITIONS.find((d) => d.id === condId);
|
||||
{conditions?.map((entry) => {
|
||||
const def = CONDITION_DEFINITIONS.find((d) => d.id === entry.id);
|
||||
if (!def) return null;
|
||||
const Icon = CONDITION_ICON_MAP[def.iconName];
|
||||
if (!Icon) return null;
|
||||
const colorClass =
|
||||
CONDITION_COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
||||
const tooltipLabel =
|
||||
entry.value === undefined ? def.label : `${def.label} ${entry.value}`;
|
||||
return (
|
||||
<Tooltip
|
||||
key={condId}
|
||||
content={`${def.label}:\n${getConditionDescription(def, edition)}`}
|
||||
key={entry.id}
|
||||
content={`${tooltipLabel}:\n${getConditionDescription(def, edition)}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Remove ${def.label}`}
|
||||
className={cn(
|
||||
"inline-flex items-center rounded p-0.5 transition-colors hover:bg-hover-neutral-bg",
|
||||
"inline-flex items-center gap-0.5 rounded p-0.5 transition-colors hover:bg-hover-neutral-bg",
|
||||
colorClass,
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove(condId);
|
||||
if (entry.value === undefined) {
|
||||
onRemove(entry.id);
|
||||
} else {
|
||||
onDecrement(entry.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon size={14} />
|
||||
{entry.value !== undefined && (
|
||||
<span className="font-medium text-xs leading-none">
|
||||
{entry.value}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -11,9 +11,8 @@ import {
|
||||
import { CrPicker } from "./cr-picker.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
|
||||
const TIER_LABEL_MAP: Record<
|
||||
RulesEdition,
|
||||
Record<DifficultyTier, { label: string; color: string }>
|
||||
const TIER_LABEL_MAP: Partial<
|
||||
Record<RulesEdition, Record<DifficultyTier, { label: string; color: string }>>
|
||||
> = {
|
||||
"5.5e": {
|
||||
0: { label: "Trivial", color: "text-muted-foreground" },
|
||||
@@ -117,7 +116,9 @@ export function DifficultyBreakdownPanel({ onClose }: { onClose: () => void }) {
|
||||
const breakdown = useDifficultyBreakdown();
|
||||
if (!breakdown) return null;
|
||||
|
||||
const tierConfig = TIER_LABEL_MAP[edition][breakdown.tier];
|
||||
const tierLabels = TIER_LABEL_MAP[edition];
|
||||
if (!tierLabels) return null;
|
||||
const tierConfig = tierLabels[breakdown.tier];
|
||||
|
||||
const handleToggle = (entry: BreakdownCombatant) => {
|
||||
const newSide = entry.side === "party" ? "enemy" : "party";
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import type { Creature, TraitBlock, TraitSegment } from "@initiative/domain";
|
||||
import type { Creature } from "@initiative/domain";
|
||||
import {
|
||||
calculateInitiative,
|
||||
formatInitiativeModifier,
|
||||
} from "@initiative/domain";
|
||||
import {
|
||||
PropertyLine,
|
||||
SectionDivider,
|
||||
TraitEntry,
|
||||
TraitSection,
|
||||
} from "./stat-block-parts.js";
|
||||
|
||||
interface StatBlockProps {
|
||||
interface DndStatBlockProps {
|
||||
creature: Creature;
|
||||
}
|
||||
|
||||
@@ -13,96 +19,7 @@ function abilityMod(score: number): string {
|
||||
return mod >= 0 ? `+${mod}` : `${mod}`;
|
||||
}
|
||||
|
||||
function PropertyLine({
|
||||
label,
|
||||
value,
|
||||
}: Readonly<{
|
||||
label: string;
|
||||
value: string | undefined;
|
||||
}>) {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<div className="text-sm">
|
||||
<span className="font-semibold">{label}</span> {value}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionDivider() {
|
||||
return (
|
||||
<div className="my-2 h-px bg-gradient-to-r from-stat-divider-from via-stat-divider-via to-transparent" />
|
||||
);
|
||||
}
|
||||
|
||||
function segmentKey(seg: TraitSegment): string {
|
||||
return seg.type === "text"
|
||||
? seg.value.slice(0, 40)
|
||||
: seg.items.map((i) => i.label ?? i.text.slice(0, 20)).join(",");
|
||||
}
|
||||
|
||||
function TraitSegments({
|
||||
segments,
|
||||
}: Readonly<{ segments: readonly TraitSegment[] }>) {
|
||||
return (
|
||||
<>
|
||||
{segments.map((seg, i) => {
|
||||
if (seg.type === "text") {
|
||||
return (
|
||||
<span key={segmentKey(seg)}>
|
||||
{i === 0 ? ` ${seg.value}` : seg.value}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={segmentKey(seg)} className="mt-1 space-y-0.5">
|
||||
{seg.items.map((item) => (
|
||||
<p key={item.label ?? item.text}>
|
||||
{item.label != null && (
|
||||
<span className="font-semibold">{item.label}. </span>
|
||||
)}
|
||||
{item.text}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TraitEntry({ trait }: Readonly<{ trait: TraitBlock }>) {
|
||||
return (
|
||||
<div className="text-sm">
|
||||
<span className="font-semibold italic">{trait.name}.</span>
|
||||
<TraitSegments segments={trait.segments} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TraitSection({
|
||||
entries,
|
||||
heading,
|
||||
}: Readonly<{
|
||||
entries: readonly TraitBlock[] | undefined;
|
||||
heading?: string;
|
||||
}>) {
|
||||
if (!entries || entries.length === 0) return null;
|
||||
return (
|
||||
<>
|
||||
<SectionDivider />
|
||||
{heading ? (
|
||||
<h3 className="font-bold text-base text-stat-heading">{heading}</h3>
|
||||
) : null}
|
||||
<div className="space-y-2">
|
||||
{entries.map((e) => (
|
||||
<TraitEntry key={e.name} trait={e} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatBlock({ creature }: Readonly<StatBlockProps>) {
|
||||
export function DndStatBlock({ creature }: Readonly<DndStatBlockProps>) {
|
||||
const abilities = [
|
||||
{ label: "STR", score: creature.abilities.str },
|
||||
{ label: "DEX", score: creature.abilities.dex },
|
||||
143
apps/web/src/components/pf2e-stat-block.tsx
Normal file
143
apps/web/src/components/pf2e-stat-block.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import type { Pf2eCreature } from "@initiative/domain";
|
||||
import { formatInitiativeModifier } from "@initiative/domain";
|
||||
import {
|
||||
PropertyLine,
|
||||
SectionDivider,
|
||||
TraitSection,
|
||||
} from "./stat-block-parts.js";
|
||||
|
||||
interface Pf2eStatBlockProps {
|
||||
creature: Pf2eCreature;
|
||||
}
|
||||
|
||||
const ALIGNMENTS = new Set([
|
||||
"lg",
|
||||
"ng",
|
||||
"cg",
|
||||
"ln",
|
||||
"n",
|
||||
"cn",
|
||||
"le",
|
||||
"ne",
|
||||
"ce",
|
||||
]);
|
||||
|
||||
function capitalize(s: string): string {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
function displayTraits(traits: readonly string[]): string[] {
|
||||
return traits.filter((t) => !ALIGNMENTS.has(t)).map(capitalize);
|
||||
}
|
||||
|
||||
function formatMod(mod: number): string {
|
||||
return mod >= 0 ? `+${mod}` : `${mod}`;
|
||||
}
|
||||
|
||||
export function Pf2eStatBlock({ creature }: Readonly<Pf2eStatBlockProps>) {
|
||||
const abilityEntries = [
|
||||
{ label: "Str", mod: creature.abilityMods.str },
|
||||
{ label: "Dex", mod: creature.abilityMods.dex },
|
||||
{ label: "Con", mod: creature.abilityMods.con },
|
||||
{ label: "Int", mod: creature.abilityMods.int },
|
||||
{ label: "Wis", mod: creature.abilityMods.wis },
|
||||
{ label: "Cha", mod: creature.abilityMods.cha },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-1 text-foreground">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<h2 className="font-bold text-stat-heading text-xl">
|
||||
{creature.name}
|
||||
</h2>
|
||||
<span className="shrink-0 font-semibold text-sm">
|
||||
Level {creature.level}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{displayTraits(creature.traits).map((trait) => (
|
||||
<span
|
||||
key={trait}
|
||||
className="rounded border border-border bg-card px-1.5 py-0.5 text-foreground text-xs"
|
||||
>
|
||||
{trait}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-1 text-muted-foreground text-xs">
|
||||
{creature.sourceDisplayName}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SectionDivider />
|
||||
|
||||
{/* Perception, Languages, Skills */}
|
||||
<div className="space-y-0.5 text-sm">
|
||||
<div>
|
||||
<span className="font-semibold">Perception</span>{" "}
|
||||
{formatInitiativeModifier(creature.perception)}
|
||||
{creature.senses ? `; ${creature.senses}` : ""}
|
||||
</div>
|
||||
<PropertyLine label="Languages" value={creature.languages} />
|
||||
<PropertyLine label="Skills" value={creature.skills} />
|
||||
</div>
|
||||
|
||||
{/* Ability Modifiers */}
|
||||
<div className="grid grid-cols-6 gap-1 text-center text-sm">
|
||||
{abilityEntries.map((a) => (
|
||||
<div key={a.label}>
|
||||
<div className="font-semibold text-muted-foreground text-xs">
|
||||
{a.label}
|
||||
</div>
|
||||
<div>{formatMod(a.mod)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<PropertyLine label="Items" value={creature.items} />
|
||||
|
||||
{/* Top abilities (before defenses) */}
|
||||
<TraitSection entries={creature.abilitiesTop} />
|
||||
|
||||
<SectionDivider />
|
||||
|
||||
{/* Defenses */}
|
||||
<div className="space-y-0.5 text-sm">
|
||||
<div>
|
||||
<span className="font-semibold">AC</span> {creature.ac}
|
||||
{creature.acConditional ? ` (${creature.acConditional})` : ""};{" "}
|
||||
<span className="font-semibold">Fort</span>{" "}
|
||||
{formatMod(creature.saveFort)},{" "}
|
||||
<span className="font-semibold">Ref</span>{" "}
|
||||
{formatMod(creature.saveRef)},{" "}
|
||||
<span className="font-semibold">Will</span>{" "}
|
||||
{formatMod(creature.saveWill)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-semibold">HP</span> {creature.hp}
|
||||
</div>
|
||||
<PropertyLine label="Immunities" value={creature.immunities} />
|
||||
<PropertyLine label="Resistances" value={creature.resistances} />
|
||||
<PropertyLine label="Weaknesses" value={creature.weaknesses} />
|
||||
</div>
|
||||
|
||||
{/* Mid abilities (reactions, auras) */}
|
||||
<TraitSection entries={creature.abilitiesMid} />
|
||||
|
||||
<SectionDivider />
|
||||
|
||||
{/* Speed */}
|
||||
<div className="text-sm">
|
||||
<span className="font-semibold">Speed</span> {creature.speed}
|
||||
</div>
|
||||
|
||||
{/* Attacks */}
|
||||
<TraitSection entries={creature.attacks} />
|
||||
|
||||
{/* Bottom abilities (active abilities) */}
|
||||
<TraitSection entries={creature.abilitiesBot} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ interface SettingsModalProps {
|
||||
const EDITION_OPTIONS: { value: RulesEdition; label: string }[] = [
|
||||
{ value: "5e", label: "5e (2014)" },
|
||||
{ value: "5.5e", label: "5.5e (2024)" },
|
||||
{ value: "pf2e", label: "Pathfinder 2e" },
|
||||
];
|
||||
|
||||
const THEME_OPTIONS: {
|
||||
@@ -36,7 +37,7 @@ export function SettingsModal({ open, onClose }: Readonly<SettingsModalProps>) {
|
||||
<div className="flex flex-col gap-5">
|
||||
<div>
|
||||
<span className="mb-2 block font-medium text-muted-foreground text-sm">
|
||||
Rules Edition
|
||||
Game System
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
{EDITION_OPTIONS.map((opt) => (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Download, Loader2, Upload } from "lucide-react";
|
||||
import { useId, useRef, useState } from "react";
|
||||
import { useAdapters } from "../contexts/adapter-context.js";
|
||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
import { Input } from "./ui/input.js";
|
||||
|
||||
@@ -14,11 +15,13 @@ export function SourceFetchPrompt({
|
||||
sourceCode,
|
||||
onSourceLoaded,
|
||||
}: Readonly<SourceFetchPromptProps>) {
|
||||
const { bestiaryIndex } = useAdapters();
|
||||
const { bestiaryIndex, pf2eBestiaryIndex } = useAdapters();
|
||||
const { fetchAndCacheSource, uploadAndCacheSource } = useBestiaryContext();
|
||||
const sourceDisplayName = bestiaryIndex.getSourceDisplayName(sourceCode);
|
||||
const { edition } = useRulesEditionContext();
|
||||
const indexPort = edition === "pf2e" ? pf2eBestiaryIndex : bestiaryIndex;
|
||||
const sourceDisplayName = indexPort.getSourceDisplayName(sourceCode);
|
||||
const [url, setUrl] = useState(() =>
|
||||
bestiaryIndex.getDefaultFetchUrl(sourceCode),
|
||||
indexPort.getDefaultFetchUrl(sourceCode),
|
||||
);
|
||||
const [status, setStatus] = useState<"idle" | "fetching" | "error">("idle");
|
||||
const [error, setError] = useState<string>("");
|
||||
|
||||
@@ -9,12 +9,15 @@ import {
|
||||
import type { CachedSourceInfo } from "../adapters/ports.js";
|
||||
import { useAdapters } from "../contexts/adapter-context.js";
|
||||
import { useBestiaryContext } from "../contexts/bestiary-context.js";
|
||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
import { Input } from "./ui/input.js";
|
||||
|
||||
export function SourceManager() {
|
||||
const { bestiaryCache } = useAdapters();
|
||||
const { refreshCache } = useBestiaryContext();
|
||||
const { edition } = useRulesEditionContext();
|
||||
const system = edition === "pf2e" ? "pf2e" : "dnd";
|
||||
const [sources, setSources] = useState<CachedSourceInfo[]>([]);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [optimisticSources, applyOptimistic] = useOptimistic(
|
||||
@@ -29,9 +32,9 @@ export function SourceManager() {
|
||||
);
|
||||
|
||||
const loadSources = useCallback(async () => {
|
||||
const cached = await bestiaryCache.getCachedSources();
|
||||
const cached = await bestiaryCache.getCachedSources(system);
|
||||
setSources(cached);
|
||||
}, [bestiaryCache]);
|
||||
}, [bestiaryCache, system]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadSources();
|
||||
@@ -39,7 +42,7 @@ export function SourceManager() {
|
||||
|
||||
const handleClearSource = async (sourceCode: string) => {
|
||||
applyOptimistic({ type: "remove", sourceCode });
|
||||
await bestiaryCache.clearSource(sourceCode);
|
||||
await bestiaryCache.clearSource(system, sourceCode);
|
||||
await loadSources();
|
||||
void refreshCache();
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CreatureId } from "@initiative/domain";
|
||||
import type { Creature, CreatureId } from "@initiative/domain";
|
||||
import { PanelRightClose, Pin, PinOff } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -7,9 +7,10 @@ import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
||||
import { useSwipeToDismiss } from "../hooks/use-swipe-to-dismiss.js";
|
||||
import { cn } from "../lib/utils.js";
|
||||
import { BulkImportPrompt } from "./bulk-import-prompt.js";
|
||||
import { DndStatBlock } from "./dnd-stat-block.js";
|
||||
import { Pf2eStatBlock } from "./pf2e-stat-block.js";
|
||||
import { SourceFetchPrompt } from "./source-fetch-prompt.js";
|
||||
import { SourceManager } from "./source-manager.js";
|
||||
import { StatBlock } from "./stat-block.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
|
||||
interface StatBlockPanelProps {
|
||||
@@ -307,7 +308,10 @@ export function StatBlockPanel({
|
||||
}
|
||||
|
||||
if (creature) {
|
||||
return <StatBlock creature={creature} />;
|
||||
if ("system" in creature && creature.system === "pf2e") {
|
||||
return <Pf2eStatBlock creature={creature} />;
|
||||
}
|
||||
return <DndStatBlock creature={creature as Creature} />;
|
||||
}
|
||||
|
||||
if (needsFetch && sourceCode) {
|
||||
|
||||
90
apps/web/src/components/stat-block-parts.tsx
Normal file
90
apps/web/src/components/stat-block-parts.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import type { TraitBlock, TraitSegment } from "@initiative/domain";
|
||||
|
||||
export function PropertyLine({
|
||||
label,
|
||||
value,
|
||||
}: Readonly<{
|
||||
label: string;
|
||||
value: string | undefined;
|
||||
}>) {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<div className="text-sm">
|
||||
<span className="font-semibold">{label}</span> {value}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SectionDivider() {
|
||||
return (
|
||||
<div className="my-2 h-px bg-gradient-to-r from-stat-divider-from via-stat-divider-via to-transparent" />
|
||||
);
|
||||
}
|
||||
|
||||
function segmentKey(seg: TraitSegment): string {
|
||||
return seg.type === "text"
|
||||
? seg.value.slice(0, 40)
|
||||
: seg.items.map((i) => i.label ?? i.text.slice(0, 20)).join(",");
|
||||
}
|
||||
|
||||
function TraitSegments({
|
||||
segments,
|
||||
}: Readonly<{ segments: readonly TraitSegment[] }>) {
|
||||
return (
|
||||
<>
|
||||
{segments.map((seg, i) => {
|
||||
if (seg.type === "text") {
|
||||
return (
|
||||
<span key={segmentKey(seg)}>
|
||||
{i === 0 ? ` ${seg.value}` : seg.value}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={segmentKey(seg)} className="mt-1 space-y-0.5">
|
||||
{seg.items.map((item) => (
|
||||
<p key={item.label ?? item.text}>
|
||||
{item.label != null && (
|
||||
<span className="font-semibold">{item.label}. </span>
|
||||
)}
|
||||
{item.text}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function TraitEntry({ trait }: Readonly<{ trait: TraitBlock }>) {
|
||||
return (
|
||||
<div className="text-sm">
|
||||
<span className="font-semibold italic">{trait.name}.</span>
|
||||
<TraitSegments segments={trait.segments} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TraitSection({
|
||||
entries,
|
||||
heading,
|
||||
}: Readonly<{
|
||||
entries: readonly TraitBlock[] | undefined;
|
||||
heading?: string;
|
||||
}>) {
|
||||
if (!entries || entries.length === 0) return null;
|
||||
return (
|
||||
<>
|
||||
<SectionDivider />
|
||||
{heading ? (
|
||||
<h3 className="font-bold text-base text-stat-heading">{heading}</h3>
|
||||
) : null}
|
||||
<div className="space-y-2">
|
||||
{entries.map((e) => (
|
||||
<TraitEntry key={e.name} trait={e} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user