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 BaseButton from '../components/BaseButton';
|
||||||
import BaseIcon from '../components/BaseIcon';
|
import BaseIcon from '../components/BaseIcon';
|
||||||
import { getPageTitle } from '../config';
|
import { getPageTitle } from '../config';
|
||||||
|
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||||
|
|
||||||
type TourPage = {
|
type TourPage = {
|
||||||
id: string;
|
id: string;
|
||||||
@ -136,6 +137,14 @@ const getAssetLabel = (asset: ProjectAsset) => {
|
|||||||
return `${baseName}${asset.cdn_url ? ` · ${asset.cdn_url}` : ''}`;
|
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 addFallbackAssetOption = (options: AssetOption[], value?: string, fallbackLabel?: string): AssetOption[] => {
|
||||||
const normalizedValue = String(value || '').trim();
|
const normalizedValue = String(value || '').trim();
|
||||||
if (!normalizedValue) return options;
|
if (!normalizedValue) return options;
|
||||||
@ -238,6 +247,7 @@ const ConstructorPage = () => {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const canvasRef = useRef<HTMLDivElement>(null);
|
const canvasRef = useRef<HTMLDivElement>(null);
|
||||||
const elementEditorRef = useRef<HTMLDivElement>(null);
|
const elementEditorRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [isAuthReady, setIsAuthReady] = useState(false);
|
||||||
|
|
||||||
const projectId = useMemo(() => {
|
const projectId = useMemo(() => {
|
||||||
const value = router.query.projectId;
|
const value = router.query.projectId;
|
||||||
@ -287,6 +297,13 @@ const ConstructorPage = () => {
|
|||||||
const reverseAnimationFrame = useRef<number | null>(null);
|
const reverseAnimationFrame = useRef<number | null>(null);
|
||||||
|
|
||||||
const activePage = useMemo(() => pages.find((item) => item.id === activePageId) || null, [activePageId, pages]);
|
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(
|
const selectedElement = useMemo(
|
||||||
() => elements.find((element) => element.id === selectedElementId) || null,
|
() => elements.find((element) => element.id === selectedElementId) || null,
|
||||||
[elements, selectedElementId],
|
[elements, selectedElementId],
|
||||||
@ -298,6 +315,13 @@ const ConstructorPage = () => {
|
|||||||
.map((asset) => ({ value: String(asset.cdn_url || ''), label: getAssetLabel(asset) })),
|
.map((asset) => ({ value: String(asset.cdn_url || ''), label: getAssetLabel(asset) })),
|
||||||
[assets],
|
[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(
|
const videoAssetOptions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
assets
|
assets
|
||||||
@ -329,7 +353,7 @@ const ConstructorPage = () => {
|
|||||||
}, [newTransitionVideoUrl, transitionVideoAssetOptions]);
|
}, [newTransitionVideoUrl, transitionVideoAssetOptions]);
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
if (!projectId) return;
|
if (!projectId || !router.isReady || !isAuthReady) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@ -352,6 +376,16 @@ const ConstructorPage = () => {
|
|||||||
setActivePageId(defaultPageId);
|
setActivePageId(defaultPageId);
|
||||||
setIsMenuOpen(pageRows.length > 0);
|
setIsMenuOpen(pageRows.length > 0);
|
||||||
} catch (error: any) {
|
} 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.';
|
const message = error?.response?.data?.message || error?.message || 'Failed to load constructor data.';
|
||||||
console.error('Failed to load constructor data:', error);
|
console.error('Failed to load constructor data:', error);
|
||||||
setErrorMessage(message);
|
setErrorMessage(message);
|
||||||
@ -360,7 +394,21 @@ const ConstructorPage = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!router.isReady) return;
|
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 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 backgroundVideoSelectOptions = addFallbackAssetOption(videoAssetOptions, backgroundVideoUrl, `Current video · ${backgroundVideoUrl}`);
|
||||||
const backgroundAudioSelectOptions = addFallbackAssetOption(audioAssetOptions, backgroundAudioUrl, `Current audio · ${backgroundAudioUrl}`);
|
const backgroundAudioSelectOptions = addFallbackAssetOption(audioAssetOptions, backgroundAudioUrl, `Current audio · ${backgroundAudioUrl}`);
|
||||||
const hasEditorSelection = Boolean(selectedElement) || selectedMenuItem !== 'none';
|
const hasEditorSelection = Boolean(selectedElement) || selectedMenuItem !== 'none';
|
||||||
@ -818,7 +975,7 @@ const ConstructorPage = () => {
|
|||||||
: selectedElement?.label || 'Element editor';
|
: selectedElement?.label || 'Element editor';
|
||||||
|
|
||||||
if (backgroundImageUrl) {
|
if (backgroundImageUrl) {
|
||||||
canvasBackgroundStyle.backgroundImage = `url(${backgroundImageUrl})`;
|
canvasBackgroundStyle.backgroundImage = `url("${backgroundImageUrl}")`;
|
||||||
canvasBackgroundStyle.backgroundSize = 'cover';
|
canvasBackgroundStyle.backgroundSize = 'cover';
|
||||||
canvasBackgroundStyle.backgroundPosition = 'center';
|
canvasBackgroundStyle.backgroundPosition = 'center';
|
||||||
}
|
}
|
||||||
@ -939,11 +1096,29 @@ const ConstructorPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref={canvasRef} className='absolute inset-0 bg-white overflow-hidden' style={canvasBackgroundStyle}>
|
<div ref={canvasRef} className='absolute inset-0 bg-white overflow-hidden' style={canvasBackgroundStyle}>
|
||||||
{backgroundVideoUrl ? (
|
{backgroundImageUrl ? (
|
||||||
<video className='absolute inset-0 w-full h-full object-cover' src={backgroundVideoUrl} autoPlay loop muted playsInline />
|
// 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}
|
) : 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 ? (
|
{isLoading ? (
|
||||||
<div className='absolute inset-0 flex items-center justify-center'>
|
<div className='absolute inset-0 flex items-center justify-center'>
|
||||||
@ -965,14 +1140,14 @@ const ConstructorPage = () => {
|
|||||||
key={element.id}
|
key={element.id}
|
||||||
type='button'
|
type='button'
|
||||||
data-constructor-element-id={element.id}
|
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'
|
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%)' }}
|
style={{ left: `${element.xPercent}%`, top: `${element.yPercent}%`, transform: 'translate(-50%, -50%)' }}
|
||||||
onMouseDown={(event) => onElementMouseDown(event, element.id)}
|
onMouseDown={(event) => onElementMouseDown(event, element.id)}
|
||||||
onClick={() => selectElementForEdit(element.id)}
|
onClick={() => selectElementForEdit(element.id)}
|
||||||
>
|
>
|
||||||
{getElementButtonTitle(element)}
|
{renderCanvasElementContent(element)}
|
||||||
</button>
|
</button>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@ -1546,7 +1721,7 @@ const ConstructorPage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
ConstructorPage.getLayout = function getLayout(page: ReactElement) {
|
ConstructorPage.getLayout = function getLayout(page: ReactElement) {
|
||||||
return page;
|
return <LayoutAuthenticated>{page}</LayoutAuthenticated>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ConstructorPage;
|
export default ConstructorPage;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user