added fade effects to gallery and for carousel slides.

This commit is contained in:
Dmitri 2026-05-04 16:49:43 +02:00
parent e855db03d1
commit e4dd94f478
22 changed files with 826 additions and 108 deletions

View File

@ -200,7 +200,7 @@ const mountRuntimeEntityRoute = (path, entityName, router) => {
app.use( app.use(
path, path,
requireRuntimeReadOrAuth, requireRuntimeReadOrAuth,
blockNonPublicRuntimeListEndpoints, blockNonPublicRuntimeListEndpoints(entityName),
sanitizePublicRuntimeListResponse(entityName), sanitizePublicRuntimeListResponse(entityName),
router, router,
); );
@ -237,18 +237,11 @@ app.use(
jwtAuth, jwtAuth,
project_element_defaultsRoutes, project_element_defaultsRoutes,
); );
app.use( // Global transition defaults - routes handle their own auth (GET public, PUT protected)
'/api/global-transition-defaults', app.use('/api/global-transition-defaults', global_transition_defaultsRoutes);
jwtAuth,
global_transition_defaultsRoutes,
);
// Environment-aware project transition settings (supports runtime public access) // Project transition settings - routes handle their own auth (production GET public, else protected)
mountRuntimeEntityRoute( app.use('/api/project-transition-settings', project_transition_settingsRoutes);
'/api/project-transition-settings',
'project_transition_settings',
project_transition_settingsRoutes,
);
app.use('/api/publish', jwtAuth, publishRoutes); app.use('/api/publish', jwtAuth, publishRoutes);

View File

@ -161,6 +161,8 @@ const RUNTIME_PUBLIC_READ_ENTITIES = new Set([
'PAGE_LINKS', 'PAGE_LINKS',
'TRANSITIONS', 'TRANSITIONS',
'PROJECT_AUDIO_TRACKS', 'PROJECT_AUDIO_TRACKS',
'GLOBAL_TRANSITION_DEFAULTS',
'PROJECT_TRANSITION_SETTINGS',
]); ]);
/** /**

View File

@ -1,5 +1,3 @@
const PUBLIC_RUNTIME_ALLOWED_PATH = '/';
const PUBLIC_RUNTIME_ENTITY_FIELDS = { const PUBLIC_RUNTIME_ENTITY_FIELDS = {
projects: [ projects: [
'id', 'id',
@ -38,6 +36,31 @@ const PUBLIC_RUNTIME_ENTITY_FIELDS = {
'sort_order', 'sort_order',
'is_enabled', '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) => { const pickFields = (record, fields) => {
@ -61,12 +84,17 @@ const isPublicRuntimeReadRequest = (req) => {
return req.isRuntimePublicRequest === true && req.method === 'GET'; return req.isRuntimePublicRequest === true && req.method === 'GET';
}; };
const blockNonPublicRuntimeListEndpoints = (req, res, next) => { const blockNonPublicRuntimeListEndpoints = (entityName) => (req, res, next) => {
if (!isPublicRuntimeReadRequest(req)) { if (!isPublicRuntimeReadRequest(req)) {
return next(); 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' }); return res.status(404).send({ message: 'Not found' });
} }
@ -79,13 +107,14 @@ const blockNonPublicRuntimeListEndpoints = (req, res, next) => {
const sanitizePublicRuntimeListResponse = (entityName) => { const sanitizePublicRuntimeListResponse = (entityName) => {
const fields = PUBLIC_RUNTIME_ENTITY_FIELDS[entityName] || []; const fields = PUBLIC_RUNTIME_ENTITY_FIELDS[entityName] || [];
const allowedPaths = PUBLIC_RUNTIME_ALLOWED_PATHS[entityName] || ['/'];
return (req, res, next) => { return (req, res, next) => {
if ( const pathMatches = allowedPaths.some((pattern) =>
!isPublicRuntimeReadRequest(req) || pattern instanceof RegExp ? pattern.test(req.path) : req.path === pattern,
req.path !== PUBLIC_RUNTIME_ALLOWED_PATH || );
fields.length === 0
) { if (!isPublicRuntimeReadRequest(req) || !pathMatches || fields.length === 0) {
return next(); return next();
} }
@ -96,16 +125,21 @@ const sanitizePublicRuntimeListResponse = (entityName) => {
return originalSend(body); return originalSend(body);
} }
if (!Array.isArray(body.rows)) { // Handle list responses with rows array
return originalSend(body); 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({ return originalSend(body);
...body,
rows: sanitizedRows,
});
}; };
return next(); return next();

View File

@ -1,13 +1,26 @@
const express = require('express'); const express = require('express');
const passport = require('passport');
const Global_transition_defaultsService = require('../services/global_transition_defaults'); const Global_transition_defaultsService = require('../services/global_transition_defaults');
const Global_transition_defaultsDBApi = require('../db/api/global_transition_defaults'); const Global_transition_defaultsDBApi = require('../db/api/global_transition_defaults');
const { wrapAsync, commonErrorHandler, isUuidV4 } = require('../helpers'); const { wrapAsync, commonErrorHandler, isUuidV4 } = require('../helpers');
const { checkCrudPermissions } = require('../middlewares/check-permissions'); const { checkCrudPermissions } = require('../middlewares/check-permissions');
const router = express.Router(); 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 * @swagger
@ -15,14 +28,14 @@ router.use(checkCrudPermissions('page_elements'));
* get: * get:
* summary: Get global transition defaults (singleton) * summary: Get global transition defaults (singleton)
* tags: [GlobalTransitionDefaults] * tags: [GlobalTransitionDefaults]
* security: * description: Publicly accessible - no authentication required.
* - bearerAuth: []
* responses: * responses:
* 200: * 200:
* description: Global transition defaults settings * description: Global transition defaults settings
*/ */
router.get( router.get(
'/', '/',
allowPublicRead,
wrapAsync(async (_req, res) => { wrapAsync(async (_req, res) => {
const payload = await Global_transition_defaultsDBApi.findOne(); const payload = await Global_transition_defaultsDBApi.findOne();
res.status(200).send(payload); res.status(200).send(payload);
@ -35,8 +48,7 @@ router.get(
* get: * get:
* summary: Get global transition defaults by ID * summary: Get global transition defaults by ID
* tags: [GlobalTransitionDefaults] * tags: [GlobalTransitionDefaults]
* security: * description: Publicly accessible - no authentication required.
* - bearerAuth: []
* parameters: * parameters:
* - in: path * - in: path
* name: id * name: id
@ -50,6 +62,7 @@ router.get(
*/ */
router.get( router.get(
'/:id', '/:id',
allowPublicRead,
wrapAsync(async (req, res) => { wrapAsync(async (req, res) => {
if (!isUuidV4(req.params.id)) { if (!isUuidV4(req.params.id)) {
return res.status(400).send('Invalid global_transition_defaults id'); return res.status(400).send('Invalid global_transition_defaults id');
@ -102,6 +115,7 @@ router.get(
*/ */
router.put( router.put(
'/:id', '/:id',
jwtAuth,
wrapAsync(async (req, res) => { wrapAsync(async (req, res) => {
await Global_transition_defaultsService.update( await Global_transition_defaultsService.update(
req.body.data, req.body.data,

View File

@ -1,13 +1,34 @@
const express = require('express'); const express = require('express');
const passport = require('passport');
const Project_transition_settingsService = require('../services/project_transition_settings'); const Project_transition_settingsService = require('../services/project_transition_settings');
const Project_transition_settingsDBApi = require('../db/api/project_transition_settings'); const Project_transition_settingsDBApi = require('../db/api/project_transition_settings');
const { wrapAsync, commonErrorHandler, isUuidV4 } = require('../helpers'); const { wrapAsync, commonErrorHandler, isUuidV4 } = require('../helpers');
const { checkCrudPermissions } = require('../middlewares/check-permissions'); const { checkCrudPermissions } = require('../middlewares/check-permissions');
const router = express.Router(); 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 * @swagger
@ -22,8 +43,7 @@ router.use(checkCrudPermissions('page_elements'));
* get: * get:
* summary: Get transition settings for a project in a specific environment * summary: Get transition settings for a project in a specific environment
* tags: [Project_transition_settings] * tags: [Project_transition_settings]
* security: * description: Production environment is publicly accessible. Dev/stage require authentication.
* - bearerAuth: []
* parameters: * parameters:
* - in: path * - in: path
* name: projectId * name: projectId
@ -39,12 +59,13 @@ router.use(checkCrudPermissions('page_elements'));
* enum: [dev, stage, production] * enum: [dev, stage, production]
* responses: * responses:
* 200: * 200:
* description: Transition settings for the project/environment * description: Transition settings for the project/environment (null if none exist)
* 404: * 401:
* description: No settings found (use global defaults) * description: Authentication required (for dev/stage environments)
*/ */
router.get( router.get(
'/project/:projectId/env/:environment', '/project/:projectId/env/:environment',
requireProductionOrAuth,
wrapAsync(async (req, res) => { wrapAsync(async (req, res) => {
const { projectId, environment } = req.params; const { projectId, environment } = req.params;
@ -63,10 +84,7 @@ router.get(
req.currentUser, req.currentUser,
); );
if (!settings) { // Return null if no settings exist (frontend will use global defaults)
return res.status(404).send({ message: 'No project-specific settings found' });
}
res.status(200).send(settings); res.status(200).send(settings);
}), }),
); );
@ -119,6 +137,7 @@ router.get(
*/ */
router.put( router.put(
'/project/:projectId/env/:environment', '/project/:projectId/env/:environment',
jwtAuth,
wrapAsync(async (req, res) => { wrapAsync(async (req, res) => {
const { projectId, environment } = req.params; const { projectId, environment } = req.params;
@ -168,6 +187,7 @@ router.put(
*/ */
router.delete( router.delete(
'/project/:projectId/env/:environment', '/project/:projectId/env/:environment',
jwtAuth,
wrapAsync(async (req, res) => { wrapAsync(async (req, res) => {
const { projectId, environment } = req.params; const { projectId, environment } = req.params;
@ -211,6 +231,7 @@ router.delete(
*/ */
router.get( router.get(
'/', '/',
jwtAuth,
wrapAsync(async (req, res) => { wrapAsync(async (req, res) => {
const payload = await Project_transition_settingsDBApi.findAll(req.query); const payload = await Project_transition_settingsDBApi.findAll(req.query);
res.status(200).send(payload); res.status(200).send(payload);
@ -240,6 +261,7 @@ router.get(
*/ */
router.post( router.post(
'/', '/',
jwtAuth,
wrapAsync(async (req, res) => { wrapAsync(async (req, res) => {
const payload = await Project_transition_settingsService.create( const payload = await Project_transition_settingsService.create(
req.body.data, req.body.data,
@ -270,6 +292,7 @@ router.post(
*/ */
router.get( router.get(
'/:id', '/:id',
jwtAuth,
wrapAsync(async (req, res) => { wrapAsync(async (req, res) => {
if (!isUuidV4(req.params.id)) { if (!isUuidV4(req.params.id)) {
return res.status(400).send({ message: 'Invalid ID' }); return res.status(400).send({ message: 'Invalid ID' });
@ -312,6 +335,7 @@ router.get(
*/ */
router.put( router.put(
'/:id', '/:id',
jwtAuth,
wrapAsync(async (req, res) => { wrapAsync(async (req, res) => {
await Project_transition_settingsService.update( await Project_transition_settingsService.update(
req.body.data, req.body.data,
@ -343,6 +367,7 @@ router.put(
*/ */
router.delete( router.delete(
'/:id', '/:id',
jwtAuth,
wrapAsync(async (req, res) => { wrapAsync(async (req, res) => {
await Project_transition_settingsService.remove( await Project_transition_settingsService.remove(
req.params.id, req.params.id,

View File

@ -17,6 +17,7 @@ import {
type ElementEffectProperties, type ElementEffectProperties,
} from '../../lib/elementEffects'; } from '../../lib/elementEffects';
import type { CanvasElement as CanvasElementType } from '../../types/constructor'; import type { CanvasElement as CanvasElementType } from '../../types/constructor';
import type { ResolvedTransitionSettings } from '../../types/transition';
interface CanvasElementProps { interface CanvasElementProps {
element: CanvasElementType; element: CanvasElementType;
@ -37,6 +38,8 @@ interface CanvasElementProps {
) => void; ) => void;
/** Letterbox styles for constraining fullscreen elements to canvas bounds */ /** Letterbox styles for constraining fullscreen elements to canvas bounds */
letterboxStyles?: React.CSSProperties; letterboxStyles?: React.CSSProperties;
/** Page transition settings (for slide transition cascade in carousel/gallery) */
pageTransitionSettings?: ResolvedTransitionSettings;
} }
const CanvasElement: React.FC<CanvasElementProps> = ({ const CanvasElement: React.FC<CanvasElementProps> = ({
@ -50,6 +53,7 @@ const CanvasElement: React.FC<CanvasElementProps> = ({
onGalleryCardClick, onGalleryCardClick,
onCarouselButtonPositionChange, onCarouselButtonPositionChange,
letterboxStyles, letterboxStyles,
pageTransitionSettings,
}) => { }) => {
// Extract effect properties from element // Extract effect properties from element
const effectProperties: Partial<ElementEffectProperties> = { const effectProperties: Partial<ElementEffectProperties> = {
@ -140,6 +144,7 @@ const CanvasElement: React.FC<CanvasElementProps> = ({
onGalleryCardClick={onGalleryCardClick} onGalleryCardClick={onGalleryCardClick}
onCarouselButtonPositionChange={onCarouselButtonPositionChange} onCarouselButtonPositionChange={onCarouselButtonPositionChange}
letterboxStyles={letterboxStyles} letterboxStyles={letterboxStyles}
pageTransitionSettings={pageTransitionSettings}
/> />
</button> </button>
); );

View File

@ -741,6 +741,7 @@ export function ElementEditorPanel({
{/* Effects Tab */} {/* Effects Tab */}
{activeTab === 'effects' && ( {activeTab === 'effects' && (
<EffectsSettingsSectionCompact <EffectsSettingsSectionCompact
elementType={selectedElement.type}
values={{ values={{
appearAnimation: selectedElement.appearAnimation || '', appearAnimation: selectedElement.appearAnimation || '',
appearAnimationDuration: appearAnimationDuration:
@ -763,11 +764,104 @@ export function ElementEditorPanel({
activeOpacity: selectedElement.activeOpacity || '', activeOpacity: selectedElement.activeOpacity || '',
activeBackgroundColor: activeBackgroundColor:
selectedElement.activeBackgroundColor || '', selectedElement.activeBackgroundColor || '',
// Slide transition values (gallery/carousel)
slideTransitionType:
selectedElement.type === 'gallery'
? selectedElement.gallerySlideTransitionType || ''
: selectedElement.carouselSlideTransitionType || '',
slideTransitionDurationMs:
selectedElement.type === 'gallery'
? selectedElement.gallerySlideTransitionDurationMs !==
undefined &&
selectedElement.gallerySlideTransitionDurationMs !== ''
? String(
selectedElement.gallerySlideTransitionDurationMs,
)
: ''
: selectedElement.carouselSlideTransitionDurationMs !==
undefined &&
selectedElement.carouselSlideTransitionDurationMs !==
''
? String(
selectedElement.carouselSlideTransitionDurationMs,
)
: '',
slideTransitionEasing:
selectedElement.type === 'gallery'
? selectedElement.gallerySlideTransitionEasing || ''
: selectedElement.carouselSlideTransitionEasing || '',
slideTransitionOverlayColor:
selectedElement.type === 'gallery'
? selectedElement.gallerySlideTransitionOverlayColor ||
''
: selectedElement.carouselSlideTransitionOverlayColor ||
'',
}} }}
onChange={(prop, value) => { onChange={(prop, value) => {
updateSelectedElement({ // Handle slide transition properties with proper prefixes
[prop]: value || undefined, 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,
});
}
}} }}
/> />
)} )}

View File

@ -6,12 +6,20 @@
*/ */
import React from 'react'; import React from 'react';
import type { EffectsSettingsSectionProps } from './types'; import type { EffectsSettingsFormValues } from './types';
import type { CanvasElementType } from '../../types/constructor';
const EffectsSettingsSectionCompact: React.FC<EffectsSettingsSectionProps> = ({ interface EffectsSettingsSectionCompactProps {
values, values: EffectsSettingsFormValues;
onChange, onChange: (prop: keyof EffectsSettingsFormValues, value: string) => void;
}) => { elementType?: CanvasElementType;
}
const EffectsSettingsSectionCompact: React.FC<
EffectsSettingsSectionCompactProps
> = ({ values, onChange, elementType }) => {
const showSlideTransition =
elementType === 'gallery' || elementType === 'carousel';
return ( return (
<div className='space-y-3'> <div className='space-y-3'>
{/* Appear Animation */} {/* Appear Animation */}
@ -243,6 +251,99 @@ const EffectsSettingsSectionCompact: React.FC<EffectsSettingsSectionProps> = ({
</div> </div>
</div> </div>
</div> </div>
{/* Slide Transition Override - Gallery/Carousel only */}
{showSlideTransition && (
<div className='mt-3 border-t border-gray-200 pt-3'>
<p className='mb-1 text-[11px] font-semibold text-gray-700'>
Slide Transition
</p>
<p className='mb-2 text-[10px] text-gray-500'>
Override page transition for slides. Leave empty for defaults.
</p>
<div className='grid grid-cols-2 gap-2'>
{/* Type */}
<div>
<label className='mb-1 block text-[10px] text-gray-500'>
Type
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.slideTransitionType || ''}
onChange={(e) => onChange('slideTransitionType', e.target.value)}
>
<option value=''>Use Default</option>
<option value='fade'>Fade</option>
<option value='none'>None (Instant)</option>
</select>
</div>
{/* Duration */}
<div>
<label className='mb-1 block text-[10px] text-gray-500'>
Duration (ms)
</label>
<input
type='number'
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.slideTransitionDurationMs || ''}
onChange={(e) =>
onChange('slideTransitionDurationMs', e.target.value)
}
placeholder='400'
min='0'
step='50'
/>
</div>
{/* Easing */}
<div>
<label className='mb-1 block text-[10px] text-gray-500'>
Easing
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.slideTransitionEasing || ''}
onChange={(e) =>
onChange('slideTransitionEasing', e.target.value)
}
>
<option value=''>Use Default</option>
<option value='ease-in-out'>Ease In-Out</option>
<option value='ease-in'>Ease In</option>
<option value='ease-out'>Ease Out</option>
<option value='linear'>Linear</option>
</select>
</div>
{/* Overlay Color */}
<div>
<label className='mb-1 block text-[10px] text-gray-500'>
Overlay Color
</label>
<div className='flex gap-1'>
<input
type='color'
className='h-[26px] w-8 cursor-pointer rounded border border-gray-300'
value={values.slideTransitionOverlayColor || '#000000'}
onChange={(e) =>
onChange('slideTransitionOverlayColor', e.target.value)
}
/>
<input
type='text'
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.slideTransitionOverlayColor || ''}
onChange={(e) =>
onChange('slideTransitionOverlayColor', e.target.value)
}
placeholder='#000000'
/>
</div>
</div>
</div>
</div>
)}
</div> </div>
); );
}; };

View File

@ -53,6 +53,12 @@ export interface EffectsSettingsFormValues {
activeScale?: string; activeScale?: string;
activeOpacity?: string; activeOpacity?: string;
activeBackgroundColor?: 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;
} }
/** /**

View File

@ -18,6 +18,7 @@ import {
import { isNavigationElementType } from '../lib/elementDefaults'; import { isNavigationElementType } from '../lib/elementDefaults';
import { isBackNavigation } from '../lib/navigationHelpers'; import { isBackNavigation } from '../lib/navigationHelpers';
import type { CanvasElement } from '../types/constructor'; import type { CanvasElement } from '../types/constructor';
import type { ResolvedTransitionSettings } from '../types/transition';
interface RuntimeElementProps { interface RuntimeElementProps {
element: CanvasElement; element: CanvasElement;
@ -30,6 +31,8 @@ interface RuntimeElementProps {
letterboxStyles?: React.CSSProperties; letterboxStyles?: React.CSSProperties;
/** Whether forward navigation is disabled (neighbor pages not yet preloaded) */ /** Whether forward navigation is disabled (neighbor pages not yet preloaded) */
isForwardNavDisabled?: boolean; isForwardNavDisabled?: boolean;
/** Page transition settings (for slide transition cascade in carousel/gallery) */
pageTransitionSettings?: ResolvedTransitionSettings;
} }
// Clamp position to canvas bounds (0-100%) // Clamp position to canvas bounds (0-100%)
@ -43,6 +46,7 @@ const RuntimeElement: React.FC<RuntimeElementProps> = ({
onGalleryCardClick, onGalleryCardClick,
letterboxStyles, letterboxStyles,
isForwardNavDisabled = false, isForwardNavDisabled = false,
pageTransitionSettings,
}) => { }) => {
// Clamp coordinates to canvas bounds // Clamp coordinates to canvas bounds
const xPercent = clamp(element.xPercent ?? 50, 0, 100); const xPercent = clamp(element.xPercent ?? 50, 0, 100);
@ -124,6 +128,7 @@ const RuntimeElement: React.FC<RuntimeElementProps> = ({
onGalleryCardClick={onGalleryCardClick} onGalleryCardClick={onGalleryCardClick}
letterboxStyles={letterboxStyles} letterboxStyles={letterboxStyles}
isDisabled={isDisabled} isDisabled={isDisabled}
pageTransitionSettings={pageTransitionSettings}
/> />
</div> </div>
); );

View File

@ -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(() => { useEffect(() => {
dispatch(fetchGlobalTransitionDefaults()); dispatch(fetchGlobalTransitionDefaults());
}, [dispatch]); }, [dispatch]);
@ -796,6 +796,7 @@ export default function RuntimePresentation({
} }
letterboxStyles={letterboxStyles} letterboxStyles={letterboxStyles}
isForwardNavDisabled={isForwardNavDisabled} isForwardNavDisabled={isForwardNavDisabled}
pageTransitionSettings={transitionSettings}
/> />
))} ))}
</div> </div>
@ -879,6 +880,8 @@ export default function RuntimePresentation({
} }
letterboxStyles={letterboxStyles} letterboxStyles={letterboxStyles}
isEditMode={false} isEditMode={false}
pageTransitionSettings={transitionSettings}
galleryElement={activeGalleryCarousel.element}
/> />
)} )}
</BackdropPortalProvider> </BackdropPortalProvider>

View File

@ -9,8 +9,14 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react';
import Icon from '@mdi/react'; import Icon from '@mdi/react';
import { mdiChevronLeft, mdiChevronRight, mdiArrowLeft } from '@mdi/js'; 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 { resolveAssetPlaybackUrl } from '../../lib/assetUrl';
import {
resolveSlideTransition,
extractGallerySlideOverride,
} from '../../lib/resolveSlideTransition';
import { useSlideTransition } from '../../hooks/useSlideTransition';
interface GalleryCarouselOverlayProps { interface GalleryCarouselOverlayProps {
cards: GalleryCard[]; cards: GalleryCard[];
@ -46,6 +52,10 @@ interface GalleryCarouselOverlayProps {
) => void; ) => void;
// Letterbox styles for constraining overlay to canvas bounds // Letterbox styles for constraining overlay to canvas bounds
letterboxStyles?: React.CSSProperties; 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) => const clamp = (value: number, min: number, max: number) =>
@ -75,9 +85,33 @@ const GalleryCarouselOverlay: React.FC<GalleryCarouselOverlayProps> = ({
isEditMode = false, isEditMode = false,
onButtonPositionChange, onButtonPositionChange,
letterboxStyles, letterboxStyles,
pageTransitionSettings,
galleryElement,
}) => { }) => {
const resolve = resolveUrl ?? resolveAssetPlaybackUrl; 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< const [draggingButton, setDraggingButton] = useState<
'prev' | 'next' | 'back' | null 'prev' | 'next' | 'back' | null
>(null); >(null);
@ -111,13 +145,15 @@ const GalleryCarouselOverlay: React.FC<GalleryCarouselOverlayProps> = ({
// Navigation handlers // Navigation handlers
const goToPrev = useCallback(() => { const goToPrev = useCallback(() => {
if (cards.length === 0) return; if (cards.length === 0) return;
setCurrentIndex((prev) => (prev - 1 + cards.length) % cards.length); const newIndex = (displayIndex - 1 + cards.length) % cards.length;
}, [cards.length]); goToIndex(newIndex);
}, [cards.length, displayIndex, goToIndex]);
const goToNext = useCallback(() => { const goToNext = useCallback(() => {
if (cards.length === 0) return; if (cards.length === 0) return;
setCurrentIndex((prev) => (prev + 1) % cards.length); const newIndex = (displayIndex + 1) % cards.length;
}, [cards.length]); goToIndex(newIndex);
}, [cards.length, displayIndex, goToIndex]);
// Keyboard navigation // Keyboard navigation
useEffect(() => { useEffect(() => {
@ -348,7 +384,7 @@ const GalleryCarouselOverlay: React.FC<GalleryCarouselOverlayProps> = ({
); );
}; };
const currentCard = cards[currentIndex]; const currentCard = cards[displayIndex];
const imageUrl = currentCard?.imageUrl ? resolve(currentCard.imageUrl) : ''; const imageUrl = currentCard?.imageUrl ? resolve(currentCard.imageUrl) : '';
return ( return (
@ -377,9 +413,21 @@ const GalleryCarouselOverlay: React.FC<GalleryCarouselOverlayProps> = ({
src={imageUrl} src={imageUrl}
alt={currentCard?.title || ''} alt={currentCard?.title || ''}
className='absolute inset-0 h-full w-full object-contain' className='absolute inset-0 h-full w-full object-contain'
style={{ ...slideTransitionStyle, opacity: slideOpacity }}
draggable={false} draggable={false}
/> />
)} )}
{/* Transition overlay (fades in during fadingOut, fades out during fadingIn) */}
{slideTransition.type === 'fade' && (
<div
className='absolute inset-0 pointer-events-none'
style={{
...overlayTransitionStyle,
backgroundColor: overlayColor,
opacity: overlayOpacity,
}}
/>
)}
{/* Prev button */} {/* Prev button */}
{renderNavButton( {renderNavButton(

View File

@ -10,6 +10,7 @@
import React from 'react'; import React from 'react';
import type { CanvasElement } from '../../types/constructor'; import type { CanvasElement } from '../../types/constructor';
import type { ResolvedTransitionSettings } from '../../types/transition';
import { useElementWrapperStyle } from './shared/useElementWrapperStyle'; import { useElementWrapperStyle } from './shared/useElementWrapperStyle';
import { import {
isNavigationElementType, isNavigationElementType,
@ -53,6 +54,8 @@ export interface UiElementRendererProps {
) => void; ) => void;
// Letterbox styles for constraining fullscreen elements to canvas bounds // Letterbox styles for constraining fullscreen elements to canvas bounds
letterboxStyles?: React.CSSProperties; letterboxStyles?: React.CSSProperties;
// Page transition settings (for slide transition cascade in carousel/gallery)
pageTransitionSettings?: ResolvedTransitionSettings;
} }
/** /**
@ -70,6 +73,7 @@ export const UiElementRenderer: React.FC<UiElementRendererProps> = ({
onGalleryCardClick, onGalleryCardClick,
onCarouselButtonPositionChange, onCarouselButtonPositionChange,
letterboxStyles, letterboxStyles,
pageTransitionSettings,
}) => { }) => {
const { className, style } = useElementWrapperStyle({ const { className, style } = useElementWrapperStyle({
element, element,
@ -101,6 +105,7 @@ export const UiElementRenderer: React.FC<UiElementRendererProps> = ({
isEditMode={isEditMode} isEditMode={isEditMode}
onButtonPositionChange={onCarouselButtonPositionChange} onButtonPositionChange={onCarouselButtonPositionChange}
letterboxStyles={letterboxStyles} letterboxStyles={letterboxStyles}
pageTransitionSettings={pageTransitionSettings}
/> />
); );
} }

View File

@ -14,21 +14,21 @@
* - Navigation-style rendering when custom icons with dimensions are set * - Navigation-style rendering when custom icons with dimensions are set
*/ */
import React, { import React, { useMemo, useCallback, useEffect, useRef, useState } from 'react';
useState,
useMemo,
useCallback,
useEffect,
useRef,
} from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import type { CSSProperties } from 'react'; import type { CSSProperties } from 'react';
import Icon from '@mdi/react'; import Icon from '@mdi/react';
import { mdiChevronLeft, mdiChevronRight } from '@mdi/js'; import { mdiChevronLeft, mdiChevronRight } from '@mdi/js';
import type { CanvasElement, CarouselSlide } from '../../../types/constructor'; import type { CanvasElement, CarouselSlide } from '../../../types/constructor';
import type { ResolvedTransitionSettings } from '../../../types/transition';
import { resolveAssetPlaybackUrl } from '../../../lib/assetUrl'; import { resolveAssetPlaybackUrl } from '../../../lib/assetUrl';
import { getFontByKey, getFontStyle } from '../../../lib/fonts'; import { getFontByKey, getFontStyle } from '../../../lib/fonts';
import { toCU } from '../../../lib/canvasScale'; import { toCU } from '../../../lib/canvasScale';
import {
resolveSlideTransition,
extractCarouselSlideOverride,
} from '../../../lib/resolveSlideTransition';
import { useSlideTransition } from '../../../hooks/useSlideTransition';
interface CarouselElementProps { interface CarouselElementProps {
element: CanvasElement; element: CanvasElement;
@ -44,6 +44,8 @@ interface CarouselElementProps {
) => void; ) => void;
// Letterbox styles for constraining full-width carousel to canvas bounds // Letterbox styles for constraining full-width carousel to canvas bounds
letterboxStyles?: CSSProperties; letterboxStyles?: CSSProperties;
// Page transition settings (for slide transition cascade)
pageTransitionSettings?: ResolvedTransitionSettings;
} }
const clamp = (value: number, min: number, max: number) => const clamp = (value: number, min: number, max: number) =>
@ -57,13 +59,31 @@ const CarouselElement: React.FC<CarouselElementProps> = ({
isEditMode = false, isEditMode = false,
onButtonPositionChange, onButtonPositionChange,
letterboxStyles, letterboxStyles,
pageTransitionSettings,
}) => { }) => {
const resolve = resolveUrl ?? resolveAssetPlaybackUrl; const resolve = resolveUrl ?? resolveAssetPlaybackUrl;
const slides: CarouselSlide[] = element.carouselSlides || []; const slides: CarouselSlide[] = element.carouselSlides || [];
const [currentIndex, setCurrentIndex] = useState(0);
const currentSlide = slides[currentIndex] || slides[0];
const isFullWidth = element.carouselFullWidth || false; 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) // Drag state (constructor edit mode only)
const [draggingButton, setDraggingButton] = useState<'prev' | 'next' | null>( const [draggingButton, setDraggingButton] = useState<'prev' | 'next' | null>(
null, null,
@ -100,13 +120,15 @@ const CarouselElement: React.FC<CarouselElementProps> = ({
// Navigation handlers (no event parameter for keyboard/swipe use) // Navigation handlers (no event parameter for keyboard/swipe use)
const goToPrev = useCallback(() => { const goToPrev = useCallback(() => {
if (slides.length === 0) return; if (slides.length === 0) return;
setCurrentIndex((prev) => (prev - 1 + slides.length) % slides.length); const newIndex = (displayIndex - 1 + slides.length) % slides.length;
}, [slides.length]); goToIndex(newIndex);
}, [slides.length, displayIndex, goToIndex]);
const goToNext = useCallback(() => { const goToNext = useCallback(() => {
if (slides.length === 0) return; if (slides.length === 0) return;
setCurrentIndex((prev) => (prev + 1) % slides.length); const newIndex = (displayIndex + 1) % slides.length;
}, [slides.length]); goToIndex(newIndex);
}, [slides.length, displayIndex, goToIndex]);
// Click handlers for buttons (with event propagation control) // Click handlers for buttons (with event propagation control)
const handlePrevClick = useCallback( const handlePrevClick = useCallback(
@ -365,9 +387,21 @@ const CarouselElement: React.FC<CarouselElementProps> = ({
src={resolve(currentSlide.imageUrl)} src={resolve(currentSlide.imageUrl)}
alt={currentSlide.caption || 'Carousel slide'} alt={currentSlide.caption || 'Carousel slide'}
className='absolute inset-0 w-full h-full object-contain' className='absolute inset-0 w-full h-full object-contain'
style={{ ...slideTransitionStyle, opacity: slideOpacity }}
draggable={false} draggable={false}
/> />
)} )}
{/* Transition overlay (fades in during fadingOut, fades out during fadingIn) */}
{slideTransition.type === 'fade' && (
<div
className='absolute inset-0 pointer-events-none'
style={{
...overlayTransitionStyle,
backgroundColor: overlayColor,
opacity: overlayOpacity,
}}
/>
)}
</div> </div>
</div> </div>
); );
@ -459,7 +493,7 @@ const CarouselElement: React.FC<CarouselElementProps> = ({
// Normal mode: inline carousel within element dimensions // Normal mode: inline carousel within element dimensions
return ( return (
<div className={className} style={style}> <div className={className} style={style}>
<div className='relative w-full h-full min-w-[120px] min-h-[80px]'> <div className='relative w-full h-full min-w-[120px] min-h-[80px] overflow-hidden'>
{/* Current slide image */} {/* Current slide image */}
{currentSlide?.imageUrl && ( {currentSlide?.imageUrl && (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
@ -467,9 +501,21 @@ const CarouselElement: React.FC<CarouselElementProps> = ({
src={resolve(currentSlide.imageUrl)} src={resolve(currentSlide.imageUrl)}
alt={currentSlide.caption || 'Carousel slide'} alt={currentSlide.caption || 'Carousel slide'}
className='w-full h-full object-cover rounded' className='w-full h-full object-cover rounded'
style={{ ...slideTransitionStyle, opacity: slideOpacity }}
draggable={false} draggable={false}
/> />
)} )}
{/* Transition overlay */}
{slideTransition.type === 'fade' && (
<div
className='absolute inset-0 pointer-events-none rounded'
style={{
...overlayTransitionStyle,
backgroundColor: overlayColor,
opacity: overlayOpacity,
}}
/>
)}
{/* Navigation buttons */} {/* Navigation buttons */}
{showNavigation && ( {showNavigation && (

View File

@ -54,6 +54,7 @@ export type {
UseTransitionCreationOptions, UseTransitionCreationOptions,
UseTransitionCreationResult, UseTransitionCreationResult,
} from './useTransitionCreation'; } from './useTransitionCreation';
export { useSlideTransition } from './useSlideTransition';
// Constructor hooks - import directly for better tree-shaking: // Constructor hooks - import directly for better tree-shaking:
// import { useOutsideClick } from '../hooks/useOutsideClick'; // import { useOutsideClick } from '../hooks/useOutsideClick';

View File

@ -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<SlideTransitionState>({
currentIndex: 0,
displayIndex: 0,
phase: 'idle',
overlayOpacity: 0,
});
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingIndexRef = useRef<number | null>(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,
};
}

View File

@ -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 = <T extends string>(
val: T | '' | undefined,
): val is Exclude<T, ''> => 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,
};
}

View File

@ -284,7 +284,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
'general' | 'css' | 'effects' 'general' | 'css' | 'effects'
>('general'); >('general');
const [activeGalleryCarousel, setActiveGalleryCarousel] = useState<{ const [activeGalleryCarousel, setActiveGalleryCarousel] = useState<{
element: CanvasElement; elementId: string;
initialIndex: number; initialIndex: number;
} | null>(null); } | null>(null);
// Track background ready state for smooth video transition completion // Track background ready state for smooth video transition completion
@ -344,6 +344,12 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
[elements], [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 // Draggable panels using useDraggable hook
const { const {
position: constructorControlsPosition, position: constructorControlsPosition,
@ -1297,7 +1303,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
const handleGalleryCardClick = useCallback( const handleGalleryCardClick = useCallback(
(element: CanvasElement, cardIndex: number) => { (element: CanvasElement, cardIndex: number) => {
if (element.galleryCards && element.galleryCards.length > 0) { 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 // because the gallery element may not be selected when the carousel is open
setElements((prev) => setElements((prev) =>
prev.map((el) => prev.map((el) =>
el.id === activeGalleryCarousel.element.id el.id === activeGalleryCarousel.elementId
? { ...el, ...positionPatch } ? { ...el, ...positionPatch }
: el, : el,
), ),
); );
// No need to update activeGalleryCarousel - it stores only elementId
// Update the active carousel element to reflect the new positions // and the element lookup is done via activeGalleryCarouselElement useMemo
setActiveGalleryCarousel((prev) =>
prev
? { ...prev, element: { ...prev.element, ...positionPatch } }
: null,
);
}, },
[activeGalleryCarousel, setElements], [activeGalleryCarousel, setElements],
); );
@ -1764,6 +1765,7 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
) )
} }
letterboxStyles={letterboxStyles} letterboxStyles={letterboxStyles}
pageTransitionSettings={transitionSettings}
/> />
); );
}) })
@ -1831,33 +1833,35 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
/> />
{/* Gallery Carousel Overlay */} {/* Gallery Carousel Overlay */}
{activeGalleryCarousel && ( {activeGalleryCarousel && activeGalleryCarouselElement && (
<GalleryCarouselOverlay <GalleryCarouselOverlay
cards={activeGalleryCarousel.element.galleryCards || []} cards={activeGalleryCarouselElement.galleryCards || []}
initialIndex={activeGalleryCarousel.initialIndex} initialIndex={activeGalleryCarousel.initialIndex}
onClose={() => setActiveGalleryCarousel(null)} onClose={() => setActiveGalleryCarousel(null)}
resolveUrl={resolveUrlWithBlob} resolveUrl={resolveUrlWithBlob}
prevIconUrl={activeGalleryCarousel.element.galleryCarouselPrevIconUrl} prevIconUrl={activeGalleryCarouselElement.galleryCarouselPrevIconUrl}
nextIconUrl={activeGalleryCarousel.element.galleryCarouselNextIconUrl} nextIconUrl={activeGalleryCarouselElement.galleryCarouselNextIconUrl}
backIconUrl={activeGalleryCarousel.element.galleryCarouselBackIconUrl} backIconUrl={activeGalleryCarouselElement.galleryCarouselBackIconUrl}
backLabel={ backLabel={
activeGalleryCarousel.element.galleryCarouselBackLabel || 'BACK' activeGalleryCarouselElement.galleryCarouselBackLabel || 'BACK'
} }
prevX={activeGalleryCarousel.element.galleryCarouselPrevX} prevX={activeGalleryCarouselElement.galleryCarouselPrevX}
prevY={activeGalleryCarousel.element.galleryCarouselPrevY} prevY={activeGalleryCarouselElement.galleryCarouselPrevY}
nextX={activeGalleryCarousel.element.galleryCarouselNextX} nextX={activeGalleryCarouselElement.galleryCarouselNextX}
nextY={activeGalleryCarousel.element.galleryCarouselNextY} nextY={activeGalleryCarouselElement.galleryCarouselNextY}
backX={activeGalleryCarousel.element.galleryCarouselBackX} backX={activeGalleryCarouselElement.galleryCarouselBackX}
backY={activeGalleryCarousel.element.galleryCarouselBackY} backY={activeGalleryCarouselElement.galleryCarouselBackY}
prevWidth={activeGalleryCarousel.element.galleryCarouselPrevWidth} prevWidth={activeGalleryCarouselElement.galleryCarouselPrevWidth}
prevHeight={activeGalleryCarousel.element.galleryCarouselPrevHeight} prevHeight={activeGalleryCarouselElement.galleryCarouselPrevHeight}
nextWidth={activeGalleryCarousel.element.galleryCarouselNextWidth} nextWidth={activeGalleryCarouselElement.galleryCarouselNextWidth}
nextHeight={activeGalleryCarousel.element.galleryCarouselNextHeight} nextHeight={activeGalleryCarouselElement.galleryCarouselNextHeight}
backWidth={activeGalleryCarousel.element.galleryCarouselBackWidth} backWidth={activeGalleryCarouselElement.galleryCarouselBackWidth}
backHeight={activeGalleryCarousel.element.galleryCarouselBackHeight} backHeight={activeGalleryCarouselElement.galleryCarouselBackHeight}
letterboxStyles={letterboxStyles} letterboxStyles={letterboxStyles}
isEditMode={isConstructorEditMode} isEditMode={isConstructorEditMode}
onButtonPositionChange={handleGalleryCarouselButtonPositionChange} onButtonPositionChange={handleGalleryCarouselButtonPositionChange}
pageTransitionSettings={transitionSettings}
galleryElement={activeGalleryCarouselElement}
/> />
)} )}

View File

@ -42,6 +42,7 @@ function isAxiosError(error: unknown): error is AxiosError<ApiError> {
} }
// Fetch singleton thunk // Fetch singleton thunk
// Backend handles public access - no special headers needed
export const fetch = createAsyncThunk< export const fetch = createAsyncThunk<
GlobalTransitionDefaults, GlobalTransitionDefaults,
void, void,

View File

@ -54,6 +54,7 @@ function buildKey(projectId: string, environment: string): string {
/** /**
* Fetch settings for a specific project and environment * Fetch settings for a specific project and environment
* Backend handles public access based on URL path - no special headers needed
*/ */
export const fetchByProjectAndEnv = createAsyncThunk< export const fetchByProjectAndEnv = createAsyncThunk<
{ key: string; data: ProjectTransitionSettingsEntity | null }, { key: string; data: ProjectTransitionSettingsEntity | null },
@ -64,15 +65,12 @@ export const fetchByProjectAndEnv = createAsyncThunk<
async ({ projectId, environment }, { rejectWithValue }) => { async ({ projectId, environment }, { rejectWithValue }) => {
const key = buildKey(projectId, environment); const key = buildKey(projectId, environment);
try { try {
const result = await axios.get<ProjectTransitionSettingsEntity>( const result = await axios.get<ProjectTransitionSettingsEntity | null>(
`project-transition-settings/project/${projectId}/env/${environment}`, `project-transition-settings/project/${projectId}/env/${environment}`,
); );
// API returns null if no settings exist (use global defaults)
return { key, data: result.data }; return { key, data: result.data };
} catch (error) { } 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) { if (isAxiosError(error) && error.response) {
return rejectWithValue(error.response.data as ApiError); return rejectWithValue(error.response.data as ApiError);
} }

View File

@ -204,6 +204,18 @@ export interface CanvasElement extends BaseCanvasElement {
carouselPrevHeight?: string; carouselPrevHeight?: string;
carouselNextWidth?: string; carouselNextWidth?: string;
carouselNextHeight?: 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; tooltipTitle?: string;
tooltipText?: string; tooltipText?: string;
tooltipTitleFontFamily?: string; tooltipTitleFontFamily?: string;
@ -261,6 +273,18 @@ export interface CanvasElement extends BaseCanvasElement {
galleryCarouselNextHeight?: string; galleryCarouselNextHeight?: string;
galleryCarouselBackWidth?: string; galleryCarouselBackWidth?: string;
galleryCarouselBackHeight?: 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;
} }
/** /**

View File

@ -11,6 +11,9 @@ import { BaseEntity } from './entities';
// Simplified: removed 'slide-left', 'slide-right', 'zoom' - only fade/none/video remain // Simplified: removed 'slide-left', 'slide-right', 'zoom' - only fade/none/video remain
export type TransitionType = 'fade' | 'none' | 'video'; export type TransitionType = 'fade' | 'none' | 'video';
// Slide transition type (subset - no 'video' for slides)
export type SlideTransitionType = 'fade' | 'none';
// Easing function options // Easing function options
export type EasingFunction = 'ease-in-out' | 'ease-in' | 'ease-out' | 'linear'; export type EasingFunction = 'ease-in-out' | 'ease-in' | 'ease-out' | 'linear';