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

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:

  1. Page Navigation Consolidation: 6+ fragmented hooks consolidated into usePageNavigationState state machine. See Navigation State Machine for details.
  2. Video Hooks Module: New hooks/video/ directory with 8 primitive hooks for composable video playback. See Video Hooks Module 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 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 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                              │
│       └────────────────────────┘                                 │
└──────────────────────────────────────────────────────────────────┘
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 = 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:

// 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 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 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:

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 RuntimeControls sound 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.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.

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

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

// 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 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:

// 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.



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