diff --git a/frontend/public/assets/vm-shot-2026-03-19T06-12-36-229Z.jpg b/frontend/public/assets/vm-shot-2026-03-19T06-12-36-229Z.jpg new file mode 100644 index 0000000..706c29c Binary files /dev/null and b/frontend/public/assets/vm-shot-2026-03-19T06-12-36-229Z.jpg differ diff --git a/frontend/src/pages/constructor.tsx b/frontend/src/pages/constructor.tsx index 1304809..b24a498 100644 --- a/frontend/src/pages/constructor.tsx +++ b/frontend/src/pages/constructor.tsx @@ -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([]); const [assets, setAssets] = useState([]); @@ -577,6 +576,7 @@ const ConstructorPage = () => { const [resolvedDurationBySource, setResolvedDurationBySource] = useState< Record >({}); + 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(null); const didSetInitialCanvasFocus = useRef(false); const durationProbeInFlightRef = useRef>(new Set()); + const pagePlaybackStartedAtRef = useRef(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(() => { - 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 = {}; 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 + Navigation icon + ); + } + const targetPageName = element.targetPageId ? pageNameById[element.targetPageId] : ''; return (
- {element.iconUrl ? ( - // eslint-disable-next-line @next/next/no-img-element - Navigation icon - ) : null} - - {element.navLabel || - (element.type === 'navigation_next' ? 'Forward' : 'Back')} - + {element.navLabel}
{targetPageName ? ( @@ -1602,18 +1620,21 @@ const ConstructorPage = () => { } if (element.type === 'tooltip') { + if (element.iconUrl) { + return ( + // eslint-disable-next-line @next/next/no-img-element + Tooltip icon + ); + } + return (
- {element.iconUrl ? ( - // eslint-disable-next-line @next/next/no-img-element - Tooltip icon - ) : null}

- {element.tooltipTitle || 'Tooltip title'} + {element.tooltipTitle}

{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 + Description icon + ); + } + return (

- {element.iconUrl ? ( - // eslint-disable-next-line @next/next/no-img-element - Description icon - ) : null}

- {element.descriptionTitle || 'Description title'} + {element.descriptionTitle}

{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 = () => { />

) : ( - elements.map((element) => ( - - )) + 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 ( + + ); + }) )}
diff --git a/frontend/src/pages/page_elements/page_elements-list.tsx b/frontend/src/pages/page_elements/page_elements-list.tsx index 5236e50..5516acd 100644 --- a/frontend/src/pages/page_elements/page_elements-list.tsx +++ b/frontend/src/pages/page_elements/page_elements-list.tsx @@ -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 = { - 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 => { +const parseJsonObject = (value?: unknown): Record => { 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; + } + + 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 | 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>({}); - 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( - 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([]); 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(); - - 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 ( <> @@ -540,51 +202,6 @@ const PagesElementsListPage = () => { className='mb-6' cardBoxClassName='flex flex-wrap items-start gap-3' > - {hasCreatePermission && ( -
- setIsAddDropdownOpen((prev) => !prev)} - disabled={isSaving} - /> - {isAddDropdownOpen && ( -
- - -
- )} -
- )} - {

- Selected pages elements + Project elements from constructor pages

{isLoadingElements ? (

Loading elements...

- ) : selectedElements.length === 0 ? ( -

No elements selected yet.

+ ) : projectElements.length === 0 ? ( +

No constructor elements found yet.

) : (
- {selectedElements.map((item) => ( -
( + - - {hasDeletePermission ? ( - handleRemoveElement(item)} - disabled={isSaving} - /> - ) : null} -
+

{item.name}

+

+ {item.pageName} • {toElementLabel(item.elementType)} +

+ ))}
)}
- - -
- - setElementName(event.target.value)} - /> -
-
- - setColor(event.target.value)} - placeholder='#111827' - /> -
-
- - setBackgroundColor(event.target.value)} - placeholder='#ffffff' - /> -
-
- - setBorder(event.target.value)} - placeholder='1px solid #d1d5db' - /> -
-
- - setIcon(event.target.value)} - placeholder='mdiStar' - /> -
-
); }; diff --git a/frontend/src/pages/page_elements/page_elements-project-edit.tsx b/frontend/src/pages/page_elements/page_elements-project-edit.tsx new file mode 100644 index 0000000..3c0ebff --- /dev/null +++ b/frontend/src/pages/page_elements/page_elements-project-edit.tsx @@ -0,0 +1,3 @@ +import ConstructorPage from '../constructor'; + +export default ConstructorPage;