39948-vm/frontend/src/lib/constructorHelpers.ts
2026-03-29 16:03:25 +04:00

286 lines
7.6 KiB
TypeScript

/**
* Constructor Helpers
*
* Utility functions for the constructor page.
* Extracted from constructor.tsx for reusability.
*/
import type {
CanvasElement,
ConstructorAsset as ProjectAsset,
AssetOption,
} from '../types/constructor';
import {
isGalleryElementType,
isCarouselElementType,
isTooltipElementType,
isDescriptionElementType,
isNavigationElementType,
isMediaElementType,
getNavigationButtonLabel,
} from './elementDefaults';
/**
* Clamp a number between min and max
*/
export const clamp = (value: number, min: number, max: number): number =>
Math.min(Math.max(value, min), max);
/**
* Get trimmed CSS value as string.
* Handles null/undefined gracefully.
*/
export const getTrimmedCssValue = (value: unknown): string => {
if (value === null || value === undefined) return '';
return String(value).trim();
};
/**
* Format asset label for display in select dropdowns.
* Shows asset name with source path suffix.
*/
export const getAssetLabel = (asset: ProjectAsset): string => {
const baseName = asset.name?.trim() || 'Untitled asset';
const source = String(asset.storage_key || asset.cdn_url || '').trim();
return `${baseName}${source ? ` · ${source}` : ''}`;
};
/**
* Get asset source value (storage_key or cdn_url).
* Used as the value in asset select dropdowns.
*/
export const getAssetSourceValue = (asset: ProjectAsset): string =>
String(asset.storage_key || asset.cdn_url || '').trim();
/**
* Check if an asset is likely a background image based on name/type.
* Used to filter assets for background image selection.
*/
export const isBackgroundImageAsset = (asset: ProjectAsset): boolean => {
if (asset.type) return asset.type === 'background_image';
const normalizedName = String(asset.name || '').toLowerCase();
if (!normalizedName) return false;
const hasBackgroundKeyword = /\bbackground\b|\bbg\b|backdrop|wallpaper/.test(
normalizedName,
);
const hasExcludedKeyword = /\bicon\b|\blogo\b/.test(normalizedName);
return hasBackgroundKeyword && !hasExcludedKeyword;
};
/**
* Add current value as a fallback option if not already present.
* Ensures the currently selected value is always available in the dropdown.
*/
export const addFallbackAssetOption = (
options: AssetOption[],
value?: string,
fallbackLabel?: string,
): AssetOption[] => {
const normalizedValue = String(value || '').trim();
if (!normalizedValue) return options;
if (options.some((option) => option.value === normalizedValue))
return options;
return [
...options,
{
value: normalizedValue,
label: fallbackLabel || `Custom URL · ${normalizedValue}`,
},
];
};
/**
* Get element button title for display in constructor menu.
* Shows element-specific info like card/slide count.
*/
export const getElementButtonTitle = (element: CanvasElement): string => {
if (isGalleryElementType(element.type)) {
return `${element.label} (${element.galleryCards?.length || 0})`;
}
if (isCarouselElementType(element.type)) {
return `${element.label} (${element.carouselSlides?.length || 0})`;
}
if (isTooltipElementType(element.type)) return element.tooltipTitle ?? '';
if (isDescriptionElementType(element.type))
return element.descriptionTitle ?? '';
if (isNavigationElementType(element.type)) {
type NavigationElementType = 'navigation_next' | 'navigation_prev';
return (
element.navLabel?.trim() ||
getNavigationButtonLabel(element.type as NavigationElementType)
);
}
if (isMediaElementType(element.type) && element.mediaUrl) {
return `${element.label} · configured`;
}
return element.label;
};
/**
* Build asset options for a specific asset type.
* Filters assets by type and creates label/value pairs.
*/
export const buildAssetOptions = (
assets: ProjectAsset[],
assetType: 'image' | 'video' | 'audio',
additionalFilter?: (asset: ProjectAsset) => boolean,
): AssetOption[] => {
return assets
.filter((asset) => {
if (asset.asset_type !== assetType) return false;
if (!getAssetSourceValue(asset)) return false;
if (additionalFilter && !additionalFilter(asset)) return false;
return true;
})
.map((asset) => ({
value: getAssetSourceValue(asset),
label: getAssetLabel(asset),
}));
};
/**
* Build background image asset options.
* Filters for image assets that appear to be backgrounds.
*/
export const buildBackgroundImageOptions = (
assets: ProjectAsset[],
): AssetOption[] => {
return buildAssetOptions(assets, 'image', isBackgroundImageAsset);
};
/**
* Build video asset options.
*/
export const buildVideoAssetOptions = (
assets: ProjectAsset[],
): AssetOption[] => {
return buildAssetOptions(assets, 'video');
};
/**
* Build audio asset options.
*/
export const buildAudioAssetOptions = (
assets: ProjectAsset[],
): AssetOption[] => {
return buildAssetOptions(assets, 'audio');
};
/**
* Build transition video asset options.
* Prefers assets marked as type='transition', falls back to tagged or all videos.
*/
export const buildTransitionVideoOptions = (
assets: ProjectAsset[],
): AssetOption[] => {
// First try assets marked as transition type
const typedAssets = assets
.filter(
(asset) =>
asset.type === 'transition' &&
asset.asset_type === 'video' &&
getAssetSourceValue(asset),
)
.map((asset) => ({
value: getAssetSourceValue(asset),
label: getAssetLabel(asset),
}));
if (typedAssets.length > 0) return typedAssets;
// Fall back to assets with [TRANSITION] tag in name
const taggedAssets = assets
.filter(
(asset) =>
asset.asset_type === 'video' &&
getAssetSourceValue(asset) &&
/\[TRANSITION\]/i.test(String(asset.name || '')),
)
.map((asset) => ({
value: getAssetSourceValue(asset),
label: getAssetLabel(asset),
}));
if (taggedAssets.length > 0) return taggedAssets;
// Fall back to all video assets
return buildVideoAssetOptions(assets);
};
/**
* Build icon asset options.
* Filters for image assets marked as type='icon'.
*/
export const buildIconAssetOptions = (
assets: ProjectAsset[],
): AssetOption[] => {
return assets
.filter(
(asset) =>
asset.type === 'icon' &&
asset.asset_type === 'image' &&
getAssetSourceValue(asset),
)
.map((asset) => ({
value: getAssetSourceValue(asset),
label: getAssetLabel(asset),
}));
};
/**
* Build all image asset options (no filtering).
*/
export const buildImageAssetOptions = (
assets: ProjectAsset[],
): AssetOption[] => {
return buildAssetOptions(assets, 'image');
};
/**
* Extract numeric value from CSS value string.
* Used for compact style inputs that expect raw numbers.
*
* @example
* extractNumericValue('24vw'); // '24'
* extractNumericValue('100px'); // '100'
* extractNumericValue(''); // ''
*/
export const extractNumericValue = (value?: string): string => {
if (!value) return '';
const match = String(value).match(/^(-?\d+\.?\d*)/);
return match ? match[1] : '';
};
/**
* Build page name lookup map by ID.
*/
export const buildPageNameById = (
pages: Array<{ id: string; name?: string }>,
): Record<string, string> => {
const acc: Record<string, string> = {};
pages.forEach((page, index) => {
acc[String(page.id)] = page.name || `Page ${index + 1}`;
});
return acc;
};
/**
* Build page name lookup map by slug.
*/
export const buildPageNameBySlug = (
pages: Array<{ slug?: string; name?: string }>,
): Record<string, string> => {
const acc: Record<string, string> = {};
pages.forEach((page, index) => {
if (page.slug) {
acc[String(page.slug)] = page.name || `Page ${index + 1}`;
}
});
return acc;
};