UI elements settings extention
This commit is contained in:
parent
b9ca9bbc10
commit
baef1fca2f
@ -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`
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
232
frontend/src/components/ElementSettings/StyleSettingsSection.tsx
Normal file
232
frontend/src/components/ElementSettings/StyleSettingsSection.tsx
Normal 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;
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
27
frontend/src/components/ElementSettings/index.ts
Normal file
27
frontend/src/components/ElementSettings/index.ts
Normal 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';
|
||||||
235
frontend/src/components/ElementSettings/types.ts
Normal file
235
frontend/src/components/ElementSettings/types.ts
Normal 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)}`;
|
||||||
|
};
|
||||||
@ -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 };
|
||||||
@ -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'
|
||||||
|
|||||||
@ -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[]>([]);
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 (
|
||||||
|
|||||||
@ -10,6 +10,11 @@ import {
|
|||||||
mdiTooltipText,
|
mdiTooltipText,
|
||||||
mdiViewCarousel,
|
mdiViewCarousel,
|
||||||
} from '@mdi/js';
|
} from '@mdi/js';
|
||||||
|
import {
|
||||||
|
ElementSettingsTabsCompact,
|
||||||
|
StyleSettingsSectionCompact,
|
||||||
|
extractNumericValue,
|
||||||
|
} from '../components/ElementSettings';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import NextImage from 'next/image';
|
import NextImage from 'next/image';
|
||||||
@ -511,7 +516,8 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
return String(value || '');
|
return String(value || '');
|
||||||
}, [router.query.projectId]);
|
}, [router.query.projectId]);
|
||||||
const pageElementsListHref = useMemo(() => {
|
const pageElementsListHref = useMemo(() => {
|
||||||
if (!projectId) return '/project-element-defaults/project-element-defaults-list';
|
if (!projectId)
|
||||||
|
return '/project-element-defaults/project-element-defaults-list';
|
||||||
return `/project-element-defaults/project-element-defaults-list?projectId=${encodeURIComponent(projectId)}`;
|
return `/project-element-defaults/project-element-defaults-list?projectId=${encodeURIComponent(projectId)}`;
|
||||||
}, [projectId]);
|
}, [projectId]);
|
||||||
|
|
||||||
@ -576,6 +582,9 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
const [editorPosition, setEditorPosition] = useState({ x: 0, y: 72 });
|
const [editorPosition, setEditorPosition] = useState({ x: 0, y: 72 });
|
||||||
const [isEditorCollapsed, setIsEditorCollapsed] = useState(false);
|
const [isEditorCollapsed, setIsEditorCollapsed] = useState(false);
|
||||||
|
const [elementEditorTab, setElementEditorTab] = useState<'general' | 'css'>(
|
||||||
|
'general',
|
||||||
|
);
|
||||||
|
|
||||||
const constructorControlsDragRef = useRef<{
|
const constructorControlsDragRef = useRef<{
|
||||||
pointerOffsetX: number;
|
pointerOffsetX: number;
|
||||||
@ -1112,8 +1121,10 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
setPages(pageRows);
|
setPages(pageRows);
|
||||||
|
|
||||||
// Extract page links and preload elements using shared utility
|
// Extract page links and preload elements using shared utility
|
||||||
const { pageLinks: syntheticPageLinks, preloadElements: allPreloadElements } =
|
const {
|
||||||
extractPageLinksAndElements(pageRows);
|
pageLinks: syntheticPageLinks,
|
||||||
|
preloadElements: allPreloadElements,
|
||||||
|
} = extractPageLinksAndElements(pageRows);
|
||||||
|
|
||||||
setPageLinks(syntheticPageLinks);
|
setPageLinks(syntheticPageLinks);
|
||||||
setAllPagesPreloadElements(allPreloadElements);
|
setAllPagesPreloadElements(allPreloadElements);
|
||||||
@ -1394,7 +1405,9 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
: undefined,
|
: undefined,
|
||||||
// Support both targetPageSlug (new) and targetPageId (legacy)
|
// Support both targetPageSlug (new) and targetPageId (legacy)
|
||||||
targetPageSlug:
|
targetPageSlug:
|
||||||
typeof item.targetPageSlug === 'string' ? item.targetPageSlug : '',
|
typeof item.targetPageSlug === 'string'
|
||||||
|
? item.targetPageSlug
|
||||||
|
: '',
|
||||||
targetPageId:
|
targetPageId:
|
||||||
typeof item.targetPageId === 'string' ? item.targetPageId : '',
|
typeof item.targetPageId === 'string' ? item.targetPageId : '',
|
||||||
transitionVideoUrl:
|
transitionVideoUrl:
|
||||||
@ -1462,7 +1475,12 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
background_audio_url: activePage.background_audio_url,
|
background_audio_url: activePage.background_audio_url,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [activePage, elementIdFromRoute, uiElementDefaultsByType, pageSwitch.switchToPage]);
|
}, [
|
||||||
|
activePage,
|
||||||
|
elementIdFromRoute,
|
||||||
|
uiElementDefaultsByType,
|
||||||
|
pageSwitch.switchToPage,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (allowedNavigationTypes.length !== 1) return;
|
if (allowedNavigationTypes.length !== 1) return;
|
||||||
@ -1727,7 +1745,9 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Transitions are now stored directly in navigation elements as transitionVideoUrl
|
// Transitions are now stored directly in navigation elements as transitionVideoUrl
|
||||||
setSuccessMessage('Transition video can be set directly on navigation elements.');
|
setSuccessMessage(
|
||||||
|
'Transition video can be set directly on navigation elements.',
|
||||||
|
);
|
||||||
setNewTransitionName('');
|
setNewTransitionName('');
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const message =
|
const message =
|
||||||
@ -2131,8 +2151,8 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
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 icon'
|
alt='Navigation icon'
|
||||||
@ -2902,6 +2922,20 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedElement && (
|
{selectedElement && (
|
||||||
|
<>
|
||||||
|
<ElementSettingsTabsCompact
|
||||||
|
activeTab={elementEditorTab}
|
||||||
|
onTabChange={(tab) =>
|
||||||
|
setElementEditorTab(tab as 'general' | 'css')
|
||||||
|
}
|
||||||
|
tabs={[
|
||||||
|
{ id: 'general', label: 'General' },
|
||||||
|
{ id: 'css', label: 'CSS Styles' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{elementEditorTab === 'general' && (
|
||||||
|
<>
|
||||||
<div className='mb-2 space-y-2'>
|
<div className='mb-2 space-y-2'>
|
||||||
<div>
|
<div>
|
||||||
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
||||||
@ -2911,7 +2945,9 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||||
value={selectedElement.label}
|
value={selectedElement.label}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
updateSelectedElement({ label: event.target.value })
|
updateSelectedElement({
|
||||||
|
label: event.target.value,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -2958,10 +2994,8 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedElement &&
|
{(selectedElement.type === 'navigation_next' ||
|
||||||
(selectedElement.type === 'navigation_next' ||
|
|
||||||
selectedElement.type === 'navigation_prev') && (
|
selectedElement.type === 'navigation_prev') && (
|
||||||
<div className='space-y-2'>
|
<div className='space-y-2'>
|
||||||
<div>
|
<div>
|
||||||
@ -2974,7 +3008,9 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
selectedElement.navType === 'back' ||
|
selectedElement.navType === 'back' ||
|
||||||
selectedElement.navType === 'forward'
|
selectedElement.navType === 'forward'
|
||||||
? selectedElement.navType
|
? selectedElement.navType
|
||||||
: getNavigationButtonKind(selectedElement.type)
|
: getNavigationButtonKind(
|
||||||
|
selectedElement.type,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const requestedKind: NavigationButtonKind =
|
const requestedKind: NavigationButtonKind =
|
||||||
@ -2983,7 +3019,8 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
: 'forward';
|
: 'forward';
|
||||||
const requestedType =
|
const requestedType =
|
||||||
getNavigationTypeFromKind(requestedKind);
|
getNavigationTypeFromKind(requestedKind);
|
||||||
const nextType = allowedNavigationTypes.includes(
|
const nextType =
|
||||||
|
allowedNavigationTypes.includes(
|
||||||
requestedType,
|
requestedType,
|
||||||
)
|
)
|
||||||
? requestedType
|
? requestedType
|
||||||
@ -3065,7 +3102,10 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
selectedElement.iconUrl,
|
selectedElement.iconUrl,
|
||||||
`Current icon · ${selectedElement.iconUrl || ''}`,
|
`Current icon · ${selectedElement.iconUrl || ''}`,
|
||||||
).map((option) => (
|
).map((option) => (
|
||||||
<option key={option.value} value={option.value}>
|
<option
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
@ -3123,7 +3163,10 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
selectedElement.transitionVideoUrl,
|
selectedElement.transitionVideoUrl,
|
||||||
`Current video · ${selectedElement.transitionVideoUrl || ''}`,
|
`Current video · ${selectedElement.transitionVideoUrl || ''}`,
|
||||||
).map((option) => (
|
).map((option) => (
|
||||||
<option key={option.value} value={option.value}>
|
<option
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
@ -3180,7 +3223,10 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
selectedElement.reverseVideoUrl,
|
selectedElement.reverseVideoUrl,
|
||||||
`Current back video · ${selectedElement.reverseVideoUrl || ''}`,
|
`Current back video · ${selectedElement.reverseVideoUrl || ''}`,
|
||||||
).map((option) => (
|
).map((option) => (
|
||||||
<option key={option.value} value={option.value}>
|
<option
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
@ -3208,7 +3254,8 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedElement && selectedElement.type === 'tooltip' && (
|
{selectedElement &&
|
||||||
|
selectedElement.type === 'tooltip' && (
|
||||||
<div className='space-y-2'>
|
<div className='space-y-2'>
|
||||||
<div>
|
<div>
|
||||||
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
||||||
@ -3218,7 +3265,9 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||||
value={selectedElement.iconUrl || ''}
|
value={selectedElement.iconUrl || ''}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
updateSelectedElement({ iconUrl: event.target.value })
|
updateSelectedElement({
|
||||||
|
iconUrl: event.target.value,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<option value=''>Not selected</option>
|
<option value=''>Not selected</option>
|
||||||
@ -3227,7 +3276,10 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
selectedElement.iconUrl,
|
selectedElement.iconUrl,
|
||||||
`Current icon · ${selectedElement.iconUrl || ''}`,
|
`Current icon · ${selectedElement.iconUrl || ''}`,
|
||||||
).map((option) => (
|
).map((option) => (
|
||||||
<option key={option.value} value={option.value}>
|
<option
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
@ -3265,7 +3317,8 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedElement && selectedElement.type === 'description' && (
|
{selectedElement &&
|
||||||
|
selectedElement.type === 'description' && (
|
||||||
<div className='space-y-2'>
|
<div className='space-y-2'>
|
||||||
<div>
|
<div>
|
||||||
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
<label className='mb-1 block text-[11px] font-semibold text-gray-600'>
|
||||||
@ -3275,7 +3328,9 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||||
value={selectedElement.iconUrl || ''}
|
value={selectedElement.iconUrl || ''}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
updateSelectedElement({ iconUrl: event.target.value })
|
updateSelectedElement({
|
||||||
|
iconUrl: event.target.value,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<option value=''>Not selected</option>
|
<option value=''>Not selected</option>
|
||||||
@ -3284,7 +3339,10 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
selectedElement.iconUrl,
|
selectedElement.iconUrl,
|
||||||
`Current icon · ${selectedElement.iconUrl || ''}`,
|
`Current icon · ${selectedElement.iconUrl || ''}`,
|
||||||
).map((option) => (
|
).map((option) => (
|
||||||
<option key={option.value} value={option.value}>
|
<option
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
@ -3326,11 +3384,13 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
<input
|
<input
|
||||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||||
value={
|
value={
|
||||||
selectedElement.descriptionTitleFontSize || '48px'
|
selectedElement.descriptionTitleFontSize ||
|
||||||
|
'48px'
|
||||||
}
|
}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
updateSelectedElement({
|
updateSelectedElement({
|
||||||
descriptionTitleFontSize: event.target.value,
|
descriptionTitleFontSize:
|
||||||
|
event.target.value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
placeholder='e.g. 48px'
|
placeholder='e.g. 48px'
|
||||||
@ -3343,11 +3403,13 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
<input
|
<input
|
||||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||||
value={
|
value={
|
||||||
selectedElement.descriptionTextFontSize || '36px'
|
selectedElement.descriptionTextFontSize ||
|
||||||
|
'36px'
|
||||||
}
|
}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
updateSelectedElement({
|
updateSelectedElement({
|
||||||
descriptionTextFontSize: event.target.value,
|
descriptionTextFontSize:
|
||||||
|
event.target.value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
placeholder='e.g. 36px'
|
placeholder='e.g. 36px'
|
||||||
@ -3365,7 +3427,8 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
}
|
}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
updateSelectedElement({
|
updateSelectedElement({
|
||||||
descriptionTitleFontFamily: event.target.value,
|
descriptionTitleFontFamily:
|
||||||
|
event.target.value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
placeholder='e.g. Arial, Helvetica, sans-serif'
|
placeholder='e.g. Arial, Helvetica, sans-serif'
|
||||||
@ -3378,11 +3441,13 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
<input
|
<input
|
||||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||||
value={
|
value={
|
||||||
selectedElement.descriptionTextFontFamily || 'inherit'
|
selectedElement.descriptionTextFontFamily ||
|
||||||
|
'inherit'
|
||||||
}
|
}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
updateSelectedElement({
|
updateSelectedElement({
|
||||||
descriptionTextFontFamily: event.target.value,
|
descriptionTextFontFamily:
|
||||||
|
event.target.value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
placeholder='e.g. Arial, Helvetica, sans-serif'
|
placeholder='e.g. Arial, Helvetica, sans-serif'
|
||||||
@ -3396,7 +3461,8 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
type='color'
|
type='color'
|
||||||
className='w-full h-8 rounded border border-gray-300 px-1 py-1'
|
className='w-full h-8 rounded border border-gray-300 px-1 py-1'
|
||||||
value={
|
value={
|
||||||
selectedElement.descriptionTitleColor || '#000000'
|
selectedElement.descriptionTitleColor ||
|
||||||
|
'#000000'
|
||||||
}
|
}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
updateSelectedElement({
|
updateSelectedElement({
|
||||||
@ -3413,7 +3479,8 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
type='color'
|
type='color'
|
||||||
className='w-full h-8 rounded border border-gray-300 px-1 py-1'
|
className='w-full h-8 rounded border border-gray-300 px-1 py-1'
|
||||||
value={
|
value={
|
||||||
selectedElement.descriptionTextColor || '#4B5563'
|
selectedElement.descriptionTextColor ||
|
||||||
|
'#4B5563'
|
||||||
}
|
}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
updateSelectedElement({
|
updateSelectedElement({
|
||||||
@ -3434,7 +3501,8 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
}
|
}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
updateSelectedElement({
|
updateSelectedElement({
|
||||||
descriptionBackgroundColor: event.target.value,
|
descriptionBackgroundColor:
|
||||||
|
event.target.value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
placeholder='e.g. transparent, #ffffff, rgba(0,0,0,0.5)'
|
placeholder='e.g. transparent, #ffffff, rgba(0,0,0,0.5)'
|
||||||
@ -3470,7 +3538,10 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
selectedElement.mediaUrl,
|
selectedElement.mediaUrl,
|
||||||
`Current media · ${selectedElement.mediaUrl || ''}`,
|
`Current media · ${selectedElement.mediaUrl || ''}`,
|
||||||
).map((option) => (
|
).map((option) => (
|
||||||
<option key={option.value} value={option.value}>
|
<option
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
@ -3479,7 +3550,9 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
<label className='flex items-center gap-2 text-[11px] text-gray-700'>
|
<label className='flex items-center gap-2 text-[11px] text-gray-700'>
|
||||||
<input
|
<input
|
||||||
type='checkbox'
|
type='checkbox'
|
||||||
checked={Boolean(selectedElement.mediaAutoplay)}
|
checked={Boolean(
|
||||||
|
selectedElement.mediaAutoplay,
|
||||||
|
)}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
updateSelectedElement({
|
updateSelectedElement({
|
||||||
mediaAutoplay: event.target.checked,
|
mediaAutoplay: event.target.checked,
|
||||||
@ -3504,7 +3577,9 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
<label className='flex items-center gap-2 text-[11px] text-gray-700'>
|
<label className='flex items-center gap-2 text-[11px] text-gray-700'>
|
||||||
<input
|
<input
|
||||||
type='checkbox'
|
type='checkbox'
|
||||||
checked={Boolean(selectedElement.mediaMuted)}
|
checked={Boolean(
|
||||||
|
selectedElement.mediaMuted,
|
||||||
|
)}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
updateSelectedElement({
|
updateSelectedElement({
|
||||||
mediaMuted: event.target.checked,
|
mediaMuted: event.target.checked,
|
||||||
@ -3517,7 +3592,8 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedElement && selectedElement.type === 'gallery' && (
|
{selectedElement &&
|
||||||
|
selectedElement.type === 'gallery' && (
|
||||||
<div className='space-y-2'>
|
<div className='space-y-2'>
|
||||||
<div className='flex items-center justify-between'>
|
<div className='flex items-center justify-between'>
|
||||||
<p className='text-[11px] font-semibold text-gray-600'>
|
<p className='text-[11px] font-semibold text-gray-600'>
|
||||||
@ -3531,7 +3607,8 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
+ Add card
|
+ Add card
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{(selectedElement.galleryCards || []).map((card, index) => (
|
{(selectedElement.galleryCards || []).map(
|
||||||
|
(card, index) => (
|
||||||
<div
|
<div
|
||||||
key={card.id}
|
key={card.id}
|
||||||
className='rounded border border-gray-200 p-2 space-y-2'
|
className='rounded border border-gray-200 p-2 space-y-2'
|
||||||
@ -3543,7 +3620,9 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
<button
|
<button
|
||||||
type='button'
|
type='button'
|
||||||
className='text-xs text-red-600 hover:underline'
|
className='text-xs text-red-600 hover:underline'
|
||||||
onClick={() => removeGalleryCard(card.id)}
|
onClick={() =>
|
||||||
|
removeGalleryCard(card.id)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
</button>
|
</button>
|
||||||
@ -3563,7 +3642,10 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
card.imageUrl,
|
card.imageUrl,
|
||||||
`Current image · ${card.imageUrl}`,
|
`Current image · ${card.imageUrl}`,
|
||||||
).map((option) => (
|
).map((option) => (
|
||||||
<option key={option.value} value={option.value}>
|
<option
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
@ -3590,11 +3672,13 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
),
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedElement && selectedElement.type === 'carousel' && (
|
{selectedElement &&
|
||||||
|
selectedElement.type === 'carousel' && (
|
||||||
<div className='space-y-2'>
|
<div className='space-y-2'>
|
||||||
<div className='rounded border border-gray-200 p-2 space-y-2'>
|
<div className='rounded border border-gray-200 p-2 space-y-2'>
|
||||||
<p className='text-[11px] font-semibold text-gray-700'>
|
<p className='text-[11px] font-semibold text-gray-700'>
|
||||||
@ -3602,7 +3686,9 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
</p>
|
</p>
|
||||||
<select
|
<select
|
||||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||||
value={selectedElement.carouselPrevIconUrl || ''}
|
value={
|
||||||
|
selectedElement.carouselPrevIconUrl || ''
|
||||||
|
}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
updateSelectedElement({
|
updateSelectedElement({
|
||||||
carouselPrevIconUrl: event.target.value,
|
carouselPrevIconUrl: event.target.value,
|
||||||
@ -3615,14 +3701,19 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
selectedElement.carouselPrevIconUrl,
|
selectedElement.carouselPrevIconUrl,
|
||||||
`Current prev icon · ${selectedElement.carouselPrevIconUrl || ''}`,
|
`Current prev icon · ${selectedElement.carouselPrevIconUrl || ''}`,
|
||||||
).map((option) => (
|
).map((option) => (
|
||||||
<option key={option.value} value={option.value}>
|
<option
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<select
|
<select
|
||||||
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
className='w-full rounded border border-gray-300 px-2 py-1 text-xs'
|
||||||
value={selectedElement.carouselNextIconUrl || ''}
|
value={
|
||||||
|
selectedElement.carouselNextIconUrl || ''
|
||||||
|
}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
updateSelectedElement({
|
updateSelectedElement({
|
||||||
carouselNextIconUrl: event.target.value,
|
carouselNextIconUrl: event.target.value,
|
||||||
@ -3635,7 +3726,10 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
selectedElement.carouselNextIconUrl,
|
selectedElement.carouselNextIconUrl,
|
||||||
`Current next icon · ${selectedElement.carouselNextIconUrl || ''}`,
|
`Current next icon · ${selectedElement.carouselNextIconUrl || ''}`,
|
||||||
).map((option) => (
|
).map((option) => (
|
||||||
<option key={option.value} value={option.value}>
|
<option
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
@ -3666,7 +3760,9 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
<button
|
<button
|
||||||
type='button'
|
type='button'
|
||||||
className='text-xs text-red-600 hover:underline'
|
className='text-xs text-red-600 hover:underline'
|
||||||
onClick={() => removeCarouselSlide(slide.id)}
|
onClick={() =>
|
||||||
|
removeCarouselSlide(slide.id)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
</button>
|
</button>
|
||||||
@ -3686,7 +3782,10 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
slide.imageUrl,
|
slide.imageUrl,
|
||||||
`Current image · ${slide.imageUrl}`,
|
`Current image · ${slide.imageUrl}`,
|
||||||
).map((option) => (
|
).map((option) => (
|
||||||
<option key={option.value} value={option.value}>
|
<option
|
||||||
|
key={option.value}
|
||||||
|
value={option.value}
|
||||||
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
@ -3708,6 +3807,101 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* CSS Styles Tab */}
|
||||||
|
{elementEditorTab === 'css' && (
|
||||||
|
<StyleSettingsSectionCompact
|
||||||
|
values={{
|
||||||
|
width: extractNumericValue(selectedElement.width),
|
||||||
|
height: extractNumericValue(selectedElement.height),
|
||||||
|
minWidth: extractNumericValue(
|
||||||
|
selectedElement.minWidth,
|
||||||
|
),
|
||||||
|
maxWidth: extractNumericValue(
|
||||||
|
selectedElement.maxWidth,
|
||||||
|
),
|
||||||
|
minHeight: extractNumericValue(
|
||||||
|
selectedElement.minHeight,
|
||||||
|
),
|
||||||
|
maxHeight: extractNumericValue(
|
||||||
|
selectedElement.maxHeight,
|
||||||
|
),
|
||||||
|
margin: selectedElement.margin || '',
|
||||||
|
padding: selectedElement.padding || '',
|
||||||
|
gap: selectedElement.gap || '',
|
||||||
|
fontSize: selectedElement.fontSize || '',
|
||||||
|
lineHeight: selectedElement.lineHeight || '',
|
||||||
|
fontWeight: selectedElement.fontWeight || '',
|
||||||
|
border: extractNumericValue(selectedElement.border),
|
||||||
|
borderRadius: extractNumericValue(
|
||||||
|
selectedElement.borderRadius,
|
||||||
|
),
|
||||||
|
opacity: selectedElement.opacity || '',
|
||||||
|
boxShadow: selectedElement.boxShadow || '',
|
||||||
|
display: selectedElement.display || '',
|
||||||
|
position: selectedElement.position || '',
|
||||||
|
justifyContent: selectedElement.justifyContent || '',
|
||||||
|
alignItems: selectedElement.alignItems || '',
|
||||||
|
textAlign: selectedElement.textAlign || '',
|
||||||
|
zIndex: selectedElement.zIndex || '',
|
||||||
|
}}
|
||||||
|
onChange={(prop, value) => {
|
||||||
|
// Handle CSS property changes
|
||||||
|
const numericProps = [
|
||||||
|
'width',
|
||||||
|
'height',
|
||||||
|
'minWidth',
|
||||||
|
'maxWidth',
|
||||||
|
'minHeight',
|
||||||
|
'maxHeight',
|
||||||
|
'border',
|
||||||
|
'borderRadius',
|
||||||
|
];
|
||||||
|
const unit = [
|
||||||
|
'width',
|
||||||
|
'minWidth',
|
||||||
|
'maxWidth',
|
||||||
|
].includes(prop)
|
||||||
|
? 'vw'
|
||||||
|
: ['height', 'minHeight', 'maxHeight'].includes(
|
||||||
|
prop,
|
||||||
|
)
|
||||||
|
? 'vh'
|
||||||
|
: ['border', 'borderRadius'].includes(prop)
|
||||||
|
? 'px'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
if (numericProps.includes(prop)) {
|
||||||
|
const trimmed = String(value || '').trim();
|
||||||
|
if (prop === 'border') {
|
||||||
|
updateSelectedElement({
|
||||||
|
[prop]: trimmed
|
||||||
|
? `${trimmed}px solid currentColor`
|
||||||
|
: 'none',
|
||||||
|
});
|
||||||
|
} else if (prop === 'borderRadius') {
|
||||||
|
updateSelectedElement({
|
||||||
|
[prop]: trimmed ? `${trimmed}px` : '0px',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
updateSelectedElement({
|
||||||
|
[prop]: trimmed
|
||||||
|
? `${trimmed}${unit}`
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
updateSelectedElement({
|
||||||
|
[prop]: value || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -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'>
|
|
||||||
Edit the JSON settings directly. These settings control the
|
{/* General Settings Tab */}
|
||||||
default appearance and behavior of new elements of this type
|
{activeTab === 'general' && (
|
||||||
created in the constructor.
|
<>
|
||||||
</p>
|
<CommonSettingsSection
|
||||||
</FormField>
|
label={form.state.label}
|
||||||
|
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>
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user