Extract useSidePanelState hook from App.tsx
Move panel view state, fold/pin state, isWideDesktop media query, and all related handlers into a dedicated hook, reducing App.tsx by ~80 lines of state management boilerplate. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -26,12 +26,7 @@ import { type SearchResult, useBestiary } from "./hooks/use-bestiary";
|
||||
import { useBulkImport } from "./hooks/use-bulk-import";
|
||||
import { useEncounter } from "./hooks/use-encounter";
|
||||
import { usePlayerCharacters } from "./hooks/use-player-characters";
|
||||
|
||||
type PanelView =
|
||||
| { mode: "closed" }
|
||||
| { mode: "creature"; creatureId: CreatureId }
|
||||
| { mode: "bulk-import" }
|
||||
| { mode: "source-manager" };
|
||||
import { useSidePanelState } from "./hooks/use-side-panel-state";
|
||||
|
||||
function rollDice(): number {
|
||||
return Math.floor(Math.random() * 20) + 1;
|
||||
@@ -119,35 +114,16 @@ export function App() {
|
||||
} = useBestiary();
|
||||
|
||||
const bulkImport = useBulkImport();
|
||||
const sidePanel = useSidePanelState();
|
||||
|
||||
const [rollSkippedCount, setRollSkippedCount] = useState(0);
|
||||
|
||||
const [panelView, setPanelView] = useState<PanelView>({ mode: "closed" });
|
||||
const selectedCreatureId =
|
||||
panelView.mode === "creature" ? panelView.creatureId : null;
|
||||
const bulkImportMode = panelView.mode === "bulk-import";
|
||||
const sourceManagerMode = panelView.mode === "source-manager";
|
||||
const [isRightPanelFolded, setIsRightPanelFolded] = useState(false);
|
||||
const [pinnedCreatureId, setPinnedCreatureId] = useState<CreatureId | null>(
|
||||
null,
|
||||
);
|
||||
const [isWideDesktop, setIsWideDesktop] = useState(
|
||||
() => window.matchMedia("(min-width: 1280px)").matches,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia("(min-width: 1280px)");
|
||||
const handler = (e: MediaQueryListEvent) => setIsWideDesktop(e.matches);
|
||||
mq.addEventListener("change", handler);
|
||||
return () => mq.removeEventListener("change", handler);
|
||||
}, []);
|
||||
|
||||
const selectedCreature: Creature | null = selectedCreatureId
|
||||
? (getCreature(selectedCreatureId) ?? null)
|
||||
const selectedCreature: Creature | null = sidePanel.selectedCreatureId
|
||||
? (getCreature(sidePanel.selectedCreatureId) ?? null)
|
||||
: null;
|
||||
|
||||
const pinnedCreature: Creature | null = pinnedCreatureId
|
||||
? (getCreature(pinnedCreatureId) ?? null)
|
||||
const pinnedCreature: Creature | null = sidePanel.pinnedCreatureId
|
||||
? (getCreature(sidePanel.pinnedCreatureId) ?? null)
|
||||
: null;
|
||||
|
||||
const handleAddFromBestiary = useCallback(
|
||||
@@ -157,10 +133,12 @@ export function App() {
|
||||
[addFromBestiary],
|
||||
);
|
||||
|
||||
const handleCombatantStatBlock = useCallback((creatureId: string) => {
|
||||
setPanelView({ mode: "creature", creatureId: creatureId as CreatureId });
|
||||
setIsRightPanelFolded(false);
|
||||
}, []);
|
||||
const handleCombatantStatBlock = useCallback(
|
||||
(creatureId: string) => {
|
||||
sidePanel.showCreature(creatureId as CreatureId);
|
||||
},
|
||||
[sidePanel.showCreature],
|
||||
);
|
||||
|
||||
const handleRollInitiative = useCallback(
|
||||
(id: CombatantId) => {
|
||||
@@ -176,25 +154,17 @@ export function App() {
|
||||
}
|
||||
}, [makeStore, getCreature]);
|
||||
|
||||
const handleViewStatBlock = useCallback((result: SearchResult) => {
|
||||
const slug = result.name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/(^-|-$)/g, "");
|
||||
const cId = `${result.source.toLowerCase()}:${slug}` as CreatureId;
|
||||
setPanelView({ mode: "creature", creatureId: cId });
|
||||
setIsRightPanelFolded(false);
|
||||
}, []);
|
||||
|
||||
const handleBulkImport = useCallback(() => {
|
||||
setPanelView({ mode: "bulk-import" });
|
||||
setIsRightPanelFolded(false);
|
||||
}, []);
|
||||
|
||||
const handleOpenSourceManager = useCallback(() => {
|
||||
setPanelView({ mode: "source-manager" });
|
||||
setIsRightPanelFolded(false);
|
||||
}, []);
|
||||
const handleViewStatBlock = useCallback(
|
||||
(result: SearchResult) => {
|
||||
const slug = result.name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/(^-|-$)/g, "");
|
||||
const cId = `${result.source.toLowerCase()}:${slug}` as CreatureId;
|
||||
sidePanel.showCreature(cId);
|
||||
},
|
||||
[sidePanel.showCreature],
|
||||
);
|
||||
|
||||
const handleStartBulkImport = useCallback(
|
||||
(baseUrl: string) => {
|
||||
@@ -209,29 +179,9 @@ export function App() {
|
||||
);
|
||||
|
||||
const handleBulkImportDone = useCallback(() => {
|
||||
setPanelView({ mode: "closed" });
|
||||
sidePanel.dismissPanel();
|
||||
bulkImport.reset();
|
||||
}, [bulkImport.reset]);
|
||||
|
||||
const handleDismissBrowsePanel = useCallback(() => {
|
||||
setPanelView({ mode: "closed" });
|
||||
}, []);
|
||||
|
||||
const handleToggleFold = useCallback(() => {
|
||||
setIsRightPanelFolded((f) => !f);
|
||||
}, []);
|
||||
|
||||
const handlePin = useCallback(() => {
|
||||
if (selectedCreatureId) {
|
||||
setPinnedCreatureId((prev) =>
|
||||
prev === selectedCreatureId ? null : selectedCreatureId,
|
||||
);
|
||||
}
|
||||
}, [selectedCreatureId]);
|
||||
|
||||
const handleUnpin = useCallback(() => {
|
||||
setPinnedCreatureId(null);
|
||||
}, []);
|
||||
}, [sidePanel.dismissPanel, bulkImport.reset]);
|
||||
|
||||
const actionBarInputRef = useRef<HTMLInputElement>(null);
|
||||
const actionBarAnim = useActionBarAnimation(encounter.combatants.length);
|
||||
@@ -255,11 +205,13 @@ export function App() {
|
||||
if (!window.matchMedia("(min-width: 1024px)").matches) return;
|
||||
const active = encounter.combatants[encounter.activeIndex];
|
||||
if (!active?.creatureId || !isLoaded) return;
|
||||
setPanelView({
|
||||
mode: "creature",
|
||||
creatureId: active.creatureId as CreatureId,
|
||||
});
|
||||
}, [encounter.activeIndex, encounter.combatants, isLoaded]);
|
||||
sidePanel.showCreature(active.creatureId as CreatureId);
|
||||
}, [
|
||||
encounter.activeIndex,
|
||||
encounter.combatants,
|
||||
isLoaded,
|
||||
sidePanel.showCreature,
|
||||
]);
|
||||
|
||||
const isEmpty = encounter.combatants.length === 0;
|
||||
const hasCreatureCombatants = encounter.combatants.some(
|
||||
@@ -299,7 +251,7 @@ export function App() {
|
||||
bestiarySearch={search}
|
||||
bestiaryLoaded={isLoaded}
|
||||
onViewStatBlock={handleViewStatBlock}
|
||||
onBulkImport={handleBulkImport}
|
||||
onBulkImport={sidePanel.showBulkImport}
|
||||
bulkImportDisabled={bulkImport.state.status === "loading"}
|
||||
inputRef={actionBarInputRef}
|
||||
playerCharacters={playerCharacters}
|
||||
@@ -308,7 +260,7 @@ export function App() {
|
||||
onRollAllInitiative={handleRollAllInitiative}
|
||||
showRollAllInitiative={hasCreatureCombatants}
|
||||
rollAllInitiativeDisabled={!canRollAllInitiative}
|
||||
onOpenSourceManager={handleOpenSourceManager}
|
||||
onOpenSourceManager={sidePanel.showSourceManager}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
@@ -356,7 +308,7 @@ export function App() {
|
||||
bestiarySearch={search}
|
||||
bestiaryLoaded={isLoaded}
|
||||
onViewStatBlock={handleViewStatBlock}
|
||||
onBulkImport={handleBulkImport}
|
||||
onBulkImport={sidePanel.showBulkImport}
|
||||
bulkImportDisabled={bulkImport.state.status === "loading"}
|
||||
inputRef={actionBarInputRef}
|
||||
playerCharacters={playerCharacters}
|
||||
@@ -365,7 +317,7 @@ export function App() {
|
||||
onRollAllInitiative={handleRollAllInitiative}
|
||||
showRollAllInitiative={hasCreatureCombatants}
|
||||
rollAllInitiativeDisabled={!canRollAllInitiative}
|
||||
onOpenSourceManager={handleOpenSourceManager}
|
||||
onOpenSourceManager={sidePanel.showSourceManager}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
@@ -373,9 +325,9 @@ export function App() {
|
||||
</div>
|
||||
|
||||
{/* Pinned Stat Block Panel (left) */}
|
||||
{pinnedCreatureId && isWideDesktop && (
|
||||
{sidePanel.pinnedCreatureId && sidePanel.isWideDesktop && (
|
||||
<StatBlockPanel
|
||||
creatureId={pinnedCreatureId}
|
||||
creatureId={sidePanel.pinnedCreatureId}
|
||||
creature={pinnedCreature}
|
||||
isSourceCached={isSourceCached}
|
||||
fetchAndCacheSource={fetchAndCacheSource}
|
||||
@@ -385,7 +337,7 @@ export function App() {
|
||||
isFolded={false}
|
||||
onToggleFold={() => {}}
|
||||
onPin={() => {}}
|
||||
onUnpin={handleUnpin}
|
||||
onUnpin={sidePanel.unpin}
|
||||
showPinButton={false}
|
||||
side="left"
|
||||
onDismiss={() => {}}
|
||||
@@ -394,29 +346,29 @@ export function App() {
|
||||
|
||||
{/* Browse Stat Block Panel (right) */}
|
||||
<StatBlockPanel
|
||||
creatureId={selectedCreatureId}
|
||||
creatureId={sidePanel.selectedCreatureId}
|
||||
creature={selectedCreature}
|
||||
isSourceCached={isSourceCached}
|
||||
fetchAndCacheSource={fetchAndCacheSource}
|
||||
uploadAndCacheSource={uploadAndCacheSource}
|
||||
refreshCache={refreshCache}
|
||||
panelRole="browse"
|
||||
isFolded={isRightPanelFolded}
|
||||
onToggleFold={handleToggleFold}
|
||||
onPin={handlePin}
|
||||
isFolded={sidePanel.isRightPanelFolded}
|
||||
onToggleFold={sidePanel.toggleFold}
|
||||
onPin={sidePanel.togglePin}
|
||||
onUnpin={() => {}}
|
||||
showPinButton={isWideDesktop && !!selectedCreature}
|
||||
showPinButton={sidePanel.isWideDesktop && !!selectedCreature}
|
||||
side="right"
|
||||
onDismiss={handleDismissBrowsePanel}
|
||||
bulkImportMode={bulkImportMode}
|
||||
onDismiss={sidePanel.dismissPanel}
|
||||
bulkImportMode={sidePanel.bulkImportMode}
|
||||
bulkImportState={bulkImport.state}
|
||||
onStartBulkImport={handleStartBulkImport}
|
||||
onBulkImportDone={handleBulkImportDone}
|
||||
sourceManagerMode={sourceManagerMode}
|
||||
sourceManagerMode={sidePanel.sourceManagerMode}
|
||||
/>
|
||||
|
||||
{/* Toast for bulk import progress when panel is closed */}
|
||||
{bulkImport.state.status === "loading" && !bulkImportMode && (
|
||||
{bulkImport.state.status === "loading" && !sidePanel.bulkImportMode && (
|
||||
<Toast
|
||||
message={`Loading sources... ${bulkImport.state.completed + bulkImport.state.failed}/${bulkImport.state.total}`}
|
||||
progress={
|
||||
@@ -428,19 +380,20 @@ export function App() {
|
||||
onDismiss={() => {}}
|
||||
/>
|
||||
)}
|
||||
{bulkImport.state.status === "complete" && !bulkImportMode && (
|
||||
{bulkImport.state.status === "complete" && !sidePanel.bulkImportMode && (
|
||||
<Toast
|
||||
message="All sources loaded"
|
||||
onDismiss={bulkImport.reset}
|
||||
autoDismissMs={3000}
|
||||
/>
|
||||
)}
|
||||
{bulkImport.state.status === "partial-failure" && !bulkImportMode && (
|
||||
<Toast
|
||||
message={`Loaded ${bulkImport.state.completed}/${bulkImport.state.total} sources (${bulkImport.state.failed} failed)`}
|
||||
onDismiss={bulkImport.reset}
|
||||
/>
|
||||
)}
|
||||
{bulkImport.state.status === "partial-failure" &&
|
||||
!sidePanel.bulkImportMode && (
|
||||
<Toast
|
||||
message={`Loaded ${bulkImport.state.completed}/${bulkImport.state.total} sources (${bulkImport.state.failed} failed)`}
|
||||
onDismiss={bulkImport.reset}
|
||||
/>
|
||||
)}
|
||||
|
||||
{rollSkippedCount > 0 && (
|
||||
<Toast
|
||||
|
||||
101
apps/web/src/hooks/use-side-panel-state.ts
Normal file
101
apps/web/src/hooks/use-side-panel-state.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import type { CreatureId } from "@initiative/domain";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
type PanelView =
|
||||
| { mode: "closed" }
|
||||
| { mode: "creature"; creatureId: CreatureId }
|
||||
| { mode: "bulk-import" }
|
||||
| { mode: "source-manager" };
|
||||
|
||||
interface SidePanelState {
|
||||
panelView: PanelView;
|
||||
selectedCreatureId: CreatureId | null;
|
||||
bulkImportMode: boolean;
|
||||
sourceManagerMode: boolean;
|
||||
isRightPanelFolded: boolean;
|
||||
pinnedCreatureId: CreatureId | null;
|
||||
isWideDesktop: boolean;
|
||||
}
|
||||
|
||||
interface SidePanelActions {
|
||||
showCreature: (creatureId: CreatureId) => void;
|
||||
showBulkImport: () => void;
|
||||
showSourceManager: () => void;
|
||||
dismissPanel: () => void;
|
||||
toggleFold: () => void;
|
||||
togglePin: () => void;
|
||||
unpin: () => void;
|
||||
}
|
||||
|
||||
export function useSidePanelState(): SidePanelState & SidePanelActions {
|
||||
const [panelView, setPanelView] = useState<PanelView>({ mode: "closed" });
|
||||
const [isRightPanelFolded, setIsRightPanelFolded] = useState(false);
|
||||
const [pinnedCreatureId, setPinnedCreatureId] = useState<CreatureId | null>(
|
||||
null,
|
||||
);
|
||||
const [isWideDesktop, setIsWideDesktop] = useState(
|
||||
() => window.matchMedia("(min-width: 1280px)").matches,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia("(min-width: 1280px)");
|
||||
const handler = (e: MediaQueryListEvent) => setIsWideDesktop(e.matches);
|
||||
mq.addEventListener("change", handler);
|
||||
return () => mq.removeEventListener("change", handler);
|
||||
}, []);
|
||||
|
||||
const selectedCreatureId =
|
||||
panelView.mode === "creature" ? panelView.creatureId : null;
|
||||
|
||||
const showCreature = useCallback((creatureId: CreatureId) => {
|
||||
setPanelView({ mode: "creature", creatureId });
|
||||
setIsRightPanelFolded(false);
|
||||
}, []);
|
||||
|
||||
const showBulkImport = useCallback(() => {
|
||||
setPanelView({ mode: "bulk-import" });
|
||||
setIsRightPanelFolded(false);
|
||||
}, []);
|
||||
|
||||
const showSourceManager = useCallback(() => {
|
||||
setPanelView({ mode: "source-manager" });
|
||||
setIsRightPanelFolded(false);
|
||||
}, []);
|
||||
|
||||
const dismissPanel = useCallback(() => {
|
||||
setPanelView({ mode: "closed" });
|
||||
}, []);
|
||||
|
||||
const toggleFold = useCallback(() => {
|
||||
setIsRightPanelFolded((f) => !f);
|
||||
}, []);
|
||||
|
||||
const togglePin = useCallback(() => {
|
||||
if (selectedCreatureId) {
|
||||
setPinnedCreatureId((prev) =>
|
||||
prev === selectedCreatureId ? null : selectedCreatureId,
|
||||
);
|
||||
}
|
||||
}, [selectedCreatureId]);
|
||||
|
||||
const unpin = useCallback(() => {
|
||||
setPinnedCreatureId(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
panelView,
|
||||
selectedCreatureId,
|
||||
bulkImportMode: panelView.mode === "bulk-import",
|
||||
sourceManagerMode: panelView.mode === "source-manager",
|
||||
isRightPanelFolded,
|
||||
pinnedCreatureId,
|
||||
isWideDesktop,
|
||||
showCreature,
|
||||
showBulkImport,
|
||||
showSourceManager,
|
||||
dismissPanel,
|
||||
toggleFold,
|
||||
togglePin,
|
||||
unpin,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user