39948-vm/frontend/docs/hooks-module.md
2026-07-03 16:11:24 +02:00

2062 lines
65 KiB
Markdown

# Frontend Hooks Module
## Overview
The Hooks module provides **47 custom React hooks** for reusable logic across the frontend application. Hooks handle everything from runtime presentation (preloading, navigation, transitions) to admin pages (tables, forms, filters), the constructor (elements, drag-drop), and React Query data fetching.
**Location:** `frontend/src/hooks/`
**Total Files:** 56 TypeScript files (41 hooks + 13 query hooks + 2 index files)
**Recent Refactors:**
1. **Page Navigation Consolidation:** 6+ fragmented hooks consolidated into `usePageNavigationState` state machine. See [Navigation State Machine](#usepagenavigationstate) for details.
2. **Video Hooks Module:** New `hooks/video/` directory with 8 primitive hooks for composable video playback. See [Video Hooks Module](./video-hooks-module.md) for details.
3. **Preloading Simplification:** Removed neighbor preloading (stream-first approach). Constructor always uses online mode - transition videos stream on-demand, then cache for replay.
---
## State Management Context
**Redux is the default** for app-wide state management. These hooks complement Redux by handling:
- **Ephemeral state** - Transient UI states that reset on unmount
- **Computed/derived values** - Data derived from Redux state
- **DOM interactions** - Refs, observers, event handlers
- **Session-scoped state** - State that doesn't persist across routes
See [Stores Module - State Management Guidelines](./stores-module.md#state-management-guidelines) for the decision tree on when to use Redux vs local hooks.
**Example:** `usePageNavigation` uses local `useState` because page history is session-scoped and isolated to a single route. If cross-component access or persistence were needed, it would be a Redux slice.
---
## Architecture
```
frontend/src/hooks/
├── index.ts # Central exports (tree-shaking friendly, 67 LOC)
├── Runtime/Preloading Hooks (10)
│ ├── usePreloadOrchestrator.ts # Main asset preloading coordinator (~720 LOC) [SIMPLIFIED]
│ ├── usePageNavigationState.ts # Unified page navigation state machine (~520 LOC)
│ ├── useTransitionPlayback.ts # Video transition playback (~795 LOC)
│ ├── useSlideTransition.ts # Slide transition animation (~180 LOC)
│ ├── usePageNavigation.ts # Page navigation state + history (~195 LOC)
│ ├── useBackgroundVideoPlayback.ts # Background video time control + play once (~225 LOC)
│ ├── useVideoSoundControl.ts # iOS autoplay sound control (~100 LOC)
│ ├── usePageDataLoader.ts # Project/page data loading (~253 LOC)
│ ├── usePageBackground.ts # Page background management (~176 LOC)
│ └── useCanvasScale.ts # Responsive canvas scaling (~110 LOC)
│ # DELETED (consolidated into usePageNavigationState):
│ # - usePageSwitch.ts (~475 LOC)
│ # - useBackgroundTransition.ts (~164 LOC)
│ # - useBackgroundUrls.ts (~104 LOC)
│ # DELETED (simplified preloading - no neighbor traversal):
│ # - useNeighborGraph.ts (~282 LOC)
├── Video Hooks (8)
│ ├── video/index.ts # Exports (~60 LOC)
│ ├── video/useVideoEventManager.ts # Event listener management (~90 LOC)
│ ├── video/useVideoBufferingState.ts # Buffering detection (~180 LOC)
│ ├── video/useVideoBlobUrl.ts # Blob URL resolution (~150 LOC)
│ ├── video/useVideoFirstFrame.ts # First frame detection (~130 LOC)
│ ├── video/useVideoErrorRecovery.ts # Error handling + retry (~160 LOC)
│ ├── video/useVideoTimeouts.ts # Timeout management (~80 LOC)
│ ├── video/useVideoPlaybackCore.ts # Composite playback logic (~280 LOC)
│ └── video/useVideoPlayer.ts # UI video player (~200 LOC)
├── Audio Hooks (2) - NEW
│ ├── audio/useAudioEventManager.ts # Audio event listener management (~115 LOC)
│ └── useBackgroundAudioPlayback.ts # Background audio playback + ducking (~220 LOC)
├── PWA/Offline Hooks (5)
│ ├── useOfflineMode.ts # PWA offline management (~468 LOC)
│ ├── usePWAPreload.ts # PWA asset preloading (~199 LOC)
│ ├── useStorageQuota.ts # Storage quota monitoring (~110 LOC)
│ ├── useNetworkAware.ts # Network condition monitoring (~163 LOC)
│ └── usePreloadProgress.ts # Preload progress tracking (~220 LOC)
├── Constructor Hooks (10)
│ ├── useConstructorElements.ts # Canvas element CRUD + local clipboard
│ ├── useCanvasElementDrag.ts # Element drag handling (~150 LOC)
│ ├── useConstructorPageActions.ts # Page save/create/duplicate support
│ ├── useConstructorData.ts # Constructor data loading (~150 LOC)
│ ├── useTransitionPreview.ts # Transition preview state (~184 LOC)
│ ├── useTransitionCreation.ts # Transition creation state (~134 LOC)
│ ├── useCanvasElapsedTime.ts # Canvas animation timing (~111 LOC)
│ ├── useMediaDurationProbe.ts # Media duration detection (~219 LOC)
│ └── useBackdropEffect.ts # Backdrop visual effects (~190 LOC)
├── Table/List Hooks (3)
│ ├── useEntityTable.ts # Complete table state management (~288 LOC)
│ ├── useFilterItems.ts # Filter state for data tables (~166 LOC)
│ └── useCSVHandling.ts # CSV upload/download operations (~141 LOC)
├── Form Hooks (2)
│ ├── useEditPageSync.ts # Edit page form sync (~166 LOC)
│ └── useFormSync.ts # Form state management (~108 LOC)
├── UI Utility Hooks (10)
│ ├── useDraggable.ts # Draggable panel management (~200 LOC)
│ ├── useOutsideClick.ts # Click outside detection (~88 LOC)
│ ├── useElementEffects.ts # Element interactive effects (~128 LOC)
│ ├── useIconPreload.ts # Icon preloading (~176 LOC)
│ ├── useDashboardCounts.ts # Dashboard entity counts (~308 LOC)
│ ├── useDevCompilationStatus.ts # Dev compilation status (~44 LOC)
│ ├── useProjectAssets.ts # Project asset URL resolution (~102 LOC)
│ ├── useAssetOptions.ts # Asset select options (~119 LOC)
│ ├── usePublishStatus.ts # Publish/save timestamp status (~113 LOC)
│ └── useAudioEffects.ts # Audio playback on hover/click (~305 LOC)
├── queries/ # React Query data fetching hooks (13)
│ ├── index.ts # Query exports (~59 LOC)
│ ├── useAccessLogsQuery.ts # Access logs query (~51 LOC)
│ ├── useAssetVariantsQuery.ts # Asset variants query (~43 LOC)
│ ├── useAssetsQuery.ts # Assets query (~109 LOC)
│ ├── useElementDefaultsQuery.ts # Element defaults query (~50 LOC)
│ ├── usePagesQuery.ts # Tour pages query (~115 LOC)
│ ├── usePermissionsQuery.ts # Permissions query (~35 LOC)
│ ├── useProjectAudioTracksQuery.ts # Audio tracks query (~109 LOC)
│ ├── useProjectMembershipsQuery.ts # Project memberships query (~94 LOC)
│ ├── useProjectQuery.ts # Project query (~84 LOC)
│ ├── usePublishEventsQuery.ts # Publish events query (~56 LOC)
│ ├── usePwaCachesQuery.ts # PWA caches query (~67 LOC)
│ ├── useRolesQuery.ts # Roles query (~112 LOC)
│ └── useUsersQuery.ts # Users query (~126 LOC)
└── Exports
└── index.ts # Tree-shaking friendly exports
```
---
## Hook Categories
### 1. Runtime/Preloading Hooks
These hooks power the runtime presentation viewer, handling asset preloading, page switching, and video transitions.
#### usePreloadOrchestrator
**File:** `usePreloadOrchestrator.ts` (~720 LOC)
**Purpose:** Main coordinator for asset preloading with priority queues and blob URL caching. Uses a **stream-first** approach - only preloads current page assets and outgoing transition videos.
```typescript
interface UsePreloadOrchestratorOptions {
pages: PreloadPage[];
pageLinks: PreloadPageLink[];
elements: PreloadElement[];
currentPageId: string | null;
pageHistory?: string[]; // Used for detecting back navigation
enabled?: boolean;
}
interface UsePreloadOrchestratorResult {
isPreloading: boolean;
currentPhase: 'idle' | 'phase1_current_page' | 'phase2_transitions';
preloadedUrls: Set<string>;
queueLength: number;
/** Version counter that increments when blob URLs become ready (triggers re-renders) */
readyUrlsVersion: number;
preloadAsset: (url: string, priority?: number) => void;
clearQueue: () => void;
getCachedBlobUrl: (url: string) => Promise<string | null>;
isUrlPreloaded: (url: string) => Promise<boolean>;
getReadyBlobUrl: (url: string) => string | null;
}
```
**Key Features:**
- **Stream-first approach:** Only preloads current page + outgoing transitions (no neighbor preloading)
- Priority-based preload queue (transition videos +150, images +100)
- Two phases: Phase 1 (current page backgrounds) → Phase 2 (transition videos)
- Dual storage strategy: Cache API (< 5MB) vs IndexedDB (>= 5MB)
- Presigned URL management with proxy fallback
- Image pre-decoding for instant display
- O(1) blob URL lookup via `readyBlobUrlsRef`
**Recent Simplification:**
The hook was refactored to remove neighbor graph traversal (previously used `useNeighborGraph`). This simplifies the system:
- Constructor always uses online mode
- Transition videos stream on-demand, then cache for replay
- Eliminates network contention from aggressive preloading
**Usage:**
```typescript
const preloadOrchestrator = usePreloadOrchestrator({
pages,
pageLinks,
elements,
currentPageId,
pageHistory,
enabled: true,
});
// Get cached blob URL for instant display
const blobUrl = preloadOrchestrator.getReadyBlobUrl(originalUrl);
// Manually preload with high priority
preloadOrchestrator.preloadAsset(transitionVideoUrl, 150);
```
---
#### usePageNavigationState
**File:** `usePageNavigationState.ts` (~520 LOC)
**Purpose:** Unified state machine for page navigation using `useReducer` for atomic state transitions. Consolidates 6+ fragmented hooks to prevent race conditions.
**Consolidates:**
- `usePageSwitch` - URL resolution and switching
- `useBackgroundState` - Background ready tracking
- `useBackgroundTransition` - Fade-from-black effects
- `useTransitionCleanup` - Video cleanup coordination
- `useBackgroundUrls` - URL resolution for display
- `pageLoadingUtils` - Loading state computation
**State Machine Phases:**
```
┌──────────────────────────────────────────────────────────────────┐
│ PAGE NAVIGATION STATE MACHINE │
├──────────────────────────────────────────────────────────────────┤
│ ┌───────┐ navigate() ┌───────────┐ │
│ │ IDLE │ ─────────────► │ PREPARING │ │
│ └───────┘ └─────┬─────┘ │
│ ▲ YES / \ NO (has transition?) │
│ │ ▼ ▼ │
│ │ ┌─────────────┐ ┌────────────┐ │
│ │ │TRANSITIONING│ │ LOADING_BG │ │
│ │ └──────┬──────┘ └─────┬──────┘ │
│ │ video ends onBackgroundReady │
│ │ ▼ │ │
│ │ ┌─────────────┐ │ │
│ │ │TRANS_DONE │ │ │
│ │ └──────┬──────┘ │ │
│ │ onBackgroundReady │ │
│ │ ▼ ▼ │
│ │ ┌────────────────────────┐ │
│ │ │ FADING_IN │ │
│ │ └───────────┬────────────┘ │
│ │ fade complete │
│ └────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
```
```typescript
type NavigationPhase =
| 'idle' // No navigation in progress
| 'preparing' // Resolving URLs, saving previous state
| 'transitioning' // Video transition playing
| 'transition_done'// Video finished, waiting for background
| 'loading_bg' // Direct navigation, waiting for background
| 'fading_in'; // Black overlay fading out
interface UsePageNavigationStateResult {
// Current state
phase: NavigationPhase;
// Current page URLs (for display)
currentImageUrl: string;
currentVideoUrl: string;
currentAudioUrl: string;
// Previous page URLs (for overlay)
previousImageUrl: string;
previousVideoUrl: string;
// Derived states (computed from phase)
isLoading: boolean;
showSpinner: boolean;
showElements: boolean;
showPreviousOverlay: boolean;
showTransitionVideo: boolean;
isFadingIn: boolean;
isSwitching: boolean;
isNewBgReady: boolean;
isBackgroundReady: boolean;
pendingTransitionComplete: boolean;
// Transition style for CSS
transitionStyle: React.CSSProperties;
// Actions
navigateToPage: (targetPage, options?) => Promise<void>;
onBackgroundReady: () => void;
onTransitionEnded: () => void;
setBackgroundDirectly: (imageUrl, videoUrl, audioUrl) => void;
onVideoBufferStateChange: (isBuffering: boolean) => void;
}
```
**Key Benefits:**
| Before | After |
|--------|-------|
| 6+ hooks with 15+ state variables | 1 hook with 1 state object |
| Race conditions from async setState | Atomic transitions via useReducer |
| Invalid flag combinations possible | State machine prevents invalid states |
| Derived state scattered | All derived state in one useMemo |
| Hard to debug | Single phase value to inspect |
**Usage:**
```typescript
const navState = usePageNavigationState({
preloadCache: preloadOrchestrator
? {
getReadyBlobUrl: preloadOrchestrator.getReadyBlobUrl,
getCachedBlobUrl: preloadOrchestrator.getCachedBlobUrl,
preloadedUrls: preloadOrchestrator.preloadedUrls,
}
: undefined,
transitionSettings,
});
// Navigate to a page
await navState.navigateToPage(targetPage, {
hasTransition: false,
isBack: false,
onSwitched: () => applyPageSelection(targetPage.id, false),
});
// Use in CanvasBackground
<CanvasBackground
backgroundImageUrl={navState.currentImageUrl}
backgroundVideoUrl={navState.currentVideoUrl}
previousBgImageUrl={navState.previousImageUrl}
isSwitching={navState.isSwitching}
isNewBgReady={navState.isNewBgReady}
onBackgroundReady={navState.onBackgroundReady}
/>
```
**Context Provider:**
A `PageNavigationContext` is available for components that need access to navigation state:
```typescript
import { PageNavigationProvider, usePageNavigationContext } from '../context/PageNavigationContext';
// In parent component
<PageNavigationProvider preloadCache={...} transitionSettings={...}>
<CanvasBackground />
<Elements />
</PageNavigationProvider>
// In child component
const { phase, onBackgroundReady } = usePageNavigationContext();
```
---
#### useTransitionPlayback
**File:** `useTransitionPlayback.ts` (~795 LOC)
**Purpose:** Coordinates video transition playback with forward and pre-generated reversed video support.
```typescript
interface TransitionPlaybackOptions {
transition: TransitionPreviewState | null;
pendingPageId: string;
transitionVideoRef: React.RefObject<HTMLVideoElement | null>;
preloadOrchestrator?: UsePreloadOrchestratorResult;
onTransitionComplete: (targetPageId: string) => void;
onTransitionError?: (error: string) => void;
}
interface TransitionPlaybackResult {
isPlaying: boolean;
play: () => Promise<void>;
stop: () => void;
pendingTransitionComplete: boolean;
}
```
**Reverse Modes:**
| Mode | Description |
|------|-------------|
| `none` | Forward playback only |
| `separate` | Use pre-generated reversed video (server-side FFmpeg reversal) |
Reversed videos are pre-generated on the server when pages are saved, using
FFmpeg for audio/video synchronization.
**Source URL Selection:**
```typescript
const sourceUrl = useMemo(() => {
if (!transition) return '';
// Use reversed video if back navigation with separate reversed video
if (transition.isBack && transition.reverseVideoUrl) {
return transition.reverseVideoUrl;
}
return transition.videoUrl;
}, [transition]);
```
**Usage:**
```typescript
const transitionPlayback = useTransitionPlayback({
transition: transitionPreview,
pendingPageId,
transitionVideoRef,
preloadOrchestrator,
onTransitionComplete: (targetId) => {
pageSwitch.switchToPage(pages.find(p => p.id === targetId));
transitionPreviewHook.closePreview();
},
});
```
---
#### useSlideTransition
**File:** `useSlideTransition.ts` (~180 LOC)
**Purpose:** Manages slide transition animation state for Gallery and Carousel elements. Implements fade-through-overlay animation pattern.
**Animation Phases:**
```
Phase 1: Fade Out (half duration)
├── overlayOpacity: 0 → 1
└── slideOpacity: 1 → 0
Phase 2: Swap (at midpoint)
└── displayIndex switches to new slide
Phase 3: Fade In (half duration)
├── overlayOpacity: 1 → 0
└── slideOpacity: 0 → 1
```
```typescript
interface UseSlideTransitionReturn {
currentIndex: number; // Logical current index
displayIndex: number; // Visual index (lags during transition)
phase: 'idle' | 'fadingOut' | 'fadingIn';
isTransitioning: boolean;
overlayOpacity: number; // 0-1
overlayColor: string; // From settings
goToIndex: (index: number) => void;
setInitialIndex: (index: number) => void;
slideTransitionStyle: CSSProperties;
overlayTransitionStyle: CSSProperties;
slideOpacity: number; // Current slide visibility
}
```
**Usage:**
```typescript
import { useSlideTransition } from '../hooks/useSlideTransition';
import { resolveSlideTransition, extractCarouselSlideOverride } from '../lib/resolveSlideTransition';
// Resolve settings with cascade
const slideSettings = resolveSlideTransition(
pageTransitionSettings,
extractCarouselSlideOverride(element),
);
const {
displayIndex,
goToIndex,
overlayOpacity,
overlayColor,
slideTransitionStyle,
overlayTransitionStyle,
slideOpacity,
} = useSlideTransition(slideSettings);
// Navigate
const handleNext = () => goToIndex((currentIndex + 1) % slides.length);
// Render
<img style={{ ...slideTransitionStyle, opacity: slideOpacity }} src={slides[displayIndex].imageUrl} />
<div style={{ ...overlayTransitionStyle, opacity: overlayOpacity, backgroundColor: overlayColor }} />
```
---
> **Note:** Runtime preloading is stream-first: the system preloads current page
> assets and outgoing transition videos.
---
#### usePageNavigation
**File:** `usePageNavigation.ts` (~195 LOC)
**Purpose:** Manages page navigation state with optional history tracking. Used by both `RuntimePresentation.tsx` and `constructor.tsx` for unified history management.
```typescript
interface UsePageNavigationResult<TPage extends NavigablePage> {
currentPageId: string | null;
currentPage: TPage | null;
pageHistory: string[];
previousPageId: string | null;
defaultPage: TPage | null;
setCurrentPageId: (pageId: string) => void;
applyPageSelection: (targetPageId: string, isBack?: boolean) => void;
isBackNavigation: (targetPageId: string) => boolean;
goBack: () => boolean;
resetHistory: () => void;
/** Get navigation context for history-based back navigation */
getNavigationContext: () => NavigationContext;
}
```
**Key Features:**
- **History limit**: `MAX_HISTORY_LENGTH = 50` prevents unbounded growth in long sessions
- **Browser-like back navigation**: History pops when `isBack=true` and target matches previous page
- **Navigation context**: `getNavigationContext()` provides `{ currentPageSlug, previousPageId }` for `resolveNavigationTarget()`
- **Unified state management**: Single source of truth for both constructor and runtime presentation
**Usage:**
```typescript
// Runtime mode with history (RuntimePresentation.tsx)
const {
currentPageId: selectedPageId,
pageHistory,
applyPageSelection,
getNavigationContext,
} = usePageNavigation({
pages,
defaultPageId: initialPageId,
trackHistory: true,
});
// Constructor mode with history (constructor.tsx)
const {
currentPageId: activePageId,
pageHistory,
applyPageSelection,
getNavigationContext,
setCurrentPageId: setActivePageId,
} = usePageNavigation({
pages,
trackHistory: true,
});
// Navigate with back flag (history pops on back)
applyPageSelection(targetPageId, isBack);
// Get context for history-based navigation
const navContext = getNavigationContext();
const navTarget = resolveNavigationTarget(element, pages, navContext);
// Go back programmatically
if (nav.goBack()) {
console.log('Went back to', nav.previousPageId);
}
```
**History Behavior:**
```
Forward: A → B → C → history: [A, B, C]
Back to B (isBack=true): → history: [A, B] ✓ Pops C
Back to A (isBack=true): → history: [A] ✓ Pops B
Forward to D: → history: [A, D] ✓ Adds D
```
---
> **Note:** `useBackgroundTransition` has been consolidated into `usePageNavigationState`. See [usePageNavigationState](#usepagenavigationstate) for the unified state machine.
---
#### useBackgroundVideoPlayback
**File:** `useBackgroundVideoPlayback.ts` (~225 LOC)
**Purpose:** Manages background video playback with custom start/end time control and "play once" functionality for tour pages.
```typescript
interface UseBackgroundVideoPlaybackOptions {
videoUrl?: string;
autoplay?: boolean; // Default: true
loop?: boolean; // Default: true
muted?: boolean; // Default: true
startTime?: number | null; // Start playback at this time (seconds)
endTime?: number | null; // Stop/loop at this time (seconds)
playOnce?: boolean; // Default: false - play only once per session
pageId?: string; // Required for playOnce tracking
}
interface UseBackgroundVideoPlaybackResult {
videoRef: RefObject<HTMLVideoElement | null>;
}
```
**Key Features:**
- Handles `loadedmetadata` event for initial seek to `startTime`
- Uses `timeupdate` event to enforce `endTime` boundary
- When `endTime` is set, handles looping via JavaScript (seeks back to `startTime`)
- Autoplay with browser policy handling (catch and ignore)
- **Play Once Mode:** When `playOnce` is true and `pageId` is provided:
- Tracks played pages in a module-level `Set<string>` (`playedOncePages`)
- On first visit: video plays normally, page ID added to Set on `ended` event
- On subsequent visits: video seeks to last frame and pauses immediately
- Session-scoped (resets on browser refresh, not persisted to storage)
**Usage:**
```typescript
const { videoRef } = useBackgroundVideoPlayback({
videoUrl: page.background_video_url,
autoplay: page.background_video_autoplay ?? true,
loop: page.background_video_loop ?? true,
muted: page.background_video_muted ?? true,
startTime: page.background_video_start_time ?? null,
endTime: page.background_video_end_time ?? null,
playOnce: page.background_video_play_once ?? false,
pageId: page.id, // Required for play-once tracking
});
// Render video with ref
<video
ref={videoRef}
src={videoUrl}
autoPlay={autoplay}
loop={endTime == null ? loop : false} // Use JS loop when endTime is set
muted={muted}
playsInline
/>
```
**Note:** When `endTime` is set, native HTML5 `loop` attribute should be disabled to allow the hook to handle looping via `timeupdate` event, properly seeking back to `startTime`.
---
#### useVideoSoundControl
**File:** `useVideoSoundControl.ts` (~100 LOC)
**Purpose:** Manages global presentation sound state with iOS autoplay compatibility. Presentation audio starts muted so visual media can autoplay, then background audio/video, element hover/click effects, media players, and gallery/info-panel videos can be unmuted by user interaction via a custom sound button.
```typescript
interface UseVideoSoundControlOptions {
pageHasSound: boolean; // Whether page settings allow sound
hasBackgroundVideo: boolean; // Whether page has a background video
hasBackgroundAudio?: boolean; // Whether page has ambient/background audio
hasElementAudio?: boolean; // Whether elements can produce sound
hasPresentationAudio?: boolean; // Whether any page in the presentation can produce sound
}
interface UseVideoSoundControlResult {
isMuted: boolean; // Current muted state (starts true)
showSoundButton: boolean; // Whether to show sound toggle button
toggleSound: () => void; // Toggle muted state
setMuted: (muted: boolean) => void; // Force muted state
}
```
**Key Features:**
- Always starts muted for iOS autoplay compatibility
- Shows sound button when background video sound, background audio, element
audio/video sound, or presentation-level audio exists
- Works with `RuntimeControls` sound toggle button
- Used by runtime presentations and constructor interact mode
**Usage:**
```tsx
const soundControl = useVideoSoundControl({
pageHasSound: page.background_video_muted === false,
hasBackgroundVideo: Boolean(backgroundVideoUrl),
hasBackgroundAudio: Boolean(backgroundAudioUrl),
hasPresentationAudio,
});
// Pass to CanvasBackground (always starts muted)
<CanvasBackground videoMuted={soundControl.isMuted} />
// Pass to RuntimeControls (sound toggle button)
<RuntimeControls
showSoundButton={soundControl.showSoundButton}
isMuted={soundControl.isMuted}
onSoundToggle={soundControl.toggleSound}
/>
```
**iOS Autoplay Policy:**
- iOS WebKit blocks autoplay for unmuted videos
- By starting muted + providing custom sound button, native controls are avoided
- CSS in `main.css` hides native `-webkit-media-controls-*` as fallback
---
### Audio Hooks
#### useAudioEventManager
**File:** `audio/useAudioEventManager.ts` (~115 LOC)
**Purpose:** Manages audio element event listener setup and cleanup with a declarative API. Mirrors the `useVideoEventManager` pattern for consistency.
```typescript
interface UseAudioEventManagerOptions {
audioRef: RefObject<HTMLAudioElement | null>;
enabled?: boolean;
handlers: AudioEventHandlers;
}
interface AudioEventHandlers {
onLoadedMetadata?: (event: Event) => void;
onLoadedData?: (event: Event) => void;
onCanPlay?: (event: Event) => void;
onPlaying?: (event: Event) => void;
onPause?: (event: Event) => void;
onEnded?: (event: Event) => void;
onTimeUpdate?: (event: Event) => void;
onError?: (event: Event) => void;
// ... other audio events
}
```
**Usage:**
```typescript
useAudioEventManager({
audioRef,
enabled: Boolean(audioUrl),
handlers: {
onLoadedMetadata: () => seekToStartTime(),
onTimeUpdate: () => enforceEndTime(),
onEnded: () => markAsPlayed(),
},
});
```
---
#### useBackgroundAudioPlayback
**File:** `useBackgroundAudioPlayback.ts` (~220 LOC)
**Purpose:** Manages background audio playback with custom start/end time control, session-scoped "play once" tracking, and integration with audio ducking system.
```typescript
interface UseBackgroundAudioPlaybackOptions {
audioUrl?: string; // Audio URL (may be blob URL)
audioStoragePath?: string; // Original storage path for play-once tracking
autoplay?: boolean; // Default: true
loop?: boolean; // Default: true
startTime?: number | null; // Start playback at time (seconds)
endTime?: number | null; // Stop/loop at time (seconds)
paused?: boolean; // External pause control (for ducking)
}
interface UseBackgroundAudioPlaybackResult {
audioRef: RefObject<HTMLAudioElement | null>;
shouldBlockAutoplay: boolean; // True if audio already played this session
}
```
**Key Features:**
- **Start/End Time Control:** Seeks to `startTime` on load, loops or pauses at `endTime`
- **Play Once Tracking:** Session-scoped Set tracks played audio (cleared on browser refresh)
- **Audio Ducking Integration:** Registers with `backgroundAudioController` for automatic pause/resume when element audio plays
- **Graceful Autoplay:** Handles browser autoplay restrictions silently
**Usage:**
```typescript
const { audioRef } = useBackgroundAudioPlayback({
audioUrl: page.background_audio_url,
autoplay: page.background_audio_autoplay ?? true,
loop: page.background_audio_loop ?? true,
startTime: page.background_audio_start_time ?? null,
endTime: page.background_audio_end_time ?? null,
});
// Render audio with ref
<audio ref={audioRef} src={audioUrl} preload="auto" hidden />
```
**Audio Ducking:** When element hover/click audio starts playing, background audio is automatically paused via `backgroundAudioController`. When element audio ends, background audio resumes.
---
#### useCanvasScale
**File:** `useCanvasScale.ts` (~110 LOC)
**Purpose:** Manages responsive canvas scaling with letterbox mode for maintaining design aspect ratio across different viewport sizes. Core hook of the UI Adaptivity System.
> **See:** [UI Adaptivity System](ui-adaptivity-system.md) for complete documentation including canvas units, letterbox mode, and element styling.
```typescript
interface UseCanvasScaleOptions {
designWidth?: number; // Design canvas width (default: 1920)
designHeight?: number; // Design canvas height (default: 1080)
}
interface UseCanvasScaleResult {
scale: number; // Current scale factor (1.0 = design size)
designWidth: number; // Design width (from options or default)
designHeight: number; // Design height (from options or default)
canvasWidth: number; // Actual canvas width in pixels
canvasHeight: number; // Actual canvas height in pixels
isPortrait: boolean; // Whether viewport is portrait orientation
showRotatePrompt: boolean; // Whether to show rotate prompt
cssVars: CSSProperties; // CSS custom properties for scaling
letterboxStyles: CSSProperties; // Styles for centered letterbox container
}
```
**Key Features:**
- Calculates scale factor: `min(viewportWidth/designWidth, viewportHeight/designHeight)`
- Provides CSS custom properties (`--cu`, `--canvas-scale`, `--design-width`, `--design-height`)
- Generates letterbox styles for centered canvas with black bars
- Handles portrait orientation detection with rotate prompt
**CSS Custom Properties:**
| Property | Description | Example |
|----------|-------------|---------|
| `--cu` | Canvas unit (1 design pixel) | `calc(1px * 0.75)` |
| `--canvas-scale` | Current scale factor | `0.75` |
| `--design-width` | Design canvas width | `1920` |
| `--design-height` | Design canvas height | `1080` |
**Usage:**
```typescript
const { cssVars, letterboxStyles, isPortrait, showRotatePrompt } = useCanvasScale({
designWidth: project?.design_width,
designHeight: project?.design_height,
});
// Apply to canvas container
<div className="bg-black w-screen h-screen overflow-hidden">
<div style={{ ...cssVars, ...letterboxStyles }}>
{/* Canvas content - all children can use var(--cu) */}
</div>
</div>
```
**Integration with Element Styles:**
```typescript
import { toCU } from '../lib/canvasScale';
// Elements use canvas units for responsive sizing
<button style={{
fontSize: toCU(16), // "calc(16 * var(--cu, 1px))"
padding: toCU(12), // Scales with viewport
borderRadius: toCU(8),
}}>
Click me
</button>
```
---
#### usePageDataLoader
**File:** `usePageDataLoader.ts` (~253 LOC)
**Purpose:** Loads project and page data for runtime and constructor.
```typescript
interface UsePageDataLoaderResult {
project: Project | null;
pages: TourPage[];
elements: CanvasElement[];
pageLinks: PreloadPageLink[];
isLoading: boolean;
error: string | null;
reload: (preservePageId?: string) => Promise<void>;
}
```
---
### 2. PWA/Offline Hooks
Hooks for Progressive Web App functionality including offline caching and network monitoring.
#### useOfflineMode
**File:** `useOfflineMode.ts` (~468 LOC)
**Purpose:** Full offline mode management with download progress tracking.
```typescript
interface UseOfflineModeOptions {
projectId: string | null;
projectSlug?: string;
projectName?: string;
enabled?: boolean;
}
interface UseOfflineModeResult {
// Status
isOfflineCapable: boolean;
isDownloaded: boolean;
isDownloading: boolean;
status: ProjectOfflineStatus;
progress: number;
downloadedAssets: number;
totalAssets: number;
downloadedBytes: number;
totalBytes: number;
error: string | null;
// Actions
startDownload: () => Promise<void>;
pauseDownload: () => void;
resumeDownload: () => void;
cancelDownload: () => void;
deleteOfflineData: () => Promise<void>;
checkForUpdates: () => Promise<boolean>;
// Info
projectInfo: OfflineProject | null;
estimatedSize: number;
formatSize: (bytes: number) => string;
}
```
**Usage:**
```typescript
const offlineMode = useOfflineMode({
projectId,
projectSlug,
projectName,
enabled: true,
});
// Start download
await offlineMode.startDownload();
// Check progress
console.log(`Downloaded: ${offlineMode.progress}%`);
console.log(`${offlineMode.downloadedAssets}/${offlineMode.totalAssets} assets`);
console.log(`Size: ${offlineMode.formatSize(offlineMode.downloadedBytes)}`);
```
---
#### useStorageQuota
**File:** `useStorageQuota.ts` (~110 LOC)
**Purpose:** Monitors storage quota and usage for offline assets.
```typescript
interface UseStorageQuotaResult extends StorageQuotaInfo {
isLoading: boolean;
error: string | null;
refresh: () => Promise<void>;
requestPersistence: () => Promise<boolean>;
isPersisted: boolean;
isWarning: boolean; // >= warningPercent (default 70%)
isCritical: boolean; // >= criticalPercent (default 90%)
formatSize: (bytes: number) => string;
}
```
---
#### useNetworkAware
**File:** `useNetworkAware.ts` (~163 LOC)
**Purpose:** Monitors network conditions and adapts preloading strategy.
```typescript
interface UseNetworkAwareResult {
networkInfo: NetworkInfo;
shouldPreloadAggressively: boolean; // 4G or high downlink
preferLowQuality: boolean; // slow-2g, 2g, or saveData
recommendedConcurrency: number; // 1-3 based on connection
suggestOfflineMode: boolean; // Poor connection detected
}
```
**Network Concurrency Table:**
| Connection | Concurrency |
|------------|-------------|
| slow-2g | 1 |
| 2g | 1 |
| 3g | 2 |
| 4g | 3 |
---
#### usePWAPreload
**File:** `usePWAPreload.ts` (~199 LOC)
**Purpose:** Orchestrates asset preloading for PWA offline caching with progress tracking.
```typescript
interface PreloadState {
isPreloading: boolean;
progress: number;
loadedCount: number;
totalCount: number;
errors: string[];
}
const result = usePWAPreload({
assets: [
{ url: 'image.jpg', type: 'image' },
{ url: 'video.mp4', type: 'video' },
],
onComplete: () => console.log('Done!'),
skipIfCached: true,
});
```
---
### 3. Constructor Hooks
Hooks for the visual tour builder (constructor.tsx).
#### useConstructorElements
**File:** `useConstructorElements.ts`
**Purpose:** Canvas element CRUD operations with selection management, nested
gallery/carousel/info-panel item helpers, and constructor-local element
copy/paste clipboard.
```typescript
interface UseConstructorElementsOptions {
initialElements?: CanvasElement[];
elementDefaultsByType?: Partial<Record<CanvasElementType, Partial<CanvasElement>>>;
allowedNavigationTypes?: NavigationElementType[];
onElementsChange?: (elements: CanvasElement[]) => void;
initialSelectedElementId?: string;
onElementSelected?: (elementId: string) => void;
onSelectionCleared?: () => void;
onElementAdded?: (element: CanvasElement) => void;
onElementRemoved?: (elementId: string) => void;
onElementCopied?: (element: CanvasElement) => void;
onElementPasted?: (element: CanvasElement) => void;
}
interface UseConstructorElementsResult {
elements: CanvasElement[];
setElements: React.Dispatch<React.SetStateAction<CanvasElement[]>>;
getElements: () => CanvasElement[];
selectedElementId: string;
selectedElement: CanvasElement | null;
copiedElement: CanvasElement | null;
canPasteElement: boolean;
selectElement: (elementId: string) => void;
clearSelection: () => void;
addElement: (type: CanvasElementType) => void;
updateSelectedElement: (patch: Partial<CanvasElement>) => void;
updateElement: (elementId: string, patch: Partial<CanvasElement>) => void;
removeSelectedElement: () => void;
copySelectedElement: () => void;
pasteCopiedElement: () => CanvasElement | null;
removeElement: (elementId: string) => void;
// Gallery cards
galleryCards: {
add: () => void;
update: (cardId: string, patch: Partial<GalleryCard>) => void;
remove: (cardId: string) => void;
};
// Gallery info spans
galleryInfoSpans: {
add: () => void;
update: (spanId: string, text: string) => void;
remove: (spanId: string) => void;
};
// Carousel slides
carouselSlides: {
add: () => void;
update: (slideId: string, patch: Partial<CarouselSlide>) => void;
remove: (slideId: string) => void;
};
updateElementPosition: (elementId: string, xPercent: number, yPercent: number) => void;
bringToFront: (elementId: string) => void;
sendToBack: (elementId: string) => void;
}
```
`setElements` is wrapped by the hook so it updates both React state and an
internal `elementsRef`. `getElements()` reads that ref synchronously. This is
required for same-tick flows such as Paste followed immediately by Save or Save
to Stage, where React may not have committed the visible re-render yet.
Element copy/paste uses `cloneElementForPaste()` from `lib/elementDefaults.ts`.
The clone preserves all non-identity settings, including CSS styles, effects,
media, links, navigation targets, transition fields, dimensions, and position.
Only the top-level element ID and nested gallery/carousel/info-panel item IDs are
regenerated.
**Usage:**
```typescript
const {
elements,
getElements,
selectedElement,
addElement,
updateSelectedElement,
updateElement,
copySelectedElement,
pasteCopiedElement,
canPasteElement,
galleryCards,
galleryInfoSpans,
} = useConstructorElements({
initialElements,
onElementsChange: setElements,
elementDefaultsByType,
});
// Add a button element
addElement('button');
// Update selected element
updateSelectedElement({ label: 'New Label' });
// Update element by ID
updateElement(elementId, { xPercent: 50 });
// Add gallery card to selected gallery
galleryCards.add();
// Add info span to selected gallery
galleryInfoSpans.add();
// Toolbar copy/paste actions
copySelectedElement();
if (canPasteElement) pasteCopiedElement();
```
---
#### useCanvasElementDrag
**File:** `useCanvasElementDrag.ts` (~150 LOC)
**Purpose:** Handles element dragging on the constructor canvas.
```typescript
interface UseCanvasElementDragResult {
isDragging: boolean;
onDragStart: (event: React.MouseEvent, elementId: string) => void;
onDragEnd: () => void;
}
```
**Usage:**
```typescript
const { isDragging, onDragStart, onDragEnd } = useCanvasElementDrag({
canvasRef,
elements,
onPositionChange: (elementId, xPercent, yPercent) => {
updateElementPosition(elementId, xPercent, yPercent);
},
});
// Attach to element
<div
onMouseDown={(e) => onDragStart(e, element.id)}
onMouseUp={onDragEnd}
>
```
---
#### useConstructorPageActions
**File:** `useConstructorPageActions.ts`
**Purpose:** Page create/save/publish operations in constructor, including page
duplication orchestration.
```typescript
interface UseConstructorPageActionsOptions {
activePageId: string;
elements: CanvasElement[];
getElements?: () => CanvasElement[];
pageBackground: PageBackgroundState;
onReload: () => Promise<void>;
}
interface UseConstructorPageActionsResult {
isSaving: boolean;
isSavingToStage: boolean;
isCreatingPage: boolean;
isDuplicatingPage: boolean;
isCreatingTransition: boolean;
saveConstructor: () => Promise<void>;
saveToStage: () => Promise<void>;
createPage: () => Promise<void>;
duplicatePage: (sourcePageId: string, name: string, slug: string) => Promise<TourPage | null>;
createTransition: (params: TransitionParams) => Promise<void>;
}
```
`saveConstructor()` serializes `getElements?.() ?? elements` into
`tour_pages.ui_schema_json.elements`. Passing `getElements` from
`useConstructorElements` keeps pasted elements from being dropped if Save/Stage
is clicked immediately after Paste.
Page duplication saves the active page first, then calls
`POST /api/tour_pages/:id/duplicate`; the backend creates an independent dev
page at the end of the presentation order. Page deletion itself is triggered
from `constructor.tsx` so the page can show the shared confirmation modal and
choose the next active page after the API call.
---
#### useTransitionPreview
**File:** `useTransitionPreview.ts` (~200 LOC)
**Purpose:** Manages transition video preview state in constructor.
```typescript
interface UseTransitionPreviewResult {
preview: TransitionPreviewState | null;
pendingPageId: string;
openPreview: (element: TransitionElement, direction: 'forward' | 'back') => void;
openPreviewWithTarget: (element: TransitionElement, direction: 'forward' | 'back', targetPageId: string) => void;
closePreview: () => void;
isActive: boolean;
}
```
---
### 4. Table/List Hooks
Hooks for admin data tables.
#### useEntityTable
**File:** `useEntityTable.ts` (~296 LOC)
**Purpose:** Complete state management for entity data tables.
```typescript
interface UseEntityTableReturn<T> {
data: T[];
columns: GridColDef[];
loading: boolean;
count: number;
// Pagination
currentPage: number;
setCurrentPage: (page: number) => void;
numPages: number;
// Sorting
sortModel: GridSortModel;
setSortModel: (model: GridSortModel) => void;
// Selection
selectedRows: string[];
setSelectedRows: (ids: string[]) => void;
// Filtering
filterItems: FilterItem[];
handleFilterSubmit: () => void;
handleFilterReset: () => void;
// Actions
handleDeleteClick: (id: string) => void;
handleDeleteConfirm: () => Promise<void>;
handleRowUpdate: (id: string, data: Partial<T>) => Promise<void>;
// Data loading
loadData: (page?: number, request?: string) => void;
}
```
**Usage:**
```typescript
const tableProps = useEntityTable<User>({
entityName: 'users',
entityNamePlural: 'users',
fetchAction: usersFetch,
deleteAction: usersDelete,
configureColumns: configureUsersCols,
filters: usersFilters,
});
// In component
<DataGrid
rows={tableProps.data}
columns={tableProps.columns}
loading={tableProps.loading}
sortModel={tableProps.sortModel}
onSortModelChange={tableProps.setSortModel}
/>
```
---
#### useFilterItems
**File:** `useFilterItems.ts` (~166 LOC)
**Purpose:** Manages filter state for data tables.
```typescript
interface UseFilterItemsReturn {
filterItems: FilterItem[];
setFilterItems: (items: FilterItem[]) => void;
addFilter: () => void;
removeFilter: (id: string) => void;
updateFilter: (id: string, fields: Partial<FilterFields>) => void;
resetFilters: () => void;
generateFilterRequest: () => string;
filterRequest: FilterRequest;
}
```
**Usage:**
```typescript
const filters: Filter[] = [
{ title: 'name', label: 'Name' },
{ title: 'status', label: 'Status', type: 'enum', options: ['active', 'inactive'] },
{ title: 'created_at', label: 'Created', date: true },
];
const { filterItems, addFilter, generateFilterRequest } = useFilterItems(filters);
// Add filter
addFilter();
// Generate query string
const queryString = generateFilterRequest(); // "&name=john&statusRange=active"
```
---
#### useCSVHandling
**File:** `useCSVHandling.ts` (~141 LOC)
**Purpose:** CSV upload and download operations.
```typescript
interface UseCSVHandlingReturn {
csvFile: File | null;
setCsvFile: (file: File | null) => void;
isModalActive: boolean;
setIsModalActive: (active: boolean) => void;
isUploading: boolean;
isDownloading: boolean;
downloadCSV: () => Promise<void>;
uploadCSV: () => Promise<void>;
error: string | null;
}
```
---
### 5. Form Hooks
Hooks for form management in edit pages.
#### useEditPageSync
**File:** `useEditPageSync.ts` (~166 LOC)
**Purpose:** Syncs edit page forms with Redux entity state.
```typescript
interface UseEditPageSyncOptions<T> {
entitySelector: (state: RootState) => unknown;
fetchAction: AsyncThunk<unknown, { id?: string; query?: string }, object>;
initialValues: T;
postProcess?: (entity: T, initial: T) => T;
idOverride?: string;
}
interface UseEditPageSyncReturn<T> {
values: T;
setValues: React.Dispatch<React.SetStateAction<T>>;
id: string | null;
isLoading: boolean;
isFound: boolean;
}
```
**Usage:**
```typescript
const initVals = { name: '', permissions: [] };
const { values, id, isLoading } = useEditPageSync({
entitySelector: (state) => state.roles.roles,
fetchAction: fetch,
initialValues: initVals,
postProcess: (entity, initial) => ({
...entity,
permissions: entity.permissions?.map(p => ({ value: p.id, label: p.name })) || [],
}),
});
// Use in Formik
<Formik enableReinitialize initialValues={values} onSubmit={handleSubmit}>
```
---
#### useFormSync
**File:** `useFormSync.ts` (~108 LOC)
**Purpose:** Generic form state management with field tracking.
```typescript
interface UseFormSyncReturn<T> {
values: T;
setFieldValue: (field: keyof T, value: T[keyof T]) => void;
setValues: (values: Partial<T>) => void;
resetForm: () => void;
isDirty: boolean;
dirtyFields: Set<keyof T>;
}
```
---
### 6. UI Utility Hooks
General-purpose utility hooks.
#### useDraggable
**File:** `useDraggable.ts` (~200 LOC)
**Purpose:** Generic draggable panel management with pointer tracking.
```typescript
interface UseDraggableResult {
position: Position;
setPosition: (position: Position) => void;
isDragging: boolean;
onDragStart: (event: React.MouseEvent) => void;
onDragStartIgnoreButtons: (event: React.MouseEvent) => void;
}
```
**Usage:**
```typescript
const { position, onDragStart, isDragging } = useDraggable({
initialPosition: { x: 20, y: 20 },
elementWidth: 400,
elementHeight: 300,
});
<div style={{ left: position.x, top: position.y }}>
<div onMouseDown={onDragStart}>Drag Handle</div>
<div>Content</div>
</div>
```
---
#### useOutsideClick
**File:** `useOutsideClick.ts` (~88 LOC)
**Purpose:** Detects clicks outside specified elements.
```typescript
useOutsideClick({
containerRef: panelRef,
ignoreRefs: [buttonRef],
ignoreDataAttribute: 'data-element-id',
selectedValue: selectedElementId,
onOutsideClick: () => setSelectedId(''),
enabled: !!selectedId,
});
```
---
#### useElementEffects
**File:** `useElementEffects.ts` (~128 LOC)
**Purpose:** Manages element interactive effects (hover, focus, active) at runtime. Exposes internal state for integration with other hooks (e.g., `useAudioEffects`).
```typescript
interface UseElementEffectsResult {
effectStyle: CSSProperties;
eventHandlers: {
onMouseEnter: () => void;
onMouseLeave: () => void;
onFocus: () => void;
onBlur: () => void;
onMouseDown: () => void;
onMouseUp: () => void;
onTouchStart: () => void;
onTouchEnd: () => void;
};
onPersistClick: () => void;
/** Exposed state for audio effects integration */
state: {
isHovered: boolean;
isActive: boolean;
};
}
```
**Priority:** active > focus > hover > base
**Usage:**
```typescript
const { effectStyle, eventHandlers, state: effectState } = useElementEffects(element);
// Use effectState for audio integration
useAudioEffects({
isHovered: effectState.isHovered,
isActive: effectState.isActive,
// ...
});
<div style={{ ...baseStyle, ...effectStyle }} {...eventHandlers}>
{content}
</div>
```
---
#### useAudioEffects
**File:** `useAudioEffects.ts` (~305 LOC)
**Purpose:** Manages audio playback for element hover and click effects. Uses HTML5 Audio API for lightweight, non-visual audio playback with authenticated URL support.
```typescript
interface UseAudioEffectsOptions {
/** URL of audio to play on hover */
hoverAudioUrl?: string;
/** URL of audio to play on click/active */
clickAudioUrl?: string;
/** Volume level 0-1 (iOS ignores this) */
volume?: number;
/** Current hover state from useElementEffects */
isHovered: boolean;
/** Current active/pressed state from useElementEffects */
isActive: boolean;
/** Optional URL resolver for preloaded blob URLs */
resolveUrl?: (url: string | undefined) => string;
/** Reset key to stop audio (e.g., element ID or page slug) */
resetKey?: string | number;
}
interface UseAudioEffectsResult {
/** Manually trigger click audio playback */
playClickAudio: () => void;
/** Stop all audio playback */
stopAll: () => void;
}
```
**Key Features:**
- **Hover audio**: Plays to completion once started (not interrupted by mouse leave)
- **Click audio**: Interruptible - clicking again restarts from beginning
- **Authenticated URLs**: Fetches audio via `fetch()` with credentials, creates blob URLs
- **Caching**: Tracks fetched URLs to prevent duplicate requests
- **Mobile support**: Touch events (via `useElementEffects`) trigger hover audio
**Cross-Browser Compatibility:**
| Feature | Chrome | Firefox | Safari | iOS Safari | Android |
|---------|--------|---------|--------|------------|---------|
| HTML5 Audio | ✅ | ✅ | ✅ | ✅ | ✅ |
| Volume control | ✅ | ✅ | ✅ | ❌ (iOS ignores) | ✅ |
| Autoplay (click) | ✅ | ✅ | ✅ | ✅ (after tap) | ✅ |
**Usage:**
```typescript
// In CanvasElement.tsx or RuntimeElement.tsx
const { effectStyle, eventHandlers, state: effectState } = useElementEffects(
isEditMode ? {} : effectProperties,
);
// Audio effects - only active in preview mode (not edit mode)
useAudioEffects({
hoverAudioUrl: isEditMode ? undefined : effectProperties.hoverAudioUrl,
clickAudioUrl: isEditMode ? undefined : effectProperties.clickAudioUrl,
volume: parseFloat(effectProperties.audioVolume || '1'),
isHovered: effectState.isHovered,
isActive: effectState.isActive,
resolveUrl, // Resolves to preloaded blob URLs
resetKey: element.id,
});
```
**Integration with Element Effects:**
The hook depends on `useElementEffects` for hover/active state. The `isHovered` and `isActive` states are passed through to trigger audio playback at the right moments.
**Constructor vs Runtime:**
- **Constructor Edit Mode**: Audio disabled (`hoverAudioUrl: undefined`)
- **Constructor Preview Mode**: Audio enabled (press F5 to test)
- **Runtime Presentation**: Audio enabled
---
#### useDashboardCounts
**File:** `useDashboardCounts.ts` (~308 LOC)
**Purpose:** Fetches entity counts for dashboard with permission filtering.
```typescript
interface UseDashboardCountsReturn {
counts: Record<string, EntityCountValue>;
loading: boolean;
error: Error | null;
refetch: () => Promise<void>;
getCount: (key: string) => EntityCountValue;
getVisibleEntities: () => EntityConfig[];
}
```
**Dashboard Entities:**
- users, roles, permissions, projects
- project_memberships, assets, asset_variants
- presigned_url_requests, tour_pages, project_audio_tracks
- publish_events, pwa_caches, access_logs
---
#### useProjectAssets
**File:** `useProjectAssets.ts` (~95 LOC)
**Purpose:** Preloads and resolves project assets (favicon, og_image, logo) to presigned URLs. Handles both `storage_key` (relative paths) and legacy `cdn_url` (full S3 URLs) formats.
```typescript
interface ProjectAssetInput {
favicon_url?: string;
og_image_url?: string;
logo_url?: string;
}
interface ProjectAssets {
faviconUrl: string | null;
ogImageUrl: string | null;
logoUrl: string | null;
isLoading: boolean;
}
function useProjectAssets(project: ProjectAssetInput | null): ProjectAssets;
```
**Key Features:**
- Extracts storage paths from URLs (handles both formats)
- Queues presigned URL requests for relative paths
- Resolves to playback URLs (presigned if available, otherwise proxy)
- Tracks URL changes to avoid redundant processing
**Usage:**
```typescript
const { faviconUrl, ogImageUrl, logoUrl, isLoading } = useProjectAssets(project);
// Use resolved URLs
{faviconUrl && <link rel="icon" href={faviconUrl} />}
{ogImageUrl && <meta property="og:image" content={ogImageUrl} />}
{logoUrl && <img src={logoUrl} alt="Logo" />}
```
---
#### usePublishStatus
**File:** `usePublishStatus.ts` (~113 LOC)
**Purpose:** Fetches the last successful publish/save events for a project to display timestamps in the UI. Follows the `useDashboardCounts` pattern with `mountedRef` and parallel API calls.
```typescript
interface UsePublishStatusOptions {
projectId: string | null;
}
interface UsePublishStatusResult {
/** Last successful dev → stage save timestamp */
lastSavedToStage: string | null;
/** Last successful stage → production publish timestamp */
lastPublishedToProduction: string | null;
/** Whether data is loading */
isLoading: boolean;
/** Refresh the status (call after new publish) */
refresh: () => Promise<void>;
}
```
**Key Features:**
- Fetches both dev→stage and stage→production events in parallel via `Promise.all`
- Uses `mountedRef` pattern to prevent state updates after unmount
- Queries `publish_events` API with `projectId`, environment filters, and `status: 'success'`
- Returns `finished_at` timestamp of most recent successful event
**Usage:**
```typescript
// In Project Dashboard (pages/projects/[projectsId].tsx)
const { lastPublishedToProduction, refresh: refreshPublishStatus } = usePublishStatus({
projectId,
});
// Display in button subtitle
<BaseButton
label="Publish to Production"
subtitle={lastPublishedToProduction
? `Last: ${dataFormatter.relativeTimestamp(lastPublishedToProduction)}`
: undefined}
onClick={() => setIsPublishModalActive(true)}
/>
// Refresh after successful publish
const handlePublish = async () => {
await axios.post('/publish', { projectId, title, description });
await refreshPublishStatus();
};
// In Constructor (pages/constructor.tsx)
const { lastSavedToStage, refresh: refreshPublishStatus } = usePublishStatus({
projectId,
});
// Project-level save timestamp: most recent updatedAt across all pages
const lastProjectSaveAt = useMemo(() => {
if (!pages.length) return null;
return pages.reduce((latest, page) => {
if (!page.updatedAt) return latest;
if (!latest) return page.updatedAt;
return new Date(page.updatedAt) > new Date(latest) ? page.updatedAt : latest;
}, null as string | null);
}, [pages]);
// Wrap saveToStage to refresh status
const handleSaveToStage = useCallback(async () => {
await saveToStage();
await refreshPublishStatus();
}, [saveToStage, refreshPublishStatus]);
// Pass timestamps to ConstructorMenu
<ConstructorMenu
lastSavedAt={lastProjectSaveAt} // Project-level, not page-level
lastSavedToStageAt={lastSavedToStage}
onSaveToStage={handleSaveToStage}
/>
```
**Integration with dataFormatter:**
```typescript
import dataFormatter from '../../helpers/dataFormatter';
// Format timestamp for display
dataFormatter.relativeTimestamp(lastPublishedToProduction);
// → "Just now", "5 min ago", "2 hours ago", "Today at 14:30", "Apr 28 at 16:45"
```
---
## Index Exports
**File:** `index.ts` (~44 LOC)
The index file exports hooks and their types for tree-shaking friendly imports:
```typescript
// Exported from index.ts (centralized)
export { useFilterItems } from './useFilterItems';
export { useCSVHandling } from './useCSVHandling';
export { useFormSync } from './useFormSync';
export { useEntityTable } from './useEntityTable';
export { useTransitionPlayback } from './useTransitionPlayback';
export type { ReverseMode, TransitionConfig, UseTransitionPlaybackOptions,
PlaybackPhase, UseTransitionPlaybackResult } from './useTransitionPlayback';
export { usePageNavigation } from './usePageNavigation';
export type { NavigablePage, UsePageNavigationOptions,
UsePageNavigationResult } from './usePageNavigation';
export { usePageNavigationState } from './usePageNavigationState';
export type { NavigationPhase, UsePageNavigationStateOptions,
UsePageNavigationStateResult } from './usePageNavigationState';
export { useBackgroundVideoPlayback } from './useBackgroundVideoPlayback';
export type { UseBackgroundVideoPlaybackOptions,
UseBackgroundVideoPlaybackResult } from './useBackgroundVideoPlayback';
export { useVideoSoundControl } from './useVideoSoundControl';
export type { UseVideoSoundControlOptions,
UseVideoSoundControlResult } from './useVideoSoundControl';
export { usePageDataLoader } from './usePageDataLoader';
export type { UsePageDataLoaderOptions, UsePageDataLoaderResult } from './usePageDataLoader';
// Import directly for better tree-shaking (not in index.ts)
// - usePreloadOrchestrator
// - useNeighborGraph
// - useConstructorElements
// - useCanvasElementDrag
// - useTransitionPreview
// - useOfflineMode
// - useOutsideClick
// - useCanvasElapsedTime
// - useDraggable
// - useMediaDurationProbe
// - useIconPreload
// - useProjectAssets
```
---
## Design Patterns
### 1. Options/Result Pattern
All hooks follow a consistent interface pattern:
```typescript
interface UseXxxOptions {
// Configuration inputs
}
interface UseXxxResult {
// State and methods
}
function useXxx(options: UseXxxOptions): UseXxxResult {
// Implementation
}
```
### 2. Callback Refs
Heavy operations use callback refs to avoid re-renders:
```typescript
const readyBlobUrlsRef = useRef<Map<string, string>>(new Map());
// O(1) lookup without triggering re-renders
const getBlobUrl = useCallback((url: string) => {
return readyBlobUrlsRef.current.get(url) || null;
}, []);
```
### 3. Cleanup on Unmount
All hooks properly cleanup resources:
```typescript
useEffect(() => {
const controller = new AbortController();
fetchData(controller.signal);
return () => controller.abort();
}, []);
```
### 4. Memoization
Expensive computations are memoized:
```typescript
const neighbors = useMemo(() => {
return buildNeighborGraph(pages, pageLinks);
}, [pages, pageLinks]);
```
---
## Hook Composition
Hooks are designed for composition - larger hooks use smaller hooks:
```
RuntimePresentation
├── usePageDataLoader
├── usePreloadOrchestrator
│ └── useNeighborGraph
├── usePageNavigationState # Unified state machine (replaces 6 hooks)
│ └── Internal: useReducer + derived state via useMemo
├── useTransitionPlayback # Uses pre-generated reversed videos
└── usePageNavigation # History tracking
```
```
ConstructorPage
├── usePageDataLoader
├── usePreloadOrchestrator
│ └── useNeighborGraph
├── usePageNavigationState # Same unified state machine
├── useConstructorElements
├── useCanvasElementDrag
├── useConstructorPageActions
├── useTransitionPreview
├── useDraggable (multiple instances)
└── useOutsideClick
```
**Note:** The `usePageNavigationState` hook consolidated:
- `usePageSwitch` - URL resolution and switching
- `useBackgroundState` - Background ready tracking
- `useBackgroundTransition` - Fade-from-black effects
- `useTransitionCleanup` - Video cleanup coordination
- `useBackgroundUrls` - URL resolution for display
- `pageLoadingUtils` - Loading state computation
---
## Performance Considerations
### 1. Tree-Shaking
Large hooks are imported directly rather than via index.ts:
```typescript
// Good - only imports what's needed
import { usePreloadOrchestrator } from '../hooks/usePreloadOrchestrator';
// Avoid for large hooks
import { usePreloadOrchestrator } from '../hooks';
```
### 2. Ref-Based State
State that doesn't need to trigger re-renders uses refs:
```typescript
// Good - no re-renders
const queueRef = useRef<PriorityQueue>(new PriorityQueue());
// Avoid for high-frequency updates
const [queue, setQueue] = useState<Item[]>([]);
```
### 3. Debounced Updates
High-frequency operations are debounced:
```typescript
const debouncedUpdate = useMemo(
() => debounce((value) => setPosition(value), 16),
[]
);
```
---
## Testing Considerations
Hooks can be tested with `@testing-library/react-hooks`:
```typescript
import { renderHook, act } from '@testing-library/react-hooks';
import { useFilterItems } from './useFilterItems';
test('adds filter', () => {
const { result } = renderHook(() => useFilterItems(filters));
act(() => {
result.current.addFilter();
});
expect(result.current.filterItems).toHaveLength(1);
});
```
---
## Component-Specific Hooks
Some hooks are co-located with their components rather than in the central hooks directory:
### useAssetUploader
**Location:** `frontend/src/components/Assets/useAssetUploader.ts` (~279 LOC)
**Purpose:** Batch asset upload management with queue tracking, concurrent uploads, and MIME validation.
```typescript
interface UseAssetUploaderReturn {
uploadingSections: string[]; // Sections with active uploads
uploadQueues: Record<string, UploadQueueItem[]>; // Per-section upload queues
runBatchUpload: (section: AssetSection, files: File[]) => Promise<void>;
}
```
**Key Features:**
- Batch upload with queue management (max 2 concurrent)
- Per-file progress tracking
- Abortable uploads via AbortController
- MIME validation via validation schema
- Media duration probing for video/audio
**Validation Integration:**
```typescript
// Passes validation schema to UploadService
const validationSchema = { assetType: section.assetFormat };
const remoteFile = await FileUploader.uploadChunked(
`assets/${projectId}`,
file,
validationSchema, // Validates MIME type matches asset format
{ onProgress, onStatus, signal }
);
```
See [Asset Upload & Variants](../../documentation/asset-upload-variants.md) for complete upload flow documentation.
---
## Related Documentation
- [Video Hooks Module](./video-hooks-module.md) - Video playback primitive hooks
- [Constructor Page Editor](./constructor-page-editor.md) - Visual tour builder
- [Runtime Presentation](./runtime-presentation.md) - Tour playback viewer
- [Navigation & Smooth Transitions](./navigation-smooth-transitions.md) - Page switching with transitions
- [Frontend Architecture](./frontend-architecture.md) - Overall frontend structure
- [Components Module](./components-module.md) - React components
- [Assets Preloading](../../documentation/assets-preloading.md) - Preloading strategy
- [Asset Upload & Variants](../../documentation/asset-upload-variants.md) - Asset upload pipeline
---
## Summary
| Category | Hooks | Total LOC | Notes |
|----------|-------|-----------|-------|
| Runtime/Preloading | 10 | ~3,274 | Includes `usePageNavigationState` (~520 LOC) |
| Video Hooks (new) | 8 | ~1,270 | New composable video primitives |
| PWA/Offline | 5 | ~1,160 | Unchanged |
| Constructor | 10 | ~2,156 | Uses shared `usePageNavigationState` |
| Table/List | 3 | ~595 | Unchanged |
| Form | 2 | ~274 | Unchanged |
| UI Utility | 10 | ~1,583 | +useAudioEffects (~305 LOC) |
| Query Hooks | 13 | ~1,051 | Unchanged |
| **Total** | **53** | **~10,843** | +8 video hooks, -4 consolidated, +1 audio hook |
**Recent Architecture Changes:**
1. **Page Navigation Consolidation:** 6+ fragmented hooks consolidated into `usePageNavigationState`:
- Replaced boolean flag combinations with explicit phases
- Prevented race conditions via atomic `useReducer` transitions
- Eliminated ~1,100 LOC of duplicated state management
2. **Video Hooks Module:** New `hooks/video/` directory with 8 primitive hooks:
- Composable primitives for any video playback scenario
- Used by `useTransitionPlayback`, `useBackgroundVideoPlayback`, `useVideoPlayer`
- See [Video Hooks Module](./video-hooks-module.md) for details
3. **Preloading Simplification:** Stream-first approach:
- Removed neighbor graph traversal (`useNeighborGraph` deleted)
- Only preloads current page + outgoing transition videos
- Videos stream on-demand, then cache for replay
4. **Flash Fix:** Instant overlay hide with rAF delay:
- `TransitionPreviewOverlay` uses `requestAnimationFrame` delay before hiding
- Ensures new background is painted before overlay removed
- `PreviousBackgroundOverlay` simplified to pure show/hide (no fade)
5. **Audio Effects:** New `useAudioEffects` hook for element sound effects:
- Plays audio on hover start (completes even after mouse leave)
- Plays audio on click/active (interruptible, restarts on re-click)
- Uses authenticated fetch with blob URLs for secure audio access
- Integrates with `useElementEffects` via exposed `state.isHovered`/`state.isActive`
- Works in constructor preview mode and runtime presentations
The hooks module provides comprehensive reusable logic that powers the application's core features while maintaining clean separation of concerns and tree-shaking friendly architecture.