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:
Lukas
2026-03-14 11:20:48 +01:00
parent 3ef2370a34
commit cce87318fb
2 changed files with 157 additions and 103 deletions

View File

@@ -26,12 +26,7 @@ import { type SearchResult, useBestiary } from "./hooks/use-bestiary";
import { useBulkImport } from "./hooks/use-bulk-import"; import { useBulkImport } from "./hooks/use-bulk-import";
import { useEncounter } from "./hooks/use-encounter"; import { useEncounter } from "./hooks/use-encounter";
import { usePlayerCharacters } from "./hooks/use-player-characters"; import { usePlayerCharacters } from "./hooks/use-player-characters";
import { useSidePanelState } from "./hooks/use-side-panel-state";
type PanelView =
| { mode: "closed" }
| { mode: "creature"; creatureId: CreatureId }
| { mode: "bulk-import" }
| { mode: "source-manager" };
function rollDice(): number { function rollDice(): number {
return Math.floor(Math.random() * 20) + 1; return Math.floor(Math.random() * 20) + 1;
@@ -119,35 +114,16 @@ export function App() {
} = useBestiary(); } = useBestiary();
const bulkImport = useBulkImport(); const bulkImport = useBulkImport();
const sidePanel = useSidePanelState();
const [rollSkippedCount, setRollSkippedCount] = useState(0); const [rollSkippedCount, setRollSkippedCount] = useState(0);
const [panelView, setPanelView] = useState<PanelView>({ mode: "closed" }); const selectedCreature: Creature | null = sidePanel.selectedCreatureId
const selectedCreatureId = ? (getCreature(sidePanel.selectedCreatureId) ?? null)
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)
: null; : null;
const pinnedCreature: Creature | null = pinnedCreatureId const pinnedCreature: Creature | null = sidePanel.pinnedCreatureId
? (getCreature(pinnedCreatureId) ?? null) ? (getCreature(sidePanel.pinnedCreatureId) ?? null)
: null; : null;
const handleAddFromBestiary = useCallback( const handleAddFromBestiary = useCallback(
@@ -157,10 +133,12 @@ export function App() {
[addFromBestiary], [addFromBestiary],
); );
const handleCombatantStatBlock = useCallback((creatureId: string) => { const handleCombatantStatBlock = useCallback(
setPanelView({ mode: "creature", creatureId: creatureId as CreatureId }); (creatureId: string) => {
setIsRightPanelFolded(false); sidePanel.showCreature(creatureId as CreatureId);
}, []); },
[sidePanel.showCreature],
);
const handleRollInitiative = useCallback( const handleRollInitiative = useCallback(
(id: CombatantId) => { (id: CombatantId) => {
@@ -176,25 +154,17 @@ export function App() {
} }
}, [makeStore, getCreature]); }, [makeStore, getCreature]);
const handleViewStatBlock = useCallback((result: SearchResult) => { const handleViewStatBlock = useCallback(
const slug = result.name (result: SearchResult) => {
.toLowerCase() const slug = result.name
.replace(/[^a-z0-9]+/g, "-") .toLowerCase()
.replace(/(^-|-$)/g, ""); .replace(/[^a-z0-9]+/g, "-")
const cId = `${result.source.toLowerCase()}:${slug}` as CreatureId; .replace(/(^-|-$)/g, "");
setPanelView({ mode: "creature", creatureId: cId }); const cId = `${result.source.toLowerCase()}:${slug}` as CreatureId;
setIsRightPanelFolded(false); sidePanel.showCreature(cId);
}, []); },
[sidePanel.showCreature],
const handleBulkImport = useCallback(() => { );
setPanelView({ mode: "bulk-import" });
setIsRightPanelFolded(false);
}, []);
const handleOpenSourceManager = useCallback(() => {
setPanelView({ mode: "source-manager" });
setIsRightPanelFolded(false);
}, []);
const handleStartBulkImport = useCallback( const handleStartBulkImport = useCallback(
(baseUrl: string) => { (baseUrl: string) => {
@@ -209,29 +179,9 @@ export function App() {
); );
const handleBulkImportDone = useCallback(() => { const handleBulkImportDone = useCallback(() => {
setPanelView({ mode: "closed" }); sidePanel.dismissPanel();
bulkImport.reset(); bulkImport.reset();
}, [bulkImport.reset]); }, [sidePanel.dismissPanel, 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);
}, []);
const actionBarInputRef = useRef<HTMLInputElement>(null); const actionBarInputRef = useRef<HTMLInputElement>(null);
const actionBarAnim = useActionBarAnimation(encounter.combatants.length); const actionBarAnim = useActionBarAnimation(encounter.combatants.length);
@@ -255,11 +205,13 @@ export function App() {
if (!window.matchMedia("(min-width: 1024px)").matches) return; if (!window.matchMedia("(min-width: 1024px)").matches) return;
const active = encounter.combatants[encounter.activeIndex]; const active = encounter.combatants[encounter.activeIndex];
if (!active?.creatureId || !isLoaded) return; if (!active?.creatureId || !isLoaded) return;
setPanelView({ sidePanel.showCreature(active.creatureId as CreatureId);
mode: "creature", }, [
creatureId: active.creatureId as CreatureId, encounter.activeIndex,
}); encounter.combatants,
}, [encounter.activeIndex, encounter.combatants, isLoaded]); isLoaded,
sidePanel.showCreature,
]);
const isEmpty = encounter.combatants.length === 0; const isEmpty = encounter.combatants.length === 0;
const hasCreatureCombatants = encounter.combatants.some( const hasCreatureCombatants = encounter.combatants.some(
@@ -299,7 +251,7 @@ export function App() {
bestiarySearch={search} bestiarySearch={search}
bestiaryLoaded={isLoaded} bestiaryLoaded={isLoaded}
onViewStatBlock={handleViewStatBlock} onViewStatBlock={handleViewStatBlock}
onBulkImport={handleBulkImport} onBulkImport={sidePanel.showBulkImport}
bulkImportDisabled={bulkImport.state.status === "loading"} bulkImportDisabled={bulkImport.state.status === "loading"}
inputRef={actionBarInputRef} inputRef={actionBarInputRef}
playerCharacters={playerCharacters} playerCharacters={playerCharacters}
@@ -308,7 +260,7 @@ export function App() {
onRollAllInitiative={handleRollAllInitiative} onRollAllInitiative={handleRollAllInitiative}
showRollAllInitiative={hasCreatureCombatants} showRollAllInitiative={hasCreatureCombatants}
rollAllInitiativeDisabled={!canRollAllInitiative} rollAllInitiativeDisabled={!canRollAllInitiative}
onOpenSourceManager={handleOpenSourceManager} onOpenSourceManager={sidePanel.showSourceManager}
autoFocus autoFocus
/> />
</div> </div>
@@ -356,7 +308,7 @@ export function App() {
bestiarySearch={search} bestiarySearch={search}
bestiaryLoaded={isLoaded} bestiaryLoaded={isLoaded}
onViewStatBlock={handleViewStatBlock} onViewStatBlock={handleViewStatBlock}
onBulkImport={handleBulkImport} onBulkImport={sidePanel.showBulkImport}
bulkImportDisabled={bulkImport.state.status === "loading"} bulkImportDisabled={bulkImport.state.status === "loading"}
inputRef={actionBarInputRef} inputRef={actionBarInputRef}
playerCharacters={playerCharacters} playerCharacters={playerCharacters}
@@ -365,7 +317,7 @@ export function App() {
onRollAllInitiative={handleRollAllInitiative} onRollAllInitiative={handleRollAllInitiative}
showRollAllInitiative={hasCreatureCombatants} showRollAllInitiative={hasCreatureCombatants}
rollAllInitiativeDisabled={!canRollAllInitiative} rollAllInitiativeDisabled={!canRollAllInitiative}
onOpenSourceManager={handleOpenSourceManager} onOpenSourceManager={sidePanel.showSourceManager}
/> />
</div> </div>
</> </>
@@ -373,9 +325,9 @@ export function App() {
</div> </div>
{/* Pinned Stat Block Panel (left) */} {/* Pinned Stat Block Panel (left) */}
{pinnedCreatureId && isWideDesktop && ( {sidePanel.pinnedCreatureId && sidePanel.isWideDesktop && (
<StatBlockPanel <StatBlockPanel
creatureId={pinnedCreatureId} creatureId={sidePanel.pinnedCreatureId}
creature={pinnedCreature} creature={pinnedCreature}
isSourceCached={isSourceCached} isSourceCached={isSourceCached}
fetchAndCacheSource={fetchAndCacheSource} fetchAndCacheSource={fetchAndCacheSource}
@@ -385,7 +337,7 @@ export function App() {
isFolded={false} isFolded={false}
onToggleFold={() => {}} onToggleFold={() => {}}
onPin={() => {}} onPin={() => {}}
onUnpin={handleUnpin} onUnpin={sidePanel.unpin}
showPinButton={false} showPinButton={false}
side="left" side="left"
onDismiss={() => {}} onDismiss={() => {}}
@@ -394,29 +346,29 @@ export function App() {
{/* Browse Stat Block Panel (right) */} {/* Browse Stat Block Panel (right) */}
<StatBlockPanel <StatBlockPanel
creatureId={selectedCreatureId} creatureId={sidePanel.selectedCreatureId}
creature={selectedCreature} creature={selectedCreature}
isSourceCached={isSourceCached} isSourceCached={isSourceCached}
fetchAndCacheSource={fetchAndCacheSource} fetchAndCacheSource={fetchAndCacheSource}
uploadAndCacheSource={uploadAndCacheSource} uploadAndCacheSource={uploadAndCacheSource}
refreshCache={refreshCache} refreshCache={refreshCache}
panelRole="browse" panelRole="browse"
isFolded={isRightPanelFolded} isFolded={sidePanel.isRightPanelFolded}
onToggleFold={handleToggleFold} onToggleFold={sidePanel.toggleFold}
onPin={handlePin} onPin={sidePanel.togglePin}
onUnpin={() => {}} onUnpin={() => {}}
showPinButton={isWideDesktop && !!selectedCreature} showPinButton={sidePanel.isWideDesktop && !!selectedCreature}
side="right" side="right"
onDismiss={handleDismissBrowsePanel} onDismiss={sidePanel.dismissPanel}
bulkImportMode={bulkImportMode} bulkImportMode={sidePanel.bulkImportMode}
bulkImportState={bulkImport.state} bulkImportState={bulkImport.state}
onStartBulkImport={handleStartBulkImport} onStartBulkImport={handleStartBulkImport}
onBulkImportDone={handleBulkImportDone} onBulkImportDone={handleBulkImportDone}
sourceManagerMode={sourceManagerMode} sourceManagerMode={sidePanel.sourceManagerMode}
/> />
{/* Toast for bulk import progress when panel is closed */} {/* Toast for bulk import progress when panel is closed */}
{bulkImport.state.status === "loading" && !bulkImportMode && ( {bulkImport.state.status === "loading" && !sidePanel.bulkImportMode && (
<Toast <Toast
message={`Loading sources... ${bulkImport.state.completed + bulkImport.state.failed}/${bulkImport.state.total}`} message={`Loading sources... ${bulkImport.state.completed + bulkImport.state.failed}/${bulkImport.state.total}`}
progress={ progress={
@@ -428,19 +380,20 @@ export function App() {
onDismiss={() => {}} onDismiss={() => {}}
/> />
)} )}
{bulkImport.state.status === "complete" && !bulkImportMode && ( {bulkImport.state.status === "complete" && !sidePanel.bulkImportMode && (
<Toast <Toast
message="All sources loaded" message="All sources loaded"
onDismiss={bulkImport.reset} onDismiss={bulkImport.reset}
autoDismissMs={3000} autoDismissMs={3000}
/> />
)} )}
{bulkImport.state.status === "partial-failure" && !bulkImportMode && ( {bulkImport.state.status === "partial-failure" &&
<Toast !sidePanel.bulkImportMode && (
message={`Loaded ${bulkImport.state.completed}/${bulkImport.state.total} sources (${bulkImport.state.failed} failed)`} <Toast
onDismiss={bulkImport.reset} message={`Loaded ${bulkImport.state.completed}/${bulkImport.state.total} sources (${bulkImport.state.failed} failed)`}
/> onDismiss={bulkImport.reset}
)} />
)}
{rollSkippedCount > 0 && ( {rollSkippedCount > 0 && (
<Toast <Toast

View 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,
};
}