diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx
index f974115..60619c7 100644
--- a/frontend/src/layouts/Authenticated.tsx
+++ b/frontend/src/layouts/Authenticated.tsx
@@ -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 (
-
- setIsAsideMobileExpanded(!isAsideMobileExpanded)}
- >
-
-
- setIsAsideLgActive(true)}
- >
-
-
-
-
-
-
-
setIsAsideLgActive(false)}
- />
+ {!isConstructorFullscreen && (
+ <>
+
+ setIsAsideMobileExpanded(!isAsideMobileExpanded)}
+ >
+
+
+ setIsAsideLgActive(true)}
+ >
+
+
+
+
+
+
+ setIsAsideLgActive(false)}
+ />
+ >
+ )}
{children}
- Hand-crafted & Made with ❤️
+ {!isConstructorFullscreen && Hand-crafted & Made with ❤️}
)
diff --git a/frontend/src/pages/constructor.tsx b/frontend/src/pages/constructor.tsx
index 7da6d9e..e23a675 100644
--- a/frontend/src/pages/constructor.tsx
+++ b/frontend/src/pages/constructor.tsx
@@ -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(null);
const transitionVideoRef = useRef(null);
const reverseAnimationFrame = useRef(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 = () => {
{card.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
-

+
})
) : (
No image
)}
@@ -909,7 +1000,11 @@ const ConstructorPage = () => {
{firstSlide?.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
-

+
})
) : (
No slide image
)}
@@ -926,7 +1021,7 @@ const ConstructorPage = () => {
-
- {backgroundImageUrl ? (
+
+ {backgroundImageSrc ? (
// eslint-disable-next-line @next/next/no-img-element

) : null}
- {backgroundVideoUrl ? (
+ {backgroundVideoSrc ? (