Basic constructor

This commit is contained in:
Flatlogic Bot 2026-03-17 15:26:24 +00:00
parent 8a20fdbd9e
commit 42684051c3
2 changed files with 165 additions and 60 deletions

View File

@ -86,6 +86,7 @@ export default function LayoutAuthenticated({
const darkMode = useAppSelector((state) => state.style.darkMode) const darkMode = useAppSelector((state) => state.style.darkMode)
const isConstructorFullscreen = router.pathname === '/constructor'
const [isAsideMobileExpanded, setIsAsideMobileExpanded] = useState(false) const [isAsideMobileExpanded, setIsAsideMobileExpanded] = useState(false)
const [isAsideLgActive, setIsAsideLgActive] = useState(false) const [isAsideLgActive, setIsAsideLgActive] = useState(false)
@ -106,43 +107,47 @@ export default function LayoutAuthenticated({
}, [router.events, dispatch]) }, [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 ( return (
<div className={`${darkMode ? 'dark' : ''} overflow-hidden lg:overflow-visible`}> <div className={`${darkMode ? 'dark' : ''} overflow-hidden lg:overflow-visible`}>
<div <div
className={`${layoutAsidePadding} ${ className={`${layoutAsidePadding} ${mobileAsideShift} ${layoutTopPadding} min-h-screen w-screen transition-position lg:w-auto ${bgColor} dark:bg-dark-800 dark:text-slate-100`}
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`}
> >
<NavBar {!isConstructorFullscreen && (
menu={menuNavBar} <>
className={`${layoutAsidePadding} ${isAsideMobileExpanded ? 'ml-60 lg:ml-0' : ''}`} <NavBar
> menu={menuNavBar}
<NavBarItemPlain className={`${layoutAsidePadding} ${isAsideMobileExpanded ? 'ml-60 lg:ml-0' : ''}`}
display="flex lg:hidden" >
onClick={() => setIsAsideMobileExpanded(!isAsideMobileExpanded)} <NavBarItemPlain
> display="flex lg:hidden"
<BaseIcon path={isAsideMobileExpanded ? mdiBackburger : mdiForwardburger} size="24" /> onClick={() => setIsAsideMobileExpanded(!isAsideMobileExpanded)}
</NavBarItemPlain> >
<NavBarItemPlain <BaseIcon path={isAsideMobileExpanded ? mdiBackburger : mdiForwardburger} size="24" />
display="hidden" </NavBarItemPlain>
onClick={() => setIsAsideLgActive(true)} <NavBarItemPlain
> display="hidden"
<BaseIcon path={mdiMenu} size="24" /> onClick={() => setIsAsideLgActive(true)}
</NavBarItemPlain> >
<NavBarItemPlain useMargin> <BaseIcon path={mdiMenu} size="24" />
<Search /> </NavBarItemPlain>
</NavBarItemPlain> <NavBarItemPlain useMargin>
</NavBar> <Search />
<AsideMenu </NavBarItemPlain>
isAsideMobileExpanded={isAsideMobileExpanded} </NavBar>
isAsideLgActive={isAsideLgActive} <AsideMenu
menu={menuAside} isAsideMobileExpanded={isAsideMobileExpanded}
onAsideLgClose={() => setIsAsideLgActive(false)} isAsideLgActive={isAsideLgActive}
/> menu={menuAside}
onAsideLgClose={() => setIsAsideLgActive(false)}
/>
</>
)}
{children} {children}
<FooterBar>Hand-crafted & Made with </FooterBar> {!isConstructorFullscreen && <FooterBar>Hand-crafted & Made with </FooterBar>}
</div> </div>
</div> </div>
) )

View File

@ -15,7 +15,7 @@ import { useRouter } from 'next/router';
import React, { ReactElement, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import React, { ReactElement, useCallback, useEffect, useMemo, useRef, useState } from 'react';
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 { baseURLApi, getPageTitle } from '../config';
import LayoutAuthenticated from '../layouts/Authenticated'; import LayoutAuthenticated from '../layouts/Authenticated';
type TourPage = { type TourPage = {
@ -36,6 +36,7 @@ type ProjectAsset = {
name?: string; name?: string;
asset_type?: 'image' | 'video' | 'audio' | 'file'; asset_type?: 'image' | 'video' | 'audio' | 'file';
cdn_url?: string | null; cdn_url?: string | null;
storage_key?: string | null;
}; };
type AssetOption = { type AssetOption = {
@ -134,7 +135,79 @@ const createLocalId = () => {
const getAssetLabel = (asset: ProjectAsset) => { const getAssetLabel = (asset: ProjectAsset) => {
const baseName = asset.name?.trim() || 'Untitled asset'; 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) => { const isBackgroundImageAsset = (asset: ProjectAsset) => {
@ -295,6 +368,7 @@ const ConstructorPage = () => {
const elementDragRef = useRef<DragElementState | null>(null); const elementDragRef = useRef<DragElementState | null>(null);
const transitionVideoRef = useRef<HTMLVideoElement | null>(null); const transitionVideoRef = useRef<HTMLVideoElement | null>(null);
const reverseAnimationFrame = useRef<number | 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 activePage = useMemo(() => pages.find((item) => item.id === activePageId) || null, [activePageId, pages]);
const pageNameById = useMemo(() => { const pageNameById = useMemo(() => {
@ -311,35 +385,37 @@ const ConstructorPage = () => {
const imageAssetOptions = useMemo( const imageAssetOptions = useMemo(
() => () =>
assets assets
.filter((asset) => asset.asset_type === 'image' && asset.cdn_url) .filter((asset) => asset.asset_type === 'image' && getAssetSourceValue(asset))
.map((asset) => ({ value: String(asset.cdn_url || ''), label: getAssetLabel(asset) })), .map((asset) => ({ value: getAssetSourceValue(asset), label: getAssetLabel(asset) })),
[assets], [assets],
); );
const backgroundImageAssetOptions = useMemo( const backgroundImageAssetOptions = useMemo(
() => () =>
assets assets
.filter((asset) => asset.asset_type === 'image' && asset.cdn_url && isBackgroundImageAsset(asset)) .filter((asset) => asset.asset_type === 'image' && getAssetSourceValue(asset) && isBackgroundImageAsset(asset))
.map((asset) => ({ value: String(asset.cdn_url || ''), label: getAssetLabel(asset) })), .map((asset) => ({ value: getAssetSourceValue(asset), label: getAssetLabel(asset) })),
[assets], [assets],
); );
const videoAssetOptions = useMemo( const videoAssetOptions = useMemo(
() => () =>
assets assets
.filter((asset) => asset.asset_type === 'video' && asset.cdn_url) .filter((asset) => asset.asset_type === 'video' && getAssetSourceValue(asset))
.map((asset) => ({ value: String(asset.cdn_url || ''), label: getAssetLabel(asset) })), .map((asset) => ({ value: getAssetSourceValue(asset), label: getAssetLabel(asset) })),
[assets], [assets],
); );
const audioAssetOptions = useMemo( const audioAssetOptions = useMemo(
() => () =>
assets assets
.filter((asset) => asset.asset_type === 'audio' && asset.cdn_url) .filter((asset) => asset.asset_type === 'audio' && getAssetSourceValue(asset))
.map((asset) => ({ value: String(asset.cdn_url || ''), label: getAssetLabel(asset) })), .map((asset) => ({ value: getAssetSourceValue(asset), label: getAssetLabel(asset) })),
[assets], [assets],
); );
const transitionVideoAssetOptions = useMemo(() => { const transitionVideoAssetOptions = useMemo(() => {
const tagged = assets const tagged = assets
.filter((asset) => asset.asset_type === 'video' && asset.cdn_url && /\[TRANSITION\]/i.test(String(asset.name || ''))) .filter(
.map((asset) => ({ value: String(asset.cdn_url || ''), label: getAssetLabel(asset) })); (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; if (tagged.length > 0) return tagged;
@ -374,7 +450,7 @@ const ConstructorPage = () => {
const defaultPageId = pageIdFromRoute || pageRows[0]?.id || ''; const defaultPageId = pageIdFromRoute || pageRows[0]?.id || '';
setActivePageId(defaultPageId); setActivePageId(defaultPageId);
setIsMenuOpen(pageRows.length > 0); setIsMenuOpen(false);
} catch (error: any) { } catch (error: any) {
if (error?.response?.status === 401) { if (error?.response?.status === 401) {
const message = 'Your session has expired. Please sign in again.'; 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(() => { useEffect(() => {
if (!activePage) { if (!activePage) {
setElements([]); setElements([]);
@ -498,7 +585,7 @@ const ConstructorPage = () => {
setSelectedElementId((current) => { setSelectedElementId((current) => {
if (!normalizedElements.length) return ''; if (!normalizedElements.length) return '';
if (normalizedElements.some((element) => element.id === current)) return current; if (normalizedElements.some((element) => element.id === current)) return current;
return normalizedElements[0].id; return '';
}); });
setBackgroundImageUrl(activePage.background_image_url || ''); setBackgroundImageUrl(activePage.background_image_url || '');
setBackgroundVideoUrl(activePage.background_video_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'> <div key={card.id} className='h-12 overflow-hidden rounded bg-gray-100'>
{card.imageUrl ? ( {card.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element // 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> <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'> <div className='h-20 overflow-hidden rounded bg-gray-100'>
{firstSlide?.imageUrl ? ( {firstSlide?.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element // 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> <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 <video
key={`${element.id}_${element.mediaUrl || ''}_${String(Boolean(element.mediaAutoplay))}_${String(Boolean(element.mediaLoop))}_${String(Boolean(element.mediaMuted))}`} 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' className='h-24 w-full rounded bg-black object-cover'
src={element.mediaUrl || ''} src={resolveAssetPlaybackUrl(element.mediaUrl)}
controls controls
autoPlay={Boolean(element.mediaAutoplay)} autoPlay={Boolean(element.mediaAutoplay)}
loop={Boolean(element.mediaLoop)} loop={Boolean(element.mediaLoop)}
@ -944,7 +1039,7 @@ const ConstructorPage = () => {
<audio <audio
key={`${element.id}_${element.mediaUrl || ''}_${String(Boolean(element.mediaAutoplay))}_${String(Boolean(element.mediaLoop))}`} key={`${element.id}_${element.mediaUrl || ''}_${String(Boolean(element.mediaAutoplay))}_${String(Boolean(element.mediaLoop))}`}
className='w-full' className='w-full'
src={element.mediaUrl || ''} src={resolveAssetPlaybackUrl(element.mediaUrl)}
controls controls
autoPlay={Boolean(element.mediaAutoplay)} autoPlay={Boolean(element.mediaAutoplay)}
loop={Boolean(element.mediaLoop)} loop={Boolean(element.mediaLoop)}
@ -957,6 +1052,9 @@ const ConstructorPage = () => {
}; };
const canvasBackgroundStyle: React.CSSProperties = {}; const canvasBackgroundStyle: React.CSSProperties = {};
const backgroundImageSrc = resolveAssetPlaybackUrl(backgroundImageUrl);
const backgroundVideoSrc = resolveAssetPlaybackUrl(backgroundVideoUrl);
const backgroundAudioSrc = resolveAssetPlaybackUrl(backgroundAudioUrl);
const backgroundImageSelectOptions = addFallbackAssetOption( const backgroundImageSelectOptions = addFallbackAssetOption(
backgroundImageAssetOptions, backgroundImageAssetOptions,
backgroundImageUrl, backgroundImageUrl,
@ -974,8 +1072,8 @@ const ConstructorPage = () => {
? 'Background audio' ? 'Background audio'
: selectedElement?.label || 'Element editor'; : selectedElement?.label || 'Element editor';
if (backgroundImageUrl) { if (backgroundImageSrc) {
canvasBackgroundStyle.backgroundImage = `url("${backgroundImageUrl}")`; canvasBackgroundStyle.backgroundImage = `url("${backgroundImageSrc}")`;
canvasBackgroundStyle.backgroundSize = 'cover'; canvasBackgroundStyle.backgroundSize = 'cover';
canvasBackgroundStyle.backgroundPosition = 'center'; canvasBackgroundStyle.backgroundPosition = 'center';
} }
@ -1095,22 +1193,22 @@ const ConstructorPage = () => {
)} )}
</div> </div>
<div ref={canvasRef} className='absolute inset-0 bg-white overflow-hidden' style={canvasBackgroundStyle}> <div ref={canvasRef} tabIndex={-1} className='absolute inset-0 bg-white overflow-hidden' style={canvasBackgroundStyle}>
{backgroundImageUrl ? ( {backgroundImageSrc ? (
// eslint-disable-next-line @next/next/no-img-element // eslint-disable-next-line @next/next/no-img-element
<img <img
key={`bg_image_${backgroundImageUrl}`} key={`bg_image_${backgroundImageSrc}`}
src={backgroundImageUrl} src={backgroundImageSrc}
alt='Background' alt='Background'
className='absolute inset-0 h-full w-full object-cover pointer-events-none select-none' className='absolute inset-0 h-full w-full object-cover pointer-events-none select-none'
/> />
) : null} ) : null}
{backgroundVideoUrl ? ( {backgroundVideoSrc ? (
<video <video
key={`bg_video_${backgroundVideoUrl}`} key={`bg_video_${backgroundVideoSrc}`}
className='absolute inset-0 w-full h-full object-cover' className='absolute inset-0 w-full h-full object-cover'
src={backgroundVideoUrl} src={backgroundVideoSrc}
autoPlay autoPlay
loop loop
muted muted
@ -1118,7 +1216,7 @@ const ConstructorPage = () => {
/> />
) : null} ) : 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 ? ( {isLoading ? (
<div className='absolute inset-0 flex items-center justify-center'> <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'> <div className='fixed inset-0 z-50 bg-black/95 flex items-center justify-center'>
<video <video
ref={transitionVideoRef} 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' className='h-full w-full object-cover'
muted muted
playsInline playsInline