Autosave: 20260319-064134

This commit is contained in:
Flatlogic Bot 2026-03-19 06:41:34 +00:00
parent 013560f0c1
commit d21a76e602
7 changed files with 586 additions and 44 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View File

@ -146,6 +146,10 @@ type EditorMenuItem =
| 'background_audio'
| 'create_transition';
type ConstructorPageProps = {
mode?: 'constructor' | 'element_edit';
};
const parseJsonObject = <T,>(value?: unknown, fallback?: T): T => {
if (!value) return (fallback || ({} as T)) as T;
@ -524,17 +528,22 @@ const getElementButtonTitle = (element: CanvasElement) => {
return element.label;
};
const ConstructorPage = () => {
const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
const router = useRouter();
const canvasRef = useRef<HTMLDivElement>(null);
const elementEditorRef = useRef<HTMLDivElement>(null);
const [isAuthReady, setIsAuthReady] = useState(false);
const isElementEditMode = mode === 'element_edit';
const projectId = useMemo(() => {
const value = router.query.projectId;
if (Array.isArray(value)) return value[0] || '';
return String(value || '');
}, [router.query.projectId]);
const pageElementsListHref = useMemo(() => {
if (!projectId) return '/page_elements/page_elements-list';
return `/page_elements/page_elements-list?projectId=${encodeURIComponent(projectId)}`;
}, [projectId]);
const pageIdFromRoute = useMemo(() => {
const value = router.query.pageId;
@ -925,8 +934,12 @@ const ConstructorPage = () => {
useEffect(() => {
if (!router.isReady) return;
if (projectId) return;
router.replace('/projects/projects-list');
}, [projectId, router]);
router.replace(
isElementEditMode
? '/page_elements/page_elements-list'
: '/projects/projects-list',
);
}, [isElementEditMode, projectId, router]);
useEffect(() => {
loadData();
@ -1939,7 +1952,9 @@ const ConstructorPage = () => {
return (
<>
<Head>
<title>{getPageTitle('Constructor')}</title>
<title>
{getPageTitle(isElementEditMode ? 'Edit Element' : 'Constructor')}
</title>
</Head>
<div className='relative w-screen h-screen bg-white overflow-hidden'>
<div className='absolute top-4 left-4 z-30 flex max-w-[80vw] flex-col gap-2'>
@ -1957,7 +1972,7 @@ const ConstructorPage = () => {
</p>
) : null}
{pages.length > 0 && (
{pages.length > 0 && !isElementEditMode && (
<div className='flex items-center gap-2'>
<select
className='border border-gray-300 rounded px-3 py-2 bg-white text-sm'
@ -1982,6 +1997,24 @@ const ConstructorPage = () => {
/>
</div>
)}
{pages.length > 0 && isElementEditMode && (
<div className='flex items-center gap-2'>
<BaseButton
color='lightDark'
label='Back to Elements'
icon={mdiExitToApp}
href={pageElementsListHref}
/>
<BaseButton
color='info'
label={isSaving ? 'Saving...' : 'Save'}
icon={mdiContentSave}
onClick={saveConstructor}
disabled={isSaving}
/>
</div>
)}
</div>
<div
@ -2928,7 +2961,7 @@ const ConstructorPage = () => {
</div>
)}
{pages.length > 0 && (
{pages.length > 0 && !isElementEditMode && (
<div
className='fixed z-40 w-60 border border-gray-200 rounded-lg bg-white shadow-xl'
style={{ left: menuPosition.x, top: menuPosition.y }}

View File

@ -1,9 +1,9 @@
import { mdiChartTimelineVariant, mdiViewDashboard } from '@mdi/js';
import { mdiChartTimelineVariant } from '@mdi/js';
import Head from 'next/head';
import Link from 'next/link';
import { useRouter } from 'next/router';
import React, { ReactElement, useCallback, useEffect, useMemo, useState } from 'react';
import axios from 'axios';
import BaseButton from '../../components/BaseButton';
import CardBox from '../../components/CardBox';
import LayoutAuthenticated from '../../layouts/Authenticated';
import SectionMain from '../../components/SectionMain';
@ -161,22 +161,8 @@ const PagesElementsListPage = () => {
loadData();
}, [loadData]);
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,
},
});
};
const getElementEditorHref = (item: ProjectElementItem) =>
`/page_elements/page_elements-project-edit/?projectId=${encodeURIComponent(routeProjectId)}&pageId=${encodeURIComponent(item.pageId)}&elementId=${encodeURIComponent(item.id)}`;
return (
<>
@ -198,19 +184,6 @@ const PagesElementsListPage = () => {
: projectName || 'No project selected'}
</p>
<CardBox
className='mb-6'
cardBoxClassName='flex flex-wrap items-start gap-3'
>
<BaseButton
color='lightDark'
icon={mdiViewDashboard}
href={constructorHref}
label='To Constructor'
disabled={!routeProjectId}
/>
</CardBox>
{errorMessage ? (
<CardBox className='mb-6 text-sm text-red-600'>
{errorMessage}
@ -229,17 +202,16 @@ const PagesElementsListPage = () => {
) : (
<div className='space-y-2'>
{projectElements.map((item) => (
<button
<Link
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)}
href={getElementEditorHref(item)}
className='block 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'
>
<p className='text-sm font-semibold'>{item.name}</p>
<p className='text-xs text-gray-500'>
{item.pageName} {toElementLabel(item.elementType)}
</p>
</button>
</Link>
))}
</div>
)}

View File

@ -1,3 +1,540 @@
import ConstructorPage from '../constructor';
import { mdiArrowLeft, mdiContentSave, mdiPencil } from '@mdi/js';
import axios from 'axios';
import Head from 'next/head';
import Link from 'next/link';
import { useRouter } from 'next/router';
import React, { ReactElement, useCallback, useEffect, useMemo, useState } from 'react';
import BaseButton from '../../components/BaseButton';
import CardBox from '../../components/CardBox';
import SectionMain from '../../components/SectionMain';
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
import { getPageTitle } from '../../config';
import { hasPermission } from '../../helpers/userPermissions';
import { useAppSelector } from '../../stores/hooks';
export default ConstructorPage;
type TourPage = {
id: string;
name?: string;
slug?: string;
sort_order?: number;
environment?: string;
source_key?: string;
requires_auth?: boolean;
ui_schema_json?: Record<string, any> | string;
background_image_url?: string;
background_video_url?: string;
background_audio_url?: string;
background_loop?: boolean;
};
type ConstructorElement = {
id: string;
type: string;
label?: string;
xPercent?: number;
yPercent?: number;
appearDelaySec?: number;
appearDurationSec?: number | null;
navLabel?: string;
targetPageId?: string;
tooltipTitle?: string;
tooltipText?: string;
descriptionTitle?: string;
descriptionText?: string;
mediaUrl?: string;
mediaAutoplay?: boolean;
mediaLoop?: boolean;
mediaMuted?: boolean;
galleryCards?: any[];
carouselSlides?: any[];
};
type ConstructorSchema = {
elements?: ConstructorElement[];
};
const parseJsonObject = <T,>(value?: unknown, fallback?: T): T => {
if (!value) return (fallback || ({} as T)) as T;
try {
if (typeof value === 'string') {
const parsed = JSON.parse(value);
return (parsed || fallback || {}) as T;
}
if (typeof value === 'object') {
return value as T;
}
return (fallback || ({} as T)) as T;
} catch (error) {
console.error('Failed to parse JSON on element edit page:', error);
return (fallback || ({} as T)) as T;
}
};
const toStringQuery = (value: string | string[] | undefined) => {
if (Array.isArray(value)) return String(value[0] || '').trim();
return String(value || '').trim();
};
const clampPercent = (value: string) => {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return 0;
return Math.min(Math.max(parsed, 0), 100);
};
const parseNullableNumber = (value: string) => {
if (!value.trim()) return null;
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 0) return null;
return parsed;
};
const parseArrayJson = (value: string, fieldName: string) => {
try {
const parsed = JSON.parse(value);
if (!Array.isArray(parsed)) {
throw new Error(`${fieldName} must be a JSON array.`);
}
return parsed;
} catch (error) {
console.error(`Failed to parse ${fieldName}:`, error);
throw error;
}
};
const PageElementsProjectEditPage = () => {
const router = useRouter();
const { currentUser } = useAppSelector((state) => state.auth);
const hasUpdatePermission = hasPermission(currentUser, 'UPDATE_PAGE_ELEMENTS');
const projectId = useMemo(() => toStringQuery(router.query.projectId), [router.query.projectId]);
const pageId = useMemo(() => toStringQuery(router.query.pageId), [router.query.pageId]);
const elementId = useMemo(() => toStringQuery(router.query.elementId), [router.query.elementId]);
const [currentPage, setCurrentPage] = useState<TourPage | null>(null);
const [element, setElement] = useState<ConstructorElement | null>(null);
const [projectPages, setProjectPages] = useState<TourPage[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const [successMessage, setSuccessMessage] = useState('');
const [label, setLabel] = useState('');
const [xPercent, setXPercent] = useState('0');
const [yPercent, setYPercent] = useState('0');
const [appearDelaySec, setAppearDelaySec] = useState('0');
const [appearDurationSec, setAppearDurationSec] = useState('');
const [navLabel, setNavLabel] = useState('');
const [targetPageId, setTargetPageId] = useState('');
const [tooltipTitle, setTooltipTitle] = useState('');
const [tooltipText, setTooltipText] = useState('');
const [descriptionTitle, setDescriptionTitle] = useState('');
const [descriptionText, setDescriptionText] = useState('');
const [mediaUrl, setMediaUrl] = useState('');
const [mediaAutoplay, setMediaAutoplay] = useState(false);
const [mediaLoop, setMediaLoop] = useState(false);
const [mediaMuted, setMediaMuted] = useState(false);
const [galleryCardsJson, setGalleryCardsJson] = useState('[]');
const [carouselSlidesJson, setCarouselSlidesJson] = useState('[]');
const applyElementToForm = useCallback((item: ConstructorElement) => {
setLabel(String(item.label || ''));
setXPercent(String(item.xPercent ?? 0));
setYPercent(String(item.yPercent ?? 0));
setAppearDelaySec(String(item.appearDelaySec ?? 0));
setAppearDurationSec(item.appearDurationSec === null || item.appearDurationSec === undefined ? '' : String(item.appearDurationSec));
setNavLabel(String(item.navLabel || ''));
setTargetPageId(String(item.targetPageId || ''));
setTooltipTitle(String(item.tooltipTitle || ''));
setTooltipText(String(item.tooltipText || ''));
setDescriptionTitle(String(item.descriptionTitle || ''));
setDescriptionText(String(item.descriptionText || ''));
setMediaUrl(String(item.mediaUrl || ''));
setMediaAutoplay(Boolean(item.mediaAutoplay));
setMediaLoop(Boolean(item.mediaLoop));
setMediaMuted(Boolean(item.mediaMuted));
setGalleryCardsJson(JSON.stringify(item.galleryCards || [], null, 2));
setCarouselSlidesJson(JSON.stringify(item.carouselSlides || [], null, 2));
}, []);
const loadData = useCallback(async () => {
if (!pageId || !elementId) return;
setIsLoading(true);
setErrorMessage('');
setSuccessMessage('');
try {
const pageResponse = await axios.get(`/tour_pages/${pageId}`);
const loadedPage = pageResponse?.data as TourPage;
const schema = parseJsonObject<ConstructorSchema>(loadedPage?.ui_schema_json, {});
const elements = Array.isArray(schema.elements) ? schema.elements : [];
const selectedElement = elements.find((item) => String(item?.id || '') === elementId);
if (!selectedElement) {
throw new Error('Element not found on this page.');
}
setCurrentPage(loadedPage);
setElement(selectedElement);
applyElementToForm(selectedElement);
if (projectId) {
const pagesResponse = await axios.get(
`/tour_pages?limit=500&page=0&sort=asc&field=sort_order&project=${encodeURIComponent(projectId)}`,
);
const rows = Array.isArray(pagesResponse?.data?.rows) ? pagesResponse.data.rows : [];
setProjectPages(rows);
}
} catch (error: any) {
const message =
error?.response?.data?.message ||
error?.message ||
'Failed to load element settings.';
console.error('Failed to load dedicated element edit page:', error);
setErrorMessage(message);
setCurrentPage(null);
setElement(null);
setProjectPages([]);
} finally {
setIsLoading(false);
}
}, [applyElementToForm, elementId, pageId, projectId]);
useEffect(() => {
loadData();
}, [loadData]);
const saveElement = async () => {
if (!currentPage || !element || !hasUpdatePermission) return;
setIsSaving(true);
setErrorMessage('');
setSuccessMessage('');
try {
const nextElement: ConstructorElement = {
...element,
label: label.trim(),
xPercent: clampPercent(xPercent),
yPercent: clampPercent(yPercent),
appearDelaySec: Number(appearDelaySec) >= 0 ? Number(appearDelaySec) : 0,
appearDurationSec: parseNullableNumber(appearDurationSec),
navLabel: navLabel.trim(),
targetPageId: targetPageId.trim(),
tooltipTitle: tooltipTitle.trim(),
tooltipText: tooltipText,
descriptionTitle: descriptionTitle.trim(),
descriptionText: descriptionText,
mediaUrl: mediaUrl.trim(),
mediaAutoplay,
mediaLoop,
mediaMuted,
};
if (element.type === 'gallery') {
nextElement.galleryCards = parseArrayJson(galleryCardsJson, 'Gallery cards');
}
if (element.type === 'carousel') {
nextElement.carouselSlides = parseArrayJson(carouselSlidesJson, 'Carousel slides');
}
const existingSchema = parseJsonObject<ConstructorSchema>(currentPage.ui_schema_json, {});
const existingElements = Array.isArray(existingSchema.elements) ? existingSchema.elements : [];
const updatedElements = existingElements.map((item) =>
String(item.id) === String(element.id) ? nextElement : item,
);
await axios.put(`/tour_pages/${currentPage.id}`, {
id: currentPage.id,
data: {
environment: currentPage.environment,
source_key: currentPage.source_key,
name: currentPage.name,
slug: currentPage.slug,
sort_order: currentPage.sort_order,
requires_auth: currentPage.requires_auth,
ui_schema_json: {
...existingSchema,
elements: updatedElements,
},
background_image_url: currentPage.background_image_url,
background_video_url: currentPage.background_video_url,
background_audio_url: currentPage.background_audio_url,
background_loop: currentPage.background_loop,
},
});
setElement(nextElement);
setCurrentPage((previous) =>
previous
? {
...previous,
ui_schema_json: {
...existingSchema,
elements: updatedElements,
},
}
: previous,
);
setSuccessMessage('Element settings saved.');
} catch (error: any) {
const message =
error?.response?.data?.message ||
error?.message ||
'Failed to save element settings.';
console.error('Failed to save dedicated element settings:', error);
setErrorMessage(message);
} finally {
setIsSaving(false);
}
};
const backHref = projectId
? `/page_elements/page_elements-list?projectId=${encodeURIComponent(projectId)}`
: '/page_elements/page_elements-list';
return (
<>
<Head>
<title>{getPageTitle('Edit Element')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton icon={mdiPencil} title='Edit Element Settings' main>
{''}
</SectionTitleLineWithButton>
<CardBox className='mb-4'>
<div className='flex flex-wrap items-center gap-2'>
<Link href={backHref}>
<BaseButton icon={mdiArrowLeft} label='Back to Elements' color='lightDark' />
</Link>
<BaseButton
color='success'
icon={mdiContentSave}
label={isSaving ? 'Saving...' : 'Save'}
onClick={saveElement}
disabled={isSaving || isLoading || !element || !hasUpdatePermission}
/>
</div>
</CardBox>
{errorMessage ? <CardBox className='mb-4 text-sm text-red-600'>{errorMessage}</CardBox> : null}
{successMessage ? <CardBox className='mb-4 text-sm text-green-600'>{successMessage}</CardBox> : null}
{isLoading ? (
<CardBox>
<p className='text-sm text-gray-500'>Loading element settings...</p>
</CardBox>
) : !element ? (
<CardBox>
<p className='text-sm text-gray-500'>Element was not found.</p>
</CardBox>
) : (
<>
<CardBox className='mb-4'>
<p className='text-xs text-gray-500'>Element ID: {element.id}</p>
<p className='text-xs text-gray-500'>Page: {currentPage?.name || currentPage?.id}</p>
<p className='mt-1 text-sm font-semibold'>Type: {element.type}</p>
</CardBox>
<CardBox className='mb-4'>
<h3 className='mb-3 text-sm font-semibold'>General</h3>
<div className='grid gap-4 md:grid-cols-2'>
<div>
<label className='mb-1 block text-sm font-semibold'>Label</label>
<input
className='w-full rounded border border-gray-300 px-2 py-2 dark:border-dark-700 dark:bg-dark-800'
value={label}
onChange={(event) => setLabel(event.target.value)}
disabled={!hasUpdatePermission}
/>
</div>
<div>
<label className='mb-1 block text-sm font-semibold'>Target page</label>
<select
className='w-full rounded border border-gray-300 px-2 py-2 dark:border-dark-700 dark:bg-dark-800'
value={targetPageId}
onChange={(event) => setTargetPageId(event.target.value)}
disabled={!hasUpdatePermission}
>
<option value=''>None</option>
{projectPages.map((page) => (
<option key={page.id} value={page.id}>
{page.name || page.id}
</option>
))}
</select>
</div>
<div>
<label className='mb-1 block text-sm font-semibold'>X percent</label>
<input
className='w-full rounded border border-gray-300 px-2 py-2 dark:border-dark-700 dark:bg-dark-800'
value={xPercent}
onChange={(event) => setXPercent(event.target.value)}
disabled={!hasUpdatePermission}
/>
</div>
<div>
<label className='mb-1 block text-sm font-semibold'>Y percent</label>
<input
className='w-full rounded border border-gray-300 px-2 py-2 dark:border-dark-700 dark:bg-dark-800'
value={yPercent}
onChange={(event) => setYPercent(event.target.value)}
disabled={!hasUpdatePermission}
/>
</div>
<div>
<label className='mb-1 block text-sm font-semibold'>Appear delay (sec)</label>
<input
className='w-full rounded border border-gray-300 px-2 py-2 dark:border-dark-700 dark:bg-dark-800'
value={appearDelaySec}
onChange={(event) => setAppearDelaySec(event.target.value)}
disabled={!hasUpdatePermission}
/>
</div>
<div>
<label className='mb-1 block text-sm font-semibold'>Appear duration (sec)</label>
<input
className='w-full rounded border border-gray-300 px-2 py-2 dark:border-dark-700 dark:bg-dark-800'
value={appearDurationSec}
onChange={(event) => setAppearDurationSec(event.target.value)}
placeholder='Leave empty for none'
disabled={!hasUpdatePermission}
/>
</div>
</div>
</CardBox>
<CardBox className='mb-4'>
<h3 className='mb-3 text-sm font-semibold'>Navigation / Text Settings</h3>
<div className='grid gap-4 md:grid-cols-2'>
<div>
<label className='mb-1 block text-sm font-semibold'>Navigation label</label>
<input
className='w-full rounded border border-gray-300 px-2 py-2 dark:border-dark-700 dark:bg-dark-800'
value={navLabel}
onChange={(event) => setNavLabel(event.target.value)}
disabled={!hasUpdatePermission}
/>
</div>
<div>
<label className='mb-1 block text-sm font-semibold'>Tooltip title</label>
<input
className='w-full rounded border border-gray-300 px-2 py-2 dark:border-dark-700 dark:bg-dark-800'
value={tooltipTitle}
onChange={(event) => setTooltipTitle(event.target.value)}
disabled={!hasUpdatePermission}
/>
</div>
<div className='md:col-span-2'>
<label className='mb-1 block text-sm font-semibold'>Tooltip text</label>
<textarea
className='h-24 w-full rounded border border-gray-300 px-2 py-2 dark:border-dark-700 dark:bg-dark-800'
value={tooltipText}
onChange={(event) => setTooltipText(event.target.value)}
disabled={!hasUpdatePermission}
/>
</div>
<div>
<label className='mb-1 block text-sm font-semibold'>Description title</label>
<input
className='w-full rounded border border-gray-300 px-2 py-2 dark:border-dark-700 dark:bg-dark-800'
value={descriptionTitle}
onChange={(event) => setDescriptionTitle(event.target.value)}
disabled={!hasUpdatePermission}
/>
</div>
<div className='md:col-span-2'>
<label className='mb-1 block text-sm font-semibold'>Description text</label>
<textarea
className='h-24 w-full rounded border border-gray-300 px-2 py-2 dark:border-dark-700 dark:bg-dark-800'
value={descriptionText}
onChange={(event) => setDescriptionText(event.target.value)}
disabled={!hasUpdatePermission}
/>
</div>
</div>
</CardBox>
<CardBox className='mb-4'>
<h3 className='mb-3 text-sm font-semibold'>Media</h3>
<div className='grid gap-4 md:grid-cols-2'>
<div className='md:col-span-2'>
<label className='mb-1 block text-sm font-semibold'>Media URL</label>
<input
className='w-full rounded border border-gray-300 px-2 py-2 dark:border-dark-700 dark:bg-dark-800'
value={mediaUrl}
onChange={(event) => setMediaUrl(event.target.value)}
disabled={!hasUpdatePermission}
/>
</div>
<label className='flex items-center gap-2 text-sm'>
<input
type='checkbox'
checked={mediaAutoplay}
onChange={(event) => setMediaAutoplay(event.target.checked)}
disabled={!hasUpdatePermission}
/>
Autoplay
</label>
<label className='flex items-center gap-2 text-sm'>
<input
type='checkbox'
checked={mediaLoop}
onChange={(event) => setMediaLoop(event.target.checked)}
disabled={!hasUpdatePermission}
/>
Loop
</label>
<label className='flex items-center gap-2 text-sm'>
<input
type='checkbox'
checked={mediaMuted}
onChange={(event) => setMediaMuted(event.target.checked)}
disabled={!hasUpdatePermission}
/>
Muted
</label>
</div>
</CardBox>
{element.type === 'gallery' ? (
<CardBox className='mb-4'>
<h3 className='mb-3 text-sm font-semibold'>Gallery cards JSON</h3>
<textarea
className='h-56 w-full rounded border border-gray-300 px-2 py-2 font-mono text-xs dark:border-dark-700 dark:bg-dark-800'
value={galleryCardsJson}
onChange={(event) => setGalleryCardsJson(event.target.value)}
disabled={!hasUpdatePermission}
/>
</CardBox>
) : null}
{element.type === 'carousel' ? (
<CardBox className='mb-4'>
<h3 className='mb-3 text-sm font-semibold'>Carousel slides JSON</h3>
<textarea
className='h-56 w-full rounded border border-gray-300 px-2 py-2 font-mono text-xs dark:border-dark-700 dark:bg-dark-800'
value={carouselSlidesJson}
onChange={(event) => setCarouselSlidesJson(event.target.value)}
disabled={!hasUpdatePermission}
/>
</CardBox>
) : null}
</>
)}
</SectionMain>
</>
);
};
PageElementsProjectEditPage.getLayout = function getLayout(page: ReactElement) {
return page;
};
export default PageElementsProjectEditPage;