UI elements settings extention

This commit is contained in:
Dmitri 2026-03-27 09:51:33 +04:00
parent b9ca9bbc10
commit baef1fca2f
25 changed files with 3760 additions and 2043 deletions

View File

@ -199,6 +199,32 @@ DELETE /api/{entity}/:id # Soft delete record
| `permissions` | Granular permissions | | `permissions` | Granular permissions |
| `project_memberships` | Team access per project | | `project_memberships` | Team access per project |
### Element Defaults Hierarchy
UI elements use a three-tier defaults system:
```
element_type_defaults (Global)
│ auto-snapshot on project creation
project_element_defaults (Project)
│ applied when creating elements
tour_pages.ui_schema_json (Instance)
```
1. **Global** (`element_type_defaults`) - Platform-wide defaults for 11 element types (navigation, tooltip, gallery, etc.). Auto-seeded on first API access.
2. **Project** (`project_element_defaults`) - Per-project overrides. Automatically snapshotted from global when a project is created. Can be customized independently.
3. **Instance** (`tour_pages.ui_schema_json`) - Page-specific elements with their settings stored inline. Created in constructor with project defaults applied.
**Additional Endpoints:**
- `POST /api/project-element-defaults/:id/reset` - Reset to current global default
- `GET /api/project-element-defaults/:id/diff` - Compare with global default
### Publishing Workflow ### Publishing Workflow
Three-tier environment model for content: `dev``stage``production` Three-tier environment model for content: `dev``stage``production`

View File

@ -70,6 +70,7 @@ frontend/src/
├── components/ # React components (PascalCase) ├── components/ # React components (PascalCase)
│ ├── Assets/ # Asset management components │ ├── Assets/ # Asset management components
│ ├── Constructor/ # Tour builder components │ ├── Constructor/ # Tour builder components
│ ├── ElementSettings/ # Shared element settings form components
│ ├── UiElements/ # Element type components │ ├── UiElements/ # Element type components
│ ├── Generic/ # Generic CRUD components │ ├── Generic/ # Generic CRUD components
│ ├── CardBox.tsx # Card container │ ├── CardBox.tsx # Card container
@ -214,6 +215,7 @@ dispatch(create({ data: newProject }));
| `useOfflineMode` | Detect offline/online status | | `useOfflineMode` | Detect offline/online status |
| `usePWAPreload` | Preload assets for offline | | `usePWAPreload` | Preload assets for offline |
| `useStorageQuota` | Monitor IndexedDB usage | | `useStorageQuota` | Monitor IndexedDB usage |
| `useElementSettingsForm` | Element settings form state (~60 fields) |
## Element Types ## Element Types
@ -243,7 +245,13 @@ Element types follow a three-tier defaults system:
The constructor fetches project defaults via `/api/project-element-defaults?projectId=xxx` and merges them when creating new elements. Existing element values in `ui_schema_json` take precedence over defaults. The constructor fetches project defaults via `/api/project-element-defaults?projectId=xxx` and merges them when creating new elements. Existing element values in `ui_schema_json` take precedence over defaults.
**Admin Pages:**
- `/element-type-defaults` - Edit global defaults (platform-wide)
- `/project-element-defaults/[id]` - Edit project defaults (with Reset to Global, Diff indicator)
**Key files:** **Key files:**
- `components/ElementSettings/` - Shared form components (tabs, CSS styling, type-specific sections)
- `components/ElementSettings/useElementSettingsForm.ts` - Form state hook (~60 fields)
- `types/constructor.ts` - `normalizeElementDefault()`, `buildElementDefaultsMap()` utilities - `types/constructor.ts` - `normalizeElementDefault()`, `buildElementDefaultsMap()` utilities
- `pages/constructor.tsx` - Loads project defaults, creates elements with merged settings - `pages/constructor.tsx` - Loads project defaults, creates elements with merged settings

View File

@ -0,0 +1,163 @@
/**
* CarouselSettingsSection
*
* Settings for carousel element type.
* Manages carousel slides with images and captions.
*/
import React from 'react';
import { mdiPlus, mdiTrashCan } from '@mdi/js';
import BaseButton from '../BaseButton';
import CardBox from '../CardBox';
import FormField from '../FormField';
import type { CarouselSettingsSectionProps } from './types';
const CarouselSettingsSection: React.FC<CarouselSettingsSectionProps> = ({
carouselPrevIconUrl,
carouselNextIconUrl,
carouselSlides,
onAddSlide,
onRemoveSlide,
onUpdateSlide,
onChange,
context,
iconAssetOptions = [],
imageAssetOptions = [],
}) => {
const isConstructor = context === 'constructor';
return (
<CardBox className='border border-gray-200 dark:border-dark-700'>
<h3 className='mb-3 text-sm font-semibold'>Carousel settings</h3>
<div className='grid gap-3 md:grid-cols-2'>
{isConstructor ? (
<>
<FormField label='Previous icon'>
<select
value={carouselPrevIconUrl}
onChange={(event) =>
onChange('carouselPrevIconUrl', event.target.value)
}
>
<option value=''>Not selected</option>
{iconAssetOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</FormField>
<FormField label='Next icon'>
<select
value={carouselNextIconUrl}
onChange={(event) =>
onChange('carouselNextIconUrl', event.target.value)
}
>
<option value=''>Not selected</option>
{iconAssetOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</FormField>
</>
) : (
<>
<FormField label='Previous icon URL'>
<input
value={carouselPrevIconUrl}
onChange={(event) =>
onChange('carouselPrevIconUrl', event.target.value)
}
/>
</FormField>
<FormField label='Next icon URL'>
<input
value={carouselNextIconUrl}
onChange={(event) =>
onChange('carouselNextIconUrl', event.target.value)
}
/>
</FormField>
</>
)}
</div>
<div className='mb-3 mt-4 flex items-center justify-between'>
<h3 className='text-sm font-semibold'>Carousel slides</h3>
<BaseButton
color='info'
icon={mdiPlus}
small
label='Add slide'
onClick={onAddSlide}
/>
</div>
<div className='space-y-4'>
{carouselSlides.length === 0 ? (
<p className='text-sm text-gray-500'>No slides yet.</p>
) : (
carouselSlides.map((slide, index) => (
<CardBox
key={slide.id}
className='border border-gray-200 dark:border-dark-700'
>
<div className='mb-3 flex items-center justify-between'>
<h4 className='text-sm font-semibold'>Slide {index + 1}</h4>
<BaseButton
color='danger'
icon={mdiTrashCan}
small
outline
onClick={() => onRemoveSlide(slide.id)}
/>
</div>
<div className='grid gap-3 md:grid-cols-2'>
{isConstructor ? (
<FormField label='Image'>
<select
value={slide.imageUrl}
onChange={(event) =>
onUpdateSlide(slide.id, 'imageUrl', event.target.value)
}
>
<option value=''>Not selected</option>
{imageAssetOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</FormField>
) : (
<FormField label='Image URL'>
<input
value={slide.imageUrl}
onChange={(event) =>
onUpdateSlide(slide.id, 'imageUrl', event.target.value)
}
/>
</FormField>
)}
<FormField label='Caption'>
<input
value={slide.caption}
onChange={(event) =>
onUpdateSlide(slide.id, 'caption', event.target.value)
}
/>
</FormField>
</div>
</CardBox>
))
)}
</div>
</CardBox>
);
};
export default CarouselSettingsSection;

View File

@ -0,0 +1,68 @@
/**
* CommonSettingsSection
*
* Common settings fields shared by all element types.
* Label, position, and appear timing.
*/
import React from 'react';
import FormField from '../FormField';
import type { CommonSettingsSectionProps } from './types';
const CommonSettingsSection: React.FC<CommonSettingsSectionProps> = ({
label,
xPercent,
yPercent,
appearDelaySec,
appearDurationSec,
onChange,
showPosition = true,
}) => {
return (
<div className='space-y-4'>
<FormField label='Label'>
<input
value={label}
onChange={(event) => onChange('label', event.target.value)}
/>
</FormField>
{showPosition && (
<div className='grid gap-4 md:grid-cols-2'>
<FormField label='X percent'>
<input
value={xPercent}
onChange={(event) => onChange('xPercent', event.target.value)}
/>
</FormField>
<FormField label='Y percent'>
<input
value={yPercent}
onChange={(event) => onChange('yPercent', event.target.value)}
/>
</FormField>
</div>
)}
<div className='grid gap-4 md:grid-cols-2'>
<FormField label='Appear delay (sec)'>
<input
value={appearDelaySec}
onChange={(event) => onChange('appearDelaySec', event.target.value)}
/>
</FormField>
<FormField label='Appear duration (sec)'>
<input
value={appearDurationSec}
onChange={(event) =>
onChange('appearDurationSec', event.target.value)
}
placeholder='Leave empty for none'
/>
</FormField>
</div>
</div>
);
};
export default CommonSettingsSection;

View File

@ -0,0 +1,139 @@
/**
* DescriptionSettingsSection
*
* Settings for description element type.
*/
import React from 'react';
import FormField from '../FormField';
import type { DescriptionSettingsSectionProps } from './types';
const DescriptionSettingsSection: React.FC<DescriptionSettingsSectionProps> = ({
iconUrl,
descriptionTitle,
descriptionText,
descriptionTitleFontSize,
descriptionTextFontSize,
descriptionTitleFontFamily,
descriptionTextFontFamily,
descriptionTitleColor,
descriptionTextColor,
descriptionBackgroundColor,
onChange,
context,
iconAssetOptions = [],
}) => {
const isConstructor = context === 'constructor';
return (
<div className='space-y-4'>
{isConstructor ? (
<FormField label='Icon'>
<select
value={iconUrl}
onChange={(event) => onChange('iconUrl', event.target.value)}
>
<option value=''>Not selected</option>
{iconAssetOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</FormField>
) : (
<FormField label='Icon URL'>
<input
value={iconUrl}
onChange={(event) => onChange('iconUrl', event.target.value)}
/>
</FormField>
)}
<FormField label='Description title'>
<input
value={descriptionTitle}
onChange={(event) => onChange('descriptionTitle', event.target.value)}
/>
</FormField>
<FormField label='Description text' hasTextareaHeight>
<textarea
value={descriptionText}
onChange={(event) => onChange('descriptionText', event.target.value)}
className='h-28 w-full rounded border border-gray-300 p-2 dark:border-dark-700 dark:bg-dark-900'
/>
</FormField>
<div className='grid gap-3 md:grid-cols-2'>
<FormField label='Title font size'>
<input
value={descriptionTitleFontSize}
onChange={(event) =>
onChange('descriptionTitleFontSize', event.target.value)
}
placeholder='e.g. 48px'
/>
</FormField>
<FormField label='Text font size'>
<input
value={descriptionTextFontSize}
onChange={(event) =>
onChange('descriptionTextFontSize', event.target.value)
}
placeholder='e.g. 36px'
/>
</FormField>
<FormField label='Title font family'>
<input
value={descriptionTitleFontFamily}
onChange={(event) =>
onChange('descriptionTitleFontFamily', event.target.value)
}
placeholder='e.g. Arial, sans-serif'
/>
</FormField>
<FormField label='Text font family'>
<input
value={descriptionTextFontFamily}
onChange={(event) =>
onChange('descriptionTextFontFamily', event.target.value)
}
placeholder='e.g. Arial, sans-serif'
/>
</FormField>
<FormField label='Title color'>
<input
type='color'
value={descriptionTitleColor || '#000000'}
onChange={(event) =>
onChange('descriptionTitleColor', event.target.value)
}
className='h-10 w-full'
/>
</FormField>
<FormField label='Text color'>
<input
type='color'
value={descriptionTextColor || '#4B5563'}
onChange={(event) =>
onChange('descriptionTextColor', event.target.value)
}
className='h-10 w-full'
/>
</FormField>
<FormField label='Background color'>
<input
value={descriptionBackgroundColor}
onChange={(event) =>
onChange('descriptionBackgroundColor', event.target.value)
}
placeholder='e.g. transparent, #ffffff'
/>
</FormField>
</div>
</div>
);
};
export default DescriptionSettingsSection;

View File

@ -0,0 +1,64 @@
/**
* ElementSettingsTabs
*
* Tab navigation for element settings.
* Provides General Settings and CSS Styles tabs.
*/
import React from 'react';
import type { ElementSettingsTabsProps } from './types';
const ElementSettingsTabs: React.FC<ElementSettingsTabsProps> = ({
activeTab,
onTabChange,
tabs,
}) => {
return (
<div className='mb-4 inline-flex overflow-hidden rounded border border-gray-300 dark:border-dark-700'>
{tabs.map((tab) => (
<button
key={tab.id}
type='button'
className={`px-4 py-2 text-sm font-medium transition-colors ${
activeTab === tab.id
? 'bg-blue-600 text-white'
: 'bg-white text-gray-700 hover:bg-gray-50 dark:bg-dark-800 dark:text-gray-200 dark:hover:bg-dark-700'
}`}
onClick={() => onTabChange(tab.id)}
>
{tab.label}
</button>
))}
</div>
);
};
/**
* Compact version for constructor sidebar
*/
export const ElementSettingsTabsCompact: React.FC<ElementSettingsTabsProps> = ({
activeTab,
onTabChange,
tabs,
}) => {
return (
<div className='mb-3 inline-flex w-full overflow-hidden rounded border border-gray-300'>
{tabs.map((tab) => (
<button
key={tab.id}
type='button'
className={`flex-1 px-2 py-1.5 text-[11px] font-semibold transition-colors ${
activeTab === tab.id
? 'bg-blue-600 text-white'
: 'bg-white text-gray-700 hover:bg-gray-50'
}`}
onClick={() => onTabChange(tab.id)}
>
{tab.label}
</button>
))}
</div>
);
};
export default ElementSettingsTabs;

View File

@ -0,0 +1,112 @@
/**
* GallerySettingsSection
*
* Settings for gallery element type.
* Manages gallery cards with images, titles, and descriptions.
*/
import React from 'react';
import { mdiPlus, mdiTrashCan } from '@mdi/js';
import BaseButton from '../BaseButton';
import CardBox from '../CardBox';
import FormField from '../FormField';
import type { GallerySettingsSectionProps } from './types';
const GallerySettingsSection: React.FC<GallerySettingsSectionProps> = ({
galleryCards,
onAddCard,
onRemoveCard,
onUpdateCard,
context,
imageAssetOptions = [],
}) => {
const isConstructor = context === 'constructor';
return (
<CardBox className='border border-gray-200 dark:border-dark-700'>
<div className='mb-3 flex items-center justify-between'>
<h3 className='text-sm font-semibold'>Gallery cards</h3>
<BaseButton
color='info'
icon={mdiPlus}
small
label='Add card'
onClick={onAddCard}
/>
</div>
<div className='space-y-4'>
{galleryCards.length === 0 ? (
<p className='text-sm text-gray-500'>No cards yet.</p>
) : (
galleryCards.map((card, index) => (
<CardBox
key={card.id}
className='border border-gray-200 dark:border-dark-700'
>
<div className='mb-3 flex items-center justify-between'>
<h4 className='text-sm font-semibold'>Card {index + 1}</h4>
<BaseButton
color='danger'
icon={mdiTrashCan}
small
outline
onClick={() => onRemoveCard(card.id)}
/>
</div>
<div className='grid gap-3 md:grid-cols-2'>
{isConstructor ? (
<FormField label='Image'>
<select
value={card.imageUrl}
onChange={(event) =>
onUpdateCard(card.id, 'imageUrl', event.target.value)
}
>
<option value=''>Not selected</option>
{imageAssetOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</FormField>
) : (
<FormField label='Image URL'>
<input
value={card.imageUrl}
onChange={(event) =>
onUpdateCard(card.id, 'imageUrl', event.target.value)
}
/>
</FormField>
)}
<FormField label='Title'>
<input
value={card.title}
onChange={(event) =>
onUpdateCard(card.id, 'title', event.target.value)
}
/>
</FormField>
<div className='md:col-span-2'>
<FormField label='Description' hasTextareaHeight>
<textarea
value={card.description}
onChange={(event) =>
onUpdateCard(card.id, 'description', event.target.value)
}
className='h-24 w-full rounded border border-gray-300 p-2 dark:border-dark-700 dark:bg-dark-900'
/>
</FormField>
</div>
</div>
</CardBox>
))
)}
</div>
</CardBox>
);
};
export default GallerySettingsSection;

View File

@ -0,0 +1,89 @@
/**
* MediaSettingsSection
*
* Settings for video_player and audio_player element types.
*/
import React from 'react';
import FormField from '../FormField';
import type { MediaSettingsSectionProps } from './types';
const MediaSettingsSection: React.FC<MediaSettingsSectionProps> = ({
elementType,
mediaUrl,
mediaAutoplay,
mediaLoop,
mediaMuted,
onChange,
context,
videoAssetOptions = [],
audioAssetOptions = [],
}) => {
const isConstructor = context === 'constructor';
const isVideo = elementType === 'video_player';
const assetOptions = isVideo ? videoAssetOptions : audioAssetOptions;
return (
<div className='space-y-4'>
{isConstructor ? (
<FormField label={isVideo ? 'Video asset' : 'Audio asset'}>
<select
value={mediaUrl}
onChange={(event) => onChange('mediaUrl', event.target.value)}
>
<option value=''>Not selected</option>
{assetOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</FormField>
) : (
<FormField label='Media URL'>
<input
value={mediaUrl}
onChange={(event) => onChange('mediaUrl', event.target.value)}
/>
</FormField>
)}
<FormField label='Playback'>
<div className='flex flex-wrap gap-4'>
<label className='inline-flex items-center gap-2 text-sm'>
<input
type='checkbox'
checked={mediaAutoplay}
onChange={(event) =>
onChange('mediaAutoplay', event.target.checked)
}
/>
Autoplay
</label>
<label className='inline-flex items-center gap-2 text-sm'>
<input
type='checkbox'
checked={mediaLoop}
onChange={(event) => onChange('mediaLoop', event.target.checked)}
/>
Loop
</label>
{isVideo && (
<label className='inline-flex items-center gap-2 text-sm'>
<input
type='checkbox'
checked={mediaMuted}
onChange={(event) =>
onChange('mediaMuted', event.target.checked)
}
/>
Muted
</label>
)}
</div>
</FormField>
</div>
);
};
export default MediaSettingsSection;

View File

@ -0,0 +1,188 @@
/**
* NavigationSettingsSection
*
* Settings for navigation_next and navigation_prev element types.
*/
import React from 'react';
import FormField from '../FormField';
import type { NavigationSettingsSectionProps } from './types';
const NavigationSettingsSection: React.FC<NavigationSettingsSectionProps> = ({
iconUrl,
navLabel,
navType,
navDisabled,
targetPageId,
targetPageSlug,
transitionVideoUrl,
transitionReverseMode,
reverseVideoUrl,
transitionDurationSec,
onChange,
context,
iconAssetOptions = [],
transitionVideoAssetOptions = [],
pageOptions = [],
}) => {
const isConstructor = context === 'constructor';
return (
<div className='space-y-4'>
{isConstructor ? (
<FormField label='Icon'>
<select
value={iconUrl}
onChange={(event) => onChange('iconUrl', event.target.value)}
>
<option value=''>Not selected</option>
{iconAssetOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</FormField>
) : (
<FormField label='Icon URL'>
<input
value={iconUrl}
onChange={(event) => onChange('iconUrl', event.target.value)}
/>
</FormField>
)}
<FormField label='Navigation label'>
<input
value={navLabel}
onChange={(event) => onChange('navLabel', event.target.value)}
/>
</FormField>
<FormField label='Navigation type'>
<select
value={navType}
onChange={(event) => onChange('navType', event.target.value)}
>
<option value='forward'>Forward</option>
<option value='back'>Back</option>
</select>
</FormField>
<FormField label='Disabled'>
<label className='inline-flex items-center gap-2 text-sm'>
<input
type='checkbox'
checked={navDisabled}
onChange={(event) => onChange('navDisabled', event.target.checked)}
/>
Button is disabled (not clickable)
</label>
</FormField>
{isConstructor && pageOptions.length > 0 ? (
<FormField label='Target page'>
<select
value={targetPageSlug}
onChange={(event) => onChange('targetPageSlug', event.target.value)}
>
<option value=''>Not selected</option>
{pageOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</FormField>
) : (
<FormField label='Target page ID (optional)'>
<input
value={targetPageId}
onChange={(event) => onChange('targetPageId', event.target.value)}
/>
</FormField>
)}
{isConstructor ? (
<FormField label='Transition video asset'>
<select
value={transitionVideoUrl}
onChange={(event) =>
onChange('transitionVideoUrl', event.target.value)
}
>
<option value=''>Not selected</option>
{transitionVideoAssetOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</FormField>
) : (
<FormField label='Transition video URL'>
<input
value={transitionVideoUrl}
onChange={(event) =>
onChange('transitionVideoUrl', event.target.value)
}
/>
</FormField>
)}
<FormField label='Reverse mode'>
<select
value={transitionReverseMode}
onChange={(event) =>
onChange('transitionReverseMode', event.target.value)
}
>
<option value='auto_reverse'>Auto reverse</option>
<option value='separate_video'>Separate reverse video</option>
</select>
</FormField>
{transitionReverseMode === 'separate_video' && (
<>
{isConstructor ? (
<FormField label='Reverse video asset'>
<select
value={reverseVideoUrl}
onChange={(event) =>
onChange('reverseVideoUrl', event.target.value)
}
>
<option value=''>Not selected</option>
{transitionVideoAssetOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</FormField>
) : (
<FormField label='Reverse video URL'>
<input
value={reverseVideoUrl}
onChange={(event) =>
onChange('reverseVideoUrl', event.target.value)
}
/>
</FormField>
)}
</>
)}
<FormField label='Transition duration (sec)'>
<input
value={transitionDurationSec}
onChange={(event) =>
onChange('transitionDurationSec', event.target.value)
}
/>
</FormField>
</div>
);
};
export default NavigationSettingsSection;

View File

@ -0,0 +1,232 @@
/**
* StyleSettingsSection
*
* CSS styling fields for UI elements.
* Used in element-type-defaults, project-element-defaults, and constructor pages.
*/
import React from 'react';
import FormField from '../FormField';
import type { StyleSettingsSectionProps } from './types';
const StyleSettingsSection: React.FC<StyleSettingsSectionProps> = ({
values,
onChange,
}) => {
return (
<div className='space-y-3'>
<h3 className='text-sm font-semibold'>View & Stylization</h3>
<p className='text-xs text-gray-500'>
Fill numbers only: width is saved as vw, height as vh, border and radius
as px.
</p>
<div className='grid gap-3 md:grid-cols-2'>
<FormField label='Width (vw)'>
<input
type='number'
step='0.1'
min='0'
value={values.width || ''}
onChange={(event) => onChange('width', event.target.value)}
placeholder='e.g. 24'
/>
</FormField>
<FormField label='Height (vh)'>
<input
type='number'
step='0.1'
min='0'
value={values.height || ''}
onChange={(event) => onChange('height', event.target.value)}
placeholder='e.g. 8'
/>
</FormField>
<FormField label='Min width (vw)'>
<input
type='number'
step='0.1'
min='0'
value={values.minWidth || ''}
onChange={(event) => onChange('minWidth', event.target.value)}
/>
</FormField>
<FormField label='Max width (vw)'>
<input
type='number'
step='0.1'
min='0'
value={values.maxWidth || ''}
onChange={(event) => onChange('maxWidth', event.target.value)}
/>
</FormField>
<FormField label='Min height (vh)'>
<input
type='number'
step='0.1'
min='0'
value={values.minHeight || ''}
onChange={(event) => onChange('minHeight', event.target.value)}
/>
</FormField>
<FormField label='Max height (vh)'>
<input
type='number'
step='0.1'
min='0'
value={values.maxHeight || ''}
onChange={(event) => onChange('maxHeight', event.target.value)}
/>
</FormField>
<FormField label='Margin'>
<input
value={values.margin || ''}
onChange={(event) => onChange('margin', event.target.value)}
placeholder='e.g. 0 auto / 0.5rem'
/>
</FormField>
<FormField label='Padding'>
<input
value={values.padding || ''}
onChange={(event) => onChange('padding', event.target.value)}
placeholder='e.g. 0.5rem 0.75rem'
/>
</FormField>
<FormField label='Gap'>
<input
value={values.gap || ''}
onChange={(event) => onChange('gap', event.target.value)}
/>
</FormField>
<FormField label='Font size'>
<input
value={values.fontSize || ''}
onChange={(event) => onChange('fontSize', event.target.value)}
placeholder='e.g. 0.875rem / clamp(...)'
/>
</FormField>
<FormField label='Line height'>
<input
value={values.lineHeight || ''}
onChange={(event) => onChange('lineHeight', event.target.value)}
/>
</FormField>
<FormField label='Font weight'>
<input
value={values.fontWeight || ''}
onChange={(event) => onChange('fontWeight', event.target.value)}
placeholder='e.g. 500 / bold'
/>
</FormField>
<FormField label='Border width (px)'>
<input
type='number'
step='1'
min='0'
value={values.border || ''}
onChange={(event) => onChange('border', event.target.value)}
placeholder='empty = none'
/>
</FormField>
<FormField label='Border radius (px)'>
<input
type='number'
step='1'
min='0'
value={values.borderRadius || ''}
onChange={(event) => onChange('borderRadius', event.target.value)}
placeholder='empty = 0'
/>
</FormField>
<FormField label='Opacity'>
<input
value={values.opacity || ''}
onChange={(event) => onChange('opacity', event.target.value)}
placeholder='0..1'
/>
</FormField>
<FormField label='Shadow'>
<input
value={values.boxShadow || ''}
onChange={(event) => onChange('boxShadow', event.target.value)}
placeholder='e.g. 0 4px 12px rgba(...)'
/>
</FormField>
<FormField label='Display'>
<select
value={values.display || ''}
onChange={(event) => onChange('display', event.target.value)}
>
<option value=''>Not set</option>
<option value='block'>block</option>
<option value='inline-block'>inline-block</option>
<option value='flex'>flex</option>
<option value='inline-flex'>inline-flex</option>
<option value='grid'>grid</option>
<option value='none'>none</option>
</select>
</FormField>
<FormField label='Position'>
<select
value={values.position || ''}
onChange={(event) => onChange('position', event.target.value)}
>
<option value=''>Not set</option>
<option value='static'>static</option>
<option value='relative'>relative</option>
<option value='absolute'>absolute</option>
<option value='fixed'>fixed</option>
<option value='sticky'>sticky</option>
</select>
</FormField>
<FormField label='Justify content'>
<select
value={values.justifyContent || ''}
onChange={(event) => onChange('justifyContent', event.target.value)}
>
<option value=''>Not set</option>
<option value='flex-start'>flex-start</option>
<option value='center'>center</option>
<option value='flex-end'>flex-end</option>
<option value='space-between'>space-between</option>
<option value='space-around'>space-around</option>
<option value='space-evenly'>space-evenly</option>
</select>
</FormField>
<FormField label='Align items'>
<select
value={values.alignItems || ''}
onChange={(event) => onChange('alignItems', event.target.value)}
>
<option value=''>Not set</option>
<option value='stretch'>stretch</option>
<option value='flex-start'>flex-start</option>
<option value='center'>center</option>
<option value='flex-end'>flex-end</option>
<option value='baseline'>baseline</option>
</select>
</FormField>
<FormField label='Text align'>
<select
value={values.textAlign || ''}
onChange={(event) => onChange('textAlign', event.target.value)}
>
<option value=''>Not set</option>
<option value='left'>left</option>
<option value='center'>center</option>
<option value='right'>right</option>
<option value='justify'>justify</option>
</select>
</FormField>
<FormField label='z-index'>
<input
value={values.zIndex || ''}
onChange={(event) => onChange('zIndex', event.target.value)}
placeholder='e.g. 1 / 10'
/>
</FormField>
</div>
</div>
);
};
export default StyleSettingsSection;

View File

@ -0,0 +1,321 @@
/**
* StyleSettingsSectionCompact
*
* Compact CSS styling fields for the constructor sidebar.
* Uses smaller inputs and labels to fit in the narrow sidebar.
*/
import React from 'react';
import type { StyleSettingsSectionProps } from './types';
const StyleSettingsSectionCompact: React.FC<StyleSettingsSectionProps> = ({
values,
onChange,
}) => {
return (
<div className='space-y-2'>
<p className='text-[10px] text-gray-500'>
Numbers only: width=vw, height=vh, border/radius=px
</p>
<div className='grid grid-cols-2 gap-2'>
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Width (vw)
</label>
<input
type='number'
step='0.1'
min='0'
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.width || ''}
onChange={(e) => onChange('width', e.target.value)}
placeholder='e.g. 24'
/>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Height (vh)
</label>
<input
type='number'
step='0.1'
min='0'
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.height || ''}
onChange={(e) => onChange('height', e.target.value)}
placeholder='e.g. 8'
/>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Min W (vw)
</label>
<input
type='number'
step='0.1'
min='0'
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.minWidth || ''}
onChange={(e) => onChange('minWidth', e.target.value)}
/>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Max W (vw)
</label>
<input
type='number'
step='0.1'
min='0'
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.maxWidth || ''}
onChange={(e) => onChange('maxWidth', e.target.value)}
/>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Min H (vh)
</label>
<input
type='number'
step='0.1'
min='0'
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.minHeight || ''}
onChange={(e) => onChange('minHeight', e.target.value)}
/>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Max H (vh)
</label>
<input
type='number'
step='0.1'
min='0'
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.maxHeight || ''}
onChange={(e) => onChange('maxHeight', e.target.value)}
/>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Margin
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.margin || ''}
onChange={(e) => onChange('margin', e.target.value)}
placeholder='0 auto'
/>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Padding
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.padding || ''}
onChange={(e) => onChange('padding', e.target.value)}
placeholder='0.5rem'
/>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Gap
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.gap || ''}
onChange={(e) => onChange('gap', e.target.value)}
/>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Font size
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.fontSize || ''}
onChange={(e) => onChange('fontSize', e.target.value)}
placeholder='0.875rem'
/>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Line height
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.lineHeight || ''}
onChange={(e) => onChange('lineHeight', e.target.value)}
/>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Font weight
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.fontWeight || ''}
onChange={(e) => onChange('fontWeight', e.target.value)}
placeholder='500'
/>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Border (px)
</label>
<input
type='number'
step='1'
min='0'
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.border || ''}
onChange={(e) => onChange('border', e.target.value)}
placeholder='0'
/>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Radius (px)
</label>
<input
type='number'
step='1'
min='0'
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.borderRadius || ''}
onChange={(e) => onChange('borderRadius', e.target.value)}
placeholder='0'
/>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Opacity
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.opacity || ''}
onChange={(e) => onChange('opacity', e.target.value)}
placeholder='0..1'
/>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
z-index
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.zIndex || ''}
onChange={(e) => onChange('zIndex', e.target.value)}
placeholder='1'
/>
</div>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Box shadow
</label>
<input
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.boxShadow || ''}
onChange={(e) => onChange('boxShadow', e.target.value)}
placeholder='0 4px 12px rgba(...)'
/>
</div>
<div className='grid grid-cols-2 gap-2'>
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Display
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.display || ''}
onChange={(e) => onChange('display', e.target.value)}
>
<option value=''>Not set</option>
<option value='block'>block</option>
<option value='inline-block'>inline-block</option>
<option value='flex'>flex</option>
<option value='inline-flex'>inline-flex</option>
<option value='grid'>grid</option>
<option value='none'>none</option>
</select>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Position
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.position || ''}
onChange={(e) => onChange('position', e.target.value)}
>
<option value=''>Not set</option>
<option value='static'>static</option>
<option value='relative'>relative</option>
<option value='absolute'>absolute</option>
<option value='fixed'>fixed</option>
<option value='sticky'>sticky</option>
</select>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Justify
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.justifyContent || ''}
onChange={(e) => onChange('justifyContent', e.target.value)}
>
<option value=''>Not set</option>
<option value='flex-start'>start</option>
<option value='center'>center</option>
<option value='flex-end'>end</option>
<option value='space-between'>between</option>
<option value='space-around'>around</option>
<option value='space-evenly'>evenly</option>
</select>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Align
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.alignItems || ''}
onChange={(e) => onChange('alignItems', e.target.value)}
>
<option value=''>Not set</option>
<option value='stretch'>stretch</option>
<option value='flex-start'>start</option>
<option value='center'>center</option>
<option value='flex-end'>end</option>
<option value='baseline'>baseline</option>
</select>
</div>
<div>
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
Text align
</label>
<select
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
value={values.textAlign || ''}
onChange={(e) => onChange('textAlign', e.target.value)}
>
<option value=''>Not set</option>
<option value='left'>left</option>
<option value='center'>center</option>
<option value='right'>right</option>
<option value='justify'>justify</option>
</select>
</div>
</div>
</div>
);
};
export default StyleSettingsSectionCompact;

View File

@ -0,0 +1,64 @@
/**
* TooltipSettingsSection
*
* Settings for tooltip element type.
*/
import React from 'react';
import FormField from '../FormField';
import type { TooltipSettingsSectionProps } from './types';
const TooltipSettingsSection: React.FC<TooltipSettingsSectionProps> = ({
iconUrl,
tooltipTitle,
tooltipText,
onChange,
context,
iconAssetOptions = [],
}) => {
const isConstructor = context === 'constructor';
return (
<div className='space-y-4'>
{isConstructor ? (
<FormField label='Icon'>
<select
value={iconUrl}
onChange={(event) => onChange('iconUrl', event.target.value)}
>
<option value=''>Not selected</option>
{iconAssetOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</FormField>
) : (
<FormField label='Icon URL'>
<input
value={iconUrl}
onChange={(event) => onChange('iconUrl', event.target.value)}
/>
</FormField>
)}
<FormField label='Tooltip title'>
<input
value={tooltipTitle}
onChange={(event) => onChange('tooltipTitle', event.target.value)}
/>
</FormField>
<FormField label='Tooltip text' hasTextareaHeight>
<textarea
value={tooltipText}
onChange={(event) => onChange('tooltipText', event.target.value)}
className='h-28 w-full rounded border border-gray-300 p-2 dark:border-dark-700 dark:bg-dark-900'
/>
</FormField>
</div>
);
};
export default TooltipSettingsSection;

View File

@ -0,0 +1,27 @@
/**
* Element Settings Components
*
* Barrel exports for all element settings form components.
*/
// Main components
export {
default as ElementSettingsTabs,
ElementSettingsTabsCompact,
} from './ElementSettingsTabs';
export { default as StyleSettingsSection } from './StyleSettingsSection';
export { default as StyleSettingsSectionCompact } from './StyleSettingsSectionCompact';
export { default as CommonSettingsSection } from './CommonSettingsSection';
export { default as NavigationSettingsSection } from './NavigationSettingsSection';
export { default as TooltipSettingsSection } from './TooltipSettingsSection';
export { default as DescriptionSettingsSection } from './DescriptionSettingsSection';
export { default as MediaSettingsSection } from './MediaSettingsSection';
export { default as GallerySettingsSection } from './GallerySettingsSection';
export { default as CarouselSettingsSection } from './CarouselSettingsSection';
// Hook
export { useElementSettingsForm } from './useElementSettingsForm';
export type { FormState } from './useElementSettingsForm';
// Types
export * from './types';

View File

@ -0,0 +1,235 @@
/**
* Element Settings Types
*
* Shared types for element settings form components.
* Used across element-type-defaults, project-element-defaults, and constructor pages.
*/
import type { ElementStyleProperties } from '../../lib/elementStyles';
import type {
CanvasElement,
CanvasElementType,
GalleryCard,
CarouselSlide,
AssetOption,
} from '../../types/constructor';
/**
* Context for element settings components.
* Determines available fields and behaviors.
*/
export type ElementSettingsContext =
| 'global' // element-type-defaults page - plain text inputs
| 'project' // project-element-defaults page - plain text inputs
| 'constructor'; // constructor page - asset selectors available
/**
* Props for CSS style settings section
*/
export interface StyleSettingsSectionProps {
values: ElementStyleProperties;
onChange: (prop: keyof ElementStyleProperties, value: string) => void;
}
/**
* Props for common settings section (label, position, appear timing)
*/
export interface CommonSettingsSectionProps {
label: string;
xPercent: string;
yPercent: string;
appearDelaySec: string;
appearDurationSec: string;
onChange: (field: string, value: string) => void;
showPosition?: boolean;
}
/**
* Props for navigation element settings
*/
export interface NavigationSettingsSectionProps {
iconUrl: string;
navLabel: string;
navType: 'forward' | 'back';
navDisabled: boolean;
targetPageId: string;
targetPageSlug: string;
transitionVideoUrl: string;
transitionReverseMode: 'auto_reverse' | 'separate_video';
reverseVideoUrl: string;
transitionDurationSec: string;
onChange: (field: string, value: string | boolean) => void;
context: ElementSettingsContext;
iconAssetOptions?: AssetOption[];
transitionVideoAssetOptions?: AssetOption[];
pageOptions?: { value: string; label: string }[];
}
/**
* Props for tooltip element settings
*/
export interface TooltipSettingsSectionProps {
iconUrl: string;
tooltipTitle: string;
tooltipText: string;
onChange: (field: string, value: string) => void;
context: ElementSettingsContext;
iconAssetOptions?: AssetOption[];
}
/**
* Props for description element settings
*/
export interface DescriptionSettingsSectionProps {
iconUrl: string;
descriptionTitle: string;
descriptionText: string;
descriptionTitleFontSize: string;
descriptionTextFontSize: string;
descriptionTitleFontFamily: string;
descriptionTextFontFamily: string;
descriptionTitleColor: string;
descriptionTextColor: string;
descriptionBackgroundColor: string;
onChange: (field: string, value: string) => void;
context: ElementSettingsContext;
iconAssetOptions?: AssetOption[];
}
/**
* Props for media (video/audio player) element settings
*/
export interface MediaSettingsSectionProps {
elementType: 'video_player' | 'audio_player';
mediaUrl: string;
mediaAutoplay: boolean;
mediaLoop: boolean;
mediaMuted: boolean;
onChange: (field: string, value: string | boolean) => void;
context: ElementSettingsContext;
videoAssetOptions?: AssetOption[];
audioAssetOptions?: AssetOption[];
}
/**
* Props for gallery element settings
*/
export interface GallerySettingsSectionProps {
galleryCards: GalleryCard[];
onAddCard: () => void;
onRemoveCard: (cardId: string) => void;
onUpdateCard: (
cardId: string,
field: keyof GalleryCard,
value: string,
) => void;
context: ElementSettingsContext;
imageAssetOptions?: AssetOption[];
}
/**
* Props for carousel element settings
*/
export interface CarouselSettingsSectionProps {
carouselPrevIconUrl: string;
carouselNextIconUrl: string;
carouselSlides: CarouselSlide[];
onAddSlide: () => void;
onRemoveSlide: (slideId: string) => void;
onUpdateSlide: (
slideId: string,
field: keyof CarouselSlide,
value: string,
) => void;
onChange: (field: string, value: string) => void;
context: ElementSettingsContext;
iconAssetOptions?: AssetOption[];
imageAssetOptions?: AssetOption[];
}
/**
* Props for element settings tabs component
*/
export interface ElementSettingsTabsProps {
activeTab: string;
onTabChange: (tabId: string) => void;
tabs: { id: string; label: string }[];
}
/**
* Element type detection helpers
*/
export function isNavigationType(type: string): boolean {
return type === 'navigation_next' || type === 'navigation_prev';
}
export function isTooltipType(type: string): boolean {
return type === 'tooltip';
}
export function isDescriptionType(type: string): boolean {
return type === 'description';
}
export function isGalleryType(type: string): boolean {
return type === 'gallery';
}
export function isCarouselType(type: string): boolean {
return type === 'carousel';
}
export function isMediaType(type: string): boolean {
return type === 'video_player' || type === 'audio_player';
}
/**
* Value normalization helpers
*/
export const clampPercent = (value: string): number => {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return 0;
return Math.min(Math.max(parsed, 0), 100);
};
export const parseNullableNumber = (value: string): number | null => {
if (!value.trim()) return null;
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 0) return null;
return parsed;
};
export const extractNumericValue = (value: unknown): string => {
const normalized = String(value ?? '').trim();
if (!normalized) return '';
const matched = normalized.match(/-?\d*\.?\d+/);
return matched ? matched[0] : '';
};
export const normalizeNumberString = (value: string): string => {
const normalized = String(value || '').trim();
if (!normalized) return '';
const parsed = Number(normalized);
if (!Number.isFinite(parsed)) return '';
return String(parsed);
};
export const toOptionalTrimmed = (value: string): string | undefined => {
const normalized = String(value || '').trim();
return normalized ? normalized : undefined;
};
export const toUnitValue = (
value: string,
unit: 'vw' | 'vh' | 'px',
): string | undefined => {
const normalized = normalizeNumberString(value);
return normalized ? `${normalized}${unit}` : undefined;
};
export const createLocalId = (): string => {
if (typeof window !== 'undefined' && window.crypto?.randomUUID) {
return window.crypto.randomUUID();
}
return `element-default_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
};

View File

@ -0,0 +1,582 @@
/**
* useElementSettingsForm
*
* Shared hook for managing element settings form state.
* Used by element-type-defaults, project-element-defaults, and constructor pages.
*/
import { useCallback, useState } from 'react';
import type { ElementStyleProperties } from '../../lib/elementStyles';
import type {
CanvasElement,
CanvasElementType,
GalleryCard,
CarouselSlide,
} from '../../types/constructor';
import { parseJsonObject } from '../../lib/parseJson';
import {
extractNumericValue,
clampPercent,
parseNullableNumber,
toUnitValue,
toOptionalTrimmed,
createLocalId,
} from './types';
interface UseElementSettingsFormOptions {
elementType: CanvasElementType | string;
}
interface FormState {
// Common settings
label: string;
xPercent: string;
yPercent: string;
appearDelaySec: string;
appearDurationSec: string;
// CSS style settings
width: string;
height: string;
minWidth: string;
maxWidth: string;
minHeight: string;
maxHeight: string;
margin: string;
padding: string;
gap: string;
fontSize: string;
lineHeight: string;
fontWeight: string;
border: string;
borderRadius: string;
opacity: string;
boxShadow: string;
display: string;
position: string;
justifyContent: string;
alignItems: string;
textAlign: string;
zIndex: string;
// Navigation settings
iconUrl: string;
navLabel: string;
navType: 'forward' | 'back';
navDisabled: boolean;
targetPageId: string;
targetPageSlug: string;
transitionVideoUrl: string;
transitionReverseMode: 'auto_reverse' | 'separate_video';
reverseVideoUrl: string;
transitionDurationSec: string;
// Tooltip settings
tooltipTitle: string;
tooltipText: string;
// Description settings
descriptionTitle: string;
descriptionText: string;
descriptionTitleFontSize: string;
descriptionTextFontSize: string;
descriptionTitleFontFamily: string;
descriptionTextFontFamily: string;
descriptionTitleColor: string;
descriptionTextColor: string;
descriptionBackgroundColor: string;
// Media settings
mediaUrl: string;
mediaAutoplay: boolean;
mediaLoop: boolean;
mediaMuted: boolean;
// Carousel settings
carouselPrevIconUrl: string;
carouselNextIconUrl: string;
// Complex arrays
galleryCards: GalleryCard[];
carouselSlides: CarouselSlide[];
}
const initialState: FormState = {
label: '',
xPercent: '0',
yPercent: '0',
appearDelaySec: '0',
appearDurationSec: '',
width: '',
height: '',
minWidth: '',
maxWidth: '',
minHeight: '',
maxHeight: '',
margin: '',
padding: '',
gap: '',
fontSize: '',
lineHeight: '',
fontWeight: '',
border: '',
borderRadius: '',
opacity: '',
boxShadow: '',
display: '',
position: '',
justifyContent: '',
alignItems: '',
textAlign: '',
zIndex: '',
iconUrl: '',
navLabel: '',
navType: 'forward',
navDisabled: false,
targetPageId: '',
targetPageSlug: '',
transitionVideoUrl: '',
transitionReverseMode: 'auto_reverse',
reverseVideoUrl: '',
transitionDurationSec: '0.7',
tooltipTitle: '',
tooltipText: '',
descriptionTitle: '',
descriptionText: '',
descriptionTitleFontSize: '',
descriptionTextFontSize: '',
descriptionTitleFontFamily: '',
descriptionTextFontFamily: '',
descriptionTitleColor: '',
descriptionTextColor: '',
descriptionBackgroundColor: '',
mediaUrl: '',
mediaAutoplay: false,
mediaLoop: false,
mediaMuted: false,
carouselPrevIconUrl: '',
carouselNextIconUrl: '',
galleryCards: [],
carouselSlides: [],
};
export function useElementSettingsForm(options: UseElementSettingsFormOptions) {
const { elementType } = options;
const [state, setState] = useState<FormState>(initialState);
// Type detection helpers
const isNavigationType =
elementType === 'navigation_next' || elementType === 'navigation_prev';
const isTooltipType = elementType === 'tooltip';
const isDescriptionType = elementType === 'description';
const isGalleryType = elementType === 'gallery';
const isCarouselType = elementType === 'carousel';
const isMediaType =
elementType === 'video_player' || elementType === 'audio_player';
/**
* Apply settings from JSON to form state
*/
const applySettings = useCallback(
(settingsValue?: string | Record<string, unknown>) => {
const settings = parseJsonObject<Record<string, unknown>>(
settingsValue,
{},
);
setState({
label: String(settings.label || ''),
xPercent: String(settings.xPercent ?? 0),
yPercent: String(settings.yPercent ?? 0),
width: extractNumericValue(settings.width),
height: extractNumericValue(settings.height),
minWidth: extractNumericValue(settings.minWidth),
maxWidth: extractNumericValue(settings.maxWidth),
minHeight: extractNumericValue(settings.minHeight),
maxHeight: extractNumericValue(settings.maxHeight),
margin: String(settings.margin || ''),
padding: String(settings.padding || ''),
gap: String(settings.gap || ''),
fontSize: String(settings.fontSize || ''),
lineHeight: String(settings.lineHeight || ''),
fontWeight: String(settings.fontWeight || ''),
border: extractNumericValue(settings.border),
borderRadius: extractNumericValue(settings.borderRadius),
opacity: settings.opacity === 0 ? '0' : String(settings.opacity || ''),
boxShadow: String(settings.boxShadow || ''),
display: String(settings.display || ''),
position: String(settings.position || ''),
justifyContent: String(settings.justifyContent || ''),
alignItems: String(settings.alignItems || ''),
textAlign: String(settings.textAlign || ''),
zIndex: String(settings.zIndex || ''),
appearDelaySec: String(settings.appearDelaySec ?? 0),
appearDurationSec:
settings.appearDurationSec === null ||
settings.appearDurationSec === undefined
? ''
: String(settings.appearDurationSec),
iconUrl: String(settings.iconUrl || ''),
navLabel: String(settings.navLabel || ''),
navType: settings.navType === 'back' ? 'back' : 'forward',
navDisabled: Boolean(settings.navDisabled),
targetPageId: String(settings.targetPageId || ''),
targetPageSlug: String(settings.targetPageSlug || ''),
transitionVideoUrl: String(settings.transitionVideoUrl || ''),
transitionReverseMode:
settings.transitionReverseMode === 'separate_video'
? 'separate_video'
: 'auto_reverse',
reverseVideoUrl: String(settings.reverseVideoUrl || ''),
transitionDurationSec: String(settings.transitionDurationSec ?? 0.7),
tooltipTitle: String(settings.tooltipTitle || ''),
tooltipText: String(settings.tooltipText || ''),
descriptionTitle: String(settings.descriptionTitle || ''),
descriptionText: String(settings.descriptionText || ''),
descriptionTitleFontSize: String(
settings.descriptionTitleFontSize || '',
),
descriptionTextFontSize: String(settings.descriptionTextFontSize || ''),
descriptionTitleFontFamily: String(
settings.descriptionTitleFontFamily || '',
),
descriptionTextFontFamily: String(
settings.descriptionTextFontFamily || '',
),
descriptionTitleColor: String(settings.descriptionTitleColor || ''),
descriptionTextColor: String(settings.descriptionTextColor || ''),
descriptionBackgroundColor: String(
settings.descriptionBackgroundColor || '',
),
mediaUrl: String(settings.mediaUrl || ''),
mediaAutoplay: Boolean(settings.mediaAutoplay),
mediaLoop: Boolean(settings.mediaLoop),
mediaMuted: Boolean(settings.mediaMuted),
carouselPrevIconUrl: String(settings.carouselPrevIconUrl || ''),
carouselNextIconUrl: String(settings.carouselNextIconUrl || ''),
galleryCards: Array.isArray(settings.galleryCards)
? settings.galleryCards.map(
(card: Record<string, unknown>, index: number) => ({
id: String(card?.id || createLocalId()),
imageUrl: String(card?.imageUrl || ''),
title: String(card?.title || `Card ${index + 1}`),
description: String(card?.description || ''),
}),
)
: [],
carouselSlides: Array.isArray(settings.carouselSlides)
? settings.carouselSlides.map(
(slide: Record<string, unknown>, index: number) => ({
id: String(slide?.id || createLocalId()),
imageUrl: String(slide?.imageUrl || ''),
caption: String(slide?.caption || `Slide ${index + 1}`),
}),
)
: [],
});
},
[],
);
/**
* Update a single field
*/
const setField = useCallback(
<K extends keyof FormState>(field: K, value: FormState[K]) => {
setState((prev) => ({ ...prev, [field]: value }));
},
[],
);
/**
* Update multiple fields at once
*/
const setFields = useCallback((updates: Partial<FormState>) => {
setState((prev) => ({ ...prev, ...updates }));
}, []);
/**
* Get CSS style values for StyleSettingsSection
*/
const getStyleValues = useCallback((): ElementStyleProperties => {
return {
width: state.width,
height: state.height,
minWidth: state.minWidth,
maxWidth: state.maxWidth,
minHeight: state.minHeight,
maxHeight: state.maxHeight,
margin: state.margin,
padding: state.padding,
gap: state.gap,
fontSize: state.fontSize,
lineHeight: state.lineHeight,
fontWeight: state.fontWeight,
border: state.border,
borderRadius: state.borderRadius,
opacity: state.opacity,
boxShadow: state.boxShadow,
display: state.display,
position: state.position,
justifyContent: state.justifyContent,
alignItems: state.alignItems,
textAlign: state.textAlign,
zIndex: state.zIndex,
};
}, [state]);
/**
* Gallery card operations
*/
const addGalleryCard = useCallback(() => {
setState((prev) => ({
...prev,
galleryCards: [
...prev.galleryCards,
{
id: createLocalId(),
imageUrl: '',
title: `Card ${prev.galleryCards.length + 1}`,
description: '',
},
],
}));
}, []);
const removeGalleryCard = useCallback((cardId: string) => {
setState((prev) => ({
...prev,
galleryCards: prev.galleryCards.filter((card) => card.id !== cardId),
}));
}, []);
const updateGalleryCard = useCallback(
(cardId: string, field: keyof GalleryCard, value: string) => {
setState((prev) => ({
...prev,
galleryCards: prev.galleryCards.map((card) =>
card.id === cardId ? { ...card, [field]: value } : card,
),
}));
},
[],
);
/**
* Carousel slide operations
*/
const addCarouselSlide = useCallback(() => {
setState((prev) => ({
...prev,
carouselSlides: [
...prev.carouselSlides,
{
id: createLocalId(),
imageUrl: '',
caption: `Slide ${prev.carouselSlides.length + 1}`,
},
],
}));
}, []);
const removeCarouselSlide = useCallback((slideId: string) => {
setState((prev) => ({
...prev,
carouselSlides: prev.carouselSlides.filter(
(slide) => slide.id !== slideId,
),
}));
}, []);
const updateCarouselSlide = useCallback(
(slideId: string, field: keyof CarouselSlide, value: string) => {
setState((prev) => ({
...prev,
carouselSlides: prev.carouselSlides.map((slide) =>
slide.id === slideId ? { ...slide, [field]: value } : slide,
),
}));
},
[],
);
/**
* Build settings JSON for saving
*/
const buildSettingsJson = useCallback((): Record<string, unknown> => {
const borderWidthValue = toUnitValue(state.border, 'px');
const settings: Record<string, unknown> = {
label: state.label.trim(),
xPercent: clampPercent(state.xPercent),
yPercent: clampPercent(state.yPercent),
border: borderWidthValue
? `${borderWidthValue} solid currentColor`
: 'none',
borderRadius: toUnitValue(state.borderRadius, 'px') || '0px',
appearDelaySec:
Number(state.appearDelaySec) >= 0 ? Number(state.appearDelaySec) : 0,
appearDurationSec: parseNullableNumber(state.appearDurationSec),
};
// Dimensional CSS properties
const widthValue = toUnitValue(state.width, 'vw');
const heightValue = toUnitValue(state.height, 'vh');
const minWidthValue = toUnitValue(state.minWidth, 'vw');
const maxWidthValue = toUnitValue(state.maxWidth, 'vw');
const minHeightValue = toUnitValue(state.minHeight, 'vh');
const maxHeightValue = toUnitValue(state.maxHeight, 'vh');
if (widthValue) settings.width = widthValue;
if (heightValue) settings.height = heightValue;
if (minWidthValue) settings.minWidth = minWidthValue;
if (maxWidthValue) settings.maxWidth = maxWidthValue;
if (minHeightValue) settings.minHeight = minHeightValue;
if (maxHeightValue) settings.maxHeight = maxHeightValue;
// Other CSS properties
const marginValue = toOptionalTrimmed(state.margin);
const paddingValue = toOptionalTrimmed(state.padding);
const gapValue = toOptionalTrimmed(state.gap);
const fontSizeValue = toOptionalTrimmed(state.fontSize);
const lineHeightValue = toOptionalTrimmed(state.lineHeight);
const fontWeightValue = toOptionalTrimmed(state.fontWeight);
const opacityValue = toOptionalTrimmed(state.opacity);
const boxShadowValue = toOptionalTrimmed(state.boxShadow);
const displayValue = toOptionalTrimmed(state.display);
const positionValue = toOptionalTrimmed(state.position);
const justifyContentValue = toOptionalTrimmed(state.justifyContent);
const alignItemsValue = toOptionalTrimmed(state.alignItems);
const textAlignValue = toOptionalTrimmed(state.textAlign);
const zIndexValue = toOptionalTrimmed(state.zIndex);
if (marginValue) settings.margin = marginValue;
if (paddingValue) settings.padding = paddingValue;
if (gapValue) settings.gap = gapValue;
if (fontSizeValue) settings.fontSize = fontSizeValue;
if (lineHeightValue) settings.lineHeight = lineHeightValue;
if (fontWeightValue) settings.fontWeight = fontWeightValue;
if (opacityValue) settings.opacity = opacityValue;
if (boxShadowValue) settings.boxShadow = boxShadowValue;
if (displayValue) settings.display = displayValue;
if (positionValue) settings.position = positionValue;
if (justifyContentValue) settings.justifyContent = justifyContentValue;
if (alignItemsValue) settings.alignItems = alignItemsValue;
if (textAlignValue) settings.textAlign = textAlignValue;
if (zIndexValue) settings.zIndex = zIndexValue;
// Navigation type settings
if (isNavigationType) {
settings.iconUrl = state.iconUrl.trim();
settings.navLabel = state.navLabel.trim();
settings.navType = state.navType;
settings.navDisabled = state.navDisabled;
settings.targetPageId = state.targetPageId.trim();
settings.targetPageSlug = state.targetPageSlug.trim();
settings.transitionVideoUrl = state.transitionVideoUrl.trim();
settings.transitionReverseMode = state.transitionReverseMode;
settings.reverseVideoUrl = state.reverseVideoUrl.trim();
settings.transitionDurationSec =
Number(state.transitionDurationSec) > 0
? Number(state.transitionDurationSec)
: 0.7;
}
// Tooltip type settings
if (isTooltipType) {
settings.iconUrl = state.iconUrl.trim();
settings.tooltipTitle = state.tooltipTitle.trim();
settings.tooltipText = state.tooltipText;
}
// Description type settings
if (isDescriptionType) {
settings.iconUrl = state.iconUrl.trim();
settings.descriptionTitle = state.descriptionTitle.trim();
settings.descriptionText = state.descriptionText;
settings.descriptionTitleFontSize =
state.descriptionTitleFontSize.trim() || '48px';
settings.descriptionTextFontSize =
state.descriptionTextFontSize.trim() || '36px';
settings.descriptionTitleFontFamily =
state.descriptionTitleFontFamily.trim() || 'inherit';
settings.descriptionTextFontFamily =
state.descriptionTextFontFamily.trim() || 'inherit';
settings.descriptionTitleColor =
state.descriptionTitleColor.trim() || '#000000';
settings.descriptionTextColor =
state.descriptionTextColor.trim() || '#4B5563';
settings.descriptionBackgroundColor =
state.descriptionBackgroundColor.trim() || 'transparent';
}
// Gallery type settings
if (isGalleryType) {
settings.galleryCards = state.galleryCards.map((card, index) => ({
id: String(card.id || createLocalId()),
imageUrl: card.imageUrl.trim(),
title: card.title.trim() || `Card ${index + 1}`,
description: card.description,
}));
}
// Carousel type settings
if (isCarouselType) {
settings.carouselSlides = state.carouselSlides.map((slide, index) => ({
id: String(slide.id || createLocalId()),
imageUrl: slide.imageUrl.trim(),
caption: slide.caption.trim() || `Slide ${index + 1}`,
}));
settings.carouselPrevIconUrl = state.carouselPrevIconUrl.trim();
settings.carouselNextIconUrl = state.carouselNextIconUrl.trim();
}
// Media type settings
if (isMediaType) {
settings.mediaUrl = state.mediaUrl.trim();
settings.mediaAutoplay = state.mediaAutoplay;
settings.mediaLoop = state.mediaLoop;
settings.mediaMuted = state.mediaMuted;
}
return settings;
}, [
state,
isNavigationType,
isTooltipType,
isDescriptionType,
isGalleryType,
isCarouselType,
isMediaType,
]);
return {
state,
setField,
setFields,
applySettings,
getStyleValues,
// Type checks
isNavigationType,
isTooltipType,
isDescriptionType,
isGalleryType,
isCarouselType,
isMediaType,
// Gallery operations
addGalleryCard,
removeGalleryCard,
updateGalleryCard,
// Carousel operations
addCarouselSlide,
removeCarouselSlide,
updateCarouselSlide,
// Build JSON
buildSettingsJson,
};
}
export type { FormState };

View File

@ -205,7 +205,12 @@ export default function RuntimePresentation({
return () => clearTimeout(fadeTimer); return () => clearTimeout(fadeTimer);
} }
}, [pendingTransitionComplete, isBackgroundReady, isOverlayFadingOut, pageSwitch.clearPreviousBackground]); }, [
pendingTransitionComplete,
isBackgroundReady,
isOverlayFadingOut,
pageSwitch.clearPreviousBackground,
]);
// Clear previous background overlay when new background is ready (direct navigation) // Clear previous background overlay when new background is ready (direct navigation)
useEffect(() => { useEffect(() => {
@ -390,7 +395,10 @@ export default function RuntimePresentation({
(element: any) => { (element: any) => {
// Disable navigation while transition is actively playing or buffering // Disable navigation while transition is actively playing or buffering
// Only block during active phases, not during fade-out (completed phase) // Only block during active phases, not during fade-out (completed phase)
const isActivelyPlaying = transitionPhase === 'preparing' || transitionPhase === 'playing' || transitionPhase === 'reversing'; const isActivelyPlaying =
transitionPhase === 'preparing' ||
transitionPhase === 'playing' ||
transitionPhase === 'reversing';
if (isActivelyPlaying || isBuffering) { if (isActivelyPlaying || isBuffering) {
return; return;
} }
@ -443,8 +451,8 @@ export default function RuntimePresentation({
height: element.height || 'auto', height: element.height || 'auto',
objectFit: 'contain', objectFit: 'contain',
}; };
// eslint-disable-next-line @next/next/no-img-element
return ( return (
// eslint-disable-next-line @next/next/no-img-element
<img <img
src={resolveAssetPlaybackUrl(element.iconUrl)} src={resolveAssetPlaybackUrl(element.iconUrl)}
alt='Navigation' alt='Navigation'
@ -708,9 +716,9 @@ export default function RuntimePresentation({
backgroundImage: `url("${pageSwitch.previousBgImageUrl}")`, backgroundImage: `url("${pageSwitch.previousBgImageUrl}")`,
backgroundSize: 'cover', backgroundSize: 'cover',
backgroundPosition: 'center', backgroundPosition: 'center',
}} }}
/> />
)} )}
{/* Background video */} {/* Background video */}
{backgroundVideoUrl && ( {backgroundVideoUrl && (

View File

@ -50,12 +50,7 @@ export interface UsePageNavigationResult<TPage extends NavigablePage> {
export function usePageNavigation<TPage extends NavigablePage>( export function usePageNavigation<TPage extends NavigablePage>(
options: UsePageNavigationOptions<TPage>, options: UsePageNavigationOptions<TPage>,
): UsePageNavigationResult<TPage> { ): UsePageNavigationResult<TPage> {
const { const { pages, defaultPageId, trackHistory = true, onPageChange } = options;
pages,
defaultPageId,
trackHistory = true,
onPageChange,
} = options;
const [currentPageId, setCurrentPageIdState] = useState<string | null>(null); const [currentPageId, setCurrentPageIdState] = useState<string | null>(null);
const [pageHistory, setPageHistory] = useState<string[]>([]); const [pageHistory, setPageHistory] = useState<string[]>([]);

View File

@ -246,10 +246,7 @@ export function usePageSwitch(
} }
// 2. Fallback: try cached blob URL (creates new blob, needs decode) // 2. Fallback: try cached blob URL (creates new blob, needs decode)
if ( if (cache?.getCachedBlobUrl && cache?.preloadedUrls?.has(originalUrl)) {
cache?.getCachedBlobUrl &&
cache?.preloadedUrls?.has(originalUrl)
) {
try { try {
const blobUrl = await cache.getCachedBlobUrl(originalUrl); const blobUrl = await cache.getCachedBlobUrl(originalUrl);
if (blobUrl) { if (blobUrl) {
@ -294,10 +291,7 @@ export function usePageSwitch(
} }
// 2. Fallback: try cached blob URL // 2. Fallback: try cached blob URL
if ( if (cache?.getCachedBlobUrl && cache?.preloadedUrls?.has(originalUrl)) {
cache?.getCachedBlobUrl &&
cache?.preloadedUrls?.has(originalUrl)
) {
try { try {
const blobUrl = await cache.getCachedBlobUrl(originalUrl); const blobUrl = await cache.getCachedBlobUrl(originalUrl);
if (blobUrl) { if (blobUrl) {

View File

@ -421,7 +421,12 @@ export function usePreloadOrchestrator(
setIsPreloading(false); setIsPreloading(false);
isProcessingRef.current = false; isProcessingRef.current = false;
} }
}, [networkInfo.isOnline, preloadedUrls, recommendedConcurrency, createReadyBlobUrl]); }, [
networkInfo.isOnline,
preloadedUrls,
recommendedConcurrency,
createReadyBlobUrl,
]);
// Add item to queue with priority sorting // Add item to queue with priority sorting
const addToQueue = useCallback( const addToQueue = useCallback(

View File

@ -351,7 +351,10 @@ export function useTransitionPlayback(
// Include reverseMode in the key so same video can play forward then reverse // Include reverseMode in the key so same video can play forward then reverse
const sourceKey = `${sourceUrl}|${currentTransition.reverseMode}`; const sourceKey = `${sourceUrl}|${currentTransition.reverseMode}`;
if (activeSourceUrlRef.current === sourceKey) { if (activeSourceUrlRef.current === sourceKey) {
logger.info('Skipping duplicate effect for same source', { sourceUrl, reverseMode: currentTransition.reverseMode }); logger.info('Skipping duplicate effect for same source', {
sourceUrl,
reverseMode: currentTransition.reverseMode,
});
return; return;
} }

View File

@ -49,8 +49,11 @@ export default function LayoutAuthenticated({
(state) => state.auth, (state) => state.auth,
); );
const bgColor = useAppSelector((state) => state.style.bgLayoutColor); const bgColor = useAppSelector((state) => state.style.bgLayoutColor);
const darkMode = useAppSelector((state) => state.style.darkMode);
const meRequestedRef = useRef<string | null>(null); const meRequestedRef = useRef<string | null>(null);
const [isAuthChecked, setIsAuthChecked] = useState(false); const [isAuthChecked, setIsAuthChecked] = useState(false);
const [isAsideMobileExpanded, setIsAsideMobileExpanded] = useState(false);
const [isAsideLgActive, setIsAsideLgActive] = useState(false);
const getStoredToken = () => { const getStoredToken = () => {
if (typeof window === 'undefined') return null; if (typeof window === 'undefined') return null;
@ -143,17 +146,8 @@ export default function LayoutAuthenticated({
if (!hasPermission(currentUser, permission)) router.push('/error'); if (!hasPermission(currentUser, permission)) router.push('/error');
}, [currentUser, permission]); }, [currentUser, permission]);
const darkMode = useAppSelector((state) => state.style.darkMode);
const isConstructorFullscreen = router.pathname === '/constructor'; const isConstructorFullscreen = router.pathname === '/constructor';
// Minimal mode: just auth check, no UI chrome (for presentations)
if (minimal) {
return <>{children}</>;
}
const [isAsideMobileExpanded, setIsAsideMobileExpanded] = useState(false);
const [isAsideLgActive, setIsAsideLgActive] = useState(false);
useEffect(() => { useEffect(() => {
const handleRouteChangeStart = () => { const handleRouteChangeStart = () => {
setIsAsideMobileExpanded(false); setIsAsideMobileExpanded(false);
@ -174,6 +168,11 @@ export default function LayoutAuthenticated({
const mobileAsideShift = const mobileAsideShift =
!isConstructorFullscreen && isAsideMobileExpanded ? 'ml-60 lg:ml-0' : ''; !isConstructorFullscreen && isAsideMobileExpanded ? 'ml-60 lg:ml-0' : '';
// Minimal mode: just auth check, no UI chrome (for presentations)
if (minimal) {
return <>{children}</>;
}
// Show loading while auth is being checked // Show loading while auth is being checked
if (!isAuthChecked) { if (!isAuthChecked) {
return ( return (

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,4 @@
import { mdiContentSave, mdiCog, mdiRefresh, mdiSync } from '@mdi/js'; import { mdiContentSave, mdiCog, mdiSync } from '@mdi/js';
import axios from 'axios'; import axios from 'axios';
import Head from 'next/head'; import Head from 'next/head';
import Link from 'next/link'; import Link from 'next/link';
@ -18,7 +18,21 @@ import SectionMain from '../../components/SectionMain';
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton'; import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
import { getPageTitle } from '../../config'; import { getPageTitle } from '../../config';
import { logger } from '../../lib/logger'; import { logger } from '../../lib/logger';
import { parseJsonObject } from '../../lib/parseJson'; import type { CanvasElementType } from '../../types/constructor';
// Import shared element settings components
import {
ElementSettingsTabs,
StyleSettingsSection,
CommonSettingsSection,
NavigationSettingsSection,
TooltipSettingsSection,
DescriptionSettingsSection,
MediaSettingsSection,
GallerySettingsSection,
CarouselSettingsSection,
useElementSettingsForm,
} from '../../components/ElementSettings';
type ProjectElementDefault = { type ProjectElementDefault = {
id: string; id: string;
@ -33,9 +47,9 @@ type ProjectElementDefault = {
type DiffResult = { type DiffResult = {
projectDefault: ProjectElementDefault | null; projectDefault: ProjectElementDefault | null;
globalDefault: any | null; globalDefault: Record<string, unknown> | null;
hasChanges: boolean; isDifferent: boolean;
canReset: boolean; hasGlobalDefault: boolean;
}; };
const toHumanLabel = (value: string) => const toHumanLabel = (value: string) =>
@ -44,6 +58,11 @@ const toHumanLabel = (value: string) =>
.map((part) => part.charAt(0).toUpperCase() + part.slice(1)) .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' '); .join(' ');
const SETTINGS_TABS = [
{ id: 'general', label: 'General Settings' },
{ id: 'css', label: 'CSS Styles' },
];
const ProjectElementDefaultDetailsPage = () => { const ProjectElementDefaultDetailsPage = () => {
const router = useRouter(); const router = useRouter();
const id = useMemo(() => { const id = useMemo(() => {
@ -61,7 +80,7 @@ const ProjectElementDefaultDetailsPage = () => {
const [item, setItem] = useState<ProjectElementDefault | null>(null); const [item, setItem] = useState<ProjectElementDefault | null>(null);
const [name, setName] = useState(''); const [name, setName] = useState('');
const [sortOrder, setSortOrder] = useState(0); const [sortOrder, setSortOrder] = useState(0);
const [settingsJson, setSettingsJson] = useState('{}'); const [activeTab, setActiveTab] = useState('general');
const [diff, setDiff] = useState<DiffResult | null>(null); const [diff, setDiff] = useState<DiffResult | null>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
@ -70,6 +89,14 @@ const ProjectElementDefaultDetailsPage = () => {
const [errorMessage, setErrorMessage] = useState(''); const [errorMessage, setErrorMessage] = useState('');
const [successMessage, setSuccessMessage] = useState(''); const [successMessage, setSuccessMessage] = useState('');
const currentElementType = (item?.element_type || '') as CanvasElementType;
// Use shared form hook
const form = useElementSettingsForm({ elementType: currentElementType });
// Extract stable callback reference to avoid infinite loop
const applySettings = form.applySettings;
const loadItem = useCallback(async () => { const loadItem = useCallback(async () => {
if (!id) return; if (!id) return;
@ -90,13 +117,15 @@ const ProjectElementDefaultDetailsPage = () => {
setItem(nextItem); setItem(nextItem);
setName(String(nextItem.name || '')); setName(String(nextItem.name || ''));
setSortOrder(Number(nextItem.sort_order || 0)); setSortOrder(Number(nextItem.sort_order || 0));
applySettings(nextItem.settings_json);
const settings = parseJsonObject(nextItem.settings_json, {}); } catch (error: unknown) {
setSettingsJson(JSON.stringify(settings, null, 2)); const err = error as {
} catch (error: any) { response?: { data?: { message?: string } };
message?: string;
};
const message = const message =
error?.response?.data?.message || err?.response?.data?.message ||
error?.message || err?.message ||
'Failed to load project element default.'; 'Failed to load project element default.';
logger.error( logger.error(
'Failed to load project element default details:', 'Failed to load project element default details:',
@ -107,7 +136,7 @@ const ProjectElementDefaultDetailsPage = () => {
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, [id]); }, [applySettings, id]);
const loadDiff = useCallback(async () => { const loadDiff = useCallback(async () => {
if (!id) return; if (!id) return;
@ -115,8 +144,11 @@ const ProjectElementDefaultDetailsPage = () => {
try { try {
const response = await axios.get(`/project-element-defaults/${id}/diff`); const response = await axios.get(`/project-element-defaults/${id}/diff`);
setDiff(response?.data || null); setDiff(response?.data || null);
} catch (error: any) { } catch (error: unknown) {
logger.error('Failed to load diff:', error); logger.error(
'Failed to load diff:',
error instanceof Error ? error : { error },
);
setDiff(null); setDiff(null);
} }
}, [id]); }, [id]);
@ -127,39 +159,39 @@ const ProjectElementDefaultDetailsPage = () => {
loadDiff(); loadDiff();
}, [loadItem, loadDiff, router.isReady]); }, [loadItem, loadDiff, router.isReady]);
// Extract stable callback reference for buildSettingsJson
const buildSettingsJson = form.buildSettingsJson;
const handleSave = useCallback(async () => { const handleSave = useCallback(async () => {
if (!id || !item) return; if (!id || !item) return;
const settings = buildSettingsJson();
try { try {
setIsSaving(true); setIsSaving(true);
setErrorMessage(''); setErrorMessage('');
setSuccessMessage(''); setSuccessMessage('');
let parsedSettings = {};
try {
parsedSettings = JSON.parse(settingsJson);
} catch {
setErrorMessage('Invalid JSON in settings.');
setIsSaving(false);
return;
}
await axios.put(`/project-element-defaults/${id}`, { await axios.put(`/project-element-defaults/${id}`, {
id, id,
data: { data: {
name: name.trim() || item.element_type, name: name.trim() || item.element_type,
sort_order: sortOrder, sort_order: sortOrder,
settings_json: parsedSettings, settings_json: settings,
}, },
}); });
setSuccessMessage('Project element default saved successfully.'); setSuccessMessage('Project element default saved successfully.');
await loadItem(); await loadItem();
await loadDiff(); await loadDiff();
} catch (error: any) { } catch (error: unknown) {
const err = error as {
response?: { data?: { message?: string } };
message?: string;
};
const message = const message =
error?.response?.data?.message || err?.response?.data?.message ||
error?.message || err?.message ||
'Failed to save project element default.'; 'Failed to save project element default.';
logger.error( logger.error(
'Failed to save project element default:', 'Failed to save project element default:',
@ -169,7 +201,7 @@ const ProjectElementDefaultDetailsPage = () => {
} finally { } finally {
setIsSaving(false); setIsSaving(false);
} }
}, [id, item, name, sortOrder, settingsJson, loadItem, loadDiff]); }, [buildSettingsJson, id, item, name, sortOrder, loadItem, loadDiff]);
const handleResetToGlobal = useCallback(async () => { const handleResetToGlobal = useCallback(async () => {
if (!id) return; if (!id) return;
@ -190,10 +222,14 @@ const ProjectElementDefaultDetailsPage = () => {
setSuccessMessage('Successfully reset to global default.'); setSuccessMessage('Successfully reset to global default.');
await loadItem(); await loadItem();
await loadDiff(); await loadDiff();
} catch (error: any) { } catch (error: unknown) {
const err = error as {
response?: { data?: { message?: string } };
message?: string;
};
const message = const message =
error?.response?.data?.message || err?.response?.data?.message ||
error?.message || err?.message ||
'Failed to reset to global default.'; 'Failed to reset to global default.';
logger.error( logger.error(
'Failed to reset to global default:', 'Failed to reset to global default:',
@ -205,6 +241,33 @@ const ProjectElementDefaultDetailsPage = () => {
} }
}, [id, loadItem, loadDiff]); }, [id, loadItem, loadDiff]);
// Extract stable callback reference for setField
const setField = form.setField;
// Handler for style section changes
const handleStyleChange = useCallback(
(prop: string, value: string) => {
setField(prop as keyof typeof form.state, value);
},
[setField],
);
// Handler for common section changes
const handleCommonChange = useCallback(
(field: string, value: string) => {
setField(field as keyof typeof form.state, value);
},
[setField],
);
// Handler for type-specific section changes
const handleTypeChange = useCallback(
(field: string, value: string | boolean) => {
setField(field as keyof typeof form.state, value as never);
},
[setField],
);
if (isLoading) { if (isLoading) {
return ( return (
<> <>
@ -260,7 +323,7 @@ const ProjectElementDefaultDetailsPage = () => {
label={isResetting ? 'Resetting...' : 'Reset to Global'} label={isResetting ? 'Resetting...' : 'Reset to Global'}
icon={mdiSync} icon={mdiSync}
onClick={handleResetToGlobal} onClick={handleResetToGlobal}
disabled={isResetting || isSaving || !diff?.canReset} disabled={isResetting || isSaving || !diff?.hasGlobalDefault}
/> />
<BaseButton <BaseButton
color='info' color='info'
@ -284,11 +347,11 @@ const ProjectElementDefaultDetailsPage = () => {
</div> </div>
)} )}
{diff?.hasChanges && ( {diff?.isDifferent && (
<div className='mb-4 rounded bg-yellow-50 p-3 text-sm text-yellow-700'> <div className='mb-4 rounded bg-yellow-50 p-3 text-sm text-yellow-700'>
This project element default has been customized and differs from This project element default has been customized and differs from
the global default. the global default.
{diff.canReset && {diff.hasGlobalDefault &&
' Click "Reset to Global" to restore the global settings.'} ' Click "Reset to Global" to restore the global settings.'}
</div> </div>
)} )}
@ -332,19 +395,126 @@ const ProjectElementDefaultDetailsPage = () => {
/> />
</FormField> </FormField>
<FormField label='Settings (JSON)'> {/* Tabs */}
<textarea <ElementSettingsTabs
className='h-64 w-full rounded border border-gray-300 px-3 py-2 font-mono text-sm' activeTab={activeTab}
value={settingsJson} onTabChange={setActiveTab}
onChange={(e) => setSettingsJson(e.target.value)} tabs={SETTINGS_TABS}
placeholder='{ }' />
/>
<p className='mt-1 text-xs text-gray-500'> {/* General Settings Tab */}
Edit the JSON settings directly. These settings control the {activeTab === 'general' && (
default appearance and behavior of new elements of this type <>
created in the constructor. <CommonSettingsSection
</p> label={form.state.label}
</FormField> xPercent={form.state.xPercent}
yPercent={form.state.yPercent}
appearDelaySec={form.state.appearDelaySec}
appearDurationSec={form.state.appearDurationSec}
onChange={handleCommonChange}
/>
{/* Type-specific sections */}
{form.isNavigationType && (
<NavigationSettingsSection
iconUrl={form.state.iconUrl}
navLabel={form.state.navLabel}
navType={form.state.navType}
navDisabled={form.state.navDisabled}
targetPageId={form.state.targetPageId}
targetPageSlug={form.state.targetPageSlug}
transitionVideoUrl={form.state.transitionVideoUrl}
transitionReverseMode={form.state.transitionReverseMode}
reverseVideoUrl={form.state.reverseVideoUrl}
transitionDurationSec={form.state.transitionDurationSec}
onChange={handleTypeChange}
context='project'
/>
)}
{form.isTooltipType && (
<TooltipSettingsSection
iconUrl={form.state.iconUrl}
tooltipTitle={form.state.tooltipTitle}
tooltipText={form.state.tooltipText}
onChange={handleTypeChange}
context='project'
/>
)}
{form.isDescriptionType && (
<DescriptionSettingsSection
iconUrl={form.state.iconUrl}
descriptionTitle={form.state.descriptionTitle}
descriptionText={form.state.descriptionText}
descriptionTitleFontSize={
form.state.descriptionTitleFontSize
}
descriptionTextFontSize={form.state.descriptionTextFontSize}
descriptionTitleFontFamily={
form.state.descriptionTitleFontFamily
}
descriptionTextFontFamily={
form.state.descriptionTextFontFamily
}
descriptionTitleColor={form.state.descriptionTitleColor}
descriptionTextColor={form.state.descriptionTextColor}
descriptionBackgroundColor={
form.state.descriptionBackgroundColor
}
onChange={handleTypeChange}
context='project'
/>
)}
{form.isGalleryType && (
<GallerySettingsSection
galleryCards={form.state.galleryCards}
onAddCard={form.addGalleryCard}
onRemoveCard={form.removeGalleryCard}
onUpdateCard={form.updateGalleryCard}
context='project'
/>
)}
{form.isCarouselType && (
<CarouselSettingsSection
carouselPrevIconUrl={form.state.carouselPrevIconUrl}
carouselNextIconUrl={form.state.carouselNextIconUrl}
carouselSlides={form.state.carouselSlides}
onAddSlide={form.addCarouselSlide}
onRemoveSlide={form.removeCarouselSlide}
onUpdateSlide={form.updateCarouselSlide}
onChange={handleTypeChange}
context='project'
/>
)}
{form.isMediaType && (
<MediaSettingsSection
elementType={
currentElementType as 'video_player' | 'audio_player'
}
mediaUrl={form.state.mediaUrl}
mediaAutoplay={form.state.mediaAutoplay}
mediaLoop={form.state.mediaLoop}
mediaMuted={form.state.mediaMuted}
onChange={handleTypeChange}
context='project'
/>
)}
</>
)}
{/* CSS Styles Tab */}
{activeTab === 'css' && (
<CardBox className='border border-gray-200 dark:border-dark-700'>
<StyleSettingsSection
values={form.getStyleValues()}
onChange={handleStyleChange}
/>
</CardBox>
)}
</div> </div>
</CardBox> </CardBox>

View File

@ -547,7 +547,6 @@ const ProjectsView = () => {
</CardBox> </CardBox>
</> </>
<> <>
<p className={'block font-bold mb-2'}> <p className={'block font-bold mb-2'}>
Project_audio_tracks Project Project_audio_tracks Project