diff --git a/backend/src/index.js b/backend/src/index.js index cc2b408..2a83051 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -200,7 +200,7 @@ const mountRuntimeEntityRoute = (path, entityName, router) => { app.use( path, requireRuntimeReadOrAuth, - blockNonPublicRuntimeListEndpoints, + blockNonPublicRuntimeListEndpoints(entityName), sanitizePublicRuntimeListResponse(entityName), router, ); @@ -237,18 +237,11 @@ app.use( jwtAuth, project_element_defaultsRoutes, ); -app.use( - '/api/global-transition-defaults', - jwtAuth, - global_transition_defaultsRoutes, -); +// Global transition defaults - routes handle their own auth (GET public, PUT protected) +app.use('/api/global-transition-defaults', global_transition_defaultsRoutes); -// Environment-aware project transition settings (supports runtime public access) -mountRuntimeEntityRoute( - '/api/project-transition-settings', - 'project_transition_settings', - project_transition_settingsRoutes, -); +// Project transition settings - routes handle their own auth (production GET public, else protected) +app.use('/api/project-transition-settings', project_transition_settingsRoutes); app.use('/api/publish', jwtAuth, publishRoutes); diff --git a/backend/src/middlewares/check-permissions.js b/backend/src/middlewares/check-permissions.js index ee3e71c..02345f8 100644 --- a/backend/src/middlewares/check-permissions.js +++ b/backend/src/middlewares/check-permissions.js @@ -161,6 +161,8 @@ const RUNTIME_PUBLIC_READ_ENTITIES = new Set([ 'PAGE_LINKS', 'TRANSITIONS', 'PROJECT_AUDIO_TRACKS', + 'GLOBAL_TRANSITION_DEFAULTS', + 'PROJECT_TRANSITION_SETTINGS', ]); /** diff --git a/backend/src/middlewares/runtime-public.js b/backend/src/middlewares/runtime-public.js index f0ff43a..f4c07e8 100644 --- a/backend/src/middlewares/runtime-public.js +++ b/backend/src/middlewares/runtime-public.js @@ -1,5 +1,3 @@ -const PUBLIC_RUNTIME_ALLOWED_PATH = '/'; - const PUBLIC_RUNTIME_ENTITY_FIELDS = { projects: [ 'id', @@ -38,6 +36,31 @@ const PUBLIC_RUNTIME_ENTITY_FIELDS = { 'sort_order', 'is_enabled', ], + global_transition_defaults: [ + 'id', + 'transition_type', + 'duration_ms', + 'easing', + 'overlay_color', + ], + project_transition_settings: [ + 'id', + 'projectId', + 'environment', + 'transition_type', + 'duration_ms', + 'easing', + 'overlay_color', + ], +}; + +// Entity-aware path patterns for public runtime access +// Entities not listed here default to allowing only '/' +const PUBLIC_RUNTIME_ALLOWED_PATHS = { + project_transition_settings: [ + '/', + /^\/project\/[a-fA-F0-9-]+\/env\/(dev|stage|production)$/, + ], }; const pickFields = (record, fields) => { @@ -61,12 +84,17 @@ const isPublicRuntimeReadRequest = (req) => { return req.isRuntimePublicRequest === true && req.method === 'GET'; }; -const blockNonPublicRuntimeListEndpoints = (req, res, next) => { +const blockNonPublicRuntimeListEndpoints = (entityName) => (req, res, next) => { if (!isPublicRuntimeReadRequest(req)) { return next(); } - if (req.path !== PUBLIC_RUNTIME_ALLOWED_PATH) { + const allowedPaths = PUBLIC_RUNTIME_ALLOWED_PATHS[entityName] || ['/']; + const pathMatches = allowedPaths.some((pattern) => + pattern instanceof RegExp ? pattern.test(req.path) : req.path === pattern, + ); + + if (!pathMatches) { return res.status(404).send({ message: 'Not found' }); } @@ -79,13 +107,14 @@ const blockNonPublicRuntimeListEndpoints = (req, res, next) => { const sanitizePublicRuntimeListResponse = (entityName) => { const fields = PUBLIC_RUNTIME_ENTITY_FIELDS[entityName] || []; + const allowedPaths = PUBLIC_RUNTIME_ALLOWED_PATHS[entityName] || ['/']; return (req, res, next) => { - if ( - !isPublicRuntimeReadRequest(req) || - req.path !== PUBLIC_RUNTIME_ALLOWED_PATH || - fields.length === 0 - ) { + const pathMatches = allowedPaths.some((pattern) => + pattern instanceof RegExp ? pattern.test(req.path) : req.path === pattern, + ); + + if (!isPublicRuntimeReadRequest(req) || !pathMatches || fields.length === 0) { return next(); } @@ -96,16 +125,21 @@ const sanitizePublicRuntimeListResponse = (entityName) => { return originalSend(body); } - if (!Array.isArray(body.rows)) { - return originalSend(body); + // Handle list responses with rows array + if (Array.isArray(body.rows)) { + const sanitizedRows = body.rows.map((row) => pickFields(row, fields)); + return originalSend({ + ...body, + rows: sanitizedRows, + }); } - const sanitizedRows = body.rows.map((row) => pickFields(row, fields)); + // Handle single object responses (e.g., from findOne or project/:id/env/:env) + if (!Array.isArray(body) && body !== null) { + return originalSend(pickFields(body, fields)); + } - return originalSend({ - ...body, - rows: sanitizedRows, - }); + return originalSend(body); }; return next(); diff --git a/backend/src/routes/global_transition_defaults.js b/backend/src/routes/global_transition_defaults.js index 625f239..197ddad 100644 --- a/backend/src/routes/global_transition_defaults.js +++ b/backend/src/routes/global_transition_defaults.js @@ -1,13 +1,26 @@ const express = require('express'); +const passport = require('passport'); const Global_transition_defaultsService = require('../services/global_transition_defaults'); const Global_transition_defaultsDBApi = require('../db/api/global_transition_defaults'); const { wrapAsync, commonErrorHandler, isUuidV4 } = require('../helpers'); const { checkCrudPermissions } = require('../middlewares/check-permissions'); const router = express.Router(); +const jwtAuth = passport.authenticate('jwt', { session: false }); -// Use page_elements permission (same as element_type_defaults) -router.use(checkCrudPermissions('page_elements')); +/** + * Middleware for public GET access. + * Marks GET requests as public runtime requests for permission bypass. + */ +const allowPublicRead = (req, _res, next) => { + if (['GET', 'OPTIONS'].includes(req.method)) { + req.isRuntimePublicRequest = true; + } + return next(); +}; + +// Apply CRUD permission checks (handles public bypass via isRuntimePublicRequest) +router.use(checkCrudPermissions('global_transition_defaults')); /** * @swagger @@ -15,14 +28,14 @@ router.use(checkCrudPermissions('page_elements')); * get: * summary: Get global transition defaults (singleton) * tags: [GlobalTransitionDefaults] - * security: - * - bearerAuth: [] + * description: Publicly accessible - no authentication required. * responses: * 200: * description: Global transition defaults settings */ router.get( '/', + allowPublicRead, wrapAsync(async (_req, res) => { const payload = await Global_transition_defaultsDBApi.findOne(); res.status(200).send(payload); @@ -35,8 +48,7 @@ router.get( * get: * summary: Get global transition defaults by ID * tags: [GlobalTransitionDefaults] - * security: - * - bearerAuth: [] + * description: Publicly accessible - no authentication required. * parameters: * - in: path * name: id @@ -50,6 +62,7 @@ router.get( */ router.get( '/:id', + allowPublicRead, wrapAsync(async (req, res) => { if (!isUuidV4(req.params.id)) { return res.status(400).send('Invalid global_transition_defaults id'); @@ -102,6 +115,7 @@ router.get( */ router.put( '/:id', + jwtAuth, wrapAsync(async (req, res) => { await Global_transition_defaultsService.update( req.body.data, diff --git a/backend/src/routes/project_transition_settings.js b/backend/src/routes/project_transition_settings.js index 7eccecb..24c5650 100644 --- a/backend/src/routes/project_transition_settings.js +++ b/backend/src/routes/project_transition_settings.js @@ -1,13 +1,34 @@ const express = require('express'); +const passport = require('passport'); const Project_transition_settingsService = require('../services/project_transition_settings'); const Project_transition_settingsDBApi = require('../db/api/project_transition_settings'); const { wrapAsync, commonErrorHandler, isUuidV4 } = require('../helpers'); const { checkCrudPermissions } = require('../middlewares/check-permissions'); const router = express.Router(); +const jwtAuth = passport.authenticate('jwt', { session: false }); -// Use page_elements permission (same as other element/transition settings) -router.use(checkCrudPermissions('page_elements')); +/** + * Middleware: Production GET is public, everything else requires JWT. + * Determines public access from URL path, not headers. + */ +const requireProductionOrAuth = (req, res, next) => { + const { environment } = req.params; + const isProduction = environment === 'production'; + const isReadOnly = ['GET', 'OPTIONS'].includes(req.method); + + if (isProduction && isReadOnly) { + // Public access for production GET - mark for permission bypass + req.isRuntimePublicRequest = true; + return next(); + } + + // Require JWT for non-production or write operations + return jwtAuth(req, res, next); +}; + +// Apply CRUD permission checks (handles public bypass via isRuntimePublicRequest) +router.use(checkCrudPermissions('project_transition_settings')); /** * @swagger @@ -22,8 +43,7 @@ router.use(checkCrudPermissions('page_elements')); * get: * summary: Get transition settings for a project in a specific environment * tags: [Project_transition_settings] - * security: - * - bearerAuth: [] + * description: Production environment is publicly accessible. Dev/stage require authentication. * parameters: * - in: path * name: projectId @@ -39,12 +59,13 @@ router.use(checkCrudPermissions('page_elements')); * enum: [dev, stage, production] * responses: * 200: - * description: Transition settings for the project/environment - * 404: - * description: No settings found (use global defaults) + * description: Transition settings for the project/environment (null if none exist) + * 401: + * description: Authentication required (for dev/stage environments) */ router.get( '/project/:projectId/env/:environment', + requireProductionOrAuth, wrapAsync(async (req, res) => { const { projectId, environment } = req.params; @@ -63,10 +84,7 @@ router.get( req.currentUser, ); - if (!settings) { - return res.status(404).send({ message: 'No project-specific settings found' }); - } - + // Return null if no settings exist (frontend will use global defaults) res.status(200).send(settings); }), ); @@ -119,6 +137,7 @@ router.get( */ router.put( '/project/:projectId/env/:environment', + jwtAuth, wrapAsync(async (req, res) => { const { projectId, environment } = req.params; @@ -168,6 +187,7 @@ router.put( */ router.delete( '/project/:projectId/env/:environment', + jwtAuth, wrapAsync(async (req, res) => { const { projectId, environment } = req.params; @@ -211,6 +231,7 @@ router.delete( */ router.get( '/', + jwtAuth, wrapAsync(async (req, res) => { const payload = await Project_transition_settingsDBApi.findAll(req.query); res.status(200).send(payload); @@ -240,6 +261,7 @@ router.get( */ router.post( '/', + jwtAuth, wrapAsync(async (req, res) => { const payload = await Project_transition_settingsService.create( req.body.data, @@ -270,6 +292,7 @@ router.post( */ router.get( '/:id', + jwtAuth, wrapAsync(async (req, res) => { if (!isUuidV4(req.params.id)) { return res.status(400).send({ message: 'Invalid ID' }); @@ -312,6 +335,7 @@ router.get( */ router.put( '/:id', + jwtAuth, wrapAsync(async (req, res) => { await Project_transition_settingsService.update( req.body.data, @@ -343,6 +367,7 @@ router.put( */ router.delete( '/:id', + jwtAuth, wrapAsync(async (req, res) => { await Project_transition_settingsService.remove( req.params.id, diff --git a/frontend/src/components/Constructor/CanvasElement.tsx b/frontend/src/components/Constructor/CanvasElement.tsx index 113e3bb..d9adbf3 100644 --- a/frontend/src/components/Constructor/CanvasElement.tsx +++ b/frontend/src/components/Constructor/CanvasElement.tsx @@ -17,6 +17,7 @@ import { type ElementEffectProperties, } from '../../lib/elementEffects'; import type { CanvasElement as CanvasElementType } from '../../types/constructor'; +import type { ResolvedTransitionSettings } from '../../types/transition'; interface CanvasElementProps { element: CanvasElementType; @@ -37,6 +38,8 @@ interface CanvasElementProps { ) => void; /** Letterbox styles for constraining fullscreen elements to canvas bounds */ letterboxStyles?: React.CSSProperties; + /** Page transition settings (for slide transition cascade in carousel/gallery) */ + pageTransitionSettings?: ResolvedTransitionSettings; } const CanvasElement: React.FC = ({ @@ -50,6 +53,7 @@ const CanvasElement: React.FC = ({ onGalleryCardClick, onCarouselButtonPositionChange, letterboxStyles, + pageTransitionSettings, }) => { // Extract effect properties from element const effectProperties: Partial = { @@ -140,6 +144,7 @@ const CanvasElement: React.FC = ({ onGalleryCardClick={onGalleryCardClick} onCarouselButtonPositionChange={onCarouselButtonPositionChange} letterboxStyles={letterboxStyles} + pageTransitionSettings={pageTransitionSettings} /> ); diff --git a/frontend/src/components/Constructor/ElementEditorPanel.tsx b/frontend/src/components/Constructor/ElementEditorPanel.tsx index 359b18f..27cfd67 100644 --- a/frontend/src/components/Constructor/ElementEditorPanel.tsx +++ b/frontend/src/components/Constructor/ElementEditorPanel.tsx @@ -741,6 +741,7 @@ export function ElementEditorPanel({ {/* Effects Tab */} {activeTab === 'effects' && ( { - updateSelectedElement({ - [prop]: value || undefined, - }); + // Handle slide transition properties with proper prefixes + if (prop === 'slideTransitionType') { + const typedValue = (value || undefined) as + | 'fade' + | 'none' + | '' + | undefined; + if (selectedElement.type === 'gallery') { + updateSelectedElement({ + gallerySlideTransitionType: typedValue, + }); + } else if (selectedElement.type === 'carousel') { + updateSelectedElement({ + carouselSlideTransitionType: typedValue, + }); + } + } else if (prop === 'slideTransitionDurationMs') { + const ms = value ? parseInt(value, 10) : undefined; + const typedMs = ms !== undefined && ms > 0 ? ms : ''; + if (selectedElement.type === 'gallery') { + updateSelectedElement({ + gallerySlideTransitionDurationMs: typedMs, + }); + } else if (selectedElement.type === 'carousel') { + updateSelectedElement({ + carouselSlideTransitionDurationMs: typedMs, + }); + } + } else if (prop === 'slideTransitionEasing') { + // Cast to proper type - form values are validated by select options + type EasingValue = + | 'ease-in-out' + | 'ease-in' + | 'ease-out' + | 'linear' + | '' + | undefined; + const typedEasing = (value || undefined) as EasingValue; + if (selectedElement.type === 'gallery') { + updateSelectedElement({ + gallerySlideTransitionEasing: typedEasing, + }); + } else if (selectedElement.type === 'carousel') { + updateSelectedElement({ + carouselSlideTransitionEasing: typedEasing, + }); + } + } else if (prop === 'slideTransitionOverlayColor') { + if (selectedElement.type === 'gallery') { + updateSelectedElement({ + gallerySlideTransitionOverlayColor: value || undefined, + }); + } else if (selectedElement.type === 'carousel') { + updateSelectedElement({ + carouselSlideTransitionOverlayColor: + value || undefined, + }); + } + } else { + // Standard effect properties + updateSelectedElement({ + [prop]: value || undefined, + }); + } }} /> )} diff --git a/frontend/src/components/ElementSettings/EffectsSettingsSectionCompact.tsx b/frontend/src/components/ElementSettings/EffectsSettingsSectionCompact.tsx index 4ea9d10..6dbb1d5 100644 --- a/frontend/src/components/ElementSettings/EffectsSettingsSectionCompact.tsx +++ b/frontend/src/components/ElementSettings/EffectsSettingsSectionCompact.tsx @@ -6,12 +6,20 @@ */ import React from 'react'; -import type { EffectsSettingsSectionProps } from './types'; +import type { EffectsSettingsFormValues } from './types'; +import type { CanvasElementType } from '../../types/constructor'; -const EffectsSettingsSectionCompact: React.FC = ({ - values, - onChange, -}) => { +interface EffectsSettingsSectionCompactProps { + values: EffectsSettingsFormValues; + onChange: (prop: keyof EffectsSettingsFormValues, value: string) => void; + elementType?: CanvasElementType; +} + +const EffectsSettingsSectionCompact: React.FC< + EffectsSettingsSectionCompactProps +> = ({ values, onChange, elementType }) => { + const showSlideTransition = + elementType === 'gallery' || elementType === 'carousel'; return (
{/* Appear Animation */} @@ -243,6 +251,99 @@ const EffectsSettingsSectionCompact: React.FC = ({
+ + {/* Slide Transition Override - Gallery/Carousel only */} + {showSlideTransition && ( +
+

+ Slide Transition +

+

+ Override page transition for slides. Leave empty for defaults. +

+
+ {/* Type */} +
+ + +
+ + {/* Duration */} +
+ + + onChange('slideTransitionDurationMs', e.target.value) + } + placeholder='400' + min='0' + step='50' + /> +
+ + {/* Easing */} +
+ + +
+ + {/* Overlay Color */} +
+ +
+ + onChange('slideTransitionOverlayColor', e.target.value) + } + /> + + onChange('slideTransitionOverlayColor', e.target.value) + } + placeholder='#000000' + /> +
+
+
+
+ )} ); }; diff --git a/frontend/src/components/ElementSettings/types.ts b/frontend/src/components/ElementSettings/types.ts index 7f850c3..028caf2 100644 --- a/frontend/src/components/ElementSettings/types.ts +++ b/frontend/src/components/ElementSettings/types.ts @@ -53,6 +53,12 @@ export interface EffectsSettingsFormValues { activeScale?: string; activeOpacity?: string; activeBackgroundColor?: string; + // Slide transition override (Gallery/Carousel only) + // These override page transition settings for this element's slides + slideTransitionType?: string; + slideTransitionDurationMs?: string; + slideTransitionEasing?: string; + slideTransitionOverlayColor?: string; } /** diff --git a/frontend/src/components/RuntimeElement.tsx b/frontend/src/components/RuntimeElement.tsx index eb48ed0..a9b25ef 100644 --- a/frontend/src/components/RuntimeElement.tsx +++ b/frontend/src/components/RuntimeElement.tsx @@ -18,6 +18,7 @@ import { import { isNavigationElementType } from '../lib/elementDefaults'; import { isBackNavigation } from '../lib/navigationHelpers'; import type { CanvasElement } from '../types/constructor'; +import type { ResolvedTransitionSettings } from '../types/transition'; interface RuntimeElementProps { element: CanvasElement; @@ -30,6 +31,8 @@ interface RuntimeElementProps { letterboxStyles?: React.CSSProperties; /** Whether forward navigation is disabled (neighbor pages not yet preloaded) */ isForwardNavDisabled?: boolean; + /** Page transition settings (for slide transition cascade in carousel/gallery) */ + pageTransitionSettings?: ResolvedTransitionSettings; } // Clamp position to canvas bounds (0-100%) @@ -43,6 +46,7 @@ const RuntimeElement: React.FC = ({ onGalleryCardClick, letterboxStyles, isForwardNavDisabled = false, + pageTransitionSettings, }) => { // Clamp coordinates to canvas bounds const xPercent = clamp(element.xPercent ?? 50, 0, 100); @@ -124,6 +128,7 @@ const RuntimeElement: React.FC = ({ onGalleryCardClick={onGalleryCardClick} letterboxStyles={letterboxStyles} isDisabled={isDisabled} + pageTransitionSettings={pageTransitionSettings} /> ); diff --git a/frontend/src/components/RuntimePresentation.tsx b/frontend/src/components/RuntimePresentation.tsx index a3ce000..f7d54db 100644 --- a/frontend/src/components/RuntimePresentation.tsx +++ b/frontend/src/components/RuntimePresentation.tsx @@ -93,7 +93,7 @@ export default function RuntimePresentation({ }, ); - // Fetch global transition defaults on mount + // Fetch global transition defaults on mount (public endpoint, no auth needed) useEffect(() => { dispatch(fetchGlobalTransitionDefaults()); }, [dispatch]); @@ -796,6 +796,7 @@ export default function RuntimePresentation({ } letterboxStyles={letterboxStyles} isForwardNavDisabled={isForwardNavDisabled} + pageTransitionSettings={transitionSettings} /> ))} @@ -879,6 +880,8 @@ export default function RuntimePresentation({ } letterboxStyles={letterboxStyles} isEditMode={false} + pageTransitionSettings={transitionSettings} + galleryElement={activeGalleryCarousel.element} /> )} diff --git a/frontend/src/components/UiElements/GalleryCarouselOverlay.tsx b/frontend/src/components/UiElements/GalleryCarouselOverlay.tsx index abb8f09..3a47858 100644 --- a/frontend/src/components/UiElements/GalleryCarouselOverlay.tsx +++ b/frontend/src/components/UiElements/GalleryCarouselOverlay.tsx @@ -9,8 +9,14 @@ import React, { useState, useEffect, useCallback, useRef } from 'react'; import Icon from '@mdi/react'; import { mdiChevronLeft, mdiChevronRight, mdiArrowLeft } from '@mdi/js'; -import type { GalleryCard } from '../../types/constructor'; +import type { GalleryCard, CanvasElement } from '../../types/constructor'; +import type { ResolvedTransitionSettings } from '../../types/transition'; import { resolveAssetPlaybackUrl } from '../../lib/assetUrl'; +import { + resolveSlideTransition, + extractGallerySlideOverride, +} from '../../lib/resolveSlideTransition'; +import { useSlideTransition } from '../../hooks/useSlideTransition'; interface GalleryCarouselOverlayProps { cards: GalleryCard[]; @@ -46,6 +52,10 @@ interface GalleryCarouselOverlayProps { ) => void; // Letterbox styles for constraining overlay to canvas bounds letterboxStyles?: React.CSSProperties; + // Page transition settings (for slide transition cascade) + pageTransitionSettings?: ResolvedTransitionSettings; + // Gallery element (for extracting slide transition override) + galleryElement?: CanvasElement; } const clamp = (value: number, min: number, max: number) => @@ -75,9 +85,33 @@ const GalleryCarouselOverlay: React.FC = ({ isEditMode = false, onButtonPositionChange, letterboxStyles, + pageTransitionSettings, + galleryElement, }) => { const resolve = resolveUrl ?? resolveAssetPlaybackUrl; - const [currentIndex, setCurrentIndex] = useState(initialIndex); + + // Resolve slide transition with cascade + const slideTransition = resolveSlideTransition( + pageTransitionSettings, + extractGallerySlideOverride(galleryElement), + ); + + // Use hook for animation state + const { + displayIndex, + overlayOpacity, + overlayColor, + goToIndex, + setInitialIndex, + slideTransitionStyle, + overlayTransitionStyle, + slideOpacity, + } = useSlideTransition(slideTransition); + + // Set initial index on mount + useEffect(() => { + setInitialIndex(initialIndex); + }, [initialIndex, setInitialIndex]); const [draggingButton, setDraggingButton] = useState< 'prev' | 'next' | 'back' | null >(null); @@ -111,13 +145,15 @@ const GalleryCarouselOverlay: React.FC = ({ // Navigation handlers const goToPrev = useCallback(() => { if (cards.length === 0) return; - setCurrentIndex((prev) => (prev - 1 + cards.length) % cards.length); - }, [cards.length]); + const newIndex = (displayIndex - 1 + cards.length) % cards.length; + goToIndex(newIndex); + }, [cards.length, displayIndex, goToIndex]); const goToNext = useCallback(() => { if (cards.length === 0) return; - setCurrentIndex((prev) => (prev + 1) % cards.length); - }, [cards.length]); + const newIndex = (displayIndex + 1) % cards.length; + goToIndex(newIndex); + }, [cards.length, displayIndex, goToIndex]); // Keyboard navigation useEffect(() => { @@ -348,7 +384,7 @@ const GalleryCarouselOverlay: React.FC = ({ ); }; - const currentCard = cards[currentIndex]; + const currentCard = cards[displayIndex]; const imageUrl = currentCard?.imageUrl ? resolve(currentCard.imageUrl) : ''; return ( @@ -377,9 +413,21 @@ const GalleryCarouselOverlay: React.FC = ({ src={imageUrl} alt={currentCard?.title || ''} className='absolute inset-0 h-full w-full object-contain' + style={{ ...slideTransitionStyle, opacity: slideOpacity }} draggable={false} /> )} + {/* Transition overlay (fades in during fadingOut, fades out during fadingIn) */} + {slideTransition.type === 'fade' && ( +
+ )} {/* Prev button */} {renderNavButton( diff --git a/frontend/src/components/UiElements/UiElementRenderer.tsx b/frontend/src/components/UiElements/UiElementRenderer.tsx index 928fe34..91dfecf 100644 --- a/frontend/src/components/UiElements/UiElementRenderer.tsx +++ b/frontend/src/components/UiElements/UiElementRenderer.tsx @@ -10,6 +10,7 @@ import React from 'react'; import type { CanvasElement } from '../../types/constructor'; +import type { ResolvedTransitionSettings } from '../../types/transition'; import { useElementWrapperStyle } from './shared/useElementWrapperStyle'; import { isNavigationElementType, @@ -53,6 +54,8 @@ export interface UiElementRendererProps { ) => void; // Letterbox styles for constraining fullscreen elements to canvas bounds letterboxStyles?: React.CSSProperties; + // Page transition settings (for slide transition cascade in carousel/gallery) + pageTransitionSettings?: ResolvedTransitionSettings; } /** @@ -70,6 +73,7 @@ export const UiElementRenderer: React.FC = ({ onGalleryCardClick, onCarouselButtonPositionChange, letterboxStyles, + pageTransitionSettings, }) => { const { className, style } = useElementWrapperStyle({ element, @@ -101,6 +105,7 @@ export const UiElementRenderer: React.FC = ({ isEditMode={isEditMode} onButtonPositionChange={onCarouselButtonPositionChange} letterboxStyles={letterboxStyles} + pageTransitionSettings={pageTransitionSettings} /> ); } diff --git a/frontend/src/components/UiElements/elements/CarouselElement.tsx b/frontend/src/components/UiElements/elements/CarouselElement.tsx index ccd8735..6c37020 100644 --- a/frontend/src/components/UiElements/elements/CarouselElement.tsx +++ b/frontend/src/components/UiElements/elements/CarouselElement.tsx @@ -14,21 +14,21 @@ * - Navigation-style rendering when custom icons with dimensions are set */ -import React, { - useState, - useMemo, - useCallback, - useEffect, - useRef, -} from 'react'; +import React, { useMemo, useCallback, useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import type { CSSProperties } from 'react'; import Icon from '@mdi/react'; import { mdiChevronLeft, mdiChevronRight } from '@mdi/js'; import type { CanvasElement, CarouselSlide } from '../../../types/constructor'; +import type { ResolvedTransitionSettings } from '../../../types/transition'; import { resolveAssetPlaybackUrl } from '../../../lib/assetUrl'; import { getFontByKey, getFontStyle } from '../../../lib/fonts'; import { toCU } from '../../../lib/canvasScale'; +import { + resolveSlideTransition, + extractCarouselSlideOverride, +} from '../../../lib/resolveSlideTransition'; +import { useSlideTransition } from '../../../hooks/useSlideTransition'; interface CarouselElementProps { element: CanvasElement; @@ -44,6 +44,8 @@ interface CarouselElementProps { ) => void; // Letterbox styles for constraining full-width carousel to canvas bounds letterboxStyles?: CSSProperties; + // Page transition settings (for slide transition cascade) + pageTransitionSettings?: ResolvedTransitionSettings; } const clamp = (value: number, min: number, max: number) => @@ -57,13 +59,31 @@ const CarouselElement: React.FC = ({ isEditMode = false, onButtonPositionChange, letterboxStyles, + pageTransitionSettings, }) => { const resolve = resolveUrl ?? resolveAssetPlaybackUrl; const slides: CarouselSlide[] = element.carouselSlides || []; - const [currentIndex, setCurrentIndex] = useState(0); - const currentSlide = slides[currentIndex] || slides[0]; const isFullWidth = element.carouselFullWidth || false; + // Resolve slide transition with cascade + const slideTransition = resolveSlideTransition( + pageTransitionSettings, + extractCarouselSlideOverride(element), + ); + + // Use hook for animation state + const { + displayIndex, + overlayOpacity, + overlayColor, + goToIndex, + slideTransitionStyle, + overlayTransitionStyle, + slideOpacity, + } = useSlideTransition(slideTransition); + + const currentSlide = slides[displayIndex] || slides[0]; + // Drag state (constructor edit mode only) const [draggingButton, setDraggingButton] = useState<'prev' | 'next' | null>( null, @@ -100,13 +120,15 @@ const CarouselElement: React.FC = ({ // Navigation handlers (no event parameter for keyboard/swipe use) const goToPrev = useCallback(() => { if (slides.length === 0) return; - setCurrentIndex((prev) => (prev - 1 + slides.length) % slides.length); - }, [slides.length]); + const newIndex = (displayIndex - 1 + slides.length) % slides.length; + goToIndex(newIndex); + }, [slides.length, displayIndex, goToIndex]); const goToNext = useCallback(() => { if (slides.length === 0) return; - setCurrentIndex((prev) => (prev + 1) % slides.length); - }, [slides.length]); + const newIndex = (displayIndex + 1) % slides.length; + goToIndex(newIndex); + }, [slides.length, displayIndex, goToIndex]); // Click handlers for buttons (with event propagation control) const handlePrevClick = useCallback( @@ -365,9 +387,21 @@ const CarouselElement: React.FC = ({ src={resolve(currentSlide.imageUrl)} alt={currentSlide.caption || 'Carousel slide'} className='absolute inset-0 w-full h-full object-contain' + style={{ ...slideTransitionStyle, opacity: slideOpacity }} draggable={false} /> )} + {/* Transition overlay (fades in during fadingOut, fades out during fadingIn) */} + {slideTransition.type === 'fade' && ( +
+ )}
); @@ -459,7 +493,7 @@ const CarouselElement: React.FC = ({ // Normal mode: inline carousel within element dimensions return (
-
+
{/* Current slide image */} {currentSlide?.imageUrl && ( // eslint-disable-next-line @next/next/no-img-element @@ -467,9 +501,21 @@ const CarouselElement: React.FC = ({ src={resolve(currentSlide.imageUrl)} alt={currentSlide.caption || 'Carousel slide'} className='w-full h-full object-cover rounded' + style={{ ...slideTransitionStyle, opacity: slideOpacity }} draggable={false} /> )} + {/* Transition overlay */} + {slideTransition.type === 'fade' && ( +
+ )} {/* Navigation buttons */} {showNavigation && ( diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index 2f1df5f..a79bba1 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -54,6 +54,7 @@ export type { UseTransitionCreationOptions, UseTransitionCreationResult, } from './useTransitionCreation'; +export { useSlideTransition } from './useSlideTransition'; // Constructor hooks - import directly for better tree-shaking: // import { useOutsideClick } from '../hooks/useOutsideClick'; diff --git a/frontend/src/hooks/useSlideTransition.ts b/frontend/src/hooks/useSlideTransition.ts new file mode 100644 index 0000000..8aab237 --- /dev/null +++ b/frontend/src/hooks/useSlideTransition.ts @@ -0,0 +1,159 @@ +/** + * useSlideTransition Hook + * + * Manages slide transition animation for Gallery/Carousel elements. + * Implements fade-through-overlay: Slide 1 -> fade out -> overlay -> fade in -> Slide 2 + */ + +import { useState, useCallback, useRef, useEffect } from 'react'; +import type { CSSProperties } from 'react'; +import type { ResolvedSlideTransition } from '../lib/resolveSlideTransition'; + +// Transition phases: idle -> fadingOut -> fadingIn -> idle +type TransitionPhase = 'idle' | 'fadingOut' | 'fadingIn'; + +interface SlideTransitionState { + currentIndex: number; + displayIndex: number; // What's actually shown (may differ during transition) + phase: TransitionPhase; + overlayOpacity: number; // 0 = hidden, 1 = fully visible +} + +interface UseSlideTransitionReturn { + /** Current logical index */ + currentIndex: number; + /** Index to display (follows currentIndex with delay during transition) */ + displayIndex: number; + /** Current transition phase */ + phase: TransitionPhase; + /** Whether any transition is active */ + isTransitioning: boolean; + /** Overlay opacity (0-1) */ + overlayOpacity: number; + /** Overlay color from settings */ + overlayColor: string; + /** Navigate to specific slide index */ + goToIndex: (index: number) => void; + /** Set initial index without transition */ + setInitialIndex: (index: number) => void; + /** CSS transition style for slide image */ + slideTransitionStyle: CSSProperties; + /** CSS transition style for overlay */ + overlayTransitionStyle: CSSProperties; + /** Current slide opacity */ + slideOpacity: number; +} + +export function useSlideTransition( + settings: ResolvedSlideTransition, +): UseSlideTransitionReturn { + const [state, setState] = useState({ + currentIndex: 0, + displayIndex: 0, + phase: 'idle', + overlayOpacity: 0, + }); + + const timeoutRef = useRef | null>(null); + const pendingIndexRef = useRef(null); + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + }; + }, []); + + // Half duration for each phase (fade out + fade in) + const halfDuration = settings.durationMs / 2; + + const goToIndex = useCallback( + (newIndex: number) => { + if (newIndex === state.currentIndex && state.phase === 'idle') return; + + // Clear pending transition + if (timeoutRef.current) clearTimeout(timeoutRef.current); + + if (settings.type === 'none') { + // Instant switch - no transition + setState({ + currentIndex: newIndex, + displayIndex: newIndex, + phase: 'idle', + overlayOpacity: 0, + }); + return; + } + + // Store pending index + pendingIndexRef.current = newIndex; + + // Phase 1: Fade out current slide (overlay fades in) + setState((prev) => ({ + ...prev, + currentIndex: newIndex, + phase: 'fadingOut', + overlayOpacity: 1, + })); + + // Phase 2: At midpoint, switch display to new slide, start fade in + timeoutRef.current = setTimeout(() => { + setState((prev) => ({ + ...prev, + displayIndex: pendingIndexRef.current ?? prev.currentIndex, + phase: 'fadingIn', + overlayOpacity: 0, + })); + + // Phase 3: Complete transition + timeoutRef.current = setTimeout(() => { + setState((prev) => ({ + ...prev, + phase: 'idle', + })); + pendingIndexRef.current = null; + }, halfDuration); + }, halfDuration); + }, + [state.currentIndex, state.phase, settings.type, halfDuration], + ); + + const setInitialIndex = useCallback((index: number) => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + pendingIndexRef.current = null; + setState({ + currentIndex: index, + displayIndex: index, + phase: 'idle', + overlayOpacity: 0, + }); + }, []); + + // CSS transition styles + const slideTransitionStyle: CSSProperties = + settings.type === 'fade' + ? { transition: `opacity ${halfDuration}ms ${settings.easing}` } + : {}; + + const overlayTransitionStyle: CSSProperties = + settings.type === 'fade' + ? { transition: `opacity ${halfDuration}ms ${settings.easing}` } + : {}; + + // Slide opacity: visible in idle, fades based on phase + const slideOpacity = state.phase === 'fadingOut' ? 0 : 1; + + return { + currentIndex: state.currentIndex, + displayIndex: state.displayIndex, + phase: state.phase, + isTransitioning: state.phase !== 'idle', + overlayOpacity: state.overlayOpacity, + overlayColor: settings.overlayColor, + goToIndex, + setInitialIndex, + slideTransitionStyle, + overlayTransitionStyle, + slideOpacity, + }; +} diff --git a/frontend/src/lib/resolveSlideTransition.ts b/frontend/src/lib/resolveSlideTransition.ts new file mode 100644 index 0000000..f4626ff --- /dev/null +++ b/frontend/src/lib/resolveSlideTransition.ts @@ -0,0 +1,147 @@ +/** + * Resolve Slide Transition Settings + * + * Cascade: Page Transition Settings -> Element Override + * + * Unlike page transitions, slide transitions: + * - Map 'video' type to 'fade' (no video between slides) + * - Support fade through overlay for crossfade effect + */ + +import type { + ResolvedTransitionSettings, + EasingFunction, + SlideTransitionType, +} from '../types/transition'; + +export interface SlideTransitionOverride { + type?: SlideTransitionType | '' | undefined; + durationMs?: number | '' | undefined; + easing?: EasingFunction | '' | undefined; + overlayColor?: string | undefined; +} + +export interface ResolvedSlideTransition { + type: SlideTransitionType; + durationMs: number; + easing: EasingFunction; + overlayColor: string; +} + +/** + * Resolve slide transition settings with cascade: + * Element override -> Page transition defaults -> Hardcoded fallback + * + * @param pageTransition - Resolved page transition (from useTransitionSettings) + * @param elementOverride - Element-level override (optional) + * @returns Final resolved settings for slide transition + */ +export function resolveSlideTransition( + pageTransition: ResolvedTransitionSettings | null | undefined, + elementOverride?: SlideTransitionOverride | null, +): ResolvedSlideTransition { + // Fallback values if no page transition available + const fallback: ResolvedSlideTransition = { + type: 'fade', + durationMs: 700, + easing: 'ease-in-out', + overlayColor: '#000000', + }; + + // Helper to check if a value is a non-empty string + const hasValue = ( + val: T | '' | undefined, + ): val is Exclude => Boolean(val); + + if (!pageTransition) { + // No page transition settings - use element override or fallback + return { + type: hasValue(elementOverride?.type) + ? elementOverride.type + : fallback.type, + durationMs: + typeof elementOverride?.durationMs === 'number' && + elementOverride.durationMs > 0 + ? elementOverride.durationMs + : fallback.durationMs, + easing: hasValue(elementOverride?.easing) + ? elementOverride.easing + : fallback.easing, + overlayColor: + elementOverride?.overlayColor && elementOverride.overlayColor !== '' + ? elementOverride.overlayColor + : fallback.overlayColor, + }; + } + + // Cascade: Element override -> Page transition + // Type: 'video' maps to 'fade' for slides + let type: SlideTransitionType = 'fade'; + if (hasValue(elementOverride?.type)) { + type = elementOverride.type; + } else if (pageTransition.type === 'none') { + type = 'none'; + } else { + type = 'fade'; // 'fade' or 'video' -> 'fade' + } + + // Duration: Element override -> Page transition + const durationMs = + typeof elementOverride?.durationMs === 'number' && + elementOverride.durationMs > 0 + ? elementOverride.durationMs + : pageTransition.durationMs; + + // Easing: Element override -> Page transition + const easing = hasValue(elementOverride?.easing) + ? elementOverride.easing + : pageTransition.easing; + + // Overlay color: Element override -> Page transition + const overlayColor = + elementOverride?.overlayColor && elementOverride.overlayColor !== '' + ? elementOverride.overlayColor + : pageTransition.overlayColor; + + return { type, durationMs, easing, overlayColor }; +} + +/** + * Extract slide transition override from carousel element + */ +export function extractCarouselSlideOverride( + element: { + carouselSlideTransitionType?: SlideTransitionType | ''; + carouselSlideTransitionDurationMs?: number | ''; + carouselSlideTransitionEasing?: EasingFunction | ''; + carouselSlideTransitionOverlayColor?: string; + } | null | undefined, +): SlideTransitionOverride | null { + if (!element) return null; + return { + type: element.carouselSlideTransitionType, + durationMs: element.carouselSlideTransitionDurationMs, + easing: element.carouselSlideTransitionEasing, + overlayColor: element.carouselSlideTransitionOverlayColor, + }; +} + +/** + * Extract slide transition override from gallery element + */ +export function extractGallerySlideOverride( + element: { + gallerySlideTransitionType?: SlideTransitionType | ''; + gallerySlideTransitionDurationMs?: number | ''; + gallerySlideTransitionEasing?: EasingFunction | ''; + gallerySlideTransitionOverlayColor?: string; + } | null | undefined, +): SlideTransitionOverride | null { + if (!element) return null; + return { + type: element.gallerySlideTransitionType, + durationMs: element.gallerySlideTransitionDurationMs, + easing: element.gallerySlideTransitionEasing, + overlayColor: element.gallerySlideTransitionOverlayColor, + }; +} diff --git a/frontend/src/pages/constructor.tsx b/frontend/src/pages/constructor.tsx index fb4312b..95570f3 100644 --- a/frontend/src/pages/constructor.tsx +++ b/frontend/src/pages/constructor.tsx @@ -284,7 +284,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { 'general' | 'css' | 'effects' >('general'); const [activeGalleryCarousel, setActiveGalleryCarousel] = useState<{ - element: CanvasElement; + elementId: string; initialIndex: number; } | null>(null); // Track background ready state for smooth video transition completion @@ -344,6 +344,12 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { [elements], ); + // Look up current element for gallery carousel (so it receives updates from element editor) + const activeGalleryCarouselElement = useMemo(() => { + if (!activeGalleryCarousel) return null; + return elements.find((el) => el.id === activeGalleryCarousel.elementId) || null; + }, [activeGalleryCarousel, elements]); + // Draggable panels using useDraggable hook const { position: constructorControlsPosition, @@ -1297,7 +1303,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { const handleGalleryCardClick = useCallback( (element: CanvasElement, cardIndex: number) => { if (element.galleryCards && element.galleryCards.length > 0) { - setActiveGalleryCarousel({ element, initialIndex: cardIndex }); + setActiveGalleryCarousel({ elementId: element.id, initialIndex: cardIndex }); } }, [], @@ -1319,18 +1325,13 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { // because the gallery element may not be selected when the carousel is open setElements((prev) => prev.map((el) => - el.id === activeGalleryCarousel.element.id + el.id === activeGalleryCarousel.elementId ? { ...el, ...positionPatch } : el, ), ); - - // Update the active carousel element to reflect the new positions - setActiveGalleryCarousel((prev) => - prev - ? { ...prev, element: { ...prev.element, ...positionPatch } } - : null, - ); + // No need to update activeGalleryCarousel - it stores only elementId + // and the element lookup is done via activeGalleryCarouselElement useMemo }, [activeGalleryCarousel, setElements], ); @@ -1764,6 +1765,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { ) } letterboxStyles={letterboxStyles} + pageTransitionSettings={transitionSettings} /> ); }) @@ -1831,33 +1833,35 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => { /> {/* Gallery Carousel Overlay */} - {activeGalleryCarousel && ( + {activeGalleryCarousel && activeGalleryCarouselElement && ( setActiveGalleryCarousel(null)} resolveUrl={resolveUrlWithBlob} - prevIconUrl={activeGalleryCarousel.element.galleryCarouselPrevIconUrl} - nextIconUrl={activeGalleryCarousel.element.galleryCarouselNextIconUrl} - backIconUrl={activeGalleryCarousel.element.galleryCarouselBackIconUrl} + prevIconUrl={activeGalleryCarouselElement.galleryCarouselPrevIconUrl} + nextIconUrl={activeGalleryCarouselElement.galleryCarouselNextIconUrl} + backIconUrl={activeGalleryCarouselElement.galleryCarouselBackIconUrl} backLabel={ - activeGalleryCarousel.element.galleryCarouselBackLabel || 'BACK' + activeGalleryCarouselElement.galleryCarouselBackLabel || 'BACK' } - prevX={activeGalleryCarousel.element.galleryCarouselPrevX} - prevY={activeGalleryCarousel.element.galleryCarouselPrevY} - nextX={activeGalleryCarousel.element.galleryCarouselNextX} - nextY={activeGalleryCarousel.element.galleryCarouselNextY} - backX={activeGalleryCarousel.element.galleryCarouselBackX} - backY={activeGalleryCarousel.element.galleryCarouselBackY} - prevWidth={activeGalleryCarousel.element.galleryCarouselPrevWidth} - prevHeight={activeGalleryCarousel.element.galleryCarouselPrevHeight} - nextWidth={activeGalleryCarousel.element.galleryCarouselNextWidth} - nextHeight={activeGalleryCarousel.element.galleryCarouselNextHeight} - backWidth={activeGalleryCarousel.element.galleryCarouselBackWidth} - backHeight={activeGalleryCarousel.element.galleryCarouselBackHeight} + prevX={activeGalleryCarouselElement.galleryCarouselPrevX} + prevY={activeGalleryCarouselElement.galleryCarouselPrevY} + nextX={activeGalleryCarouselElement.galleryCarouselNextX} + nextY={activeGalleryCarouselElement.galleryCarouselNextY} + backX={activeGalleryCarouselElement.galleryCarouselBackX} + backY={activeGalleryCarouselElement.galleryCarouselBackY} + prevWidth={activeGalleryCarouselElement.galleryCarouselPrevWidth} + prevHeight={activeGalleryCarouselElement.galleryCarouselPrevHeight} + nextWidth={activeGalleryCarouselElement.galleryCarouselNextWidth} + nextHeight={activeGalleryCarouselElement.galleryCarouselNextHeight} + backWidth={activeGalleryCarouselElement.galleryCarouselBackWidth} + backHeight={activeGalleryCarouselElement.galleryCarouselBackHeight} letterboxStyles={letterboxStyles} isEditMode={isConstructorEditMode} onButtonPositionChange={handleGalleryCarouselButtonPositionChange} + pageTransitionSettings={transitionSettings} + galleryElement={activeGalleryCarouselElement} /> )} diff --git a/frontend/src/stores/global_transition_defaults/globalTransitionDefaultsSlice.ts b/frontend/src/stores/global_transition_defaults/globalTransitionDefaultsSlice.ts index a53e12e..62996dd 100644 --- a/frontend/src/stores/global_transition_defaults/globalTransitionDefaultsSlice.ts +++ b/frontend/src/stores/global_transition_defaults/globalTransitionDefaultsSlice.ts @@ -42,6 +42,7 @@ function isAxiosError(error: unknown): error is AxiosError { } // Fetch singleton thunk +// Backend handles public access - no special headers needed export const fetch = createAsyncThunk< GlobalTransitionDefaults, void, diff --git a/frontend/src/stores/project_transition_settings/projectTransitionSettingsSlice.ts b/frontend/src/stores/project_transition_settings/projectTransitionSettingsSlice.ts index 19c7567..507d771 100644 --- a/frontend/src/stores/project_transition_settings/projectTransitionSettingsSlice.ts +++ b/frontend/src/stores/project_transition_settings/projectTransitionSettingsSlice.ts @@ -54,6 +54,7 @@ function buildKey(projectId: string, environment: string): string { /** * Fetch settings for a specific project and environment + * Backend handles public access based on URL path - no special headers needed */ export const fetchByProjectAndEnv = createAsyncThunk< { key: string; data: ProjectTransitionSettingsEntity | null }, @@ -64,15 +65,12 @@ export const fetchByProjectAndEnv = createAsyncThunk< async ({ projectId, environment }, { rejectWithValue }) => { const key = buildKey(projectId, environment); try { - const result = await axios.get( + const result = await axios.get( `project-transition-settings/project/${projectId}/env/${environment}`, ); + // API returns null if no settings exist (use global defaults) return { key, data: result.data }; } catch (error) { - if (isAxiosError(error) && error.response?.status === 404) { - // No settings found - not an error, just means use global defaults - return { key, data: null }; - } if (isAxiosError(error) && error.response) { return rejectWithValue(error.response.data as ApiError); } diff --git a/frontend/src/types/constructor.ts b/frontend/src/types/constructor.ts index ecca573..73a9b56 100644 --- a/frontend/src/types/constructor.ts +++ b/frontend/src/types/constructor.ts @@ -204,6 +204,18 @@ export interface CanvasElement extends BaseCanvasElement { carouselPrevHeight?: string; carouselNextWidth?: string; carouselNextHeight?: string; + // ═══════════════════════════════════════════════════════════════════ + // Carousel slide transition override + // Inherits from page transitions (global → project) if not set + // ═══════════════════════════════════════════════════════════════════ + /** Override transition type for carousel slides ('fade' | 'none' | '' for default) */ + carouselSlideTransitionType?: 'fade' | 'none' | ''; + /** Override transition duration in ms (number or '' for default) */ + carouselSlideTransitionDurationMs?: number | ''; + /** Override transition easing function */ + carouselSlideTransitionEasing?: EasingFunction | ''; + /** Override overlay color for slide transitions */ + carouselSlideTransitionOverlayColor?: string; tooltipTitle?: string; tooltipText?: string; tooltipTitleFontFamily?: string; @@ -261,6 +273,18 @@ export interface CanvasElement extends BaseCanvasElement { galleryCarouselNextHeight?: string; galleryCarouselBackWidth?: string; galleryCarouselBackHeight?: string; + // ═══════════════════════════════════════════════════════════════════ + // Gallery carousel overlay slide transition override + // Inherits from page transitions (global → project) if not set + // ═══════════════════════════════════════════════════════════════════ + /** Override transition type for gallery slides ('fade' | 'none' | '' for default) */ + gallerySlideTransitionType?: 'fade' | 'none' | ''; + /** Override transition duration in ms (number or '' for default) */ + gallerySlideTransitionDurationMs?: number | ''; + /** Override transition easing function */ + gallerySlideTransitionEasing?: EasingFunction | ''; + /** Override overlay color for slide transitions */ + gallerySlideTransitionOverlayColor?: string; } /** diff --git a/frontend/src/types/transition.ts b/frontend/src/types/transition.ts index 12203de..bea18ed 100644 --- a/frontend/src/types/transition.ts +++ b/frontend/src/types/transition.ts @@ -11,6 +11,9 @@ import { BaseEntity } from './entities'; // Simplified: removed 'slide-left', 'slide-right', 'zoom' - only fade/none/video remain export type TransitionType = 'fade' | 'none' | 'video'; +// Slide transition type (subset - no 'video' for slides) +export type SlideTransitionType = 'fade' | 'none'; + // Easing function options export type EasingFunction = 'ease-in-out' | 'ease-in' | 'ease-out' | 'linear';