Add jsinspect-plus structural duplication gate, extract shared helpers
Add jsinspect-plus (AST-based structural duplication detector) to pnpm check with threshold 50 / min 3 instances. Fix all findings: - Extract condition icon/color maps to shared condition-styles.ts - Extract useClickOutside hook (5 components) - Extract dispatchAction + resolveAndRename in use-encounter - Extract runEncounterAction in application layer (13 use cases) - Extract findCombatant helper in domain (9 functions) - Extract TraitSection in stat-block (4 trait rendering blocks) - Extract DialogHeader in dialog.tsx (4 dialogs) Net result: -263 lines across 40 files. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,66 +3,17 @@ import {
|
||||
getConditionDescription,
|
||||
getConditionsForEdition,
|
||||
} from "@initiative/domain";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
ArrowDown,
|
||||
Ban,
|
||||
BatteryLow,
|
||||
Droplet,
|
||||
EarOff,
|
||||
EyeOff,
|
||||
Gem,
|
||||
Ghost,
|
||||
Hand,
|
||||
Heart,
|
||||
Link,
|
||||
Moon,
|
||||
ShieldMinus,
|
||||
Siren,
|
||||
Snail,
|
||||
Sparkles,
|
||||
ZapOff,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||
import { useClickOutside } from "../hooks/use-click-outside.js";
|
||||
import { cn } from "../lib/utils";
|
||||
import {
|
||||
CONDITION_COLOR_CLASSES,
|
||||
CONDITION_ICON_MAP,
|
||||
} from "./condition-styles.js";
|
||||
import { Tooltip } from "./ui/tooltip.js";
|
||||
|
||||
const ICON_MAP: Record<string, LucideIcon> = {
|
||||
EyeOff,
|
||||
Heart,
|
||||
EarOff,
|
||||
BatteryLow,
|
||||
Siren,
|
||||
Hand,
|
||||
Ban,
|
||||
Ghost,
|
||||
ZapOff,
|
||||
Gem,
|
||||
Droplet,
|
||||
ArrowDown,
|
||||
Link,
|
||||
ShieldMinus,
|
||||
Snail,
|
||||
Sparkles,
|
||||
Moon,
|
||||
};
|
||||
|
||||
const COLOR_CLASSES: Record<string, string> = {
|
||||
neutral: "text-muted-foreground",
|
||||
pink: "text-pink-400",
|
||||
amber: "text-amber-400",
|
||||
orange: "text-orange-400",
|
||||
gray: "text-gray-400",
|
||||
violet: "text-violet-400",
|
||||
yellow: "text-yellow-400",
|
||||
slate: "text-slate-400",
|
||||
green: "text-green-400",
|
||||
indigo: "text-indigo-400",
|
||||
sky: "text-sky-400",
|
||||
};
|
||||
|
||||
interface ConditionPickerProps {
|
||||
anchorRef: React.RefObject<HTMLElement | null>;
|
||||
activeConditions: readonly ConditionId[] | undefined;
|
||||
@@ -104,15 +55,7 @@ export function ConditionPicker({
|
||||
setPos({ top, left: anchorRect.left, maxHeight });
|
||||
}, [anchorRef]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [onClose]);
|
||||
useClickOutside(ref, onClose);
|
||||
|
||||
const { edition } = useRulesEditionContext();
|
||||
const conditions = getConditionsForEdition(edition);
|
||||
@@ -129,10 +72,11 @@ export function ConditionPicker({
|
||||
}
|
||||
>
|
||||
{conditions.map((def) => {
|
||||
const Icon = ICON_MAP[def.iconName];
|
||||
const Icon = CONDITION_ICON_MAP[def.iconName];
|
||||
if (!Icon) return null;
|
||||
const isActive = active.has(def.id);
|
||||
const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
||||
const colorClass =
|
||||
CONDITION_COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
||||
return (
|
||||
<Tooltip
|
||||
key={def.id}
|
||||
|
||||
54
apps/web/src/components/condition-styles.ts
Normal file
54
apps/web/src/components/condition-styles.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
ArrowDown,
|
||||
Ban,
|
||||
BatteryLow,
|
||||
Droplet,
|
||||
EarOff,
|
||||
EyeOff,
|
||||
Gem,
|
||||
Ghost,
|
||||
Hand,
|
||||
Heart,
|
||||
Link,
|
||||
Moon,
|
||||
ShieldMinus,
|
||||
Siren,
|
||||
Snail,
|
||||
Sparkles,
|
||||
ZapOff,
|
||||
} from "lucide-react";
|
||||
|
||||
export const CONDITION_ICON_MAP: Record<string, LucideIcon> = {
|
||||
EyeOff,
|
||||
Heart,
|
||||
EarOff,
|
||||
BatteryLow,
|
||||
Siren,
|
||||
Hand,
|
||||
Ban,
|
||||
Ghost,
|
||||
ZapOff,
|
||||
Gem,
|
||||
Droplet,
|
||||
ArrowDown,
|
||||
Link,
|
||||
ShieldMinus,
|
||||
Snail,
|
||||
Sparkles,
|
||||
Moon,
|
||||
};
|
||||
|
||||
export const CONDITION_COLOR_CLASSES: Record<string, string> = {
|
||||
neutral: "text-muted-foreground",
|
||||
pink: "text-pink-400",
|
||||
amber: "text-amber-400",
|
||||
orange: "text-orange-400",
|
||||
gray: "text-gray-400",
|
||||
violet: "text-violet-400",
|
||||
yellow: "text-yellow-400",
|
||||
slate: "text-slate-400",
|
||||
green: "text-green-400",
|
||||
indigo: "text-indigo-400",
|
||||
sky: "text-sky-400",
|
||||
};
|
||||
@@ -3,65 +3,15 @@ import {
|
||||
type ConditionId,
|
||||
getConditionDescription,
|
||||
} from "@initiative/domain";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import {
|
||||
ArrowDown,
|
||||
Ban,
|
||||
BatteryLow,
|
||||
Droplet,
|
||||
EarOff,
|
||||
EyeOff,
|
||||
Gem,
|
||||
Ghost,
|
||||
Hand,
|
||||
Heart,
|
||||
Link,
|
||||
Moon,
|
||||
Plus,
|
||||
ShieldMinus,
|
||||
Siren,
|
||||
Snail,
|
||||
Sparkles,
|
||||
ZapOff,
|
||||
} from "lucide-react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||
import { cn } from "../lib/utils.js";
|
||||
import {
|
||||
CONDITION_COLOR_CLASSES,
|
||||
CONDITION_ICON_MAP,
|
||||
} from "./condition-styles.js";
|
||||
import { Tooltip } from "./ui/tooltip.js";
|
||||
|
||||
const ICON_MAP: Record<string, LucideIcon> = {
|
||||
EyeOff,
|
||||
Heart,
|
||||
EarOff,
|
||||
BatteryLow,
|
||||
Siren,
|
||||
Hand,
|
||||
Ban,
|
||||
Ghost,
|
||||
ZapOff,
|
||||
Gem,
|
||||
Droplet,
|
||||
ArrowDown,
|
||||
Link,
|
||||
ShieldMinus,
|
||||
Snail,
|
||||
Sparkles,
|
||||
Moon,
|
||||
};
|
||||
|
||||
const COLOR_CLASSES: Record<string, string> = {
|
||||
neutral: "text-muted-foreground",
|
||||
pink: "text-pink-400",
|
||||
amber: "text-amber-400",
|
||||
orange: "text-orange-400",
|
||||
gray: "text-gray-400",
|
||||
violet: "text-violet-400",
|
||||
yellow: "text-yellow-400",
|
||||
slate: "text-slate-400",
|
||||
green: "text-green-400",
|
||||
indigo: "text-indigo-400",
|
||||
sky: "text-sky-400",
|
||||
};
|
||||
|
||||
interface ConditionTagsProps {
|
||||
conditions: readonly ConditionId[] | undefined;
|
||||
onRemove: (conditionId: ConditionId) => void;
|
||||
@@ -79,9 +29,10 @@ export function ConditionTags({
|
||||
{conditions?.map((condId) => {
|
||||
const def = CONDITION_DEFINITIONS.find((d) => d.id === condId);
|
||||
if (!def) return null;
|
||||
const Icon = ICON_MAP[def.iconName];
|
||||
const Icon = CONDITION_ICON_MAP[def.iconName];
|
||||
if (!Icon) return null;
|
||||
const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
||||
const colorClass =
|
||||
CONDITION_COLOR_CLASSES[def.color] ?? "text-muted-foreground";
|
||||
return (
|
||||
<Tooltip
|
||||
key={condId}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Check, ClipboardCopy, Download, X } from "lucide-react";
|
||||
import { Check, ClipboardCopy, Download } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { Button } from "./ui/button.js";
|
||||
import { Dialog } from "./ui/dialog.js";
|
||||
import { Dialog, DialogHeader } from "./ui/dialog.js";
|
||||
import { Input } from "./ui/input.js";
|
||||
|
||||
interface ExportMethodDialogProps {
|
||||
@@ -30,18 +29,7 @@ export function ExportMethodDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose} className="w-80">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="font-semibold text-lg">Export Encounter</h2>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={handleClose}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<DialogHeader title="Export Encounter" onClose={handleClose} />
|
||||
<div className="mb-3">
|
||||
<Input
|
||||
type="text"
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useClickOutside } from "../hooks/use-click-outside.js";
|
||||
import { Input } from "./ui/input";
|
||||
|
||||
const DIGITS_ONLY_REGEX = /^\d+$/;
|
||||
@@ -48,15 +49,7 @@ export function HpAdjustPopover({
|
||||
requestAnimationFrame(() => inputRef.current?.focus());
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, [onClose]);
|
||||
useClickOutside(ref, onClose);
|
||||
|
||||
const parsedValue =
|
||||
inputValue === "" ? null : Number.parseInt(inputValue, 10);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ClipboardPaste, FileUp, X } from "lucide-react";
|
||||
import { ClipboardPaste, FileUp } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Button } from "./ui/button.js";
|
||||
import { Dialog } from "./ui/dialog.js";
|
||||
import { Dialog, DialogHeader } from "./ui/dialog.js";
|
||||
|
||||
interface ImportMethodDialogProps {
|
||||
open: boolean;
|
||||
@@ -41,18 +41,7 @@ export function ImportMethodDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={handleClose} className="w-80">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="font-semibold text-lg">Import Encounter</h2>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={handleClose}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<DialogHeader title="Import Encounter" onClose={handleClose} />
|
||||
{mode === "pick" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
|
||||
import { Pencil, Plus, Trash2, X } from "lucide-react";
|
||||
import { Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
|
||||
import { Button } from "./ui/button";
|
||||
import { ConfirmButton } from "./ui/confirm-button";
|
||||
import { Dialog } from "./ui/dialog";
|
||||
import { Dialog, DialogHeader } from "./ui/dialog";
|
||||
|
||||
interface PlayerManagementProps {
|
||||
open: boolean;
|
||||
@@ -24,19 +24,7 @@ export function PlayerManagement({
|
||||
}: Readonly<PlayerManagementProps>) {
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} className="card-glow w-full max-w-md">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="font-semibold text-foreground text-lg">
|
||||
Player Characters
|
||||
</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<X size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
<DialogHeader title="Player Characters" onClose={onClose} />
|
||||
|
||||
{characters.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 py-8 text-center">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { RollMode } from "@initiative/domain";
|
||||
import { ChevronsDown, ChevronsUp } from "lucide-react";
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import { useClickOutside } from "../hooks/use-click-outside.js";
|
||||
|
||||
interface RollModeMenuProps {
|
||||
readonly position: { x: number; y: number };
|
||||
@@ -34,22 +35,7 @@ export function RollModeMenu({
|
||||
setPos({ top, left });
|
||||
}, [position.x, position.y]);
|
||||
|
||||
useEffect(() => {
|
||||
function handleMouseDown(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
document.addEventListener("mousedown", handleMouseDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleMouseDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [onClose]);
|
||||
useClickOutside(ref, onClose);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import type { RulesEdition } from "@initiative/domain";
|
||||
import { Monitor, Moon, Sun, X } from "lucide-react";
|
||||
import { Monitor, Moon, Sun } from "lucide-react";
|
||||
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
||||
import { useThemeContext } from "../contexts/theme-context.js";
|
||||
import { cn } from "../lib/utils.js";
|
||||
import { Button } from "./ui/button.js";
|
||||
import { Dialog } from "./ui/dialog.js";
|
||||
import { Dialog, DialogHeader } from "./ui/dialog.js";
|
||||
|
||||
interface SettingsModalProps {
|
||||
open: boolean;
|
||||
@@ -32,17 +31,7 @@ export function SettingsModal({ open, onClose }: Readonly<SettingsModalProps>) {
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} className="card-glow w-full max-w-sm">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="font-semibold text-foreground text-lg">Settings</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onClose}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<X size={20} />
|
||||
</Button>
|
||||
</div>
|
||||
<DialogHeader title="Settings" onClose={onClose} />
|
||||
|
||||
<div className="flex flex-col gap-5">
|
||||
<div>
|
||||
|
||||
@@ -34,6 +34,31 @@ function SectionDivider() {
|
||||
);
|
||||
}
|
||||
|
||||
function TraitSection({
|
||||
entries,
|
||||
heading,
|
||||
}: Readonly<{
|
||||
entries: readonly { name: string; text: string }[] | 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) => (
|
||||
<div key={e.name} className="text-sm">
|
||||
<span className="font-semibold italic">{e.name}.</span> {e.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatBlock({ creature }: Readonly<StatBlockProps>) {
|
||||
const abilities = [
|
||||
{ label: "STR", score: creature.abilities.str },
|
||||
@@ -134,19 +159,7 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Traits */}
|
||||
{creature.traits && creature.traits.length > 0 && (
|
||||
<>
|
||||
<SectionDivider />
|
||||
<div className="space-y-2">
|
||||
{creature.traits.map((t) => (
|
||||
<div key={t.name} className="text-sm">
|
||||
<span className="font-semibold italic">{t.name}.</span> {t.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<TraitSection entries={creature.traits} />
|
||||
|
||||
{/* Spellcasting */}
|
||||
{creature.spellcasting && creature.spellcasting.length > 0 && (
|
||||
@@ -190,52 +203,9 @@ export function StatBlock({ creature }: Readonly<StatBlockProps>) {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{creature.actions && creature.actions.length > 0 && (
|
||||
<>
|
||||
<SectionDivider />
|
||||
<h3 className="font-bold text-base text-stat-heading">Actions</h3>
|
||||
<div className="space-y-2">
|
||||
{creature.actions.map((a) => (
|
||||
<div key={a.name} className="text-sm">
|
||||
<span className="font-semibold italic">{a.name}.</span> {a.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Bonus Actions */}
|
||||
{creature.bonusActions && creature.bonusActions.length > 0 && (
|
||||
<>
|
||||
<SectionDivider />
|
||||
<h3 className="font-bold text-base text-stat-heading">
|
||||
Bonus Actions
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{creature.bonusActions.map((a) => (
|
||||
<div key={a.name} className="text-sm">
|
||||
<span className="font-semibold italic">{a.name}.</span> {a.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Reactions */}
|
||||
{creature.reactions && creature.reactions.length > 0 && (
|
||||
<>
|
||||
<SectionDivider />
|
||||
<h3 className="font-bold text-base text-stat-heading">Reactions</h3>
|
||||
<div className="space-y-2">
|
||||
{creature.reactions.map((a) => (
|
||||
<div key={a.name} className="text-sm">
|
||||
<span className="font-semibold italic">{a.name}.</span> {a.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<TraitSection entries={creature.actions} heading="Actions" />
|
||||
<TraitSection entries={creature.bonusActions} heading="Bonus Actions" />
|
||||
<TraitSection entries={creature.reactions} heading="Reactions" />
|
||||
|
||||
{/* Legendary Actions */}
|
||||
{!!creature.legendaryActions && (
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useClickOutside } from "../../hooks/use-click-outside.js";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Button } from "./button";
|
||||
|
||||
@@ -42,32 +43,7 @@ export function ConfirmButton({
|
||||
return () => clearTimeout(timerRef.current);
|
||||
}, []);
|
||||
|
||||
// Click-outside listener when confirming
|
||||
useEffect(() => {
|
||||
if (!isConfirming) return;
|
||||
|
||||
function handleMouseDown(e: MouseEvent) {
|
||||
if (
|
||||
wrapperRef.current &&
|
||||
!wrapperRef.current.contains(e.target as Node)
|
||||
) {
|
||||
revert();
|
||||
}
|
||||
}
|
||||
|
||||
function handleEscapeKey(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
revert();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("mousedown", handleMouseDown);
|
||||
document.addEventListener("keydown", handleEscapeKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleMouseDown);
|
||||
document.removeEventListener("keydown", handleEscapeKey);
|
||||
};
|
||||
}, [isConfirming, revert]);
|
||||
useClickOutside(wrapperRef, revert, isConfirming);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { X } from "lucide-react";
|
||||
import { type ReactNode, useEffect, useRef } from "react";
|
||||
import { cn } from "../../lib/utils.js";
|
||||
import { Button } from "./button.js";
|
||||
|
||||
interface DialogProps {
|
||||
open: boolean;
|
||||
@@ -48,3 +50,22 @@ export function Dialog({ open, onClose, className, children }: DialogProps) {
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function DialogHeader({
|
||||
title,
|
||||
onClose,
|
||||
}: Readonly<{ title: string; onClose: () => void }>) {
|
||||
return (
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="font-semibold text-foreground text-lg">{title}</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
onClick={onClose}
|
||||
className="text-muted-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { EllipsisVertical } from "lucide-react";
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { type ReactNode, useRef, useState } from "react";
|
||||
import { useClickOutside } from "../../hooks/use-click-outside.js";
|
||||
import { Button } from "./button";
|
||||
|
||||
export interface OverflowMenuItem {
|
||||
@@ -18,23 +19,7 @@ export function OverflowMenu({ items }: OverflowMenuProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleMouseDown(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
}
|
||||
document.addEventListener("mousedown", handleMouseDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleMouseDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
useClickOutside(ref, () => setOpen(false), open);
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
|
||||
27
apps/web/src/hooks/use-click-outside.ts
Normal file
27
apps/web/src/hooks/use-click-outside.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { RefObject } from "react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function useClickOutside(
|
||||
ref: RefObject<HTMLElement | null>,
|
||||
onClose: () => void,
|
||||
active = true,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
|
||||
function handleMouseDown(e: MouseEvent) {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") onClose();
|
||||
}
|
||||
document.addEventListener("mousedown", handleMouseDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleMouseDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [ref, onClose, active]);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
CombatantInit,
|
||||
ConditionId,
|
||||
CreatureId,
|
||||
DomainError,
|
||||
DomainEvent,
|
||||
Encounter,
|
||||
PlayerCharacter,
|
||||
@@ -120,167 +121,90 @@ export function useEncounter() {
|
||||
return result;
|
||||
}, []);
|
||||
|
||||
const advanceTurn = useCallback(() => {
|
||||
const result = withUndo(() => advanceTurnUseCase(makeStore()));
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
}, [makeStore, withUndo]);
|
||||
|
||||
const retreatTurn = useCallback(() => {
|
||||
const result = withUndo(() => retreatTurnUseCase(makeStore()));
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
}, [makeStore, withUndo]);
|
||||
const dispatchAction = useCallback(
|
||||
(action: () => DomainEvent[] | DomainError) => {
|
||||
const result = withUndo(action);
|
||||
if (!isDomainError(result)) {
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
}
|
||||
},
|
||||
[withUndo],
|
||||
);
|
||||
|
||||
const nextId = useRef(deriveNextId(encounter));
|
||||
|
||||
const advanceTurn = useCallback(
|
||||
() => dispatchAction(() => advanceTurnUseCase(makeStore())),
|
||||
[makeStore, dispatchAction],
|
||||
);
|
||||
|
||||
const retreatTurn = useCallback(
|
||||
() => dispatchAction(() => retreatTurnUseCase(makeStore())),
|
||||
[makeStore, dispatchAction],
|
||||
);
|
||||
|
||||
const addCombatant = useCallback(
|
||||
(name: string, init?: CombatantInit) => {
|
||||
const id = combatantId(`c-${++nextId.current}`);
|
||||
const result = withUndo(() =>
|
||||
addCombatantUseCase(makeStore(), id, name, init),
|
||||
);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
dispatchAction(() => addCombatantUseCase(makeStore(), id, name, init));
|
||||
},
|
||||
[makeStore, withUndo],
|
||||
[makeStore, dispatchAction],
|
||||
);
|
||||
|
||||
const removeCombatant = useCallback(
|
||||
(id: CombatantId) => {
|
||||
const result = withUndo(() => removeCombatantUseCase(makeStore(), id));
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore, withUndo],
|
||||
(id: CombatantId) =>
|
||||
dispatchAction(() => removeCombatantUseCase(makeStore(), id)),
|
||||
[makeStore, dispatchAction],
|
||||
);
|
||||
|
||||
const editCombatant = useCallback(
|
||||
(id: CombatantId, newName: string) => {
|
||||
const result = withUndo(() =>
|
||||
editCombatantUseCase(makeStore(), id, newName),
|
||||
);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore, withUndo],
|
||||
(id: CombatantId, newName: string) =>
|
||||
dispatchAction(() => editCombatantUseCase(makeStore(), id, newName)),
|
||||
[makeStore, dispatchAction],
|
||||
);
|
||||
|
||||
const setInitiative = useCallback(
|
||||
(id: CombatantId, value: number | undefined) => {
|
||||
const result = withUndo(() =>
|
||||
setInitiativeUseCase(makeStore(), id, value),
|
||||
);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore, withUndo],
|
||||
(id: CombatantId, value: number | undefined) =>
|
||||
dispatchAction(() => setInitiativeUseCase(makeStore(), id, value)),
|
||||
[makeStore, dispatchAction],
|
||||
);
|
||||
|
||||
const setHp = useCallback(
|
||||
(id: CombatantId, maxHp: number | undefined) => {
|
||||
const result = withUndo(() => setHpUseCase(makeStore(), id, maxHp));
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore, withUndo],
|
||||
(id: CombatantId, maxHp: number | undefined) =>
|
||||
dispatchAction(() => setHpUseCase(makeStore(), id, maxHp)),
|
||||
[makeStore, dispatchAction],
|
||||
);
|
||||
|
||||
const adjustHp = useCallback(
|
||||
(id: CombatantId, delta: number) => {
|
||||
const result = withUndo(() => adjustHpUseCase(makeStore(), id, delta));
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore, withUndo],
|
||||
(id: CombatantId, delta: number) =>
|
||||
dispatchAction(() => adjustHpUseCase(makeStore(), id, delta)),
|
||||
[makeStore, dispatchAction],
|
||||
);
|
||||
|
||||
const setTempHp = useCallback(
|
||||
(id: CombatantId, tempHp: number | undefined) => {
|
||||
const result = withUndo(() => setTempHpUseCase(makeStore(), id, tempHp));
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore, withUndo],
|
||||
(id: CombatantId, tempHp: number | undefined) =>
|
||||
dispatchAction(() => setTempHpUseCase(makeStore(), id, tempHp)),
|
||||
[makeStore, dispatchAction],
|
||||
);
|
||||
|
||||
const setAc = useCallback(
|
||||
(id: CombatantId, value: number | undefined) => {
|
||||
const result = withUndo(() => setAcUseCase(makeStore(), id, value));
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore, withUndo],
|
||||
(id: CombatantId, value: number | undefined) =>
|
||||
dispatchAction(() => setAcUseCase(makeStore(), id, value)),
|
||||
[makeStore, dispatchAction],
|
||||
);
|
||||
|
||||
const toggleCondition = useCallback(
|
||||
(id: CombatantId, conditionId: ConditionId) => {
|
||||
const result = withUndo(() =>
|
||||
(id: CombatantId, conditionId: ConditionId) =>
|
||||
dispatchAction(() =>
|
||||
toggleConditionUseCase(makeStore(), id, conditionId),
|
||||
);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore, withUndo],
|
||||
),
|
||||
[makeStore, dispatchAction],
|
||||
);
|
||||
|
||||
const toggleConcentration = useCallback(
|
||||
(id: CombatantId) => {
|
||||
const result = withUndo(() =>
|
||||
toggleConcentrationUseCase(makeStore(), id),
|
||||
);
|
||||
|
||||
if (isDomainError(result)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore, withUndo],
|
||||
(id: CombatantId) =>
|
||||
dispatchAction(() => toggleConcentrationUseCase(makeStore(), id)),
|
||||
[makeStore, dispatchAction],
|
||||
);
|
||||
|
||||
const clearEncounter = useCallback(() => {
|
||||
@@ -298,16 +222,11 @@ export function useEncounter() {
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
}, [makeStore]);
|
||||
|
||||
const addOneFromBestiary = useCallback(
|
||||
(
|
||||
entry: BestiaryIndexEntry,
|
||||
): { cId: CreatureId; events: DomainEvent[] } | null => {
|
||||
const resolveAndRename = useCallback(
|
||||
(name: string): string => {
|
||||
const store = makeStore();
|
||||
const existingNames = store.get().combatants.map((c) => c.name);
|
||||
const { newName, renames } = resolveCreatureName(
|
||||
entry.name,
|
||||
existingNames,
|
||||
);
|
||||
const { newName, renames } = resolveCreatureName(name, existingNames);
|
||||
|
||||
for (const { from, to } of renames) {
|
||||
const target = store.get().combatants.find((c) => c.name === from);
|
||||
@@ -316,6 +235,17 @@ export function useEncounter() {
|
||||
}
|
||||
}
|
||||
|
||||
return newName;
|
||||
},
|
||||
[makeStore],
|
||||
);
|
||||
|
||||
const addOneFromBestiary = useCallback(
|
||||
(
|
||||
entry: BestiaryIndexEntry,
|
||||
): { cId: CreatureId; events: DomainEvent[] } | null => {
|
||||
const newName = resolveAndRename(entry.name);
|
||||
|
||||
const slug = entry.name
|
||||
.toLowerCase()
|
||||
.replaceAll(/[^a-z0-9]+/g, "-")
|
||||
@@ -333,7 +263,7 @@ export function useEncounter() {
|
||||
|
||||
return { cId, events: result };
|
||||
},
|
||||
[makeStore],
|
||||
[makeStore, resolveAndRename],
|
||||
);
|
||||
|
||||
const addFromBestiary = useCallback(
|
||||
@@ -385,16 +315,7 @@ export function useEncounter() {
|
||||
const addFromPlayerCharacter = useCallback(
|
||||
(pc: PlayerCharacter) => {
|
||||
const snapshot = encounterRef.current;
|
||||
const store = makeStore();
|
||||
const existingNames = store.get().combatants.map((c) => c.name);
|
||||
const { newName, renames } = resolveCreatureName(pc.name, existingNames);
|
||||
|
||||
for (const { from, to } of renames) {
|
||||
const target = store.get().combatants.find((c) => c.name === from);
|
||||
if (target) {
|
||||
editCombatantUseCase(makeStore(), target.id, to);
|
||||
}
|
||||
}
|
||||
const newName = resolveAndRename(pc.name);
|
||||
|
||||
const id = combatantId(`c-${++nextId.current}`);
|
||||
const result = addCombatantUseCase(makeStore(), id, newName, {
|
||||
@@ -406,7 +327,7 @@ export function useEncounter() {
|
||||
});
|
||||
|
||||
if (isDomainError(result)) {
|
||||
store.save(snapshot);
|
||||
makeStore().save(snapshot);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -416,7 +337,7 @@ export function useEncounter() {
|
||||
|
||||
setEvents((prev) => [...prev, ...result]);
|
||||
},
|
||||
[makeStore],
|
||||
[makeStore, resolveAndRename],
|
||||
);
|
||||
|
||||
const undoAction = useCallback(() => {
|
||||
|
||||
Reference in New Issue
Block a user