From cce87318fb354fbb5fe81a40d6abb4de6bfeb9d6 Mon Sep 17 00:00:00 2001 From: Lukas Date: Sat, 14 Mar 2026 11:20:48 +0100 Subject: [PATCH] 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 --- apps/web/src/App.tsx | 159 ++++++++------------- apps/web/src/hooks/use-side-panel-state.ts | 101 +++++++++++++ 2 files changed, 157 insertions(+), 103 deletions(-) create mode 100644 apps/web/src/hooks/use-side-panel-state.ts diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 1f5bb40..3952cbf 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -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({ 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( - 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(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 /> @@ -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} /> @@ -373,9 +325,9 @@ export function App() { {/* Pinned Stat Block Panel (left) */} - {pinnedCreatureId && isWideDesktop && ( + {sidePanel.pinnedCreatureId && sidePanel.isWideDesktop && ( {}} onPin={() => {}} - onUnpin={handleUnpin} + onUnpin={sidePanel.unpin} showPinButton={false} side="left" onDismiss={() => {}} @@ -394,29 +346,29 @@ export function App() { {/* Browse Stat Block Panel (right) */} {}} - 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 && ( {}} /> )} - {bulkImport.state.status === "complete" && !bulkImportMode && ( + {bulkImport.state.status === "complete" && !sidePanel.bulkImportMode && ( )} - {bulkImport.state.status === "partial-failure" && !bulkImportMode && ( - - )} + {bulkImport.state.status === "partial-failure" && + !sidePanel.bulkImportMode && ( + + )} {rollSkippedCount > 0 && ( void; + showBulkImport: () => void; + showSourceManager: () => void; + dismissPanel: () => void; + toggleFold: () => void; + togglePin: () => void; + unpin: () => void; +} + +export function useSidePanelState(): SidePanelState & SidePanelActions { + const [panelView, setPanelView] = useState({ mode: "closed" }); + const [isRightPanelFolded, setIsRightPanelFolded] = useState(false); + const [pinnedCreatureId, setPinnedCreatureId] = useState( + 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, + }; +}