Files
initiative/apps/web/src/components/spell-detail-popover.tsx
Lukas 1eaeecad32
All checks were successful
CI / check (push) Successful in 2m31s
CI / build-image (push) Successful in 17s
Add PF2e equipment display with detail popovers in stat blocks
Extract shared DetailPopover shell from spell popovers. Normalize
weapon/consumable/equipment/armor items from Foundry data into
mundane (Items line) and detailed (Equipment section with clickable
popovers). Scrolls/wands show embedded spell info. Bump IDB cache v7.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 21:11:54 +02:00

179 lines
4.3 KiB
TypeScript

import type { ActivityCost, SpellReference } from "@initiative/domain";
import { DetailPopover } from "./detail-popover.js";
import { RichDescription } from "./rich-description.js";
import { ActivityIcon } from "./stat-block-parts.js";
interface SpellDetailPopoverProps {
readonly spell: SpellReference;
readonly anchorRect: DOMRect;
readonly onClose: () => void;
}
const RANK_LABELS = [
"Cantrip",
"1st",
"2nd",
"3rd",
"4th",
"5th",
"6th",
"7th",
"8th",
"9th",
"10th",
];
function formatRank(rank: number | undefined): string {
if (rank === undefined) return "";
return RANK_LABELS[rank] ?? `Rank ${rank}`;
}
function parseActionCost(cost: string): ActivityCost | null {
if (cost === "free") return { number: 1, unit: "free" };
if (cost === "reaction") return { number: 1, unit: "reaction" };
const n = Number(cost);
if (n >= 1 && n <= 3) return { number: n, unit: "action" };
return null;
}
function SpellActionCost({ cost }: Readonly<{ cost: string | undefined }>) {
if (!cost) return null;
const activity = parseActionCost(cost);
if (activity) {
return (
<span className="shrink-0 text-lg">
<ActivityIcon activity={activity} />
</span>
);
}
return <span className="shrink-0 text-muted-foreground text-xs">{cost}</span>;
}
function SpellHeader({ spell }: Readonly<{ spell: SpellReference }>) {
return (
<div className="flex items-center justify-between gap-2">
<h3 className="font-bold text-lg text-stat-heading">{spell.name}</h3>
<SpellActionCost cost={spell.actionCost} />
</div>
);
}
function SpellTraits({ traits }: Readonly<{ traits: readonly string[] }>) {
if (traits.length === 0) return null;
return (
<div className="flex flex-wrap gap-1">
{traits.map((t) => (
<span
key={t}
className="rounded border border-border bg-card px-1.5 py-0.5 text-foreground text-xs"
>
{t}
</span>
))}
</div>
);
}
function LabeledValue({
label,
value,
}: Readonly<{ label: string; value: string }>) {
return (
<>
<span className="font-semibold">{label}</span> {value}
</>
);
}
function SpellRangeLine({ spell }: Readonly<{ spell: SpellReference }>) {
const items: { label: string; value: string }[] = [];
if (spell.range) items.push({ label: "Range", value: spell.range });
if (spell.target) items.push({ label: "Target", value: spell.target });
if (spell.area) items.push({ label: "Area", value: spell.area });
if (items.length === 0) return null;
return (
<div>
{items.map((item, i) => (
<span key={item.label}>
{i > 0 ? "; " : ""}
<LabeledValue label={item.label} value={item.value} />
</span>
))}
</div>
);
}
function SpellMeta({ spell }: Readonly<{ spell: SpellReference }>) {
const hasTraditions =
spell.traditions !== undefined && spell.traditions.length > 0;
return (
<div className="space-y-0.5 text-xs">
{spell.rank === undefined ? null : (
<div>
<span className="font-semibold">{formatRank(spell.rank)}</span>
{hasTraditions ? (
<span className="text-muted-foreground">
{" "}
({spell.traditions?.join(", ")})
</span>
) : null}
</div>
)}
<SpellRangeLine spell={spell} />
{spell.duration ? (
<div>
<LabeledValue label="Duration" value={spell.duration} />
</div>
) : null}
{spell.defense ? (
<div>
<LabeledValue label="Defense" value={spell.defense} />
</div>
) : null}
</div>
);
}
function SpellDetailContent({ spell }: Readonly<{ spell: SpellReference }>) {
return (
<div className="space-y-2 text-sm">
<SpellHeader spell={spell} />
<SpellTraits traits={spell.traits ?? []} />
<SpellMeta spell={spell} />
{spell.description ? (
<RichDescription
text={spell.description}
className="whitespace-pre-line text-foreground"
/>
) : (
<p className="text-muted-foreground italic">
No description available.
</p>
)}
{spell.heightening ? (
<RichDescription
text={spell.heightening}
className="whitespace-pre-line text-foreground text-xs"
/>
) : null}
</div>
);
}
export function SpellDetailPopover({
spell,
anchorRect,
onClose,
}: Readonly<SpellDetailPopoverProps>) {
return (
<DetailPopover
anchorRect={anchorRect}
onClose={onClose}
ariaLabel={`Spell details: ${spell.name}`}
>
<SpellDetailContent spell={spell} />
</DetailPopover>
);
}