Compare commits
7 Commits
07cdd4867a
...
0.7.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63e233bd8d | ||
|
|
8c62ec28f2 | ||
|
|
72195e90f6 | ||
|
|
6ac8e67970 | ||
|
|
a4797d5b15 | ||
|
|
d48e39ced4 | ||
|
|
b7406c4b54 |
@@ -2,7 +2,12 @@ import {
|
|||||||
rollAllInitiativeUseCase,
|
rollAllInitiativeUseCase,
|
||||||
rollInitiativeUseCase,
|
rollInitiativeUseCase,
|
||||||
} from "@initiative/application";
|
} from "@initiative/application";
|
||||||
import type { CombatantId, Creature, CreatureId } from "@initiative/domain";
|
import {
|
||||||
|
type CombatantId,
|
||||||
|
type Creature,
|
||||||
|
type CreatureId,
|
||||||
|
isDomainError,
|
||||||
|
} from "@initiative/domain";
|
||||||
import {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -14,7 +19,6 @@ import { ActionBar } from "./components/action-bar";
|
|||||||
import { CombatantRow } from "./components/combatant-row";
|
import { CombatantRow } from "./components/combatant-row";
|
||||||
import { CreatePlayerModal } from "./components/create-player-modal";
|
import { CreatePlayerModal } from "./components/create-player-modal";
|
||||||
import { PlayerManagement } from "./components/player-management";
|
import { PlayerManagement } from "./components/player-management";
|
||||||
import { SourceManager } from "./components/source-manager";
|
|
||||||
import { StatBlockPanel } from "./components/stat-block-panel";
|
import { StatBlockPanel } from "./components/stat-block-panel";
|
||||||
import { Toast } from "./components/toast";
|
import { Toast } from "./components/toast";
|
||||||
import { TurnNavigation } from "./components/turn-navigation";
|
import { TurnNavigation } from "./components/turn-navigation";
|
||||||
@@ -110,10 +114,12 @@ export function App() {
|
|||||||
|
|
||||||
const bulkImport = useBulkImport();
|
const bulkImport = useBulkImport();
|
||||||
|
|
||||||
|
const [rollSkippedCount, setRollSkippedCount] = useState(0);
|
||||||
|
|
||||||
const [selectedCreatureId, setSelectedCreatureId] =
|
const [selectedCreatureId, setSelectedCreatureId] =
|
||||||
useState<CreatureId | null>(null);
|
useState<CreatureId | null>(null);
|
||||||
const [bulkImportMode, setBulkImportMode] = useState(false);
|
const [bulkImportMode, setBulkImportMode] = useState(false);
|
||||||
const [sourceManagerOpen, setSourceManagerOpen] = useState(false);
|
const [sourceManagerMode, setSourceManagerMode] = useState(false);
|
||||||
const [isRightPanelFolded, setIsRightPanelFolded] = useState(false);
|
const [isRightPanelFolded, setIsRightPanelFolded] = useState(false);
|
||||||
const [pinnedCreatureId, setPinnedCreatureId] = useState<CreatureId | null>(
|
const [pinnedCreatureId, setPinnedCreatureId] = useState<CreatureId | null>(
|
||||||
null,
|
null,
|
||||||
@@ -146,6 +152,8 @@ export function App() {
|
|||||||
|
|
||||||
const handleCombatantStatBlock = useCallback((creatureId: string) => {
|
const handleCombatantStatBlock = useCallback((creatureId: string) => {
|
||||||
setSelectedCreatureId(creatureId as CreatureId);
|
setSelectedCreatureId(creatureId as CreatureId);
|
||||||
|
setBulkImportMode(false);
|
||||||
|
setSourceManagerMode(false);
|
||||||
setIsRightPanelFolded(false);
|
setIsRightPanelFolded(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -157,7 +165,10 @@ export function App() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleRollAllInitiative = useCallback(() => {
|
const handleRollAllInitiative = useCallback(() => {
|
||||||
rollAllInitiativeUseCase(makeStore(), rollDice, getCreature);
|
const result = rollAllInitiativeUseCase(makeStore(), rollDice, getCreature);
|
||||||
|
if (!isDomainError(result) && result.skippedNoSource > 0) {
|
||||||
|
setRollSkippedCount(result.skippedNoSource);
|
||||||
|
}
|
||||||
}, [makeStore, getCreature]);
|
}, [makeStore, getCreature]);
|
||||||
|
|
||||||
const handleViewStatBlock = useCallback((result: SearchResult) => {
|
const handleViewStatBlock = useCallback((result: SearchResult) => {
|
||||||
@@ -167,12 +178,23 @@ export function App() {
|
|||||||
.replace(/(^-|-$)/g, "");
|
.replace(/(^-|-$)/g, "");
|
||||||
const cId = `${result.source.toLowerCase()}:${slug}` as CreatureId;
|
const cId = `${result.source.toLowerCase()}:${slug}` as CreatureId;
|
||||||
setSelectedCreatureId(cId);
|
setSelectedCreatureId(cId);
|
||||||
|
setBulkImportMode(false);
|
||||||
|
setSourceManagerMode(false);
|
||||||
setIsRightPanelFolded(false);
|
setIsRightPanelFolded(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleBulkImport = useCallback(() => {
|
const handleBulkImport = useCallback(() => {
|
||||||
setBulkImportMode(true);
|
setBulkImportMode(true);
|
||||||
|
setSourceManagerMode(false);
|
||||||
setSelectedCreatureId(null);
|
setSelectedCreatureId(null);
|
||||||
|
setIsRightPanelFolded(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleOpenSourceManager = useCallback(() => {
|
||||||
|
setSourceManagerMode(true);
|
||||||
|
setBulkImportMode(false);
|
||||||
|
setSelectedCreatureId(null);
|
||||||
|
setIsRightPanelFolded(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleStartBulkImport = useCallback(
|
const handleStartBulkImport = useCallback(
|
||||||
@@ -195,6 +217,7 @@ export function App() {
|
|||||||
const handleDismissBrowsePanel = useCallback(() => {
|
const handleDismissBrowsePanel = useCallback(() => {
|
||||||
setSelectedCreatureId(null);
|
setSelectedCreatureId(null);
|
||||||
setBulkImportMode(false);
|
setBulkImportMode(false);
|
||||||
|
setSourceManagerMode(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleToggleFold = useCallback(() => {
|
const handleToggleFold = useCallback(() => {
|
||||||
@@ -236,10 +259,15 @@ export function App() {
|
|||||||
const active = encounter.combatants[encounter.activeIndex];
|
const active = encounter.combatants[encounter.activeIndex];
|
||||||
if (!active?.creatureId || !isLoaded) return;
|
if (!active?.creatureId || !isLoaded) return;
|
||||||
setSelectedCreatureId(active.creatureId as CreatureId);
|
setSelectedCreatureId(active.creatureId as CreatureId);
|
||||||
|
setBulkImportMode(false);
|
||||||
|
setSourceManagerMode(false);
|
||||||
}, [encounter.activeIndex, encounter.combatants, isLoaded]);
|
}, [encounter.activeIndex, encounter.combatants, isLoaded]);
|
||||||
|
|
||||||
const isEmpty = encounter.combatants.length === 0;
|
const isEmpty = encounter.combatants.length === 0;
|
||||||
const showRollAllInitiative = encounter.combatants.some(
|
const hasCreatureCombatants = encounter.combatants.some(
|
||||||
|
(c) => c.creatureId != null,
|
||||||
|
);
|
||||||
|
const canRollAllInitiative = encounter.combatants.some(
|
||||||
(c) => c.creatureId != null && c.initiative == null,
|
(c) => c.creatureId != null && c.initiative == null,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -280,20 +308,15 @@ export function App() {
|
|||||||
onAddFromPlayerCharacter={addFromPlayerCharacter}
|
onAddFromPlayerCharacter={addFromPlayerCharacter}
|
||||||
onManagePlayers={() => setManagementOpen(true)}
|
onManagePlayers={() => setManagementOpen(true)}
|
||||||
onRollAllInitiative={handleRollAllInitiative}
|
onRollAllInitiative={handleRollAllInitiative}
|
||||||
showRollAllInitiative={showRollAllInitiative}
|
showRollAllInitiative={hasCreatureCombatants}
|
||||||
onOpenSourceManager={() => setSourceManagerOpen((o) => !o)}
|
rollAllInitiativeDisabled={!canRollAllInitiative}
|
||||||
|
onOpenSourceManager={handleOpenSourceManager}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{sourceManagerOpen && (
|
|
||||||
<div className="shrink-0 rounded-md border border-border bg-card px-4 py-3">
|
|
||||||
<SourceManager onCacheCleared={refreshCache} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Scrollable area — combatant list */}
|
{/* Scrollable area — combatant list */}
|
||||||
<div className="flex-1 overflow-y-auto min-h-0">
|
<div className="flex-1 overflow-y-auto min-h-0">
|
||||||
<div className="flex flex-col px-2 py-2">
|
<div className="flex flex-col px-2 py-2">
|
||||||
@@ -342,8 +365,9 @@ export function App() {
|
|||||||
onAddFromPlayerCharacter={addFromPlayerCharacter}
|
onAddFromPlayerCharacter={addFromPlayerCharacter}
|
||||||
onManagePlayers={() => setManagementOpen(true)}
|
onManagePlayers={() => setManagementOpen(true)}
|
||||||
onRollAllInitiative={handleRollAllInitiative}
|
onRollAllInitiative={handleRollAllInitiative}
|
||||||
showRollAllInitiative={showRollAllInitiative}
|
showRollAllInitiative={hasCreatureCombatants}
|
||||||
onOpenSourceManager={() => setSourceManagerOpen((o) => !o)}
|
rollAllInitiativeDisabled={!canRollAllInitiative}
|
||||||
|
onOpenSourceManager={handleOpenSourceManager}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -390,6 +414,7 @@ export function App() {
|
|||||||
bulkImportState={bulkImport.state}
|
bulkImportState={bulkImport.state}
|
||||||
onStartBulkImport={handleStartBulkImport}
|
onStartBulkImport={handleStartBulkImport}
|
||||||
onBulkImportDone={handleBulkImportDone}
|
onBulkImportDone={handleBulkImportDone}
|
||||||
|
sourceManagerMode={sourceManagerMode}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Toast for bulk import progress when panel is closed */}
|
{/* Toast for bulk import progress when panel is closed */}
|
||||||
@@ -419,6 +444,14 @@ export function App() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{rollSkippedCount > 0 && (
|
||||||
|
<Toast
|
||||||
|
message={`${rollSkippedCount} skipped — bestiary source not loaded`}
|
||||||
|
onDismiss={() => setRollSkippedCount(0)}
|
||||||
|
autoDismissMs={4000}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<CreatePlayerModal
|
<CreatePlayerModal
|
||||||
open={createPlayerOpen}
|
open={createPlayerOpen}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
@@ -431,8 +464,8 @@ export function App() {
|
|||||||
name,
|
name,
|
||||||
ac,
|
ac,
|
||||||
maxHp,
|
maxHp,
|
||||||
color,
|
color: color ?? null,
|
||||||
icon,
|
icon: icon ?? null,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
createPlayerCharacter(name, ac, maxHp, color, icon);
|
createPlayerCharacter(name, ac, maxHp, color, icon);
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ interface ActionBarProps {
|
|||||||
onManagePlayers?: () => void;
|
onManagePlayers?: () => void;
|
||||||
onRollAllInitiative?: () => void;
|
onRollAllInitiative?: () => void;
|
||||||
showRollAllInitiative?: boolean;
|
showRollAllInitiative?: boolean;
|
||||||
|
rollAllInitiativeDisabled?: boolean;
|
||||||
onOpenSourceManager?: () => void;
|
onOpenSourceManager?: () => void;
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
}
|
}
|
||||||
@@ -60,6 +61,7 @@ function AddModeSuggestions({
|
|||||||
onSetQueued,
|
onSetQueued,
|
||||||
onConfirmQueued,
|
onConfirmQueued,
|
||||||
onAddFromPlayerCharacter,
|
onAddFromPlayerCharacter,
|
||||||
|
onClear,
|
||||||
}: {
|
}: {
|
||||||
nameInput: string;
|
nameInput: string;
|
||||||
suggestions: SearchResult[];
|
suggestions: SearchResult[];
|
||||||
@@ -67,6 +69,7 @@ function AddModeSuggestions({
|
|||||||
suggestionIndex: number;
|
suggestionIndex: number;
|
||||||
queued: QueuedCreature | null;
|
queued: QueuedCreature | null;
|
||||||
onDismiss: () => void;
|
onDismiss: () => void;
|
||||||
|
onClear: () => void;
|
||||||
onClickSuggestion: (result: SearchResult) => void;
|
onClickSuggestion: (result: SearchResult) => void;
|
||||||
onSetSuggestionIndex: (i: number) => void;
|
onSetSuggestionIndex: (i: number) => void;
|
||||||
onSetQueued: (q: QueuedCreature | null) => void;
|
onSetQueued: (q: QueuedCreature | null) => void;
|
||||||
@@ -95,9 +98,12 @@ function AddModeSuggestions({
|
|||||||
</div>
|
</div>
|
||||||
<ul>
|
<ul>
|
||||||
{pcMatches.map((pc) => {
|
{pcMatches.map((pc) => {
|
||||||
const PcIcon = PLAYER_ICON_MAP[pc.icon as PlayerIcon];
|
const PcIcon = pc.icon
|
||||||
const pcColor =
|
? PLAYER_ICON_MAP[pc.icon as PlayerIcon]
|
||||||
PLAYER_COLOR_HEX[pc.color as keyof typeof PLAYER_COLOR_HEX];
|
: undefined;
|
||||||
|
const pcColor = pc.color
|
||||||
|
? PLAYER_COLOR_HEX[pc.color as keyof typeof PLAYER_COLOR_HEX]
|
||||||
|
: undefined;
|
||||||
return (
|
return (
|
||||||
<li key={pc.id}>
|
<li key={pc.id}>
|
||||||
<button
|
<button
|
||||||
@@ -106,7 +112,7 @@ function AddModeSuggestions({
|
|||||||
onMouseDown={(e) => e.preventDefault()}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onAddFromPlayerCharacter?.(pc);
|
onAddFromPlayerCharacter?.(pc);
|
||||||
onDismiss();
|
onClear();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{PcIcon && (
|
{PcIcon && (
|
||||||
@@ -235,7 +241,7 @@ function buildOverflowItems(opts: {
|
|||||||
if (opts.bestiaryLoaded && opts.onBulkImport) {
|
if (opts.bestiaryLoaded && opts.onBulkImport) {
|
||||||
items.push({
|
items.push({
|
||||||
icon: <Import className="h-4 w-4" />,
|
icon: <Import className="h-4 w-4" />,
|
||||||
label: "Bulk Import",
|
label: "Import All Sources",
|
||||||
onClick: opts.onBulkImport,
|
onClick: opts.onBulkImport,
|
||||||
disabled: opts.bulkImportDisabled,
|
disabled: opts.bulkImportDisabled,
|
||||||
});
|
});
|
||||||
@@ -257,6 +263,7 @@ export function ActionBar({
|
|||||||
onManagePlayers,
|
onManagePlayers,
|
||||||
onRollAllInitiative,
|
onRollAllInitiative,
|
||||||
showRollAllInitiative,
|
showRollAllInitiative,
|
||||||
|
rollAllInitiativeDisabled,
|
||||||
onOpenSourceManager,
|
onOpenSourceManager,
|
||||||
autoFocus,
|
autoFocus,
|
||||||
}: ActionBarProps) {
|
}: ActionBarProps) {
|
||||||
@@ -522,6 +529,7 @@ export function ActionBar({
|
|||||||
suggestionIndex={suggestionIndex}
|
suggestionIndex={suggestionIndex}
|
||||||
queued={queued}
|
queued={queued}
|
||||||
onDismiss={dismissSuggestions}
|
onDismiss={dismissSuggestions}
|
||||||
|
onClear={clearInput}
|
||||||
onClickSuggestion={handleClickSuggestion}
|
onClickSuggestion={handleClickSuggestion}
|
||||||
onSetSuggestionIndex={setSuggestionIndex}
|
onSetSuggestionIndex={setSuggestionIndex}
|
||||||
onSetQueued={setQueued}
|
onSetQueued={setQueued}
|
||||||
@@ -569,6 +577,7 @@ export function ActionBar({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="text-muted-foreground hover:text-hover-action"
|
className="text-muted-foreground hover:text-hover-action"
|
||||||
onClick={onRollAllInitiative}
|
onClick={onRollAllInitiative}
|
||||||
|
disabled={rollAllInitiativeDisabled}
|
||||||
title="Roll all initiative"
|
title="Roll all initiative"
|
||||||
aria-label="Roll all initiative"
|
aria-label="Roll all initiative"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -75,11 +75,10 @@ export function BulkImportPrompt({
|
|||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-foreground">
|
<h3 className="text-sm font-semibold text-foreground">
|
||||||
Bulk Import Sources
|
Import All Sources
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
Load stat block data for all {totalSources} sources at once. This will
|
Load stat block data for all {totalSources} sources at once.
|
||||||
download approximately 12.5 MB of data.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export function ColorPalette({ value, onChange }: ColorPaletteProps) {
|
|||||||
<button
|
<button
|
||||||
key={color}
|
key={color}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onChange(color)}
|
onClick={() => onChange(value === color ? "" : color)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-8 w-8 rounded-full transition-all",
|
"h-8 w-8 rounded-full transition-all",
|
||||||
value === color
|
value === color
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ interface CreatePlayerModalProps {
|
|||||||
name: string,
|
name: string,
|
||||||
ac: number,
|
ac: number,
|
||||||
maxHp: number,
|
maxHp: number,
|
||||||
color: string,
|
color: string | undefined,
|
||||||
icon: string,
|
icon: string | undefined,
|
||||||
) => void;
|
) => void;
|
||||||
playerCharacter?: PlayerCharacter;
|
playerCharacter?: PlayerCharacter;
|
||||||
}
|
}
|
||||||
@@ -40,14 +40,14 @@ export function CreatePlayerModal({
|
|||||||
setName(playerCharacter.name);
|
setName(playerCharacter.name);
|
||||||
setAc(String(playerCharacter.ac));
|
setAc(String(playerCharacter.ac));
|
||||||
setMaxHp(String(playerCharacter.maxHp));
|
setMaxHp(String(playerCharacter.maxHp));
|
||||||
setColor(playerCharacter.color);
|
setColor(playerCharacter.color ?? "");
|
||||||
setIcon(playerCharacter.icon);
|
setIcon(playerCharacter.icon ?? "");
|
||||||
} else {
|
} else {
|
||||||
setName("");
|
setName("");
|
||||||
setAc("10");
|
setAc("10");
|
||||||
setMaxHp("10");
|
setMaxHp("10");
|
||||||
setColor("blue");
|
setColor("");
|
||||||
setIcon("sword");
|
setIcon("");
|
||||||
}
|
}
|
||||||
setError("");
|
setError("");
|
||||||
}
|
}
|
||||||
@@ -81,7 +81,7 @@ export function CreatePlayerModal({
|
|||||||
setError("Max HP must be at least 1");
|
setError("Max HP must be at least 1");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onSave(trimmed, acNum, hpNum, color, icon);
|
onSave(trimmed, acNum, hpNum, color || undefined, icon || undefined);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export function IconGrid({ value, onChange }: IconGridProps) {
|
|||||||
<button
|
<button
|
||||||
key={iconId}
|
key={iconId}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onChange(iconId)}
|
onClick={() => onChange(value === iconId ? "" : iconId)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex h-9 w-9 items-center justify-center rounded-md transition-all",
|
"flex h-9 w-9 items-center justify-center rounded-md transition-all",
|
||||||
value === iconId
|
value === iconId
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
import type {
|
import type { PlayerCharacter, PlayerCharacterId } from "@initiative/domain";
|
||||||
PlayerCharacter,
|
|
||||||
PlayerCharacterId,
|
|
||||||
PlayerIcon,
|
|
||||||
} from "@initiative/domain";
|
|
||||||
import { Pencil, Plus, Trash2, X } from "lucide-react";
|
import { Pencil, Plus, Trash2, X } from "lucide-react";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
|
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map";
|
||||||
@@ -73,9 +69,8 @@ export function PlayerManagement({
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{characters.map((pc) => {
|
{characters.map((pc) => {
|
||||||
const Icon = PLAYER_ICON_MAP[pc.icon as PlayerIcon];
|
const Icon = pc.icon ? PLAYER_ICON_MAP[pc.icon] : undefined;
|
||||||
const color =
|
const color = pc.color ? PLAYER_COLOR_HEX[pc.color] : undefined;
|
||||||
PLAYER_COLOR_HEX[pc.color as keyof typeof PLAYER_COLOR_HEX];
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={pc.id}
|
key={pc.id}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { BulkImportState } from "../hooks/use-bulk-import.js";
|
|||||||
import { useSwipeToDismiss } from "../hooks/use-swipe-to-dismiss.js";
|
import { useSwipeToDismiss } from "../hooks/use-swipe-to-dismiss.js";
|
||||||
import { BulkImportPrompt } from "./bulk-import-prompt.js";
|
import { BulkImportPrompt } from "./bulk-import-prompt.js";
|
||||||
import { SourceFetchPrompt } from "./source-fetch-prompt.js";
|
import { SourceFetchPrompt } from "./source-fetch-prompt.js";
|
||||||
|
import { SourceManager } from "./source-manager.js";
|
||||||
import { StatBlock } from "./stat-block.js";
|
import { StatBlock } from "./stat-block.js";
|
||||||
import { Button } from "./ui/button.js";
|
import { Button } from "./ui/button.js";
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ interface StatBlockPanelProps {
|
|||||||
bulkImportState?: BulkImportState;
|
bulkImportState?: BulkImportState;
|
||||||
onStartBulkImport?: (baseUrl: string) => void;
|
onStartBulkImport?: (baseUrl: string) => void;
|
||||||
onBulkImportDone?: () => void;
|
onBulkImportDone?: () => void;
|
||||||
|
sourceManagerMode?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractSourceCode(cId: CreatureId): string {
|
function extractSourceCode(cId: CreatureId): string {
|
||||||
@@ -236,6 +238,7 @@ export function StatBlockPanel({
|
|||||||
bulkImportState,
|
bulkImportState,
|
||||||
onStartBulkImport,
|
onStartBulkImport,
|
||||||
onBulkImportDone,
|
onBulkImportDone,
|
||||||
|
sourceManagerMode,
|
||||||
}: StatBlockPanelProps) {
|
}: StatBlockPanelProps) {
|
||||||
const [isDesktop, setIsDesktop] = useState(
|
const [isDesktop, setIsDesktop] = useState(
|
||||||
() => window.matchMedia("(min-width: 1024px)").matches,
|
() => window.matchMedia("(min-width: 1024px)").matches,
|
||||||
@@ -269,7 +272,7 @@ export function StatBlockPanel({
|
|||||||
});
|
});
|
||||||
}, [creatureId, creature, isSourceCached]);
|
}, [creatureId, creature, isSourceCached]);
|
||||||
|
|
||||||
if (!creatureId && !bulkImportMode) return null;
|
if (!creatureId && !bulkImportMode && !sourceManagerMode) return null;
|
||||||
|
|
||||||
const sourceCode = creatureId ? extractSourceCode(creatureId) : "";
|
const sourceCode = creatureId ? extractSourceCode(creatureId) : "";
|
||||||
|
|
||||||
@@ -279,6 +282,10 @@ export function StatBlockPanel({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
|
if (sourceManagerMode) {
|
||||||
|
return <SourceManager onCacheCleared={refreshCache} />;
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
bulkImportMode &&
|
bulkImportMode &&
|
||||||
bulkImportState &&
|
bulkImportState &&
|
||||||
@@ -324,7 +331,12 @@ export function StatBlockPanel({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const creatureName =
|
const creatureName =
|
||||||
creature?.name ?? (bulkImportMode ? "Bulk Import" : "Creature");
|
creature?.name ??
|
||||||
|
(sourceManagerMode
|
||||||
|
? "Sources"
|
||||||
|
: bulkImportMode
|
||||||
|
? "Import All Sources"
|
||||||
|
: "Creature");
|
||||||
|
|
||||||
if (isDesktop) {
|
if (isDesktop) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export function Toast({
|
|||||||
}, [autoDismissMs, onDismiss]);
|
}, [autoDismissMs, onDismiss]);
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<div className="fixed bottom-4 left-1/2 z-50 -translate-x-1/2">
|
<div className="fixed bottom-4 left-4 z-50">
|
||||||
<div className="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3 shadow-lg">
|
<div className="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3 shadow-lg">
|
||||||
<span className="text-sm text-foreground">{message}</span>
|
<span className="text-sm text-foreground">{message}</span>
|
||||||
{progress !== undefined && (
|
{progress !== undefined && (
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ interface EditFields {
|
|||||||
readonly name?: string;
|
readonly name?: string;
|
||||||
readonly ac?: number;
|
readonly ac?: number;
|
||||||
readonly maxHp?: number;
|
readonly maxHp?: number;
|
||||||
readonly color?: string;
|
readonly color?: string | null;
|
||||||
readonly icon?: string;
|
readonly icon?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePlayerCharacters() {
|
export function usePlayerCharacters() {
|
||||||
@@ -51,7 +51,13 @@ export function usePlayerCharacters() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const createCharacter = useCallback(
|
const createCharacter = useCallback(
|
||||||
(name: string, ac: number, maxHp: number, color: string, icon: string) => {
|
(
|
||||||
|
name: string,
|
||||||
|
ac: number,
|
||||||
|
maxHp: number,
|
||||||
|
color: string | undefined,
|
||||||
|
icon: string | undefined,
|
||||||
|
) => {
|
||||||
const id = generatePcId();
|
const id = generatePcId();
|
||||||
const result = createPlayerCharacterUseCase(
|
const result = createPlayerCharacterUseCase(
|
||||||
makeStore(),
|
makeStore(),
|
||||||
|
|||||||
@@ -15,6 +15,13 @@ export function savePlayerCharacters(characters: PlayerCharacter[]): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isValidOptionalMember(
|
||||||
|
value: unknown,
|
||||||
|
valid: ReadonlySet<string>,
|
||||||
|
): boolean {
|
||||||
|
return value === undefined || (typeof value === "string" && valid.has(value));
|
||||||
|
}
|
||||||
|
|
||||||
function rehydrateCharacter(raw: unknown): PlayerCharacter | null {
|
function rehydrateCharacter(raw: unknown): PlayerCharacter | null {
|
||||||
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
||||||
return null;
|
return null;
|
||||||
@@ -35,10 +42,8 @@ function rehydrateCharacter(raw: unknown): PlayerCharacter | null {
|
|||||||
entry.maxHp < 1
|
entry.maxHp < 1
|
||||||
)
|
)
|
||||||
return null;
|
return null;
|
||||||
if (typeof entry.color !== "string" || !VALID_PLAYER_COLORS.has(entry.color))
|
if (!isValidOptionalMember(entry.color, VALID_PLAYER_COLORS)) return null;
|
||||||
return null;
|
if (!isValidOptionalMember(entry.icon, VALID_PLAYER_ICONS)) return null;
|
||||||
if (typeof entry.icon !== "string" || !VALID_PLAYER_ICONS.has(entry.icon))
|
|
||||||
return null;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: playerCharacterId(entry.id),
|
id: playerCharacterId(entry.id),
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ export function createPlayerCharacterUseCase(
|
|||||||
name: string,
|
name: string,
|
||||||
ac: number,
|
ac: number,
|
||||||
maxHp: number,
|
maxHp: number,
|
||||||
color: string,
|
color: string | undefined,
|
||||||
icon: string,
|
icon: string | undefined,
|
||||||
): DomainEvent[] | DomainError {
|
): DomainEvent[] | DomainError {
|
||||||
const characters = store.getAll();
|
const characters = store.getAll();
|
||||||
const result = createPlayerCharacter(
|
const result = createPlayerCharacter(
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ interface EditFields {
|
|||||||
readonly name?: string;
|
readonly name?: string;
|
||||||
readonly ac?: number;
|
readonly ac?: number;
|
||||||
readonly maxHp?: number;
|
readonly maxHp?: number;
|
||||||
readonly color?: string;
|
readonly color?: string | null;
|
||||||
readonly icon?: string;
|
readonly icon?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function editPlayerCharacterUseCase(
|
export function editPlayerCharacterUseCase(
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ export type {
|
|||||||
} from "./ports.js";
|
} from "./ports.js";
|
||||||
export { removeCombatantUseCase } from "./remove-combatant-use-case.js";
|
export { removeCombatantUseCase } from "./remove-combatant-use-case.js";
|
||||||
export { retreatTurnUseCase } from "./retreat-turn-use-case.js";
|
export { retreatTurnUseCase } from "./retreat-turn-use-case.js";
|
||||||
export { rollAllInitiativeUseCase } from "./roll-all-initiative-use-case.js";
|
export {
|
||||||
|
type RollAllResult,
|
||||||
|
rollAllInitiativeUseCase,
|
||||||
|
} from "./roll-all-initiative-use-case.js";
|
||||||
export { rollInitiativeUseCase } from "./roll-initiative-use-case.js";
|
export { rollInitiativeUseCase } from "./roll-initiative-use-case.js";
|
||||||
export { setAcUseCase } from "./set-ac-use-case.js";
|
export { setAcUseCase } from "./set-ac-use-case.js";
|
||||||
export { setHpUseCase } from "./set-hp-use-case.js";
|
export { setHpUseCase } from "./set-hp-use-case.js";
|
||||||
|
|||||||
@@ -10,20 +10,29 @@ import {
|
|||||||
} from "@initiative/domain";
|
} from "@initiative/domain";
|
||||||
import type { EncounterStore } from "./ports.js";
|
import type { EncounterStore } from "./ports.js";
|
||||||
|
|
||||||
|
export interface RollAllResult {
|
||||||
|
events: DomainEvent[];
|
||||||
|
skippedNoSource: number;
|
||||||
|
}
|
||||||
|
|
||||||
export function rollAllInitiativeUseCase(
|
export function rollAllInitiativeUseCase(
|
||||||
store: EncounterStore,
|
store: EncounterStore,
|
||||||
rollDice: () => number,
|
rollDice: () => number,
|
||||||
getCreature: (id: CreatureId) => Creature | undefined,
|
getCreature: (id: CreatureId) => Creature | undefined,
|
||||||
): DomainEvent[] | DomainError {
|
): RollAllResult | DomainError {
|
||||||
let encounter = store.get();
|
let encounter = store.get();
|
||||||
const allEvents: DomainEvent[] = [];
|
const allEvents: DomainEvent[] = [];
|
||||||
|
let skippedNoSource = 0;
|
||||||
|
|
||||||
for (const combatant of encounter.combatants) {
|
for (const combatant of encounter.combatants) {
|
||||||
if (!combatant.creatureId) continue;
|
if (!combatant.creatureId) continue;
|
||||||
if (combatant.initiative !== undefined) continue;
|
if (combatant.initiative !== undefined) continue;
|
||||||
|
|
||||||
const creature = getCreature(combatant.creatureId);
|
const creature = getCreature(combatant.creatureId);
|
||||||
if (!creature) continue;
|
if (!creature) {
|
||||||
|
skippedNoSource++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const { modifier } = calculateInitiative({
|
const { modifier } = calculateInitiative({
|
||||||
dexScore: creature.abilities.dex,
|
dexScore: creature.abilities.dex,
|
||||||
@@ -47,5 +56,5 @@ export function rollAllInitiativeUseCase(
|
|||||||
}
|
}
|
||||||
|
|
||||||
store.save(encounter);
|
store.save(encounter);
|
||||||
return allEvents;
|
return { events: allEvents, skippedNoSource };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -219,6 +219,49 @@ describe("createPlayerCharacter", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("allows undefined color", () => {
|
||||||
|
const result = createPlayerCharacter(
|
||||||
|
[],
|
||||||
|
id,
|
||||||
|
"Test",
|
||||||
|
10,
|
||||||
|
50,
|
||||||
|
undefined,
|
||||||
|
"sword",
|
||||||
|
);
|
||||||
|
if (isDomainError(result)) throw new Error(result.message);
|
||||||
|
expect(result.characters[0].color).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows undefined icon", () => {
|
||||||
|
const result = createPlayerCharacter(
|
||||||
|
[],
|
||||||
|
id,
|
||||||
|
"Test",
|
||||||
|
10,
|
||||||
|
50,
|
||||||
|
"blue",
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
if (isDomainError(result)) throw new Error(result.message);
|
||||||
|
expect(result.characters[0].icon).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows both color and icon undefined", () => {
|
||||||
|
const result = createPlayerCharacter(
|
||||||
|
[],
|
||||||
|
id,
|
||||||
|
"Test",
|
||||||
|
10,
|
||||||
|
50,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
if (isDomainError(result)) throw new Error(result.message);
|
||||||
|
expect(result.characters[0].color).toBeUndefined();
|
||||||
|
expect(result.characters[0].icon).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("emits exactly one event on success", () => {
|
it("emits exactly one event on success", () => {
|
||||||
const { events } = success([], "Test", 10, 50);
|
const { events } = success([], "Test", 10, 50);
|
||||||
expect(events).toHaveLength(1);
|
expect(events).toHaveLength(1);
|
||||||
|
|||||||
@@ -106,6 +106,22 @@ describe("editPlayerCharacter", () => {
|
|||||||
expect(result.events).toHaveLength(1);
|
expect(result.events).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("clears color when set to null", () => {
|
||||||
|
const result = editPlayerCharacter([makePC({ color: "green" })], id, {
|
||||||
|
color: null,
|
||||||
|
});
|
||||||
|
if (isDomainError(result)) throw new Error(result.message);
|
||||||
|
expect(result.characters[0].color).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears icon when set to null", () => {
|
||||||
|
const result = editPlayerCharacter([makePC({ icon: "sword" })], id, {
|
||||||
|
icon: null,
|
||||||
|
});
|
||||||
|
if (isDomainError(result)) throw new Error(result.message);
|
||||||
|
expect(result.characters[0].icon).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("event includes old and new name", () => {
|
it("event includes old and new name", () => {
|
||||||
const result = editPlayerCharacter([makePC()], id, { name: "Strider" });
|
const result = editPlayerCharacter([makePC()], id, { name: "Strider" });
|
||||||
if (isDomainError(result)) throw new Error(result.message);
|
if (isDomainError(result)) throw new Error(result.message);
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ export function createPlayerCharacter(
|
|||||||
name: string,
|
name: string,
|
||||||
ac: number,
|
ac: number,
|
||||||
maxHp: number,
|
maxHp: number,
|
||||||
color: string,
|
color: string | undefined,
|
||||||
icon: string,
|
icon: string | undefined,
|
||||||
): CreatePlayerCharacterSuccess | DomainError {
|
): CreatePlayerCharacterSuccess | DomainError {
|
||||||
const trimmed = name.trim();
|
const trimmed = name.trim();
|
||||||
|
|
||||||
@@ -49,7 +49,7 @@ export function createPlayerCharacter(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!VALID_PLAYER_COLORS.has(color)) {
|
if (color !== undefined && !VALID_PLAYER_COLORS.has(color)) {
|
||||||
return {
|
return {
|
||||||
kind: "domain-error",
|
kind: "domain-error",
|
||||||
code: "invalid-color",
|
code: "invalid-color",
|
||||||
@@ -57,7 +57,7 @@ export function createPlayerCharacter(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!VALID_PLAYER_ICONS.has(icon)) {
|
if (icon !== undefined && !VALID_PLAYER_ICONS.has(icon)) {
|
||||||
return {
|
return {
|
||||||
kind: "domain-error",
|
kind: "domain-error",
|
||||||
code: "invalid-icon",
|
code: "invalid-icon",
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ interface EditFields {
|
|||||||
readonly name?: string;
|
readonly name?: string;
|
||||||
readonly ac?: number;
|
readonly ac?: number;
|
||||||
readonly maxHp?: number;
|
readonly maxHp?: number;
|
||||||
readonly color?: string;
|
readonly color?: string | null;
|
||||||
readonly icon?: string;
|
readonly icon?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateFields(fields: EditFields): DomainError | null {
|
function validateFields(fields: EditFields): DomainError | null {
|
||||||
@@ -50,14 +50,22 @@ function validateFields(fields: EditFields): DomainError | null {
|
|||||||
message: "Max HP must be a positive integer",
|
message: "Max HP must be a positive integer",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (fields.color !== undefined && !VALID_PLAYER_COLORS.has(fields.color)) {
|
if (
|
||||||
|
fields.color !== undefined &&
|
||||||
|
fields.color !== null &&
|
||||||
|
!VALID_PLAYER_COLORS.has(fields.color)
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
kind: "domain-error",
|
kind: "domain-error",
|
||||||
code: "invalid-color",
|
code: "invalid-color",
|
||||||
message: `Invalid color: ${fields.color}`,
|
message: `Invalid color: ${fields.color}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (fields.icon !== undefined && !VALID_PLAYER_ICONS.has(fields.icon)) {
|
if (
|
||||||
|
fields.icon !== undefined &&
|
||||||
|
fields.icon !== null &&
|
||||||
|
!VALID_PLAYER_ICONS.has(fields.icon)
|
||||||
|
) {
|
||||||
return {
|
return {
|
||||||
kind: "domain-error",
|
kind: "domain-error",
|
||||||
code: "invalid-icon",
|
code: "invalid-icon",
|
||||||
@@ -78,11 +86,11 @@ function applyFields(
|
|||||||
maxHp: fields.maxHp !== undefined ? fields.maxHp : existing.maxHp,
|
maxHp: fields.maxHp !== undefined ? fields.maxHp : existing.maxHp,
|
||||||
color:
|
color:
|
||||||
fields.color !== undefined
|
fields.color !== undefined
|
||||||
? (fields.color as PlayerCharacter["color"])
|
? ((fields.color as PlayerCharacter["color"]) ?? undefined)
|
||||||
: existing.color,
|
: existing.color,
|
||||||
icon:
|
icon:
|
||||||
fields.icon !== undefined
|
fields.icon !== undefined
|
||||||
? (fields.icon as PlayerCharacter["icon"])
|
? ((fields.icon as PlayerCharacter["icon"]) ?? undefined)
|
||||||
: existing.icon,
|
: existing.icon,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,8 +72,8 @@ export interface PlayerCharacter {
|
|||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly ac: number;
|
readonly ac: number;
|
||||||
readonly maxHp: number;
|
readonly maxHp: number;
|
||||||
readonly color: PlayerColor;
|
readonly color?: PlayerColor;
|
||||||
readonly icon: PlayerIcon;
|
readonly icon?: PlayerIcon;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PlayerCharacterList {
|
export interface PlayerCharacterList {
|
||||||
|
|||||||
Reference in New Issue
Block a user