Autosave: 20260319-061301
This commit is contained in:
parent
08ac54f0b5
commit
013560f0c1
BIN
frontend/public/assets/vm-shot-2026-03-19T06-12-36-229Z.jpg
Normal file
BIN
frontend/public/assets/vm-shot-2026-03-19T06-12-36-229Z.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
import ConstructorPage from '../constructor';
|
||||
|
||||
export default ConstructorPage;
|
||||
Loading…
x
Reference in New Issue
Block a user