Basic constructor
This commit is contained in:
parent
8a20fdbd9e
commit
42684051c3
@ -86,6 +86,7 @@ export default function LayoutAuthenticated({
|
||||
|
||||
|
||||
const darkMode = useAppSelector((state) => state.style.darkMode)
|
||||
const isConstructorFullscreen = router.pathname === '/constructor'
|
||||
|
||||
const [isAsideMobileExpanded, setIsAsideMobileExpanded] = useState(false)
|
||||
const [isAsideLgActive, setIsAsideLgActive] = useState(false)
|
||||
@ -106,43 +107,47 @@ export default function LayoutAuthenticated({
|
||||
}, [router.events, dispatch])
|
||||
|
||||
|
||||
const layoutAsidePadding = 'lg:pl-60'
|
||||
const layoutAsidePadding = isConstructorFullscreen ? '' : 'lg:pl-60'
|
||||
const layoutTopPadding = isConstructorFullscreen ? '' : 'pt-14'
|
||||
const mobileAsideShift = !isConstructorFullscreen && isAsideMobileExpanded ? 'ml-60 lg:ml-0' : ''
|
||||
|
||||
return (
|
||||
<div className={`${darkMode ? 'dark' : ''} overflow-hidden lg:overflow-visible`}>
|
||||
<div
|
||||
className={`${layoutAsidePadding} ${
|
||||
isAsideMobileExpanded ? 'ml-60 lg:ml-0' : ''
|
||||
} pt-14 min-h-screen w-screen transition-position lg:w-auto ${bgColor} dark:bg-dark-800 dark:text-slate-100`}
|
||||
className={`${layoutAsidePadding} ${mobileAsideShift} ${layoutTopPadding} min-h-screen w-screen transition-position lg:w-auto ${bgColor} dark:bg-dark-800 dark:text-slate-100`}
|
||||
>
|
||||
<NavBar
|
||||
menu={menuNavBar}
|
||||
className={`${layoutAsidePadding} ${isAsideMobileExpanded ? 'ml-60 lg:ml-0' : ''}`}
|
||||
>
|
||||
<NavBarItemPlain
|
||||
display="flex lg:hidden"
|
||||
onClick={() => setIsAsideMobileExpanded(!isAsideMobileExpanded)}
|
||||
>
|
||||
<BaseIcon path={isAsideMobileExpanded ? mdiBackburger : mdiForwardburger} size="24" />
|
||||
</NavBarItemPlain>
|
||||
<NavBarItemPlain
|
||||
display="hidden"
|
||||
onClick={() => setIsAsideLgActive(true)}
|
||||
>
|
||||
<BaseIcon path={mdiMenu} size="24" />
|
||||
</NavBarItemPlain>
|
||||
<NavBarItemPlain useMargin>
|
||||
<Search />
|
||||
</NavBarItemPlain>
|
||||
</NavBar>
|
||||
<AsideMenu
|
||||
isAsideMobileExpanded={isAsideMobileExpanded}
|
||||
isAsideLgActive={isAsideLgActive}
|
||||
menu={menuAside}
|
||||
onAsideLgClose={() => setIsAsideLgActive(false)}
|
||||
/>
|
||||
{!isConstructorFullscreen && (
|
||||
<>
|
||||
<NavBar
|
||||
menu={menuNavBar}
|
||||
className={`${layoutAsidePadding} ${isAsideMobileExpanded ? 'ml-60 lg:ml-0' : ''}`}
|
||||
>
|
||||
<NavBarItemPlain
|
||||
display="flex lg:hidden"
|
||||
onClick={() => setIsAsideMobileExpanded(!isAsideMobileExpanded)}
|
||||
>
|
||||
<BaseIcon path={isAsideMobileExpanded ? mdiBackburger : mdiForwardburger} size="24" />
|
||||
</NavBarItemPlain>
|
||||
<NavBarItemPlain
|
||||
display="hidden"
|
||||
onClick={() => setIsAsideLgActive(true)}
|
||||
>
|
||||
<BaseIcon path={mdiMenu} size="24" />
|
||||
</NavBarItemPlain>
|
||||
<NavBarItemPlain useMargin>
|
||||
<Search />
|
||||
</NavBarItemPlain>
|
||||
</NavBar>
|
||||
<AsideMenu
|
||||
isAsideMobileExpanded={isAsideMobileExpanded}
|
||||
isAsideLgActive={isAsideLgActive}
|
||||
menu={menuAside}
|
||||
onAsideLgClose={() => setIsAsideLgActive(false)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{children}
|
||||
<FooterBar>Hand-crafted & Made with ❤️</FooterBar>
|
||||
{!isConstructorFullscreen && <FooterBar>Hand-crafted & Made with ❤️</FooterBar>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -15,7 +15,7 @@ import { useRouter } from 'next/router';
|
||||
import React, { ReactElement, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import BaseButton from '../components/BaseButton';
|
||||
import BaseIcon from '../components/BaseIcon';
|
||||
import { getPageTitle } from '../config';
|
||||
import { baseURLApi, getPageTitle } from '../config';
|
||||
import LayoutAuthenticated from '../layouts/Authenticated';
|
||||
|
||||
type TourPage = {
|
||||
@ -36,6 +36,7 @@ type ProjectAsset = {
|
||||
name?: string;
|
||||
asset_type?: 'image' | 'video' | 'audio' | 'file';
|
||||
cdn_url?: string | null;
|
||||
storage_key?: string | null;
|
||||
};
|
||||
|
||||
type AssetOption = {
|
||||
@ -134,7 +135,79 @@ const createLocalId = () => {
|
||||
|
||||
const getAssetLabel = (asset: ProjectAsset) => {
|
||||
const baseName = asset.name?.trim() || 'Untitled asset';
|
||||
return `${baseName}${asset.cdn_url ? ` · ${asset.cdn_url}` : ''}`;
|
||||
const source = String(asset.storage_key || asset.cdn_url || '').trim();
|
||||
return `${baseName}${source ? ` · ${source}` : ''}`;
|
||||
};
|
||||
|
||||
const getAssetSourceValue = (asset: ProjectAsset) => String(asset.storage_key || asset.cdn_url || '').trim();
|
||||
|
||||
const extractPrivateUrlFromDownloadPath = (value: string) => {
|
||||
const normalized = String(value || '').trim();
|
||||
if (!normalized) return '';
|
||||
|
||||
try {
|
||||
const parsed = new URL(normalized, 'http://localhost');
|
||||
const privateUrl = parsed.searchParams.get('privateUrl');
|
||||
return String(privateUrl || '').trim();
|
||||
} catch (error) {
|
||||
console.error('Failed to parse download URL:', error);
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const extractS3ObjectKey = (value: string) => {
|
||||
const normalized = String(value || '').trim();
|
||||
if (!normalized) return '';
|
||||
|
||||
try {
|
||||
const parsed = new URL(normalized);
|
||||
const hostname = String(parsed.hostname || '').toLowerCase();
|
||||
if (!hostname.includes('amazonaws.com') && !hostname.includes('cloudfront.net')) return '';
|
||||
const decodedPath = decodeURIComponent(String(parsed.pathname || '').replace(/^\/+/, ''));
|
||||
if (!decodedPath) return '';
|
||||
|
||||
const pathParts = decodedPath.split('/').filter(Boolean);
|
||||
if (pathParts.length <= 1) return decodedPath;
|
||||
|
||||
const firstPart = pathParts[0];
|
||||
const isLikelyStoragePrefix = /^[a-f0-9]{24,64}$/i.test(firstPart);
|
||||
if (isLikelyStoragePrefix) {
|
||||
return pathParts.slice(1).join('/');
|
||||
}
|
||||
|
||||
return decodedPath;
|
||||
} catch (error) {
|
||||
console.error('Failed to parse S3 asset URL:', error);
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const resolveAssetPlaybackUrl = (value?: string) => {
|
||||
const normalized = String(value || '').trim();
|
||||
if (!normalized) return '';
|
||||
|
||||
if (normalized.startsWith('data:') || normalized.startsWith('blob:')) return normalized;
|
||||
|
||||
if (normalized.startsWith('/api/file/download')) return normalized;
|
||||
|
||||
if (normalized.startsWith('/file/download')) return `${baseURLApi}${normalized}`;
|
||||
|
||||
if (normalized.startsWith('http://') || normalized.startsWith('https://')) {
|
||||
const downloadPrivateUrl = extractPrivateUrlFromDownloadPath(normalized);
|
||||
if (downloadPrivateUrl) {
|
||||
return `${baseURLApi}/file/download?privateUrl=${encodeURIComponent(downloadPrivateUrl)}`;
|
||||
}
|
||||
|
||||
const s3ObjectKey = extractS3ObjectKey(normalized);
|
||||
if (s3ObjectKey) {
|
||||
return `${baseURLApi}/file/download?privateUrl=${encodeURIComponent(s3ObjectKey)}`;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const normalizedPrivateUrl = normalized.replace(/^\/+/, '');
|
||||
return `${baseURLApi}/file/download?privateUrl=${encodeURIComponent(normalizedPrivateUrl)}`;
|
||||
};
|
||||
|
||||
const isBackgroundImageAsset = (asset: ProjectAsset) => {
|
||||
@ -295,6 +368,7 @@ const ConstructorPage = () => {
|
||||
const elementDragRef = useRef<DragElementState | null>(null);
|
||||
const transitionVideoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const reverseAnimationFrame = useRef<number | null>(null);
|
||||
const didSetInitialCanvasFocus = useRef(false);
|
||||
|
||||
const activePage = useMemo(() => pages.find((item) => item.id === activePageId) || null, [activePageId, pages]);
|
||||
const pageNameById = useMemo(() => {
|
||||
@ -311,35 +385,37 @@ const ConstructorPage = () => {
|
||||
const imageAssetOptions = useMemo(
|
||||
() =>
|
||||
assets
|
||||
.filter((asset) => asset.asset_type === 'image' && asset.cdn_url)
|
||||
.map((asset) => ({ value: String(asset.cdn_url || ''), label: getAssetLabel(asset) })),
|
||||
.filter((asset) => asset.asset_type === 'image' && getAssetSourceValue(asset))
|
||||
.map((asset) => ({ value: getAssetSourceValue(asset), 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) })),
|
||||
.filter((asset) => asset.asset_type === 'image' && getAssetSourceValue(asset) && isBackgroundImageAsset(asset))
|
||||
.map((asset) => ({ value: getAssetSourceValue(asset), label: getAssetLabel(asset) })),
|
||||
[assets],
|
||||
);
|
||||
const videoAssetOptions = useMemo(
|
||||
() =>
|
||||
assets
|
||||
.filter((asset) => asset.asset_type === 'video' && asset.cdn_url)
|
||||
.map((asset) => ({ value: String(asset.cdn_url || ''), label: getAssetLabel(asset) })),
|
||||
.filter((asset) => asset.asset_type === 'video' && getAssetSourceValue(asset))
|
||||
.map((asset) => ({ value: getAssetSourceValue(asset), label: getAssetLabel(asset) })),
|
||||
[assets],
|
||||
);
|
||||
const audioAssetOptions = useMemo(
|
||||
() =>
|
||||
assets
|
||||
.filter((asset) => asset.asset_type === 'audio' && asset.cdn_url)
|
||||
.map((asset) => ({ value: String(asset.cdn_url || ''), label: getAssetLabel(asset) })),
|
||||
.filter((asset) => asset.asset_type === 'audio' && getAssetSourceValue(asset))
|
||||
.map((asset) => ({ value: getAssetSourceValue(asset), label: getAssetLabel(asset) })),
|
||||
[assets],
|
||||
);
|
||||
const transitionVideoAssetOptions = useMemo(() => {
|
||||
const tagged = assets
|
||||
.filter((asset) => asset.asset_type === 'video' && asset.cdn_url && /\[TRANSITION\]/i.test(String(asset.name || '')))
|
||||
.map((asset) => ({ value: String(asset.cdn_url || ''), label: getAssetLabel(asset) }));
|
||||
.filter(
|
||||
(asset) => asset.asset_type === 'video' && getAssetSourceValue(asset) && /\[TRANSITION\]/i.test(String(asset.name || '')),
|
||||
)
|
||||
.map((asset) => ({ value: getAssetSourceValue(asset), label: getAssetLabel(asset) }));
|
||||
|
||||
if (tagged.length > 0) return tagged;
|
||||
|
||||
@ -374,7 +450,7 @@ const ConstructorPage = () => {
|
||||
|
||||
const defaultPageId = pageIdFromRoute || pageRows[0]?.id || '';
|
||||
setActivePageId(defaultPageId);
|
||||
setIsMenuOpen(pageRows.length > 0);
|
||||
setIsMenuOpen(false);
|
||||
} catch (error: any) {
|
||||
if (error?.response?.status === 401) {
|
||||
const message = 'Your session has expired. Please sign in again.';
|
||||
@ -440,6 +516,17 @@ const ConstructorPage = () => {
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!router.isReady || !isAuthReady || isLoading) return;
|
||||
if (didSetInitialCanvasFocus.current) return;
|
||||
if (!canvasRef.current) return;
|
||||
|
||||
didSetInitialCanvasFocus.current = true;
|
||||
requestAnimationFrame(() => {
|
||||
canvasRef.current?.focus({ preventScroll: true });
|
||||
});
|
||||
}, [isAuthReady, isLoading, router.isReady]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activePage) {
|
||||
setElements([]);
|
||||
@ -498,7 +585,7 @@ const ConstructorPage = () => {
|
||||
setSelectedElementId((current) => {
|
||||
if (!normalizedElements.length) return '';
|
||||
if (normalizedElements.some((element) => element.id === current)) return current;
|
||||
return normalizedElements[0].id;
|
||||
return '';
|
||||
});
|
||||
setBackgroundImageUrl(activePage.background_image_url || '');
|
||||
setBackgroundVideoUrl(activePage.background_video_url || '');
|
||||
@ -890,7 +977,11 @@ const ConstructorPage = () => {
|
||||
<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' />
|
||||
<img
|
||||
src={resolveAssetPlaybackUrl(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>
|
||||
)}
|
||||
@ -909,7 +1000,11 @@ const ConstructorPage = () => {
|
||||
<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' />
|
||||
<img
|
||||
src={resolveAssetPlaybackUrl(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>
|
||||
)}
|
||||
@ -926,7 +1021,7 @@ const ConstructorPage = () => {
|
||||
<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 || ''}
|
||||
src={resolveAssetPlaybackUrl(element.mediaUrl)}
|
||||
controls
|
||||
autoPlay={Boolean(element.mediaAutoplay)}
|
||||
loop={Boolean(element.mediaLoop)}
|
||||
@ -944,7 +1039,7 @@ const ConstructorPage = () => {
|
||||
<audio
|
||||
key={`${element.id}_${element.mediaUrl || ''}_${String(Boolean(element.mediaAutoplay))}_${String(Boolean(element.mediaLoop))}`}
|
||||
className='w-full'
|
||||
src={element.mediaUrl || ''}
|
||||
src={resolveAssetPlaybackUrl(element.mediaUrl)}
|
||||
controls
|
||||
autoPlay={Boolean(element.mediaAutoplay)}
|
||||
loop={Boolean(element.mediaLoop)}
|
||||
@ -957,6 +1052,9 @@ const ConstructorPage = () => {
|
||||
};
|
||||
|
||||
const canvasBackgroundStyle: React.CSSProperties = {};
|
||||
const backgroundImageSrc = resolveAssetPlaybackUrl(backgroundImageUrl);
|
||||
const backgroundVideoSrc = resolveAssetPlaybackUrl(backgroundVideoUrl);
|
||||
const backgroundAudioSrc = resolveAssetPlaybackUrl(backgroundAudioUrl);
|
||||
const backgroundImageSelectOptions = addFallbackAssetOption(
|
||||
backgroundImageAssetOptions,
|
||||
backgroundImageUrl,
|
||||
@ -974,8 +1072,8 @@ const ConstructorPage = () => {
|
||||
? 'Background audio'
|
||||
: selectedElement?.label || 'Element editor';
|
||||
|
||||
if (backgroundImageUrl) {
|
||||
canvasBackgroundStyle.backgroundImage = `url("${backgroundImageUrl}")`;
|
||||
if (backgroundImageSrc) {
|
||||
canvasBackgroundStyle.backgroundImage = `url("${backgroundImageSrc}")`;
|
||||
canvasBackgroundStyle.backgroundSize = 'cover';
|
||||
canvasBackgroundStyle.backgroundPosition = 'center';
|
||||
}
|
||||
@ -1095,22 +1193,22 @@ const ConstructorPage = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div ref={canvasRef} className='absolute inset-0 bg-white overflow-hidden' style={canvasBackgroundStyle}>
|
||||
{backgroundImageUrl ? (
|
||||
<div ref={canvasRef} tabIndex={-1} className='absolute inset-0 bg-white overflow-hidden' style={canvasBackgroundStyle}>
|
||||
{backgroundImageSrc ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
key={`bg_image_${backgroundImageUrl}`}
|
||||
src={backgroundImageUrl}
|
||||
key={`bg_image_${backgroundImageSrc}`}
|
||||
src={backgroundImageSrc}
|
||||
alt='Background'
|
||||
className='absolute inset-0 h-full w-full object-cover pointer-events-none select-none'
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{backgroundVideoUrl ? (
|
||||
{backgroundVideoSrc ? (
|
||||
<video
|
||||
key={`bg_video_${backgroundVideoUrl}`}
|
||||
key={`bg_video_${backgroundVideoSrc}`}
|
||||
className='absolute inset-0 w-full h-full object-cover'
|
||||
src={backgroundVideoUrl}
|
||||
src={backgroundVideoSrc}
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
@ -1118,7 +1216,7 @@ const ConstructorPage = () => {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{backgroundAudioUrl ? <audio key={`bg_audio_${backgroundAudioUrl}`} src={backgroundAudioUrl} autoPlay loop hidden /> : null}
|
||||
{backgroundAudioSrc ? <audio key={`bg_audio_${backgroundAudioSrc}`} src={backgroundAudioSrc} autoPlay loop hidden /> : null}
|
||||
|
||||
{isLoading ? (
|
||||
<div className='absolute inset-0 flex items-center justify-center'>
|
||||
@ -1681,7 +1779,9 @@ const ConstructorPage = () => {
|
||||
<div className='fixed inset-0 z-50 bg-black/95 flex items-center justify-center'>
|
||||
<video
|
||||
ref={transitionVideoRef}
|
||||
src={transitionPreview.reverseMode === 'separate' ? transitionPreview.reverseVideoUrl || '' : transitionPreview.videoUrl}
|
||||
src={resolveAssetPlaybackUrl(
|
||||
transitionPreview.reverseMode === 'separate' ? transitionPreview.reverseVideoUrl || '' : transitionPreview.videoUrl,
|
||||
)}
|
||||
className='h-full w-full object-cover'
|
||||
muted
|
||||
playsInline
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user