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 { 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