Autosave: 20260319-061301

This commit is contained in:
Flatlogic Bot 2026-03-19 06:13:01 +00:00
parent 08ac54f0b5
commit 013560f0c1
4 changed files with 231 additions and 635 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View File

@ -510,16 +510,10 @@ const getElementButtonTitle = (element: CanvasElement) => {
return `${element.label} (${element.carouselSlides?.length || 0})`;
}
if (element.type === 'tooltip' && element.tooltipTitle)
return element.tooltipTitle;
if (element.type === 'description' && element.descriptionTitle)
return element.descriptionTitle;
if (
(element.type === 'navigation_next' ||
element.type === 'navigation_prev') &&
element.navLabel
)
return element.navLabel;
if (element.type === 'tooltip') return element.tooltipTitle ?? '';
if (element.type === 'description') return element.descriptionTitle ?? '';
if (element.type === 'navigation_next' || element.type === 'navigation_prev')
return element.navLabel ?? '';
if (
(element.type === 'video_player' || element.type === 'audio_player') &&
element.mediaUrl
@ -547,6 +541,11 @@ const ConstructorPage = () => {
if (Array.isArray(value)) return value[0] || '';
return String(value || '');
}, [router.query.pageId]);
const elementIdFromRoute = useMemo(() => {
const value = router.query.elementId;
if (Array.isArray(value)) return value[0] || '';
return String(value || '');
}, [router.query.elementId]);
const [pages, setPages] = useState<TourPage[]>([]);
const [assets, setAssets] = useState<ProjectAsset[]>([]);
@ -577,6 +576,7 @@ const ConstructorPage = () => {
const [resolvedDurationBySource, setResolvedDurationBySource] = useState<
Record<string, number | null>
>({});
const [canvasElapsedSec, setCanvasElapsedSec] = useState(0);
const [menuPosition, setMenuPosition] = useState({ x: 0, y: 110 });
const [isMenuOpen, setIsMenuOpen] = useState(false);
@ -596,22 +596,15 @@ const ConstructorPage = () => {
const reverseAnimationFrame = useRef<number | null>(null);
const didSetInitialCanvasFocus = useRef(false);
const durationProbeInFlightRef = useRef<Set<string>>(new Set());
const pagePlaybackStartedAtRef = useRef<number>(Date.now());
const activePage = useMemo(
() => pages.find((item) => item.id === activePageId) || null,
[activePageId, pages],
);
const activePageIndex = useMemo(
() => pages.findIndex((item) => item.id === activePageId),
[activePageId, pages],
);
const allowedNavigationTypes = useMemo<NavigationElementType[]>(() => {
if (pages.length <= 1) return ['navigation_next'];
if (activePageIndex < 0) return ['navigation_next', 'navigation_prev'];
if (activePageIndex <= 0) return ['navigation_next'];
if (activePageIndex >= pages.length - 1) return ['navigation_prev'];
return ['navigation_next', 'navigation_prev'];
}, [activePageIndex, pages.length]);
}, []);
const pageNameById = useMemo(() => {
const acc: Record<string, string> = {};
pages.forEach((page, index) => {
@ -970,6 +963,25 @@ const ConstructorPage = () => {
});
}, [isAuthReady, isLoading, router.isReady]);
useEffect(() => {
if (typeof window === 'undefined') return;
if (isLoading || !activePageId) {
setCanvasElapsedSec(0);
return;
}
pagePlaybackStartedAtRef.current = Date.now();
setCanvasElapsedSec(0);
const intervalId = window.setInterval(() => {
const elapsed =
(Date.now() - pagePlaybackStartedAtRef.current) / 1000;
setCanvasElapsedSec(elapsed > 0 ? elapsed : 0);
}, 100);
return () => window.clearInterval(intervalId);
}, [activePageId, isLoading]);
useEffect(() => {
if (!activePage) {
setElements([]);
@ -1076,6 +1088,12 @@ const ConstructorPage = () => {
setSelectedMenuItem('none');
setSelectedElementId((current) => {
if (!normalizedElements.length) return '';
if (
elementIdFromRoute &&
normalizedElements.some((element) => element.id === elementIdFromRoute)
) {
return elementIdFromRoute;
}
if (normalizedElements.some((element) => element.id === current))
return current;
return '';
@ -1083,7 +1101,7 @@ const ConstructorPage = () => {
setBackgroundImageUrl(activePage.background_image_url || '');
setBackgroundVideoUrl(activePage.background_video_url || '');
setBackgroundAudioUrl(activePage.background_audio_url || '');
}, [activePage]);
}, [activePage, elementIdFromRoute]);
useEffect(() => {
if (allowedNavigationTypes.length !== 1) return;
@ -1573,24 +1591,24 @@ const ConstructorPage = () => {
element.type === 'navigation_next' ||
element.type === 'navigation_prev'
) {
if (element.iconUrl) {
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolveAssetPlaybackUrl(element.iconUrl)}
alt='Navigation icon'
className='block h-auto w-auto max-h-[220px] max-w-[220px] object-contain'
/>
);
}
const targetPageName = element.targetPageId
? pageNameById[element.targetPageId]
: '';
return (
<div className='flex flex-col items-start gap-1'>
<div className='flex items-center gap-2'>
{element.iconUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolveAssetPlaybackUrl(element.iconUrl)}
alt='Navigation icon'
className='h-4 w-4 object-contain'
/>
) : null}
<span>
{element.navLabel ||
(element.type === 'navigation_next' ? 'Forward' : 'Back')}
</span>
<span>{element.navLabel}</span>
</div>
{targetPageName ? (
<span className='text-[10px] text-gray-500'>
@ -1602,18 +1620,21 @@ const ConstructorPage = () => {
}
if (element.type === 'tooltip') {
if (element.iconUrl) {
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolveAssetPlaybackUrl(element.iconUrl)}
alt='Tooltip icon'
className='block h-auto w-auto max-h-[220px] max-w-[220px] object-contain'
/>
);
}
return (
<div className='max-w-[200px] text-left'>
{element.iconUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolveAssetPlaybackUrl(element.iconUrl)}
alt='Tooltip icon'
className='mb-1 h-5 w-5 object-contain'
/>
) : null}
<p className='text-[11px] font-bold'>
{element.tooltipTitle || 'Tooltip title'}
{element.tooltipTitle}
</p>
<p className='text-[10px] text-gray-600 line-clamp-3'>
{element.tooltipText || 'Tooltip text'}
@ -1623,18 +1644,21 @@ const ConstructorPage = () => {
}
if (element.type === 'description') {
if (element.iconUrl) {
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolveAssetPlaybackUrl(element.iconUrl)}
alt='Description icon'
className='block h-auto w-auto max-h-[220px] max-w-[220px] object-contain'
/>
);
}
return (
<div className='max-w-[220px] text-left'>
{element.iconUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolveAssetPlaybackUrl(element.iconUrl)}
alt='Description icon'
className='mb-1 h-5 w-5 object-contain'
/>
) : null}
<p className='text-[11px] font-bold'>
{element.descriptionTitle || 'Description title'}
{element.descriptionTitle}
</p>
<p className='text-[10px] text-gray-600 line-clamp-4'>
{element.descriptionText || 'Description text'}
@ -1770,6 +1794,20 @@ const ConstructorPage = () => {
return getElementButtonTitle(element);
};
const isElementVisibleOnCanvas = (element: CanvasElement) => {
const delay = Number(element.appearDelaySec || 0);
if (canvasElapsedSec < delay) return false;
if (element.appearDurationSec === null || element.appearDurationSec === undefined) {
return true;
}
const duration = Number(element.appearDurationSec);
if (!Number.isFinite(duration) || duration <= 0) return true;
return canvasElapsedSec <= delay + duration;
};
const canvasBackgroundStyle: React.CSSProperties = {};
const backgroundImageSrc = resolveAssetPlaybackUrl(backgroundImageUrl);
const backgroundVideoSrc = resolveAssetPlaybackUrl(backgroundVideoUrl);
@ -1999,27 +2037,43 @@ const ConstructorPage = () => {
/>
</div>
) : (
elements.map((element) => (
<button
key={element.id}
type='button'
data-constructor-element-id={element.id}
className={`absolute border rounded px-3 py-2 text-xs font-semibold shadow cursor-move text-left ${
selectedElementId === element.id
? 'border-blue-500 bg-blue-50'
: 'border-blue-200 bg-white/95'
}`}
style={{
left: `${element.xPercent}%`,
top: `${element.yPercent}%`,
transform: 'translate(-50%, -50%)',
}}
onMouseDown={(event) => onElementMouseDown(event, element.id)}
onClick={() => selectElementForEdit(element.id)}
>
{renderCanvasElementContent(element)}
</button>
))
elements.map((element) => {
const shouldRender =
selectedElementId === element.id ||
isElementVisibleOnCanvas(element);
if (!shouldRender) return null;
const hasIconDrivenSize =
Boolean(element.iconUrl) &&
(element.type === 'navigation_next' ||
element.type === 'navigation_prev' ||
element.type === 'tooltip' ||
element.type === 'description');
return (
<button
key={element.id}
type='button'
data-constructor-element-id={element.id}
className={`absolute border rounded text-xs font-semibold shadow cursor-move text-left ${
hasIconDrivenSize ? 'overflow-hidden p-0 leading-none' : 'px-3 py-2'
} ${
selectedElementId === element.id
? 'border-blue-500 bg-blue-50'
: 'border-blue-200 bg-white/95'
}`}
style={{
left: `${element.xPercent}%`,
top: `${element.yPercent}%`,
transform: 'translate(-50%, -50%)',
}}
onMouseDown={(event) => onElementMouseDown(event, element.id)}
onClick={() => selectElementForEdit(element.id)}
>
{renderCanvasElementContent(element)}
</button>
);
})
)}
</div>

View File

@ -1,128 +1,59 @@
import { mdiChartTimelineVariant, mdiClose, mdiViewDashboard } from '@mdi/js';
import { mdiChartTimelineVariant, mdiViewDashboard } from '@mdi/js';
import Head from 'next/head';
import { useRouter } from 'next/router';
import React, {
ReactElement,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import React, { ReactElement, useCallback, useEffect, useMemo, useState } from 'react';
import axios from 'axios';
import { toast } from 'react-toastify';
import BaseButton from '../../components/BaseButton';
import CardBox from '../../components/CardBox';
import CardBoxModal from '../../components/CardBoxModal';
import LayoutAuthenticated from '../../layouts/Authenticated';
import SectionMain from '../../components/SectionMain';
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
import { getPageTitle } from '../../config';
import { hasPermission } from '../../helpers/userPermissions';
import { useAppSelector } from '../../stores/hooks';
type ElementSettings = {
color: string;
backgroundColor: string;
border: string;
icon: string;
type TourPage = {
id: string;
name?: string;
sort_order?: number;
ui_schema_json?: string;
};
type ConstructorElement = {
id?: string;
type?: string;
label?: string;
navLabel?: string;
tooltipTitle?: string;
descriptionTitle?: string;
};
type ConstructorSchema = {
elements?: ConstructorElement[];
};
type ProjectElementItem = {
id: string;
pageId: string;
pageName: string;
elementType: string;
name: string;
settings: ElementSettings;
};
type PlatformElementOption = {
elementType: string;
name: string;
defaults: ElementSettings;
};
type PageElementRecord = {
id: string;
element_type?: string;
name?: string;
style_json?: string;
content_json?: string;
};
const FALLBACK_ELEMENT_TYPES = [
'nav_button',
'spot',
'description',
'tooltip',
'gallery',
'carousel',
'logo',
'video_player',
'popup',
];
const FALLBACK_DEFAULTS_BY_TYPE: Record<string, ElementSettings> = {
nav_button: {
color: '#111827',
backgroundColor: '#ffffff',
border: '1px solid #d1d5db',
icon: 'mdiArrowRight',
},
spot: {
color: '#111827',
backgroundColor: '#fde68a',
border: '1px solid #f59e0b',
icon: 'mdiMapMarker',
},
description: {
color: '#111827',
backgroundColor: '#f3f4f6',
border: '1px solid #d1d5db',
icon: 'mdiTextBox',
},
tooltip: {
color: '#ffffff',
backgroundColor: '#1f2937',
border: '1px solid #1f2937',
icon: 'mdiTooltipText',
},
gallery: {
color: '#111827',
backgroundColor: '#ffffff',
border: '1px solid #d1d5db',
icon: 'mdiImageMultiple',
},
carousel: {
color: '#111827',
backgroundColor: '#ffffff',
border: '1px solid #d1d5db',
icon: 'mdiViewCarousel',
},
logo: {
color: '#111827',
backgroundColor: '#ffffff',
border: '1px solid #d1d5db',
icon: 'mdiImage',
},
video_player: {
color: '#ffffff',
backgroundColor: '#111827',
border: '1px solid #111827',
icon: 'mdiPlayCircle',
},
popup: {
color: '#111827',
backgroundColor: '#ffffff',
border: '1px solid #d1d5db',
icon: 'mdiOpenInNew',
},
};
const parseJsonObject = (value?: string): Record<string, any> => {
const parseJsonObject = (value?: unknown): Record<string, any> => {
if (!value) return {};
try {
const parsed = JSON.parse(value);
return typeof parsed === 'object' && parsed !== null ? parsed : {};
} catch {
if (typeof value === 'string') {
const parsed = JSON.parse(value);
return typeof parsed === 'object' && parsed !== null ? parsed : {};
}
if (typeof value === 'object') {
return value as Record<string, any>;
}
return {};
} catch (error) {
console.error('Failed to parse page schema JSON on pages elements list:', error);
return {};
}
};
@ -133,23 +64,21 @@ const toElementLabel = (value: string) =>
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
const createLocalId = () => {
if (typeof window !== 'undefined' && window.crypto?.randomUUID) {
return window.crypto.randomUUID();
const getElementName = (element: ConstructorElement) => {
if (element.type === 'navigation_next' || element.type === 'navigation_prev') {
return String(element.navLabel || '').trim();
}
return `pe_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
};
if (element.type === 'tooltip') {
return String(element.tooltipTitle || '').trim();
}
const normalizeSettings = (
settings: Partial<ElementSettings> | undefined,
fallback: ElementSettings,
): ElementSettings => ({
color: settings?.color || fallback.color,
backgroundColor: settings?.backgroundColor || fallback.backgroundColor,
border: settings?.border || fallback.border,
icon: settings?.icon || fallback.icon,
});
if (element.type === 'description') {
return String(element.descriptionTitle || '').trim();
}
return String(element.label || '').trim();
};
const PagesElementsListPage = () => {
const router = useRouter();
@ -159,75 +88,16 @@ const PagesElementsListPage = () => {
return String(value || '');
}, [router.query.projectId]);
const { currentUser } = useAppSelector((state) => state.auth);
const hasCreatePermission = hasPermission(
currentUser,
'CREATE_PAGE_ELEMENTS',
);
const hasUpdatePermission = hasPermission(
currentUser,
'UPDATE_PAGE_ELEMENTS',
);
const hasDeletePermission = hasPermission(
currentUser,
'DELETE_PAGE_ELEMENTS',
);
const [projectName, setProjectName] = useState('');
const [isLoadingProject, setIsLoadingProject] = useState(false);
const [isLoadingElements, setIsLoadingElements] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const [themeConfig, setThemeConfig] = useState<Record<string, any>>({});
const [selectedElements, setSelectedElements] = useState<
ProjectElementItem[]
>([]);
const [platformElements, setPlatformElements] = useState<
PlatformElementOption[]
>([]);
const [isAddDropdownOpen, setIsAddDropdownOpen] = useState(false);
const [newElementType, setNewElementType] = useState('');
const [isSettingsModalActive, setIsSettingsModalActive] = useState(false);
const [activeElement, setActiveElement] = useState<ProjectElementItem | null>(
null,
);
const [elementName, setElementName] = useState('');
const [color, setColor] = useState('');
const [backgroundColor, setBackgroundColor] = useState('');
const [border, setBorder] = useState('');
const [icon, setIcon] = useState('');
const [isSaving, setIsSaving] = useState(false);
// Check if projects exist and redirect if not
useEffect(() => {
const checkProjects = async () => {
try {
const response = await axios.get('/projects/autocomplete?limit=1');
const projects = Array.isArray(response?.data) ? response.data : [];
if (projects.length === 0) {
toast('Please create a project first', {
type: 'info',
position: 'bottom-center',
});
router.replace('/projects/projects-new');
}
} catch (error) {
console.error('Failed to check projects:', error);
}
};
checkProjects();
}, [router]);
const [projectElements, setProjectElements] = useState<ProjectElementItem[]>([]);
const loadData = useCallback(async () => {
if (!routeProjectId) {
setProjectName('');
setThemeConfig({});
setSelectedElements([]);
setPlatformElements([]);
setNewElementType('');
setProjectElements([]);
return;
}
@ -236,92 +106,51 @@ const PagesElementsListPage = () => {
setErrorMessage('');
try {
const [projectResponse, pageElementsResponse] = await Promise.all([
const [projectResponse, pagesResponse] = await Promise.all([
axios.get(`/projects/${routeProjectId}`),
axios.get('/page_elements?limit=1000&page=0&sort=desc&field=updatedAt'),
axios.get(
`/tour_pages?limit=500&page=0&sort=asc&field=sort_order&project=${routeProjectId}`,
),
]);
const project = projectResponse?.data || {};
setProjectName(project?.name || '');
const parsedThemeConfig = parseJsonObject(project?.theme_config_json);
const rawProjectElements = Array.isArray(parsedThemeConfig?.pageElements)
? parsedThemeConfig.pageElements
const pageRows: TourPage[] = Array.isArray(pagesResponse?.data?.rows)
? pagesResponse.data.rows
: [];
const normalizedProjectElements: ProjectElementItem[] = rawProjectElements
.filter((item: any) => item && item.elementType)
.map((item: any) => {
const elementType = String(item.elementType);
const defaults = FALLBACK_DEFAULTS_BY_TYPE[elementType] || {
color: '#111827',
backgroundColor: '#ffffff',
border: '1px solid #d1d5db',
icon: '',
};
const items: ProjectElementItem[] = [];
return {
id: String(item.id || createLocalId()),
pageRows.forEach((page, pageIndex) => {
const schema = parseJsonObject(page.ui_schema_json) as ConstructorSchema;
const elements = Array.isArray(schema.elements) ? schema.elements : [];
elements.forEach((element) => {
const elementType = String(element?.type || '').trim();
const elementId = String(element?.id || '').trim();
if (!elementType || !elementId) return;
items.push({
id: elementId,
pageId: String(page.id),
pageName: String(page.name || `Page ${pageIndex + 1}`),
elementType,
name: String(item.name || toElementLabel(elementType)),
settings: normalizeSettings(item.settings || {}, defaults),
};
});
const rows = Array.isArray(pageElementsResponse?.data?.rows)
? pageElementsResponse.data.rows
: [];
const optionsMap = new Map<string, PlatformElementOption>();
FALLBACK_ELEMENT_TYPES.forEach((type) => {
optionsMap.set(type, {
elementType: type,
name: toElementLabel(type),
defaults: FALLBACK_DEFAULTS_BY_TYPE[type],
name: getElementName(element),
});
});
});
rows.forEach((row: PageElementRecord) => {
const elementType = String(row.element_type || '').trim();
if (!elementType || optionsMap.has(elementType)) return;
const rowStyle = parseJsonObject(row.style_json);
const rowContent = parseJsonObject(row.content_json);
const fallback = FALLBACK_DEFAULTS_BY_TYPE[elementType] || {
color: '#111827',
backgroundColor: '#ffffff',
border: '1px solid #d1d5db',
icon: '',
};
optionsMap.set(elementType, {
elementType,
name: row.name || toElementLabel(elementType),
defaults: {
color: String(rowStyle.color || fallback.color),
backgroundColor: String(
rowStyle.backgroundColor || fallback.backgroundColor,
),
border: String(rowStyle.border || fallback.border),
icon: String(rowContent.icon || fallback.icon),
},
});
});
const normalizedOptions = Array.from(optionsMap.values());
setThemeConfig(parsedThemeConfig);
setSelectedElements(normalizedProjectElements);
setPlatformElements(normalizedOptions);
setProjectElements(items);
} catch (error: any) {
const message =
error?.response?.data?.message ||
error?.message ||
'Failed to load pages elements.';
setErrorMessage(message);
console.error('Failed to load pages elements list:', error);
setThemeConfig({});
setSelectedElements([]);
setPlatformElements([]);
console.error('Failed to load project elements from constructor pages:', error);
setProjectName('');
setProjectElements([]);
} finally {
setIsLoadingProject(false);
setIsLoadingElements(false);
@ -332,190 +161,23 @@ const PagesElementsListPage = () => {
loadData();
}, [loadData]);
const selectedTypes = useMemo(
() => new Set(selectedElements.map((item) => item.elementType)),
[selectedElements],
);
const availableToAdd = useMemo(
() =>
platformElements.filter((item) => !selectedTypes.has(item.elementType)),
[platformElements, selectedTypes],
);
useEffect(() => {
if (!availableToAdd.length) {
setNewElementType('');
return;
}
if (!availableToAdd.some((item) => item.elementType === newElementType)) {
setNewElementType(availableToAdd[0].elementType);
}
}, [availableToAdd, newElementType]);
const saveProjectElements = useCallback(
async (elements: ProjectElementItem[]) => {
if (!routeProjectId) return;
const nextThemeConfig = {
...(themeConfig || {}),
pageElements: elements.map((item) => ({
id: item.id,
elementType: item.elementType,
name: item.name,
settings: item.settings,
})),
};
await axios.put(`/projects/${routeProjectId}`, {
id: routeProjectId,
data: {
theme_config_json: JSON.stringify(nextThemeConfig),
},
});
setThemeConfig(nextThemeConfig);
},
[routeProjectId, themeConfig],
);
const handleAddElement = async () => {
if (!hasCreatePermission) return;
if (!routeProjectId) {
setErrorMessage('Please select a project first.');
return;
}
if (!newElementType || selectedTypes.has(newElementType)) {
return;
}
const selectedOption = platformElements.find(
(item) => item.elementType === newElementType,
);
if (!selectedOption) {
setErrorMessage('Selected element type is not available.');
return;
}
setIsSaving(true);
setErrorMessage('');
try {
const updatedElements = [
...selectedElements,
{
id: createLocalId(),
elementType: selectedOption.elementType,
name: selectedOption.name,
settings: { ...selectedOption.defaults },
},
];
await saveProjectElements(updatedElements);
setSelectedElements(updatedElements);
setIsAddDropdownOpen(false);
} catch (error: any) {
const message =
error?.response?.data?.message ||
error?.message ||
'Failed to add element.';
setErrorMessage(message);
console.error('Failed to add project element:', error);
} finally {
setIsSaving(false);
}
};
const handleRemoveElement = async (item: ProjectElementItem) => {
if (!hasDeletePermission) return;
const confirmed = window.confirm(
`Remove ${item.name || toElementLabel(item.elementType)} from this project?`,
);
if (!confirmed) return;
setIsSaving(true);
setErrorMessage('');
try {
const updatedElements = selectedElements.filter(
(existing) => existing.id !== item.id,
);
await saveProjectElements(updatedElements);
setSelectedElements(updatedElements);
} catch (error: any) {
const message =
error?.response?.data?.message ||
error?.message ||
'Failed to remove element.';
setErrorMessage(message);
console.error('Failed to remove project element:', error);
} finally {
setIsSaving(false);
}
};
const openSettings = (item: ProjectElementItem) => {
if (!hasUpdatePermission) return;
setActiveElement(item);
setElementName(item.name || toElementLabel(item.elementType));
setColor(item.settings.color);
setBackgroundColor(item.settings.backgroundColor);
setBorder(item.settings.border);
setIcon(item.settings.icon);
setIsSettingsModalActive(true);
};
const closeSettings = () => {
setIsSettingsModalActive(false);
setActiveElement(null);
};
const saveSettings = async () => {
if (!activeElement || !hasUpdatePermission) return;
setIsSaving(true);
setErrorMessage('');
try {
const updatedElements = selectedElements.map((item) => {
if (item.id !== activeElement.id) return item;
return {
...item,
name: elementName,
settings: {
color,
backgroundColor,
border,
icon,
},
};
});
await saveProjectElements(updatedElements);
setSelectedElements(updatedElements);
closeSettings();
} catch (error: any) {
const message =
error?.response?.data?.message ||
error?.message ||
'Failed to save element settings.';
setErrorMessage(message);
console.error('Failed to update project element settings:', error);
} finally {
setIsSaving(false);
}
};
const constructorHref = routeProjectId
? `/constructor?projectId=${routeProjectId}`
: '/constructor';
const openElementInEditor = (item: ProjectElementItem) => {
if (!routeProjectId) return;
router.push({
pathname: '/page_elements/page_elements-project-edit',
query: {
projectId: routeProjectId,
pageId: item.pageId,
elementId: item.id,
},
});
};
return (
<>
<Head>
@ -540,51 +202,6 @@ const PagesElementsListPage = () => {
className='mb-6'
cardBoxClassName='flex flex-wrap items-start gap-3'
>
{hasCreatePermission && (
<div className='relative'>
<BaseButton
color='info'
label='Add element'
onClick={() => setIsAddDropdownOpen((prev) => !prev)}
disabled={isSaving}
/>
{isAddDropdownOpen && (
<div className='absolute z-20 mt-2 w-72 rounded border border-gray-200 bg-white p-3 shadow-lg dark:bg-dark-900 dark:border-dark-700'>
<select
className='mb-2 w-full rounded border border-gray-300 px-2 py-2 dark:bg-dark-800'
value={newElementType}
onChange={(event) => setNewElementType(event.target.value)}
disabled={isSaving || availableToAdd.length === 0}
>
{platformElements.map((item) => (
<option
key={item.elementType}
value={item.elementType}
disabled={selectedTypes.has(item.elementType)}
>
{item.name}
{selectedTypes.has(item.elementType)
? ' (selected)'
: ''}
</option>
))}
</select>
<BaseButton
color='success'
label={isSaving ? 'Adding...' : 'Add'}
onClick={handleAddElement}
disabled={
isSaving ||
!availableToAdd.length ||
!newElementType ||
selectedTypes.has(newElementType)
}
/>
</div>
)}
</div>
)}
<BaseButton
color='lightDark'
icon={mdiViewDashboard}
@ -602,110 +219,32 @@ const PagesElementsListPage = () => {
<CardBox>
<h3 className='mb-3 text-lg font-semibold'>
Selected pages elements
Project elements from constructor pages
</h3>
{isLoadingElements ? (
<p className='text-sm text-gray-500'>Loading elements...</p>
) : selectedElements.length === 0 ? (
<p className='text-sm text-gray-500'>No elements selected yet.</p>
) : projectElements.length === 0 ? (
<p className='text-sm text-gray-500'>No constructor elements found yet.</p>
) : (
<div className='space-y-2'>
{selectedElements.map((item) => (
<div
key={item.id}
className='flex items-center justify-between gap-3 rounded border border-gray-200 px-3 py-2 dark:border-dark-700'
{projectElements.map((item) => (
<button
key={`${item.pageId}_${item.id}`}
type='button'
className='w-full rounded border border-gray-200 px-3 py-2 text-left hover:bg-gray-50 dark:border-dark-700 dark:hover:bg-dark-800'
onClick={() => openElementInEditor(item)}
>
<button
type='button'
className='flex-1 text-left'
onClick={() => openSettings(item)}
disabled={!hasUpdatePermission || isSaving}
>
<p className='text-sm font-semibold'>
{item.name || toElementLabel(item.elementType)}
</p>
<p className='text-xs text-gray-500'>
{toElementLabel(item.elementType)} icon:{' '}
{item.settings.icon || '-'}
</p>
</button>
{hasDeletePermission ? (
<BaseButton
color='danger'
icon={mdiClose}
small
roundedFull
onClick={() => handleRemoveElement(item)}
disabled={isSaving}
/>
) : null}
</div>
<p className='text-sm font-semibold'>{item.name}</p>
<p className='text-xs text-gray-500'>
{item.pageName} {toElementLabel(item.elementType)}
</p>
</button>
))}
</div>
)}
</CardBox>
</SectionMain>
<CardBoxModal
title={
activeElement
? `Edit ${toElementLabel(activeElement.elementType)}`
: 'Element settings'
}
buttonColor='info'
buttonLabel={isSaving ? 'Saving...' : 'Save settings'}
isActive={isSettingsModalActive}
onConfirm={saveSettings}
onCancel={closeSettings}
isConfirmDisabled={isSaving || !hasUpdatePermission}
>
<div>
<label className='mb-1 block text-sm font-semibold'>Name</label>
<input
className='w-full rounded border border-gray-300 px-2 py-2 dark:bg-dark-800 dark:border-dark-700'
value={elementName}
onChange={(event) => setElementName(event.target.value)}
/>
</div>
<div>
<label className='mb-1 block text-sm font-semibold'>Color</label>
<input
className='w-full rounded border border-gray-300 px-2 py-2 dark:bg-dark-800 dark:border-dark-700'
value={color}
onChange={(event) => setColor(event.target.value)}
placeholder='#111827'
/>
</div>
<div>
<label className='mb-1 block text-sm font-semibold'>Background</label>
<input
className='w-full rounded border border-gray-300 px-2 py-2 dark:bg-dark-800 dark:border-dark-700'
value={backgroundColor}
onChange={(event) => setBackgroundColor(event.target.value)}
placeholder='#ffffff'
/>
</div>
<div>
<label className='mb-1 block text-sm font-semibold'>Border</label>
<input
className='w-full rounded border border-gray-300 px-2 py-2 dark:bg-dark-800 dark:border-dark-700'
value={border}
onChange={(event) => setBorder(event.target.value)}
placeholder='1px solid #d1d5db'
/>
</div>
<div>
<label className='mb-1 block text-sm font-semibold'>Icon</label>
<input
className='w-full rounded border border-gray-300 px-2 py-2 dark:bg-dark-800 dark:border-dark-700'
value={icon}
onChange={(event) => setIcon(event.target.value)}
placeholder='mdiStar'
/>
</div>
</CardBoxModal>
</>
);
};

View File

@ -0,0 +1,3 @@
import ConstructorPage from '../constructor';
export default ConstructorPage;