diff --git a/backend/src/index.js b/backend/src/index.js index 2c5d744..6a72890 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -124,7 +124,7 @@ app.use( crossOriginEmbedderPolicy: false, }), ); -app.use(cors({ origin: true })); +app.use(cors({ origin: true, credentials: true })); require('./auth/auth'); // Request logger applied early so all routes are logged diff --git a/frontend/src/components/Constructor/CanvasElement.tsx b/frontend/src/components/Constructor/CanvasElement.tsx index 0794ff4..29c88a9 100644 --- a/frontend/src/components/Constructor/CanvasElement.tsx +++ b/frontend/src/components/Constructor/CanvasElement.tsx @@ -10,6 +10,7 @@ import React, { useCallback } from 'react'; import UiElementRenderer from '../UiElements/UiElementRenderer'; import { useElementEffects } from '../../hooks/useElementEffects'; +import { useAudioEffects } from '../../hooks/useAudioEffects'; import { buildTransitionStyle, buildAppearAnimationStyle, @@ -90,14 +91,34 @@ const CanvasElement: React.FC = ({ hoverRevealDelay: element.hoverRevealDelay, hoverRevealPersist: element.hoverRevealPersist, hoverPersistOnClick: element.hoverPersistOnClick, + // Audio effects + hoverAudioUrl: element.hoverAudioUrl, + clickAudioUrl: element.clickAudioUrl, + audioVolume: element.audioVolume, }; // Use effects hook - disabled in edit mode to avoid interfering with dragging // Pass forceVisible when info panel is open to keep trigger visible - const { effectStyle, eventHandlers, onPersistClick } = useElementEffects( - isEditMode ? {} : effectProperties, - { forceVisible: isInfoPanelOpen }, - ); + const { + effectStyle, + eventHandlers, + onPersistClick, + state: effectState, + } = useElementEffects(isEditMode ? {} : effectProperties, { + forceVisible: isInfoPanelOpen, + }); + + // Audio effects - only active in preview mode (not edit mode) + // Uses resolveUrl prop to resolve preloaded blob URLs + useAudioEffects({ + hoverAudioUrl: isEditMode ? undefined : effectProperties.hoverAudioUrl, + clickAudioUrl: isEditMode ? undefined : effectProperties.clickAudioUrl, + volume: parseFloat(effectProperties.audioVolume || '1'), + isHovered: effectState.isHovered, + isActive: effectState.isActive, + resolveUrl, // Already available as prop + resetKey: element.id, + }); // Clamp position to canvas bounds (0-100%) const clamp = (value: number, min: number, max: number) => diff --git a/frontend/src/components/Constructor/ElementEditorPanel.tsx b/frontend/src/components/Constructor/ElementEditorPanel.tsx index 55f44a2..73405a5 100644 --- a/frontend/src/components/Constructor/ElementEditorPanel.tsx +++ b/frontend/src/components/Constructor/ElementEditorPanel.tsx @@ -1682,6 +1682,10 @@ export function ElementEditorPanel({ hoverPersistOnClick: selectedElement.hoverPersistOnClick ? 'true' : '', + // Audio effects + hoverAudioUrl: selectedElement.hoverAudioUrl || '', + clickAudioUrl: selectedElement.clickAudioUrl || '', + audioVolume: selectedElement.audioVolume || '1', // Slide transition values (gallery/carousel) slideTransitionType: selectedElement.type === 'gallery' @@ -1792,6 +1796,7 @@ export function ElementEditorPanel({ }); } }} + audioAssetOptions={assetOptions.audio} /> )} diff --git a/frontend/src/components/ElementSettings/EffectsSettingsSectionCompact.tsx b/frontend/src/components/ElementSettings/EffectsSettingsSectionCompact.tsx index e1dc7fa..721758a 100644 --- a/frontend/src/components/ElementSettings/EffectsSettingsSectionCompact.tsx +++ b/frontend/src/components/ElementSettings/EffectsSettingsSectionCompact.tsx @@ -7,17 +7,18 @@ import React from 'react'; import type { EffectsSettingsFormValues } from './types'; -import type { CanvasElementType } from '../../types/constructor'; +import type { CanvasElementType, AssetOption } from '../../types/constructor'; interface EffectsSettingsSectionCompactProps { values: EffectsSettingsFormValues; onChange: (prop: keyof EffectsSettingsFormValues, value: string) => void; elementType?: CanvasElementType; + audioAssetOptions?: AssetOption[]; } const EffectsSettingsSectionCompact: React.FC< EffectsSettingsSectionCompactProps -> = ({ values, onChange, elementType }) => { +> = ({ values, onChange, elementType, audioAssetOptions }) => { const showSlideTransition = elementType === 'gallery' || elementType === 'carousel'; return ( @@ -152,6 +153,24 @@ const EffectsSettingsSectionCompact: React.FC< placeholder='0.2' /> + {/* Hover Sound */} +
+ + +
+ {/* Click Sound */} +
+ + +
+ {/* Audio Volume - only show if either audio is set */} + {(values.hoverAudioUrl || values.clickAudioUrl) && ( +
+

+ Audio Volume +

+
+ onChange('audioVolume', e.target.value)} + /> + + {Math.round(parseFloat(values.audioVolume || '1') * 100)}% + +
+
+ )} + {/* Slide Transition Override - Gallery/Carousel only */} {showSlideTransition && (
diff --git a/frontend/src/components/ElementSettings/types.ts b/frontend/src/components/ElementSettings/types.ts index 2aaf25c..fcf92d5 100644 --- a/frontend/src/components/ElementSettings/types.ts +++ b/frontend/src/components/ElementSettings/types.ts @@ -64,6 +64,10 @@ export interface EffectsSettingsFormValues { hoverRevealPersist?: string; // Persist hover effects after click hoverPersistOnClick?: string; + // Audio effects + hoverAudioUrl?: string; + clickAudioUrl?: string; + audioVolume?: string; // Slide transition override (Gallery/Carousel only) // These override page transition settings for this element's slides slideTransitionType?: string; diff --git a/frontend/src/components/RuntimeElement.tsx b/frontend/src/components/RuntimeElement.tsx index bb071db..511eec4 100644 --- a/frontend/src/components/RuntimeElement.tsx +++ b/frontend/src/components/RuntimeElement.tsx @@ -9,6 +9,7 @@ import React from 'react'; import UiElementRenderer from './UiElements/UiElementRenderer'; import { useElementEffects } from '../hooks/useElementEffects'; +import { useAudioEffects } from '../hooks/useAudioEffects'; import { buildTransitionStyle, buildAppearAnimationStyle, @@ -63,13 +64,27 @@ const RuntimeElement: React.FC = ({ // Use effects hook for interactive states // Pass forceVisible when info panel is open to keep trigger visible - const { effectStyle, eventHandlers, onPersistClick } = useElementEffects( - effectProperties, - { - resetKey: element.id, // Reset reveal on element change - forceVisible: isInfoPanelOpen, - }, - ); + const { + effectStyle, + eventHandlers, + onPersistClick, + state: effectState, + } = useElementEffects(effectProperties, { + resetKey: element.id, // Reset reveal on element change + forceVisible: isInfoPanelOpen, + }); + + // Audio effects - uses exposed state from useElementEffects + // resolveUrl prop resolves to preloaded blob URLs via RuntimePresentation + useAudioEffects({ + hoverAudioUrl: effectProperties.hoverAudioUrl, + clickAudioUrl: effectProperties.clickAudioUrl, + volume: parseFloat(effectProperties.audioVolume || '1'), + isHovered: effectState.isHovered, + isActive: effectState.isActive, + resolveUrl, // Resolves to cached blob URLs (passed from RuntimePresentation) + resetKey: element.id, + }); // Combined click handler // Skip toggle for info panel elements (their visibility is tied to panel open state) diff --git a/frontend/src/components/UiElements/ImageDetailPanel.tsx b/frontend/src/components/UiElements/ImageDetailPanel.tsx index bb628aa..4bd020f 100644 --- a/frontend/src/components/UiElements/ImageDetailPanel.tsx +++ b/frontend/src/components/UiElements/ImageDetailPanel.tsx @@ -126,7 +126,10 @@ const ImageDetailPanel: React.FC = ({ return () => { document.removeEventListener('fullscreenchange', handleFullscreenChange); - document.removeEventListener('webkitfullscreenchange', handleFullscreenChange); + document.removeEventListener( + 'webkitfullscreenchange', + handleFullscreenChange, + ); }; }, []); @@ -140,17 +143,35 @@ const ImageDetailPanel: React.FC = ({ // Enter fullscreen if (panel.requestFullscreen) { await panel.requestFullscreen(); - } else if ((panel as HTMLDivElement & { webkitRequestFullscreen?: () => Promise }).webkitRequestFullscreen) { + } else if ( + ( + panel as HTMLDivElement & { + webkitRequestFullscreen?: () => Promise; + } + ).webkitRequestFullscreen + ) { // Safari fallback - await (panel as HTMLDivElement & { webkitRequestFullscreen: () => Promise }).webkitRequestFullscreen(); + await ( + panel as HTMLDivElement & { + webkitRequestFullscreen: () => Promise; + } + ).webkitRequestFullscreen(); } } else { // Exit fullscreen if (document.exitFullscreen) { await document.exitFullscreen(); - } else if ((document as Document & { webkitExitFullscreen?: () => Promise }).webkitExitFullscreen) { + } else if ( + ( + document as Document & { + webkitExitFullscreen?: () => Promise; + } + ).webkitExitFullscreen + ) { // Safari fallback - await (document as Document & { webkitExitFullscreen: () => Promise }).webkitExitFullscreen(); + await ( + document as Document & { webkitExitFullscreen: () => Promise } + ).webkitExitFullscreen(); } } } catch { @@ -222,7 +243,8 @@ const ImageDetailPanel: React.FC = ({ // Zero doesn't need a unit if (trimmed === '0') return '0'; // Already uses canvas units - return as-is - if (trimmed.includes('var(--cu') || trimmed.includes('--cu')) return trimmed; + if (trimmed.includes('var(--cu') || trimmed.includes('--cu')) + return trimmed; // CSS functions (calc, var, min, max, etc.) - return as-is if (/^(calc|var|min|max|clamp)\(/i.test(trimmed)) return trimmed; // Already has a unit suffix - return as-is @@ -420,7 +442,9 @@ const ImageDetailPanel: React.FC = ({ type='button' className='absolute top-2 right-10 z-10 p-1 rounded-full text-white/70 hover:text-white hover:bg-white/10 transition-colors' onClick={toggleFullscreen} - aria-label={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'} + aria-label={ + isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen' + } > {isFullscreen ? ( = ({ // Zero doesn't need a unit if (trimmed === '0') return '0'; // Already uses canvas units - return as-is - if (trimmed.includes('var(--cu') || trimmed.includes('--cu')) return trimmed; + if (trimmed.includes('var(--cu') || trimmed.includes('--cu')) + return trimmed; // CSS functions (calc, var, min, max, etc.) - return as-is if (/^(calc|var|min|max|clamp)\(/i.test(trimmed)) return trimmed; // Already has a unit suffix - return as-is diff --git a/frontend/src/config/preload.config.ts b/frontend/src/config/preload.config.ts index 4d24791..d901368 100644 --- a/frontend/src/config/preload.config.ts +++ b/frontend/src/config/preload.config.ts @@ -85,6 +85,8 @@ export const PRELOAD_CONFIG = { 'url', 'poster', 'thumbnail', + 'hoverAudioUrl', + 'clickAudioUrl', ] as const, images: [ 'iconUrl', diff --git a/frontend/src/hooks/useAssetOptions.ts b/frontend/src/hooks/useAssetOptions.ts index cce7137..aaa02c6 100644 --- a/frontend/src/hooks/useAssetOptions.ts +++ b/frontend/src/hooks/useAssetOptions.ts @@ -103,8 +103,7 @@ export function useAssetOptions({ () => assets .filter( - (asset) => - asset.asset_type === 'embed' && getAssetSourceValue(asset), + (asset) => asset.asset_type === 'embed' && getAssetSourceValue(asset), ) .map((asset) => ({ value: getAssetSourceValue(asset), diff --git a/frontend/src/hooks/useAudioEffects.ts b/frontend/src/hooks/useAudioEffects.ts new file mode 100644 index 0000000..3f9bc37 --- /dev/null +++ b/frontend/src/hooks/useAudioEffects.ts @@ -0,0 +1,304 @@ +/** + * useAudioEffects Hook + * + * Manages audio playback for element hover and click effects. + * Uses HTML5 Audio API for lightweight, non-visual audio playback. + * + * Behavior: + * - Hover audio: Plays to completion once started (not interrupted by mouse leave) + * - Click audio: Interruptible - clicking again restarts from beginning + * + * Cross-browser notes: + * - iOS Safari: Volume property is read-only (always 1.0) + * - Mobile browsers: Require user interaction before audio can play + * - Touch on element counts as user gesture (unlocks audio context) + */ + +import { useEffect, useRef, useCallback, useState } from 'react'; +import { logger } from '../lib/logger'; + +/** + * Fetch audio file with credentials and return a blob URL. + * This handles authenticated URLs that require cookies/headers. + */ +async function fetchAudioAsBlobUrl(url: string): Promise { + try { + const response = await fetch(url, { + credentials: 'include', // Include cookies for auth + }); + if (!response.ok) { + logger.warn('[AudioEffects] Failed to fetch audio:', { + status: response.status, + }); + return null; + } + const blob = await response.blob(); + return URL.createObjectURL(blob); + } catch (error) { + logger.warn('[AudioEffects] Error fetching audio:', error); + return null; + } +} + +export 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; +} + +export interface UseAudioEffectsResult { + /** Manually trigger click audio playback */ + playClickAudio: () => void; + /** Stop all audio playback */ + stopAll: () => void; +} + +/** + * Hook for managing audio playback on element hover and click. + */ +export function useAudioEffects({ + hoverAudioUrl, + clickAudioUrl, + volume = 1, + isHovered, + isActive, + resolveUrl, + resetKey, +}: UseAudioEffectsOptions): UseAudioEffectsResult { + const hoverAudioRef = useRef(null); + const clickAudioRef = useRef(null); + const hoverBlobUrlRef = useRef(null); + const clickBlobUrlRef = useRef(null); + const wasHoveredRef = useRef(false); + const wasActiveRef = useRef(false); + + // Track which URLs we've already fetched to prevent duplicate fetches + const lastFetchedHoverUrlRef = useRef(null); + const lastFetchedClickUrlRef = useRef(null); + + // Store resolveUrl in ref to avoid dependency issues + const resolveUrlRef = useRef(resolveUrl); + resolveUrlRef.current = resolveUrl; + + // Track loading state + const [hoverAudioReady, setHoverAudioReady] = useState(false); + const [clickAudioReady, setClickAudioReady] = useState(false); + + // Initialize hover audio element - fetch with credentials for auth + useEffect(() => { + if (!hoverAudioUrl) { + hoverAudioRef.current = null; + setHoverAudioReady(false); + lastFetchedHoverUrlRef.current = null; + return; + } + + // Skip if we already fetched this URL + if ( + lastFetchedHoverUrlRef.current === hoverAudioUrl && + hoverAudioRef.current + ) { + return; + } + + let cancelled = false; + const resolve = resolveUrlRef.current; + const resolvedUrl = resolve ? resolve(hoverAudioUrl) : hoverAudioUrl; + + logger.debug('[AudioEffects] Initializing hover audio:', { + original: hoverAudioUrl, + resolved: resolvedUrl, + }); + + // Check if URL is already a blob URL (from preload cache) + if (resolvedUrl.startsWith('blob:')) { + const audio = new Audio(resolvedUrl); + audio.volume = Math.max(0, Math.min(1, volume)); + audio.preload = 'auto'; + hoverAudioRef.current = audio; + setHoverAudioReady(true); + lastFetchedHoverUrlRef.current = hoverAudioUrl; + logger.debug('[AudioEffects] Hover audio ready (blob URL)'); + return; + } + + // Fetch with credentials and create blob URL + fetchAudioAsBlobUrl(resolvedUrl).then((blobUrl) => { + if (cancelled || !blobUrl) return; + + // Clean up previous blob URL if any + if (hoverBlobUrlRef.current) { + URL.revokeObjectURL(hoverBlobUrlRef.current); + } + + hoverBlobUrlRef.current = blobUrl; + const audio = new Audio(blobUrl); + audio.volume = Math.max(0, Math.min(1, volume)); + audio.preload = 'auto'; + hoverAudioRef.current = audio; + setHoverAudioReady(true); + lastFetchedHoverUrlRef.current = hoverAudioUrl; + logger.debug('[AudioEffects] Hover audio ready (fetched)'); + }); + + return () => { + cancelled = true; + }; + }, [hoverAudioUrl, volume]); + + // Initialize click audio element - fetch with credentials for auth + useEffect(() => { + if (!clickAudioUrl) { + clickAudioRef.current = null; + setClickAudioReady(false); + lastFetchedClickUrlRef.current = null; + return; + } + + // Skip if we already fetched this URL + if ( + lastFetchedClickUrlRef.current === clickAudioUrl && + clickAudioRef.current + ) { + return; + } + + let cancelled = false; + const resolve = resolveUrlRef.current; + const resolvedUrl = resolve ? resolve(clickAudioUrl) : clickAudioUrl; + + // Check if URL is already a blob URL (from preload cache) + if (resolvedUrl.startsWith('blob:')) { + const audio = new Audio(resolvedUrl); + audio.volume = Math.max(0, Math.min(1, volume)); + audio.preload = 'auto'; + clickAudioRef.current = audio; + setClickAudioReady(true); + lastFetchedClickUrlRef.current = clickAudioUrl; + return; + } + + // Fetch with credentials and create blob URL + fetchAudioAsBlobUrl(resolvedUrl).then((blobUrl) => { + if (cancelled || !blobUrl) return; + + // Clean up previous blob URL if any + if (clickBlobUrlRef.current) { + URL.revokeObjectURL(clickBlobUrlRef.current); + } + + clickBlobUrlRef.current = blobUrl; + const audio = new Audio(blobUrl); + audio.volume = Math.max(0, Math.min(1, volume)); + audio.preload = 'auto'; + clickAudioRef.current = audio; + setClickAudioReady(true); + lastFetchedClickUrlRef.current = clickAudioUrl; + logger.debug('[AudioEffects] Click audio ready (fetched)'); + }); + + return () => { + cancelled = true; + }; + }, [clickAudioUrl, volume]); + + // Clean up blob URLs on unmount + useEffect(() => { + return () => { + if (hoverBlobUrlRef.current) { + URL.revokeObjectURL(hoverBlobUrlRef.current); + } + if (clickBlobUrlRef.current) { + URL.revokeObjectURL(clickBlobUrlRef.current); + } + }; + }, []); + + // Update volume on both audio elements when it changes + useEffect(() => { + const clampedVolume = Math.max(0, Math.min(1, volume)); + if (hoverAudioRef.current) { + hoverAudioRef.current.volume = clampedVolume; + } + if (clickAudioRef.current) { + clickAudioRef.current.volume = clampedVolume; + } + }, [volume]); + + // Play hover audio when hover starts (plays to completion, not interrupted) + useEffect(() => { + if ( + isHovered && + !wasHoveredRef.current && + hoverAudioRef.current && + hoverAudioReady + ) { + logger.debug('[AudioEffects] Playing hover audio...'); + hoverAudioRef.current.currentTime = 0; + // Graceful autoplay handling - catch and ignore autoplay restrictions + // Audio will work after user's first click/tap on any element + hoverAudioRef.current.play().catch((err) => { + logger.warn('[AudioEffects] Play failed:', err); + }); + } + wasHoveredRef.current = isHovered; + }, [isHovered, hoverAudioReady]); + + // Play click audio when active state begins + useEffect(() => { + if ( + isActive && + !wasActiveRef.current && + clickAudioRef.current && + clickAudioReady + ) { + clickAudioRef.current.currentTime = 0; + // Click implies user interaction, so this should rarely fail + clickAudioRef.current.play().catch(() => { + // Silent fail for edge cases + }); + } + wasActiveRef.current = isActive; + }, [isActive, clickAudioReady]); + + // Manual click audio trigger + const playClickAudio = useCallback(() => { + if (clickAudioRef.current && clickAudioReady) { + clickAudioRef.current.currentTime = 0; + // eslint-disable-next-line @typescript-eslint/no-empty-function + clickAudioRef.current.play().catch(() => {}); + } + }, [clickAudioReady]); + + // Stop all audio playback + const stopAll = useCallback(() => { + if (hoverAudioRef.current) { + hoverAudioRef.current.pause(); + hoverAudioRef.current.currentTime = 0; + } + if (clickAudioRef.current) { + clickAudioRef.current.pause(); + clickAudioRef.current.currentTime = 0; + } + }, []); + + // Reset audio on key change (e.g., page navigation) + useEffect(() => { + stopAll(); + wasHoveredRef.current = false; + wasActiveRef.current = false; + }, [resetKey, stopAll]); + + return { playClickAudio, stopAll }; +} diff --git a/frontend/src/hooks/useElementEffects.ts b/frontend/src/hooks/useElementEffects.ts index df72ee5..6a09780 100644 --- a/frontend/src/hooks/useElementEffects.ts +++ b/frontend/src/hooks/useElementEffects.ts @@ -54,6 +54,11 @@ interface UseElementEffectsResult { }; /** Call this in onClick to toggle hover persistence (if enabled) */ onPersistClick: () => void; + /** Exposed state for audio effects integration */ + state: { + isHovered: boolean; + isActive: boolean; + }; } /** @@ -225,5 +230,9 @@ export function useElementEffects( onTouchEnd, }, onPersistClick: onClick, + state: { + isHovered: state.isHovered, + isActive: state.isActive, + }, }; } diff --git a/frontend/src/lib/elementDefaults.ts b/frontend/src/lib/elementDefaults.ts index 70e3030..76e8ac5 100644 --- a/frontend/src/lib/elementDefaults.ts +++ b/frontend/src/lib/elementDefaults.ts @@ -45,6 +45,9 @@ export const ELEMENT_EFFECT_PROPS = [ 'hoverRevealDelay', 'hoverRevealPersist', 'hoverPersistOnClick', + 'hoverAudioUrl', + 'clickAudioUrl', + 'audioVolume', ] as const; /** diff --git a/frontend/src/lib/elementEffects.ts b/frontend/src/lib/elementEffects.ts index a22262d..beed554 100644 --- a/frontend/src/lib/elementEffects.ts +++ b/frontend/src/lib/elementEffects.ts @@ -78,6 +78,10 @@ export interface ElementEffectProperties { hoverRevealPersist?: boolean; // Persist hover effects after click (applies to all hover effects) hoverPersistOnClick?: boolean; + // Audio effects + hoverAudioUrl?: string; + clickAudioUrl?: string; + audioVolume?: string; } /** @@ -107,6 +111,9 @@ export const EFFECT_PROPS = [ 'hoverRevealDelay', 'hoverRevealPersist', 'hoverPersistOnClick', + 'hoverAudioUrl', + 'clickAudioUrl', + 'audioVolume', ] as const; export type EffectPropName = (typeof EFFECT_PROPS)[number]; @@ -294,7 +301,7 @@ export function hasActiveEffects( } /** - * Check if element has any effects configured. + * Check if element has any effects configured (visual or audio). */ export function hasAnyEffects( effects: Partial, @@ -304,10 +311,20 @@ export function hasAnyEffects( hasHoverEffects(effects) || hasFocusEffects(effects) || hasActiveEffects(effects) || - hasHoverReveal(effects) + hasHoverReveal(effects) || + hasAudioEffects(effects) ); } +/** + * Check if element has any audio effects configured. + */ +export function hasAudioEffects( + effects: Partial, +): boolean { + return Boolean(effects.hoverAudioUrl || effects.clickAudioUrl); +} + /** * Check if element has hover reveal enabled. */