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>
179 lines
4.3 KiB
TypeScript
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>
|
|
);
|
|
}
|