Autosave: 20260317-145604
This commit is contained in:
parent
a8942e7c5d
commit
8a20fdbd9e
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user