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 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>
)

View File

@ -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