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 { 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(
|
||||||
|
(result: SearchResult) => {
|
||||||
const slug = result.name
|
const slug = result.name
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.replace(/[^a-z0-9]+/g, "-")
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
.replace(/(^-|-$)/g, "");
|
.replace(/(^-|-$)/g, "");
|
||||||
const cId = `${result.source.toLowerCase()}:${slug}` as CreatureId;
|
const cId = `${result.source.toLowerCase()}:${slug}` as CreatureId;
|
||||||
setPanelView({ mode: "creature", creatureId: cId });
|
sidePanel.showCreature(cId);
|
||||||
setIsRightPanelFolded(false);
|
},
|
||||||
}, []);
|
[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,14 +380,15 @@ 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" &&
|
||||||
|
!sidePanel.bulkImportMode && (
|
||||||
<Toast
|
<Toast
|
||||||
message={`Loaded ${bulkImport.state.completed}/${bulkImport.state.total} sources (${bulkImport.state.failed} failed)`}
|
message={`Loaded ${bulkImport.state.completed}/${bulkImport.state.total} sources (${bulkImport.state.failed} failed)`}
|
||||||
onDismiss={bulkImport.reset}
|
onDismiss={bulkImport.reset}
|
||||||
|
|||||||
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