65 KiB
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:
- Page Navigation Consolidation: 6+ fragmented hooks consolidated into
usePageNavigationStatestate machine. See Navigation State Machine for details. - Video Hooks Module: New
hooks/video/directory with 8 primitive hooks for composable video playback. See Video Hooks Module for details. - 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 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.
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:
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 switchinguseBackgroundState- Background ready trackinguseBackgroundTransition- Fade-from-black effectsuseTransitionCleanup- Video cleanup coordinationuseBackgroundUrls- URL resolution for displaypageLoadingUtils- 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 │
│ └────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
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:
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:
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.
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:
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:
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
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:
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.
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 = 50prevents unbounded growth in long sessions - Browser-like back navigation: History pops when
isBack=trueand target matches previous page - Navigation context:
getNavigationContext()provides{ currentPageSlug, previousPageId }forresolveNavigationTarget() - Unified state management: Single source of truth for both constructor and runtime presentation
Usage:
// 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:
useBackgroundTransitionhas been consolidated intousePageNavigationState. See 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.
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
loadedmetadataevent for initial seek tostartTime - Uses
timeupdateevent to enforceendTimeboundary - When
endTimeis set, handles looping via JavaScript (seeks back tostartTime) - Autoplay with browser policy handling (catch and ignore)
- Play Once Mode: When
playOnceis true andpageIdis provided:- Tracks played pages in a module-level
Set<string>(playedOncePages) - On first visit: video plays normally, page ID added to Set on
endedevent - On subsequent visits: video seeks to last frame and pauses immediately
- Session-scoped (resets on browser refresh, not persisted to storage)
- Tracks played pages in a module-level
Usage:
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.
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
RuntimeControlssound toggle button - Used by runtime presentations and constructor interact mode
Usage:
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.csshides 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.
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:
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.
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
startTimeon load, loops or pauses atendTime - Play Once Tracking: Session-scoped Set tracks played audio (cleared on browser refresh)
- Audio Ducking Integration: Registers with
backgroundAudioControllerfor automatic pause/resume when element audio plays - Graceful Autoplay: Handles browser autoplay restrictions silently
Usage:
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 for complete documentation including canvas units, letterbox mode, and element styling.
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:
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:
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.
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.
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:
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.
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.
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.
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.
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:
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.
interface UseCanvasElementDragResult {
isDragging: boolean;
onDragStart: (event: React.MouseEvent, elementId: string) => void;
onDragEnd: () => void;
}
Usage:
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.
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.
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.
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:
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.
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:
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.
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.
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:
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.
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.
interface UseDraggableResult {
position: Position;
setPosition: (position: Position) => void;
isDragging: boolean;
onDragStart: (event: React.MouseEvent) => void;
onDragStartIgnoreButtons: (event: React.MouseEvent) => void;
}
Usage:
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.
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).
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:
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.
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:
// 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.
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.
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:
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.
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
mountedRefpattern to prevent state updates after unmount - Queries
publish_eventsAPI withprojectId, environment filters, andstatus: 'success' - Returns
finished_attimestamp of most recent successful event
Usage:
// 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:
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:
// 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:
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:
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:
useEffect(() => {
const controller = new AbortController();
fetchData(controller.signal);
return () => controller.abort();
}, []);
4. Memoization
Expensive computations are memoized:
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 switchinguseBackgroundState- Background ready trackinguseBackgroundTransition- Fade-from-black effectsuseTransitionCleanup- Video cleanup coordinationuseBackgroundUrls- URL resolution for displaypageLoadingUtils- Loading state computation
Performance Considerations
1. Tree-Shaking
Large hooks are imported directly rather than via index.ts:
// 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:
// 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:
const debouncedUpdate = useMemo(
() => debounce((value) => setPosition(value), 16),
[]
);
Testing Considerations
Hooks can be tested with @testing-library/react-hooks:
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.
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:
// 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 for complete upload flow documentation.
Related Documentation
- Video Hooks Module - Video playback primitive hooks
- Constructor Page Editor - Visual tour builder
- Runtime Presentation - Tour playback viewer
- Navigation & Smooth Transitions - Page switching with transitions
- Frontend Architecture - Overall frontend structure
- Components Module - React components
- Assets Preloading - Preloading strategy
- Asset Upload & Variants - 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:
-
Page Navigation Consolidation: 6+ fragmented hooks consolidated into
usePageNavigationState:- Replaced boolean flag combinations with explicit phases
- Prevented race conditions via atomic
useReducertransitions - Eliminated ~1,100 LOC of duplicated state management
-
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 for details
-
Preloading Simplification: Stream-first approach:
- Removed neighbor graph traversal (
useNeighborGraphdeleted) - Only preloads current page + outgoing transition videos
- Videos stream on-demand, then cache for replay
- Removed neighbor graph traversal (
-
Flash Fix: Instant overlay hide with rAF delay:
TransitionPreviewOverlayusesrequestAnimationFramedelay before hiding- Ensures new background is painted before overlay removed
PreviousBackgroundOverlaysimplified to pure show/hide (no fade)
-
Audio Effects: New
useAudioEffectshook 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
useElementEffectsvia exposedstate.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.