Autosave: 20260317-145604

This commit is contained in:
Flatlogic Bot 2026-03-17 14:56:05 +00:00
parent a8942e7c5d
commit 8a20fdbd9e

View File

@ -16,6 +16,7 @@ import React, { ReactElement, useCallback, useEffect, useMemo, useRef, useState
import BaseButton from '../components/BaseButton';
import BaseIcon from '../components/BaseIcon';
import { getPageTitle } from '../config';
import LayoutAuthenticated from '../layouts/Authenticated';
type TourPage = {
id: string;
@ -136,6 +137,14 @@ const getAssetLabel = (asset: ProjectAsset) => {
return `${baseName}${asset.cdn_url ? ` · ${asset.cdn_url}` : ''}`;
};
const isBackgroundImageAsset = (asset: ProjectAsset) => {
const normalizedName = String(asset.name || '').toLowerCase();
if (!normalizedName) return false;
const hasBackgroundKeyword = /\bbackground\b|\bbg\b|backdrop|wallpaper/.test(normalizedName);
const hasExcludedKeyword = /\bicon\b|\blogo\b/.test(normalizedName);
return hasBackgroundKeyword && !hasExcludedKeyword;
};
const addFallbackAssetOption = (options: AssetOption[], value?: string, fallbackLabel?: string): AssetOption[] => {
const normalizedValue = String(value || '').trim();
if (!normalizedValue) return options;
@ -238,6 +247,7 @@ const ConstructorPage = () => {
const router = useRouter();
const canvasRef = useRef<HTMLDivElement>(null);
const elementEditorRef = useRef<HTMLDivElement>(null);
const [isAuthReady, setIsAuthReady] = useState(false);
const projectId = useMemo(() => {
const value = router.query.projectId;
@ -287,6 +297,13 @@ const ConstructorPage = () => {
const reverseAnimationFrame = useRef<number | null>(null);
const activePage = useMemo(() => pages.find((item) => item.id === activePageId) || null, [activePageId, pages]);
const pageNameById = useMemo(() => {
const acc: Record<string, string> = {};
pages.forEach((page, index) => {
acc[String(page.id)] = page.name || `Page ${index + 1}`;
});
return acc;
}, [pages]);
const selectedElement = useMemo(
() => elements.find((element) => element.id === selectedElementId) || null,
[elements, selectedElementId],
@ -298,6 +315,13 @@ const ConstructorPage = () => {
.map((asset) => ({ value: String(asset.cdn_url || ''), label: getAssetLabel(asset) })),
[assets],
);
const backgroundImageAssetOptions = useMemo(
() =>
assets
.filter((asset) => asset.asset_type === 'image' && asset.cdn_url && isBackgroundImageAsset(asset))
.map((asset) => ({ value: String(asset.cdn_url || ''), label: getAssetLabel(asset) })),
[assets],
);
const videoAssetOptions = useMemo(
() =>
assets
@ -329,7 +353,7 @@ const ConstructorPage = () => {
}, [newTransitionVideoUrl, transitionVideoAssetOptions]);
const loadData = useCallback(async () => {
if (!projectId) return;
if (!projectId || !router.isReady || !isAuthReady) return;
try {
setIsLoading(true);
@ -352,6 +376,16 @@ const ConstructorPage = () => {
setActivePageId(defaultPageId);
setIsMenuOpen(pageRows.length > 0);
} catch (error: any) {
if (error?.response?.status === 401) {
const message = 'Your session has expired. Please sign in again.';
console.error('Unauthorized constructor request:', error);
setErrorMessage(message);
setPages([]);
setAssets([]);
router.replace('/login');
return;
}
const message = error?.response?.data?.message || error?.message || 'Failed to load constructor data.';
console.error('Failed to load constructor data:', error);
setErrorMessage(message);
@ -360,7 +394,21 @@ const ConstructorPage = () => {
} finally {
setIsLoading(false);
}
}, [pageIdFromRoute, projectId]);
}, [isAuthReady, pageIdFromRoute, projectId, router]);
useEffect(() => {
if (!router.isReady || typeof window === 'undefined') return;
const token = sessionStorage.getItem('token') || localStorage.getItem('token');
if (!token) {
setIsAuthReady(false);
setErrorMessage('Please sign in to continue.');
router.replace('/login');
return;
}
setIsAuthReady(true);
}, [router]);
useEffect(() => {
if (!router.isReady) return;
@ -803,8 +851,117 @@ const ConstructorPage = () => {
};
};
const renderCanvasElementContent = (element: CanvasElement) => {
if (element.type === 'navigation_next' || element.type === 'navigation_prev') {
const targetPageName = element.targetPageId ? pageNameById[element.targetPageId] : '';
return (
<div className='flex flex-col items-start gap-1'>
<span>{element.navLabel || (element.type === 'navigation_next' ? 'Forward' : 'Back')}</span>
{targetPageName ? <span className='text-[10px] text-gray-500'>To: {targetPageName}</span> : null}
</div>
);
}
if (element.type === 'tooltip') {
return (
<div className='max-w-[200px] text-left'>
<p className='text-[11px] font-bold'>{element.tooltipTitle || 'Tooltip title'}</p>
<p className='text-[10px] text-gray-600 line-clamp-3'>{element.tooltipText || 'Tooltip text'}</p>
</div>
);
}
if (element.type === 'description') {
return (
<div className='max-w-[220px] text-left'>
<p className='text-[11px] font-bold'>{element.descriptionTitle || 'Description title'}</p>
<p className='text-[10px] text-gray-600 line-clamp-4'>{element.descriptionText || 'Description text'}</p>
</div>
);
}
if (element.type === 'gallery') {
const cards = element.galleryCards || [];
return (
<div className='w-[220px]'>
<p className='mb-1 text-left text-[10px] font-semibold text-gray-600'>Gallery ({cards.length})</p>
<div className='grid grid-cols-3 gap-1'>
{cards.slice(0, 6).map((card) => (
<div key={card.id} className='h-12 overflow-hidden rounded bg-gray-100'>
{card.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={card.imageUrl} alt={card.title || 'Gallery card'} className='h-full w-full object-cover' />
) : (
<div className='flex h-full items-center justify-center text-[9px] text-gray-400'>No image</div>
)}
</div>
))}
</div>
</div>
);
}
if (element.type === 'carousel') {
const firstSlide = (element.carouselSlides || [])[0];
return (
<div className='w-[220px] text-left'>
<p className='mb-1 text-[10px] font-semibold text-gray-600'>Carousel ({element.carouselSlides?.length || 0})</p>
<div className='h-20 overflow-hidden rounded bg-gray-100'>
{firstSlide?.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={firstSlide.imageUrl} alt={firstSlide.caption || 'Carousel slide'} className='h-full w-full object-cover' />
) : (
<div className='flex h-full items-center justify-center text-[10px] text-gray-400'>No slide image</div>
)}
</div>
<p className='mt-1 text-[10px] text-gray-600 line-clamp-1'>{firstSlide?.caption || 'No caption'}</p>
</div>
);
}
if (element.type === 'video_player') {
return (
<div className='w-[220px] text-left'>
<p className='mb-1 text-[10px] font-semibold text-gray-600'>Video player</p>
<video
key={`${element.id}_${element.mediaUrl || ''}_${String(Boolean(element.mediaAutoplay))}_${String(Boolean(element.mediaLoop))}_${String(Boolean(element.mediaMuted))}`}
className='h-24 w-full rounded bg-black object-cover'
src={element.mediaUrl || ''}
controls
autoPlay={Boolean(element.mediaAutoplay)}
loop={Boolean(element.mediaLoop)}
muted={Boolean(element.mediaMuted)}
playsInline
/>
</div>
);
}
if (element.type === 'audio_player') {
return (
<div className='w-[240px] text-left'>
<p className='mb-1 text-[10px] font-semibold text-gray-600'>Audio player</p>
<audio
key={`${element.id}_${element.mediaUrl || ''}_${String(Boolean(element.mediaAutoplay))}_${String(Boolean(element.mediaLoop))}`}
className='w-full'
src={element.mediaUrl || ''}
controls
autoPlay={Boolean(element.mediaAutoplay)}
loop={Boolean(element.mediaLoop)}
/>
</div>
);
}
return getElementButtonTitle(element);
};
const canvasBackgroundStyle: React.CSSProperties = {};
const backgroundImageSelectOptions = addFallbackAssetOption(imageAssetOptions, backgroundImageUrl, `Current image · ${backgroundImageUrl}`);
const backgroundImageSelectOptions = addFallbackAssetOption(
backgroundImageAssetOptions,
backgroundImageUrl,
`Current image · ${backgroundImageUrl}`,
);
const backgroundVideoSelectOptions = addFallbackAssetOption(videoAssetOptions, backgroundVideoUrl, `Current video · ${backgroundVideoUrl}`);
const backgroundAudioSelectOptions = addFallbackAssetOption(audioAssetOptions, backgroundAudioUrl, `Current audio · ${backgroundAudioUrl}`);
const hasEditorSelection = Boolean(selectedElement) || selectedMenuItem !== 'none';
@ -818,7 +975,7 @@ const ConstructorPage = () => {
: selectedElement?.label || 'Element editor';
if (backgroundImageUrl) {
canvasBackgroundStyle.backgroundImage = `url(${backgroundImageUrl})`;
canvasBackgroundStyle.backgroundImage = `url("${backgroundImageUrl}")`;
canvasBackgroundStyle.backgroundSize = 'cover';
canvasBackgroundStyle.backgroundPosition = 'center';
}
@ -939,11 +1096,29 @@ const ConstructorPage = () => {
</div>
<div ref={canvasRef} className='absolute inset-0 bg-white overflow-hidden' style={canvasBackgroundStyle}>
{backgroundVideoUrl ? (
<video className='absolute inset-0 w-full h-full object-cover' src={backgroundVideoUrl} autoPlay loop muted playsInline />
{backgroundImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
key={`bg_image_${backgroundImageUrl}`}
src={backgroundImageUrl}
alt='Background'
className='absolute inset-0 h-full w-full object-cover pointer-events-none select-none'
/>
) : null}
{backgroundAudioUrl ? <audio src={backgroundAudioUrl} autoPlay loop hidden /> : null}
{backgroundVideoUrl ? (
<video
key={`bg_video_${backgroundVideoUrl}`}
className='absolute inset-0 w-full h-full object-cover'
src={backgroundVideoUrl}
autoPlay
loop
muted
playsInline
/>
) : null}
{backgroundAudioUrl ? <audio key={`bg_audio_${backgroundAudioUrl}`} src={backgroundAudioUrl} autoPlay loop hidden /> : null}
{isLoading ? (
<div className='absolute inset-0 flex items-center justify-center'>
@ -965,14 +1140,14 @@ const ConstructorPage = () => {
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 ${
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)}
>
{getElementButtonTitle(element)}
{renderCanvasElementContent(element)}
</button>
))
)}
@ -1546,7 +1721,7 @@ const ConstructorPage = () => {
};
ConstructorPage.getLayout = function getLayout(page: ReactElement) {
return page;
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
};
export default ConstructorPage;