fixed imports issue

This commit is contained in:
Dmitri 2026-04-11 14:54:10 +04:00
parent 0a36a87cd4
commit ad9c788b21
21 changed files with 332 additions and 493 deletions

View File

@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./build/types/routes.d.ts" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.

File diff suppressed because one or more lines are too long

View File

@ -25,8 +25,14 @@ const PageSelector: React.FC<PageSelectorProps> = ({
const sortedPages = useMemo(() => {
return [...pages].sort((a, b) => {
// Primary sort: sort_order ascending (undefined/null goes to end)
const orderA = typeof a.sort_order === 'number' ? a.sort_order : Number.MAX_SAFE_INTEGER;
const orderB = typeof b.sort_order === 'number' ? b.sort_order : Number.MAX_SAFE_INTEGER;
const orderA =
typeof a.sort_order === 'number'
? a.sort_order
: Number.MAX_SAFE_INTEGER;
const orderB =
typeof b.sort_order === 'number'
? b.sort_order
: Number.MAX_SAFE_INTEGER;
if (orderA !== orderB) {
return orderA - orderB;
}

View File

@ -27,15 +27,15 @@ export const RotatePrompt: React.FC<RotatePromptProps> = ({
if (!show) return null;
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/90">
<div className="text-center text-white px-8">
<div className='fixed inset-0 z-[100] flex items-center justify-center bg-black/90'>
<div className='text-center text-white px-8'>
<Icon
path={mdiScreenRotation}
size={3}
className="mx-auto mb-6 animate-pulse"
className='mx-auto mb-6 animate-pulse'
/>
<p className="text-lg font-medium">{message}</p>
<p className="text-sm text-gray-400 mt-2">
<p className='text-lg font-medium'>{message}</p>
<p className='text-sm text-gray-400 mt-2'>
This presentation is optimized for landscape viewing
</p>
</div>

View File

@ -94,7 +94,7 @@ export default function RuntimePresentation({
useCanvasScale({
designWidth: currentPage?.design_width ?? undefined,
designHeight: currentPage?.design_height ?? undefined,
});
});
const [transitionPreview, setTransitionPreview] = useState<{
targetPageId: string;
@ -542,186 +542,193 @@ export default function RuntimePresentation({
backgroundRepeat: 'no-repeat',
}}
>
<BackdropPortalProvider>
{/* Background image element - z-1 keeps it below backdrop blur (z-5).
<BackdropPortalProvider>
{/* Background image element - z-1 keeps it below backdrop blur (z-5).
CSS backgroundImage provides instant display.
Use native img for blob URLs to prevent repeated fetch requests from Next.js Image. */}
{backgroundImageUrl && !backgroundVideoUrl && (
<div className='absolute inset-0 z-1 pointer-events-none'>
{backgroundImageUrl.startsWith('blob:') ? (
// eslint-disable-next-line @next/next/no-img-element
<img
key={backgroundImageUrl}
src={backgroundImageUrl}
alt=''
className='absolute inset-0 w-full h-full object-contain'
onLoad={() => {
setIsBackgroundReady(true);
pageSwitch.markBackgroundReady();
}}
onError={() => {
setIsBackgroundReady(true);
pageSwitch.markBackgroundReady();
}}
/>
) : (
<Image
key={backgroundImageUrl}
src={backgroundImageUrl}
alt=''
fill
sizes='100vw'
className='object-contain'
priority
unoptimized
onLoad={() => {
setIsBackgroundReady(true);
pageSwitch.markBackgroundReady();
}}
onError={() => {
setIsBackgroundReady(true);
pageSwitch.markBackgroundReady();
{backgroundImageUrl && !backgroundVideoUrl && (
<div className='absolute inset-0 z-1 pointer-events-none'>
{backgroundImageUrl.startsWith('blob:') ? (
// eslint-disable-next-line @next/next/no-img-element
<img
key={backgroundImageUrl}
src={backgroundImageUrl}
alt=''
className='absolute inset-0 w-full h-full object-contain'
onLoad={() => {
setIsBackgroundReady(true);
pageSwitch.markBackgroundReady();
}}
onError={() => {
setIsBackgroundReady(true);
pageSwitch.markBackgroundReady();
}}
/>
) : (
<Image
key={backgroundImageUrl}
src={backgroundImageUrl}
alt=''
fill
sizes='100vw'
className='object-contain'
priority
unoptimized
onLoad={() => {
setIsBackgroundReady(true);
pageSwitch.markBackgroundReady();
}}
onError={() => {
setIsBackgroundReady(true);
pageSwitch.markBackgroundReady();
}}
/>
)}
</div>
)}
{/* Previous background overlay - shows during direct navigation until new bg is ready */}
{pageSwitch.previousBgImageUrl &&
pageSwitch.isSwitching &&
!pageSwitch.isNewBgReady && (
<div
className='absolute inset-0 pointer-events-none z-10'
style={{
backgroundImage: `url("${pageSwitch.previousBgImageUrl}")`,
backgroundSize: 'contain',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
}}
/>
)}
</div>
)}
{/* Previous background overlay - shows during direct navigation until new bg is ready */}
{pageSwitch.previousBgImageUrl &&
pageSwitch.isSwitching &&
!pageSwitch.isNewBgReady && (
<div
className='absolute inset-0 pointer-events-none z-10'
style={{
backgroundImage: `url("${pageSwitch.previousBgImageUrl}")`,
backgroundSize: 'contain',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
}}
{/* Background video - z-1 keeps it below backdrop blur (z-5) */}
{backgroundVideoUrl && (
<video
ref={bgVideoRef}
key={backgroundVideoUrl}
className='absolute inset-0 z-1 h-full w-full object-contain'
src={backgroundVideoUrl}
autoPlay={videoAutoplay}
loop={useNativeLoop}
muted={videoMuted}
playsInline
/>
)}
{/* Background video - z-1 keeps it below backdrop blur (z-5) */}
{backgroundVideoUrl && (
<video
ref={bgVideoRef}
key={backgroundVideoUrl}
className='absolute inset-0 z-1 h-full w-full object-contain'
src={backgroundVideoUrl}
autoPlay={videoAutoplay}
loop={useNativeLoop}
muted={videoMuted}
playsInline
/>
)}
{/* Page elements - z-40 ensures they appear above carousel background (z-10) and carousel controls (z-30) */}
<div
className='absolute inset-0 z-40'
style={{
opacity: elementsOpacity,
transition: isFadingIn
? `opacity ${CANVAS_CONFIG.pageTransition.fadeInDurationMs}ms ${CANVAS_CONFIG.pageTransition.easing}`
: 'none',
}}
>
{pageElements.map((element: CanvasElement) => (
<RuntimeElement
key={element.id}
element={element}
onClick={() => handleElementClick(element)}
resolveUrl={resolveUrlWithBlob}
onGalleryCardClick={(cardIndex) =>
handleGalleryCardClick(element, cardIndex)
}
/>
))}
</div>
{/* Page elements - z-40 ensures they appear above carousel background (z-10) and carousel controls (z-30) */}
<div
className='absolute inset-0 z-40'
style={{
opacity: elementsOpacity,
transition: isFadingIn
? `opacity ${CANVAS_CONFIG.pageTransition.fadeInDurationMs}ms ${CANVAS_CONFIG.pageTransition.easing}`
: 'none',
}}
>
{pageElements.map((element: CanvasElement) => (
<RuntimeElement
key={element.id}
element={element}
onClick={() => handleElementClick(element)}
resolveUrl={resolveUrlWithBlob}
onGalleryCardClick={(cardIndex) =>
handleGalleryCardClick(element, cardIndex)
}
{/* Controls: Offline toggle and Fullscreen button */}
<div className='absolute top-4 right-4 z-50 flex items-center gap-2'>
<OfflineToggle
projectId={project?.id || null}
projectSlug={projectSlug}
projectName={project?.name}
pages={pages}
showLabel={false}
size='small'
/>
))}
</div>
{/* Controls: Offline toggle and Fullscreen button */}
<div className='absolute top-4 right-4 z-50 flex items-center gap-2'>
<OfflineToggle
projectId={project?.id || null}
projectSlug={projectSlug}
projectName={project?.name}
pages={pages}
showLabel={false}
size='small'
/>
<BaseButton
icon={isFullscreen ? mdiFullscreenExit : mdiFullscreen}
color='info'
small
onClick={toggleFullscreen}
/>
</div>
{/* Transition overlay - uses useTransitionPlayback hook for smooth transitions */}
{/* Opacity is 0 during 'preparing' phase to show old page while video loads */}
{/* Also fades to 0 when isOverlayFadingOut to reveal the new page underneath */}
{transitionPreview && (
<div className='fixed inset-0 z-50 overflow-hidden pointer-events-none'>
<video
ref={transitionVideoRef}
className='absolute inset-0 h-full w-full object-cover transition-opacity duration-300 ease-linear'
style={{
opacity:
transitionPhase === 'preparing' ||
isBuffering ||
isOverlayFadingOut
? 0
: 1,
}}
muted
playsInline
preload='auto'
disablePictureInPicture
<BaseButton
icon={isFullscreen ? mdiFullscreenExit : mdiFullscreen}
color='info'
small
onClick={toggleFullscreen}
/>
</div>
)}
{/* Gallery Carousel Overlay */}
{activeGalleryCarousel && (
<GalleryCarouselOverlay
cards={activeGalleryCarousel.element.galleryCards || []}
initialIndex={activeGalleryCarousel.initialIndex}
onClose={() => setActiveGalleryCarousel(null)}
resolveUrl={resolveUrlWithBlob}
prevIconUrl={
activeGalleryCarousel.element.galleryCarouselPrevIconUrl
}
nextIconUrl={
activeGalleryCarousel.element.galleryCarouselNextIconUrl
}
backIconUrl={
activeGalleryCarousel.element.galleryCarouselBackIconUrl
}
backLabel={
activeGalleryCarousel.element.galleryCarouselBackLabel || 'BACK'
}
prevX={activeGalleryCarousel.element.galleryCarouselPrevX}
prevY={activeGalleryCarousel.element.galleryCarouselPrevY}
nextX={activeGalleryCarousel.element.galleryCarouselNextX}
nextY={activeGalleryCarousel.element.galleryCarouselNextY}
backX={activeGalleryCarousel.element.galleryCarouselBackX}
backY={activeGalleryCarousel.element.galleryCarouselBackY}
prevWidth={activeGalleryCarousel.element.galleryCarouselPrevWidth}
prevHeight={
activeGalleryCarousel.element.galleryCarouselPrevHeight
}
nextWidth={activeGalleryCarousel.element.galleryCarouselNextWidth}
nextHeight={
activeGalleryCarousel.element.galleryCarouselNextHeight
}
backWidth={activeGalleryCarousel.element.galleryCarouselBackWidth}
backHeight={
activeGalleryCarousel.element.galleryCarouselBackHeight
}
isEditMode={false}
/>
)}
</BackdropPortalProvider>
{/* Transition overlay - uses useTransitionPlayback hook for smooth transitions */}
{/* Opacity is 0 during 'preparing' phase to show old page while video loads */}
{/* Also fades to 0 when isOverlayFadingOut to reveal the new page underneath */}
{transitionPreview && (
<div className='fixed inset-0 z-50 overflow-hidden pointer-events-none'>
<video
ref={transitionVideoRef}
className='absolute inset-0 h-full w-full object-cover transition-opacity duration-300 ease-linear'
style={{
opacity:
transitionPhase === 'preparing' ||
isBuffering ||
isOverlayFadingOut
? 0
: 1,
}}
muted
playsInline
preload='auto'
disablePictureInPicture
/>
</div>
)}
{/* Gallery Carousel Overlay */}
{activeGalleryCarousel && (
<GalleryCarouselOverlay
cards={activeGalleryCarousel.element.galleryCards || []}
initialIndex={activeGalleryCarousel.initialIndex}
onClose={() => setActiveGalleryCarousel(null)}
resolveUrl={resolveUrlWithBlob}
prevIconUrl={
activeGalleryCarousel.element.galleryCarouselPrevIconUrl
}
nextIconUrl={
activeGalleryCarousel.element.galleryCarouselNextIconUrl
}
backIconUrl={
activeGalleryCarousel.element.galleryCarouselBackIconUrl
}
backLabel={
activeGalleryCarousel.element.galleryCarouselBackLabel ||
'BACK'
}
prevX={activeGalleryCarousel.element.galleryCarouselPrevX}
prevY={activeGalleryCarousel.element.galleryCarouselPrevY}
nextX={activeGalleryCarousel.element.galleryCarouselNextX}
nextY={activeGalleryCarousel.element.galleryCarouselNextY}
backX={activeGalleryCarousel.element.galleryCarouselBackX}
backY={activeGalleryCarousel.element.galleryCarouselBackY}
prevWidth={
activeGalleryCarousel.element.galleryCarouselPrevWidth
}
prevHeight={
activeGalleryCarousel.element.galleryCarouselPrevHeight
}
nextWidth={
activeGalleryCarousel.element.galleryCarouselNextWidth
}
nextHeight={
activeGalleryCarousel.element.galleryCarouselNextHeight
}
backWidth={
activeGalleryCarousel.element.galleryCarouselBackWidth
}
backHeight={
activeGalleryCarousel.element.galleryCarouselBackHeight
}
isEditMode={false}
/>
)}
</BackdropPortalProvider>
</div>
{/* End inner canvas container */}

View File

@ -248,7 +248,8 @@ const CarouselElement: React.FC<CarouselElementProps> = ({
if (!value || value.trim() === '') return undefined;
const trimmed = value.trim();
// If value already uses canvas units or calc, preserve it
if (trimmed.includes('var(--cu') || trimmed.includes('--cu')) return trimmed;
if (trimmed.includes('var(--cu') || trimmed.includes('--cu'))
return trimmed;
if (/^calc\(/i.test(trimmed)) return trimmed;
// If value already has other units, convert them
const vwMatch = trimmed.match(/^(-?\d*\.?\d+)vw$/i);

View File

@ -1,217 +0,0 @@
/**
* useBackgroundDimensionSuggestion Hook
*
* Detects background media dimensions and suggests canvas size updates.
* Used in constructor to prompt user when background resolution differs from project settings.
*/
import { useEffect, useCallback, useState, useRef } from 'react';
import { logger } from '../lib/logger';
interface MediaDimensions {
width: number;
height: number;
}
interface UseBackgroundDimensionSuggestionOptions {
/** URL of the background media (image or video) */
mediaUrl: string | undefined;
/** Current project design width */
currentDesignWidth: number;
/** Current project design height */
currentDesignHeight: number;
/** Callback when dimensions differ from current settings */
onSuggest: (width: number, height: number) => void;
/** Whether suggestion is enabled */
enabled?: boolean;
}
/**
* Detect dimensions of an image URL.
*/
async function getImageDimensions(
url: string,
): Promise<MediaDimensions | null> {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
resolve({
width: img.naturalWidth,
height: img.naturalHeight,
});
};
img.onerror = () => {
resolve(null);
};
img.src = url;
});
}
/**
* Detect dimensions of a video URL.
*/
async function getVideoDimensions(
url: string,
): Promise<MediaDimensions | null> {
return new Promise((resolve) => {
const video = document.createElement('video');
video.preload = 'metadata';
video.onloadedmetadata = () => {
resolve({
width: video.videoWidth,
height: video.videoHeight,
});
// Clean up
video.src = '';
video.load();
};
video.onerror = () => {
resolve(null);
};
video.src = url;
});
}
/**
* Determine if URL is likely a video based on extension.
*/
function isVideoUrl(url: string): boolean {
const videoExtensions = ['.mp4', '.webm', '.mov', '.avi', '.mkv', '.m4v'];
const lowercaseUrl = url.toLowerCase();
return videoExtensions.some((ext) => lowercaseUrl.includes(ext));
}
/**
* Check if URL is a resolved/loadable URL (not a raw storage path).
* Only process: http(s) URLs, blob URLs, data URLs
*/
function isLoadableUrl(url: string): boolean {
return (
url.startsWith('http://') ||
url.startsWith('https://') ||
url.startsWith('blob:') ||
url.startsWith('data:')
);
}
/**
* Get media dimensions from URL (image or video).
*/
async function getMediaDimensions(
url: string,
): Promise<MediaDimensions | null> {
if (isVideoUrl(url)) {
return getVideoDimensions(url);
}
return getImageDimensions(url);
}
/**
* Hook to detect background media dimensions and suggest canvas updates.
*
* @example
* const { suggestion, dismissSuggestion } = useBackgroundDimensionSuggestion({
* mediaUrl: backgroundImageUrl,
* currentDesignWidth: project.design_width ?? 1920,
* currentDesignHeight: project.design_height ?? 1080,
* onSuggest: (width, height) => {
* // Show suggestion UI
* },
* });
*/
export function useBackgroundDimensionSuggestion({
mediaUrl,
currentDesignWidth,
currentDesignHeight,
onSuggest,
enabled = true,
}: UseBackgroundDimensionSuggestionOptions) {
const [suggestion, setSuggestion] = useState<MediaDimensions | null>(null);
const lastCheckedUrlRef = useRef<string | null>(null);
const dismissedUrlsRef = useRef<Set<string>>(new Set());
const dismissSuggestion = useCallback(() => {
if (mediaUrl) {
dismissedUrlsRef.current.add(mediaUrl);
}
setSuggestion(null);
}, [mediaUrl]);
const acceptSuggestion = useCallback(() => {
if (suggestion) {
onSuggest(suggestion.width, suggestion.height);
setSuggestion(null);
}
}, [suggestion, onSuggest]);
useEffect(() => {
if (!enabled || !mediaUrl) {
setSuggestion(null);
return;
}
// Skip raw storage paths - only process resolved URLs (http, https, blob, data)
if (!isLoadableUrl(mediaUrl)) {
return;
}
// Skip if already checked this URL
if (lastCheckedUrlRef.current === mediaUrl) {
return;
}
// Skip if user dismissed this URL
if (dismissedUrlsRef.current.has(mediaUrl)) {
return;
}
lastCheckedUrlRef.current = mediaUrl;
const detectDimensions = async () => {
try {
const dimensions = await getMediaDimensions(mediaUrl);
if (!dimensions) {
logger.warn('[CanvasSuggestion] Failed to detect media dimensions', {
url: mediaUrl,
});
return;
}
// Check if dimensions differ significantly (allow for small rounding)
const widthDiff = Math.abs(dimensions.width - currentDesignWidth);
const heightDiff = Math.abs(dimensions.height - currentDesignHeight);
const threshold = 10; // pixels
if (widthDiff > threshold || heightDiff > threshold) {
logger.info('[CanvasSuggestion] Detected different dimensions', {
media: dimensions,
current: { width: currentDesignWidth, height: currentDesignHeight },
});
setSuggestion(dimensions);
}
} catch (error) {
logger.error(
'[CanvasSuggestion] Error detecting dimensions',
error instanceof Error ? error : { error },
);
}
};
void detectDimensions();
}, [mediaUrl, currentDesignWidth, currentDesignHeight, enabled]);
return {
/** Suggested dimensions from detected media */
suggestion,
/** Dismiss the current suggestion */
dismissSuggestion,
/** Accept the suggestion and call onSuggest callback */
acceptSuggestion,
/** Whether a suggestion is currently available */
hasSuggestion: suggestion !== null,
};
}

View File

@ -53,7 +53,9 @@ interface CanvasScaleResult {
*
* return <div style={cssVars}>{content}</div>;
*/
export function useCanvasScale(options?: UseCanvasScaleOptions): CanvasScaleResult {
export function useCanvasScale(
options?: UseCanvasScaleOptions,
): CanvasScaleResult {
const designWidth = options?.designWidth ?? CANVAS_CONFIG.defaults.width;
const designHeight = options?.designHeight ?? CANVAS_CONFIG.defaults.height;

View File

@ -29,7 +29,11 @@ const EMPTY_ASSETS: Asset[] = [];
interface UseConstructorDataResult {
// Project
project: { name: string; design_width?: number; design_height?: number } | null;
project: {
name: string;
design_width?: number;
design_height?: number;
} | null;
projectName: string;
// Pages

View File

@ -82,7 +82,13 @@ const formatBytes = (bytes: number): string => {
export function useOfflineMode(
options: UseOfflineModeOptions,
): UseOfflineModeResult {
const { projectId, projectSlug, projectName, pages, enabled = true } = options;
const {
projectId,
projectSlug,
projectName,
pages,
enabled = true,
} = options;
const [projectInfo, setProjectInfo] = useState<OfflineProject | null>(null);
const [discoveredAssets, setDiscoveredAssets] = useState<AssetToCache[]>([]);
@ -141,7 +147,9 @@ export function useOfflineMode(
// Find the asset to get its size
const asset = assetsRef.current.find(
(a) => a.storageKey === data.storageKey || `offline-${a.storageKey}` === data.assetId,
(a) =>
a.storageKey === data.storageKey ||
`offline-${a.storageKey}` === data.assetId,
);
const assetSize = asset?.sizeBytes || 0;
@ -152,7 +160,10 @@ export function useOfflineMode(
const downloaded = downloadedCountRef.current;
const dlBytes = downloadedBytesRef.current;
const total = assetsRef.current.length;
const totalSize = assetsRef.current.reduce((sum, a) => sum + (a.sizeBytes || 0), 0);
const totalSize = assetsRef.current.reduce(
(sum, a) => sum + (a.sizeBytes || 0),
0,
);
// Update state
setDownloadedAssets(downloaded);
@ -266,7 +277,9 @@ export function useOfflineMode(
// Discover assets from pages (frontend-only, no backend call)
const assets = discoverAssets();
if (assets.length === 0) {
throw new Error('No assets discovered. Make sure pages data is provided.');
throw new Error(
'No assets discovered. Make sure pages data is provided.',
);
}
// Store assets for event-driven progress tracking
@ -275,7 +288,10 @@ export function useOfflineMode(
setTotalAssets(assets.length);
// Estimate total size (may not have exact sizes for all assets)
const estimatedTotalSize = assets.reduce((sum, a) => sum + (a.sizeBytes || 0), 0);
const estimatedTotalSize = assets.reduce(
(sum, a) => sum + (a.sizeBytes || 0),
0,
);
setTotalBytes(estimatedTotalSize);
// Create or update project record
@ -307,7 +323,8 @@ export function useOfflineMode(
// Partial downloads from online preload need to be re-downloaded fully for offline
const assetsToDownload: AssetToCache[] = [];
for (const asset of assets) {
const assetInfo: CachedAssetInfo | null = await StorageManager.getAssetInfo(asset.storageKey);
const assetInfo: CachedAssetInfo | null =
await StorageManager.getAssetInfo(asset.storageKey);
if (assetInfo?.exists && !assetInfo.isPartial) {
// Fully cached, skip
@ -318,10 +335,13 @@ export function useOfflineMode(
});
} else if (assetInfo?.exists && assetInfo.isPartial) {
// Partial download - need full download for offline
logger.info('[useOfflineMode] Upgrading partial download for offline', {
storageKey: asset.storageKey.slice(-50),
partialSize: assetInfo.sizeBytes,
});
logger.info(
'[useOfflineMode] Upgrading partial download for offline',
{
storageKey: asset.storageKey.slice(-50),
partialSize: assetInfo.sizeBytes,
},
);
assetsToDownload.push(asset);
} else {
// Not cached at all
@ -367,9 +387,12 @@ export function useOfflineMode(
count: Object.keys(presignedUrls).length,
});
} catch (err) {
logger.warn('[useOfflineMode] Failed to fetch presigned URLs, using proxy', {
error: err instanceof Error ? err.message : 'unknown',
});
logger.warn(
'[useOfflineMode] Failed to fetch presigned URLs, using proxy',
{
error: err instanceof Error ? err.message : 'unknown',
},
);
}
}
@ -378,7 +401,9 @@ export function useOfflineMode(
// Progress is tracked via event subscriptions (see useEffect above)
for (const asset of assetsToDownload) {
// Resolve download URL - prefer presigned, fallback to proxy
const downloadUrl = presignedUrls[asset.storageKey] || resolveAssetPlaybackUrl(asset.storageKey);
const downloadUrl =
presignedUrls[asset.storageKey] ||
resolveAssetPlaybackUrl(asset.storageKey);
downloadManager
.addJob({
@ -491,7 +516,9 @@ export function useOfflineMode(
// Computed values
const isDownloaded = status === 'downloaded';
const isDownloading = status === 'downloading' && !isPaused;
const estimatedSize = discoveredAssets.reduce((sum, a) => sum + (a.sizeBytes || 0), 0) || totalBytes;
const estimatedSize =
discoveredAssets.reduce((sum, a) => sum + (a.sizeBytes || 0), 0) ||
totalBytes;
return {
isOfflineCapable,

View File

@ -33,7 +33,11 @@ import type {
PreloadPageLink,
PreloadElement,
} from '../../types/preload';
import type { AssetType, AssetVariantType, CachedAssetInfo } from '../../types/offline';
import type {
AssetType,
AssetVariantType,
CachedAssetInfo,
} from '../../types/offline';
// Re-export for convenience
export type { CachedAssetInfo };
@ -102,7 +106,9 @@ export class AssetCacheService {
* Get cache status for a single asset
* Returns whether it exists, if it's partial, and size info
*/
static async getAssetInfo(storageKey: string): Promise<CachedAssetInfo | null> {
static async getAssetInfo(
storageKey: string,
): Promise<CachedAssetInfo | null> {
return StorageManager.getAssetInfo(storageKey);
}
@ -225,7 +231,8 @@ export class AssetCacheService {
// Determine download parameters based on mode
const isOnlineMode = mode === 'online';
const isCurrentPageAsset = currentPageId && asset.pageId === currentPageId;
const isCurrentPageAsset =
currentPageId && asset.pageId === currentPageId;
// Online mode: use partial downloads for neighbor page media
// Offline mode: always full downloads

View File

@ -47,10 +47,7 @@ const DEFAULT_OPTIONS: AssetDiscoveryOptions = {
/**
* Classify asset type based on field name
*/
export function classifyAssetType(
fieldName: string,
url: string,
): AssetType {
export function classifyAssetType(fieldName: string, url: string): AssetType {
const lowerField = fieldName.toLowerCase();
const lowerUrl = url.toLowerCase();
@ -331,7 +328,11 @@ export function getPrioritizedAssets(
assets.push({
...asset,
priority: calculateAssetPriority(asset.assetType, isCurrentPage, distance),
priority: calculateAssetPriority(
asset.assetType,
isCurrentPage,
distance,
),
});
};
@ -348,8 +349,8 @@ export function getPrioritizedAssets(
const currentPageElements = elements.filter(
(el) => el.pageId === currentPageId,
);
extractElementAssets(currentPageElements, currentPageId).forEach((asset) =>
addAssetWithPriority(asset, true, 0),
extractElementAssets(currentPageElements, currentPageId).forEach(
(asset) => addAssetWithPriority(asset, true, 0),
);
}
@ -402,7 +403,12 @@ export function toPreloadAssetInfo(asset: AssetToCache): PreloadAssetInfo {
return {
url: asset.originalUrl,
pageId: asset.pageId,
assetType: assetType as 'image' | 'video' | 'audio' | 'transition' | 'other',
assetType: assetType as
| 'image'
| 'video'
| 'audio'
| 'transition'
| 'other',
priority: asset.priority,
};
}

View File

@ -4,7 +4,11 @@
* Unified asset caching for online preload and offline download.
*/
export { AssetCacheService, type DownloadProgress, type QueueDownloadOptions } from './AssetCacheService';
export {
AssetCacheService,
type DownloadProgress,
type QueueDownloadOptions,
} from './AssetCacheService';
export type { CachedAssetInfo } from '../../types/offline';
export {

View File

@ -130,7 +130,13 @@ export function remToDesignPx(value: number): number {
*/
export function normalizeToCanvasUnits(
value: string | number | undefined,
property: 'width' | 'height' | 'fontSize' | 'padding' | 'borderRadius' | 'gap',
property:
| 'width'
| 'height'
| 'fontSize'
| 'padding'
| 'borderRadius'
| 'gap',
designWidth: number = CANVAS_CONFIG.defaults.width,
designHeight: number = CANVAS_CONFIG.defaults.height,
): string {

View File

@ -417,7 +417,8 @@ export function buildGallerySpanGridStyle(
element: Partial<CanvasElement>,
): CSSProperties {
const columns = getGalleryGridColumns(element, 'span');
const gap = normalizeCanvasUnit(element.gallerySpanGap) || 'calc(8 * var(--cu, 1px))';
const gap =
normalizeCanvasUnit(element.gallerySpanGap) || 'calc(8 * var(--cu, 1px))';
return {
display: 'grid',
@ -518,7 +519,8 @@ export function buildGalleryCardGridStyle(
element: Partial<CanvasElement>,
): CSSProperties {
const columns = getGalleryGridColumns(element, 'card');
const gap = normalizeCanvasUnit(element.galleryCardGap) || 'calc(8 * var(--cu, 1px))';
const gap =
normalizeCanvasUnit(element.galleryCardGap) || 'calc(8 * var(--cu, 1px))';
return {
display: 'grid',

View File

@ -92,9 +92,12 @@ class DownloadManagerClass {
// For partial downloads, check session cache first (fast path)
if (isPartialDownload && this.partialDownloadsReady.has(storageKey)) {
logger.info('[DownloadManager] Partial download already ready (session)', {
storageKey: storageKey.slice(-50),
});
logger.info(
'[DownloadManager] Partial download already ready (session)',
{
storageKey: storageKey.slice(-50),
},
);
return;
}
@ -416,10 +419,13 @@ class DownloadManagerClass {
markPresignedUrlFailed(job.storageKey);
const proxyUrl = buildProxyUrl(job.storageKey);
logger.info('[DownloadManager] Presigned URL failed, retrying with proxy', {
storageKey: job.storageKey.slice(-50),
error: errorMessage,
});
logger.info(
'[DownloadManager] Presigned URL failed, retrying with proxy',
{
storageKey: job.storageKey.slice(-50),
error: errorMessage,
},
);
// Update job to use proxy URL and retry immediately
job.url = proxyUrl;
@ -718,7 +724,10 @@ class DownloadManagerClass {
* This enables the SW to cache the full response when the browser fetches the media
* during playback, using the canonical storage key instead of the expiring presigned URL.
*/
private registerUrlForCaching(presignedUrl: string, storageKey: string): void {
private registerUrlForCaching(
presignedUrl: string,
storageKey: string,
): void {
if (navigator.serviceWorker?.controller) {
navigator.serviceWorker.controller.postMessage({
type: 'REGISTER_CACHE_URL',

View File

@ -86,9 +86,7 @@ import {
type NavigationElementType,
} from '../context/ConstructorContext';
import { useCanvasScale } from '../hooks/useCanvasScale';
import { useBackgroundDimensionSuggestion } from '../hooks/useBackgroundDimensionSuggestion';
import { CANVAS_CONFIG } from '../config/canvas.config';
import { CanvasDimensionSuggestion } from '../components/CanvasDimensionSuggestion';
// Constructor helpers (extracted utilities)
import {
@ -195,35 +193,6 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
backgroundVideoEndTime,
} = usePageBackground();
// Background dimension auto-detection for canvas size suggestions
const handleDimensionSuggestionAccept = useCallback(
async (width: number, height: number) => {
if (!projectId) return;
try {
await axios.put(`/projects/${projectId}`, {
data: { design_width: width, design_height: height },
});
// Refetch to update the project data
await refetchData();
} catch (error) {
logger.error('Failed to update project dimensions', error instanceof Error ? error : { error });
}
},
[projectId, refetchData],
);
const {
suggestion: dimensionSuggestion,
dismissSuggestion: dismissDimensionSuggestion,
acceptSuggestion: acceptDimensionSuggestion,
} = useBackgroundDimensionSuggestion({
mediaUrl: backgroundImageUrl || backgroundVideoUrl,
currentDesignWidth: project?.design_width ?? 1920,
currentDesignHeight: project?.design_height ?? 1080,
onSuggest: handleDimensionSuggestionAccept,
enabled: !isElementEditMode && !!project,
});
const [selectedMenuItem, setSelectedMenuItem] =
useState<EditorMenuItem>('none');
// Transition preview state managed by useTransitionPreview hook (below)
@ -389,6 +358,16 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
// Destructure stable callback reference to avoid infinite loops in useEffect deps
const pageSwitchToPage = pageSwitch.switchToPage;
// Use shared background transition hook for direct navigation clearing and fade-in
// (No fade-out needed in constructor - transitions complete immediately)
// NOTE: Must be defined before switchToPage callback which uses resetFadeIn
const { isFadingIn, elementsOpacity, resetFadeIn } = useBackgroundTransition({
pageSwitch,
fadeIn: {
hasActiveTransition: Boolean(transitionPreview),
},
});
// Helper to switch pages without flash
// Uses usePageSwitch hook to resolve blob URLs from preload cache
// Also updates storage path state for editing/saving purposes
@ -424,7 +403,12 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
},
);
},
[pageSwitchToPage, updateBackgroundFromPage, applyPageSelection, resetFadeIn],
[
pageSwitchToPage,
updateBackgroundFromPage,
applyPageSelection,
resetFadeIn,
],
);
const { isBuffering: isReverseBuffering } = useTransitionPlayback({
@ -481,15 +465,6 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
},
});
// Use shared background transition hook for direct navigation clearing and fade-in
// (No fade-out needed in constructor - transitions complete immediately)
const { isFadingIn, elementsOpacity, resetFadeIn } = useBackgroundTransition({
pageSwitch,
fadeIn: {
hasActiveTransition: Boolean(transitionPreview),
},
});
const iconPreloadTargets = useMemo(() => {
const preloadableTypes: CanvasElementType[] = [
'navigation_next',
@ -1479,7 +1454,11 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
ref={canvasRef}
tabIndex={-1}
className={`z-20 overflow-clip ${hasFullWidthCarousel ? 'bg-transparent' : 'bg-black'}`}
style={{ ...canvasCssVars, ...letterboxStyles, ...canvasBackgroundStyle }}
style={{
...canvasCssVars,
...letterboxStyles,
...canvasBackgroundStyle,
}}
>
<BackdropPortalProvider>
<CanvasBackground
@ -1643,18 +1622,6 @@ const ConstructorPage = ({ mode = 'constructor' }: ConstructorPageProps) => {
/>
)}
{/* Canvas Dimension Suggestion */}
{dimensionSuggestion && (
<CanvasDimensionSuggestion
suggestedWidth={dimensionSuggestion.width}
suggestedHeight={dimensionSuggestion.height}
currentWidth={project?.design_width ?? 1920}
currentHeight={project?.design_height ?? 1080}
onAccept={acceptDimensionSuggestion}
onDismiss={dismissDimensionSuggestion}
/>
)}
<style jsx>{`
.menu-action-btn {
width: 100%;

View File

@ -89,7 +89,11 @@ const Dashboard = () => {
});
const { rolesWidgets, loading } = useAppSelector((state) => state.roles) as {
rolesWidgets: Array<{ id?: string; widget_id?: string; [key: string]: unknown }>;
rolesWidgets: Array<{
id?: string;
widget_id?: string;
[key: string]: unknown;
}>;
loading: boolean;
};

View File

@ -122,8 +122,10 @@ const EditProjectsPage = () => {
useEffect(() => {
if (typeof project === 'object' && project !== null) {
const projectData = project as unknown as Record<string, unknown>;
const width = Number(projectData.design_width) || CANVAS_CONFIG.defaults.width;
const height = Number(projectData.design_height) || CANVAS_CONFIG.defaults.height;
const width =
Number(projectData.design_width) || CANVAS_CONFIG.defaults.width;
const height =
Number(projectData.design_height) || CANVAS_CONFIG.defaults.height;
// Check if dimensions match a preset
const matchesPreset = CANVAS_CONFIG.presets.some(
@ -409,7 +411,8 @@ const EditProjectsPage = () => {
</div>
<p className='text-xs text-gray-500 mt-1'>
Set to match your background image/video resolution for best
quality. UI elements scale proportionally on different screens.
quality. UI elements scale proportionally on different
screens.
</p>
<BaseDivider />

View File

@ -418,7 +418,8 @@ self.addEventListener('message', (event) => {
if (payload?.storageKey) {
// Extract storage path from presigned URL (or use storageKey directly)
const storagePath = payload.presignedUrl
? extractStoragePathFromUrl(payload.presignedUrl) || payload.storageKey
? extractStoragePathFromUrl(payload.presignedUrl) ||
payload.storageKey
: payload.storageKey;
storagePathToKeyMap.set(storagePath, payload.storageKey);
console.log('[SW] Registered storage path for caching', {