added fade effects to gallery and for carousel slides.
This commit is contained in:
parent
e855db03d1
commit
e4dd94f478
@ -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);
|
||||
|
||||
|
||||
@ -161,6 +161,8 @@ const RUNTIME_PUBLIC_READ_ENTITIES = new Set([
|
||||
'PAGE_LINKS',
|
||||
'TRANSITIONS',
|
||||
'PROJECT_AUDIO_TRACKS',
|
||||
'GLOBAL_TRANSITION_DEFAULTS',
|
||||
'PROJECT_TRANSITION_SETTINGS',
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<CanvasElementProps> = ({
|
||||
@ -50,6 +53,7 @@ const CanvasElement: React.FC<CanvasElementProps> = ({
|
||||
onGalleryCardClick,
|
||||
onCarouselButtonPositionChange,
|
||||
letterboxStyles,
|
||||
pageTransitionSettings,
|
||||
}) => {
|
||||
// Extract effect properties from element
|
||||
const effectProperties: Partial<ElementEffectProperties> = {
|
||||
@ -140,6 +144,7 @@ const CanvasElement: React.FC<CanvasElementProps> = ({
|
||||
onGalleryCardClick={onGalleryCardClick}
|
||||
onCarouselButtonPositionChange={onCarouselButtonPositionChange}
|
||||
letterboxStyles={letterboxStyles}
|
||||
pageTransitionSettings={pageTransitionSettings}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
|
||||
@ -741,6 +741,7 @@ export function ElementEditorPanel({
|
||||
{/* Effects Tab */}
|
||||
{activeTab === 'effects' && (
|
||||
<EffectsSettingsSectionCompact
|
||||
elementType={selectedElement.type}
|
||||
values={{
|
||||
appearAnimation: selectedElement.appearAnimation || '',
|
||||
appearAnimationDuration:
|
||||
@ -763,11 +764,104 @@ export function ElementEditorPanel({
|
||||
activeOpacity: selectedElement.activeOpacity || '',
|
||||
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) => {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -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<EffectsSettingsSectionProps> = ({
|
||||
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 (
|
||||
<div className='space-y-3'>
|
||||
{/* Appear Animation */}
|
||||
@ -243,6 +251,99 @@ const EffectsSettingsSectionCompact: React.FC<EffectsSettingsSectionProps> = ({
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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<RuntimeElementProps> = ({
|
||||
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<RuntimeElementProps> = ({
|
||||
onGalleryCardClick={onGalleryCardClick}
|
||||
letterboxStyles={letterboxStyles}
|
||||
isDisabled={isDisabled}
|
||||
pageTransitionSettings={pageTransitionSettings}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@ -879,6 +880,8 @@ export default function RuntimePresentation({
|
||||
}
|
||||
letterboxStyles={letterboxStyles}
|
||||
isEditMode={false}
|
||||
pageTransitionSettings={transitionSettings}
|
||||
galleryElement={activeGalleryCarousel.element}
|
||||
/>
|
||||
)}
|
||||
</BackdropPortalProvider>
|
||||
|
||||
@ -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<GalleryCarouselOverlayProps> = ({
|
||||
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<GalleryCarouselOverlayProps> = ({
|
||||
// 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<GalleryCarouselOverlayProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const currentCard = cards[currentIndex];
|
||||
const currentCard = cards[displayIndex];
|
||||
const imageUrl = currentCard?.imageUrl ? resolve(currentCard.imageUrl) : '';
|
||||
|
||||
return (
|
||||
@ -377,9 +413,21 @@ const GalleryCarouselOverlay: React.FC<GalleryCarouselOverlayProps> = ({
|
||||
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' && (
|
||||
<div
|
||||
className='absolute inset-0 pointer-events-none'
|
||||
style={{
|
||||
...overlayTransitionStyle,
|
||||
backgroundColor: overlayColor,
|
||||
opacity: overlayOpacity,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Prev button */}
|
||||
{renderNavButton(
|
||||
|
||||
@ -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<UiElementRendererProps> = ({
|
||||
onGalleryCardClick,
|
||||
onCarouselButtonPositionChange,
|
||||
letterboxStyles,
|
||||
pageTransitionSettings,
|
||||
}) => {
|
||||
const { className, style } = useElementWrapperStyle({
|
||||
element,
|
||||
@ -101,6 +105,7 @@ export const UiElementRenderer: React.FC<UiElementRendererProps> = ({
|
||||
isEditMode={isEditMode}
|
||||
onButtonPositionChange={onCarouselButtonPositionChange}
|
||||
letterboxStyles={letterboxStyles}
|
||||
pageTransitionSettings={pageTransitionSettings}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<CarouselElementProps> = ({
|
||||
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<CarouselElementProps> = ({
|
||||
// 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<CarouselElementProps> = ({
|
||||
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' && (
|
||||
<div
|
||||
className='absolute inset-0 pointer-events-none'
|
||||
style={{
|
||||
...overlayTransitionStyle,
|
||||
backgroundColor: overlayColor,
|
||||
opacity: overlayOpacity,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -459,7 +493,7 @@ const CarouselElement: React.FC<CarouselElementProps> = ({
|
||||
// Normal mode: inline carousel within element dimensions
|
||||
return (
|
||||
<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 */}
|
||||
{currentSlide?.imageUrl && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
@ -467,9 +501,21 @@ const CarouselElement: React.FC<CarouselElementProps> = ({
|
||||
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' && (
|
||||
<div
|
||||
className='absolute inset-0 pointer-events-none rounded'
|
||||
style={{
|
||||
...overlayTransitionStyle,
|
||||
backgroundColor: overlayColor,
|
||||
opacity: overlayOpacity,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Navigation buttons */}
|
||||
{showNavigation && (
|
||||
|
||||
@ -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';
|
||||
|
||||
159
frontend/src/hooks/useSlideTransition.ts
Normal file
159
frontend/src/hooks/useSlideTransition.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
147
frontend/src/lib/resolveSlideTransition.ts
Normal file
147
frontend/src/lib/resolveSlideTransition.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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 && (
|
||||
<GalleryCarouselOverlay
|
||||
cards={activeGalleryCarousel.element.galleryCards || []}
|
||||
cards={activeGalleryCarouselElement.galleryCards || []}
|
||||
initialIndex={activeGalleryCarousel.initialIndex}
|
||||
onClose={() => 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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@ -42,6 +42,7 @@ function isAxiosError(error: unknown): error is AxiosError<ApiError> {
|
||||
}
|
||||
|
||||
// Fetch singleton thunk
|
||||
// Backend handles public access - no special headers needed
|
||||
export const fetch = createAsyncThunk<
|
||||
GlobalTransitionDefaults,
|
||||
void,
|
||||
|
||||
@ -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<ProjectTransitionSettingsEntity>(
|
||||
const result = await axios.get<ProjectTransitionSettingsEntity | null>(
|
||||
`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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user