2026-03-31 12:13:06 +04:00

623 lines
20 KiB
TypeScript

import { mdiContentSave, mdiCog, mdiSync } from '@mdi/js';
import axios from 'axios';
import Head from 'next/head';
import Link from 'next/link';
import { useRouter } from 'next/router';
import React, {
ReactElement,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import BaseButton from '../../components/BaseButton';
import CardBox from '../../components/CardBox';
import FormField from '../../components/FormField';
import LayoutAuthenticated from '../../layouts/Authenticated';
import SectionMain from '../../components/SectionMain';
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
import { getPageTitle } from '../../config';
import { logger } from '../../lib/logger';
import type {
CanvasElementType,
ConstructorAsset,
AssetOption,
} from '../../types/constructor';
// Import shared element settings components
import {
ElementSettingsTabs,
StyleSettingsSection,
EffectsSettingsSection,
CommonSettingsSection,
NavigationSettingsSection,
TooltipSettingsSection,
DescriptionSettingsSection,
MediaSettingsSection,
GallerySettingsSection,
CarouselSettingsSection,
useElementSettingsForm,
} from '../../components/ElementSettings';
type ProjectElementDefault = {
id: string;
element_type: string;
name?: string;
sort_order?: number;
settings_json?: string | Record<string, unknown>;
source_element_id?: string;
snapshot_version?: number;
projectId: string;
};
type DiffResult = {
projectDefault: ProjectElementDefault | null;
globalDefault: Record<string, unknown> | null;
isDifferent: boolean;
hasGlobalDefault: boolean;
};
const toHumanLabel = (value: string) =>
String(value || '')
.split('_')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
const SETTINGS_TABS = [
{ id: 'general', label: 'General Settings' },
{ id: 'css', label: 'CSS Styles' },
{ id: 'effects', label: 'Effects' },
];
const ProjectElementDefaultDetailsPage = () => {
const router = useRouter();
const id = useMemo(() => {
const routeValue = router.query.id;
if (Array.isArray(routeValue)) return routeValue[0] || '';
return String(routeValue || '');
}, [router.query.id]);
const projectId = useMemo(() => {
const routeValue = router.query.projectId;
if (Array.isArray(routeValue)) return routeValue[0] || '';
return String(routeValue || '');
}, [router.query.projectId]);
const [item, setItem] = useState<ProjectElementDefault | null>(null);
const [name, setName] = useState('');
const [sortOrder, setSortOrder] = useState(0);
const [activeTab, setActiveTab] = useState('general');
const [assets, setAssets] = useState<ConstructorAsset[]>([]);
const [diff, setDiff] = useState<DiffResult | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isResetting, setIsResetting] = useState(false);
const [errorMessage, setErrorMessage] = useState('');
const [successMessage, setSuccessMessage] = useState('');
const currentElementType = (item?.element_type || '') as CanvasElementType;
// Use shared form hook
const form = useElementSettingsForm({ elementType: currentElementType });
// Helper functions for asset options
const getAssetSourceValue = (asset: ConstructorAsset) =>
String(asset.storage_key || asset.cdn_url || '').trim();
const getAssetLabel = (asset: ConstructorAsset) => {
const baseName = asset.name?.trim() || 'Untitled asset';
const source = getAssetSourceValue(asset);
return `${baseName}${source ? ` · ${source}` : ''}`;
};
// Build icon asset options from project assets
const iconAssetOptions: AssetOption[] = useMemo(
() =>
assets
.filter(
(asset) =>
asset.type === 'icon' &&
asset.asset_type === 'image' &&
getAssetSourceValue(asset),
)
.map((asset) => ({
value: getAssetSourceValue(asset),
label: getAssetLabel(asset),
})),
[assets],
);
// Extract stable callback reference to avoid infinite loop
const applySettings = form.applySettings;
const loadItem = useCallback(async () => {
if (!id) return;
try {
setIsLoading(true);
setErrorMessage('');
setSuccessMessage('');
const response = await axios.get(`/project-element-defaults/${id}`);
const nextItem: ProjectElementDefault | null = response?.data || null;
if (!nextItem) {
setErrorMessage('Project element default not found.');
setItem(null);
return;
}
setItem(nextItem);
setName(String(nextItem.name || ''));
setSortOrder(Number(nextItem.sort_order || 0));
applySettings(nextItem.settings_json);
// Load project assets for icon selector
if (nextItem.projectId) {
try {
const assetsResponse = await axios.get(
`/assets?limit=500&page=0&sort=desc&field=createdAt&projectId=${nextItem.projectId}`,
);
const assetRows: ConstructorAsset[] = Array.isArray(
assetsResponse?.data?.rows,
)
? assetsResponse.data.rows
: [];
setAssets(assetRows);
} catch (assetError) {
logger.error(
'Failed to load project assets:',
assetError instanceof Error ? assetError : { error: assetError },
);
setAssets([]);
}
}
} catch (error: unknown) {
const err = error as {
response?: { data?: { message?: string } };
message?: string;
};
const message =
err?.response?.data?.message ||
err?.message ||
'Failed to load project element default.';
logger.error(
'Failed to load project element default details:',
error instanceof Error ? error : { error },
);
setErrorMessage(message);
setItem(null);
} finally {
setIsLoading(false);
}
}, [applySettings, id]);
const loadDiff = useCallback(async () => {
if (!id) return;
try {
const response = await axios.get(`/project-element-defaults/${id}/diff`);
setDiff(response?.data || null);
} catch (error: unknown) {
logger.error(
'Failed to load diff:',
error instanceof Error ? error : { error },
);
setDiff(null);
}
}, [id]);
useEffect(() => {
if (!router.isReady) return;
loadItem();
loadDiff();
}, [loadItem, loadDiff, router.isReady]);
// Extract stable callback reference for buildSettingsJson
const buildSettingsJson = form.buildSettingsJson;
const handleSave = useCallback(async () => {
if (!id || !item) return;
const settings = buildSettingsJson();
try {
setIsSaving(true);
setErrorMessage('');
setSuccessMessage('');
await axios.put(`/project-element-defaults/${id}`, {
id,
data: {
element_type: item.element_type,
name: name.trim() || item.element_type,
sort_order: sortOrder,
settings_json: settings,
},
});
setSuccessMessage('Project element default saved successfully.');
await loadItem();
await loadDiff();
} catch (error: unknown) {
const err = error as {
response?: { data?: { message?: string } };
message?: string;
};
const message =
err?.response?.data?.message ||
err?.message ||
'Failed to save project element default.';
logger.error(
'Failed to save project element default:',
error instanceof Error ? error : { error },
);
setErrorMessage(message);
} finally {
setIsSaving(false);
}
}, [buildSettingsJson, id, item, name, sortOrder, loadItem, loadDiff]);
const handleResetToGlobal = useCallback(async () => {
if (!id) return;
const confirmed = window.confirm(
'Are you sure you want to reset this element default to the global default? ' +
'This will overwrite your project-specific settings.',
);
if (!confirmed) return;
try {
setIsResetting(true);
setErrorMessage('');
setSuccessMessage('');
await axios.post(`/project-element-defaults/${id}/reset`);
setSuccessMessage('Successfully reset to global default.');
await loadItem();
await loadDiff();
} catch (error: unknown) {
const err = error as {
response?: { data?: { message?: string } };
message?: string;
};
const message =
err?.response?.data?.message ||
err?.message ||
'Failed to reset to global default.';
logger.error(
'Failed to reset to global default:',
error instanceof Error ? error : { error },
);
setErrorMessage(message);
} finally {
setIsResetting(false);
}
}, [id, loadItem, loadDiff]);
// Extract stable callback reference for setField
const setField = form.setField;
// Handler for style section changes
const handleStyleChange = useCallback(
(prop: string, value: string) => {
setField(prop as keyof typeof form.state, value);
},
[setField],
);
// Handler for effects section changes
const handleEffectChange = useCallback(
(prop: string, value: string) => {
setField(prop as keyof typeof form.state, value);
},
[setField],
);
// Handler for common section changes
const handleCommonChange = useCallback(
(field: string, value: string) => {
setField(field as keyof typeof form.state, value);
},
[setField],
);
// Handler for type-specific section changes
const handleTypeChange = useCallback(
(field: string, value: string | boolean) => {
setField(field as keyof typeof form.state, value as never);
},
[setField],
);
if (isLoading) {
return (
<>
<Head>
<title>{getPageTitle('Project Element Default')}</title>
</Head>
<SectionMain>
<CardBox>
<p className='text-sm text-gray-500'>Loading...</p>
</CardBox>
</SectionMain>
</>
);
}
if (!item) {
return (
<>
<Head>
<title>{getPageTitle('Project Element Default')}</title>
</Head>
<SectionMain>
<CardBox>
<p className='text-sm text-red-600'>
{errorMessage || 'Project element default not found.'}
</p>
<Link
href={`/project-element-defaults?projectId=${projectId}`}
className='mt-4 inline-block text-sm text-blue-600 hover:underline'
>
Back to list
</Link>
</CardBox>
</SectionMain>
</>
);
}
return (
<>
<Head>
<title>{getPageTitle(`Edit: ${item.element_type}`)}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton
icon={mdiCog}
title={`Edit: ${toHumanLabel(item.element_type)}`}
main
>
<div className='flex gap-2'>
<BaseButton
color='warning'
label={isResetting ? 'Resetting...' : 'Reset to Global'}
icon={mdiSync}
onClick={handleResetToGlobal}
disabled={isResetting || isSaving || !diff?.hasGlobalDefault}
/>
<BaseButton
color='info'
label={isSaving ? 'Saving...' : 'Save'}
icon={mdiContentSave}
onClick={handleSave}
disabled={isSaving || isResetting}
/>
</div>
</SectionTitleLineWithButton>
{errorMessage && (
<div className='mb-4 rounded bg-red-50 p-3 text-sm text-red-600'>
{errorMessage}
</div>
)}
{successMessage && (
<div className='mb-4 rounded bg-green-50 p-3 text-sm text-green-700'>
{successMessage}
</div>
)}
{diff?.isDifferent && (
<div className='mb-4 rounded bg-yellow-50 p-3 text-sm text-yellow-700'>
This project element default has been customized and differs from
the global default.
{diff.hasGlobalDefault &&
' Click "Reset to Global" to restore the global settings.'}
</div>
)}
<CardBox>
<div className='space-y-4'>
<FormField label='Element Type'>
<input
type='text'
className='rounded border border-gray-300 bg-gray-100 px-3 py-2 text-sm'
value={toHumanLabel(item.element_type)}
disabled
/>
</FormField>
<FormField label='Display Name'>
<input
type='text'
className='rounded border border-gray-300 px-3 py-2 text-sm'
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={toHumanLabel(item.element_type)}
/>
</FormField>
<FormField label='Sort Order'>
<input
type='number'
className='rounded border border-gray-300 px-3 py-2 text-sm'
value={sortOrder}
onChange={(e) => setSortOrder(Number(e.target.value) || 0)}
/>
</FormField>
<FormField label='Snapshot Version'>
<input
type='text'
className='rounded border border-gray-300 bg-gray-100 px-3 py-2 text-sm'
value={item.snapshot_version || 1}
disabled
/>
</FormField>
{/* Tabs */}
<ElementSettingsTabs
activeTab={activeTab}
onTabChange={setActiveTab}
tabs={SETTINGS_TABS}
/>
{/* General Settings Tab */}
{activeTab === 'general' && (
<>
<CommonSettingsSection
label={form.state.label}
xPercent={form.state.xPercent}
yPercent={form.state.yPercent}
appearDelaySec={form.state.appearDelaySec}
appearDurationSec={form.state.appearDurationSec}
onChange={handleCommonChange}
/>
{/* Type-specific sections */}
{form.isNavigationType && (
<NavigationSettingsSection
iconUrl={form.state.iconUrl}
navLabel={form.state.navLabel}
navLabelFontFamily={form.state.navLabelFontFamily}
navType={form.state.navType}
navDisabled={form.state.navDisabled}
targetPageId={form.state.targetPageId}
targetPageSlug={form.state.targetPageSlug}
transitionVideoUrl={form.state.transitionVideoUrl}
transitionReverseMode={form.state.transitionReverseMode}
reverseVideoUrl={form.state.reverseVideoUrl}
onChange={handleTypeChange}
context='project'
iconAssetOptions={iconAssetOptions}
/>
)}
{form.isTooltipType && (
<TooltipSettingsSection
iconUrl={form.state.iconUrl}
tooltipTitle={form.state.tooltipTitle}
tooltipText={form.state.tooltipText}
tooltipTitleFontFamily={form.state.tooltipTitleFontFamily}
tooltipTextFontFamily={form.state.tooltipTextFontFamily}
onChange={handleTypeChange}
context='project'
/>
)}
{form.isDescriptionType && (
<DescriptionSettingsSection
iconUrl={form.state.iconUrl}
descriptionTitle={form.state.descriptionTitle}
descriptionText={form.state.descriptionText}
descriptionTitleFontSize={
form.state.descriptionTitleFontSize
}
descriptionTextFontSize={form.state.descriptionTextFontSize}
descriptionTitleFontFamily={
form.state.descriptionTitleFontFamily
}
descriptionTextFontFamily={
form.state.descriptionTextFontFamily
}
descriptionTitleColor={form.state.descriptionTitleColor}
descriptionTextColor={form.state.descriptionTextColor}
onChange={handleTypeChange}
context='project'
/>
)}
{form.isGalleryType && (
<GallerySettingsSection
galleryCards={form.state.galleryCards}
galleryTitleFontFamily={form.state.galleryTitleFontFamily}
galleryTextFontFamily={form.state.galleryTextFontFamily}
onAddCard={form.addGalleryCard}
onRemoveCard={form.removeGalleryCard}
onUpdateCard={form.updateGalleryCard}
onChange={handleTypeChange}
context='project'
/>
)}
{form.isCarouselType && (
<CarouselSettingsSection
carouselPrevIconUrl={form.state.carouselPrevIconUrl}
carouselNextIconUrl={form.state.carouselNextIconUrl}
carouselCaptionFontFamily={form.state.carouselCaptionFontFamily}
carouselSlides={form.state.carouselSlides}
onAddSlide={form.addCarouselSlide}
onRemoveSlide={form.removeCarouselSlide}
onUpdateSlide={form.updateCarouselSlide}
onChange={handleTypeChange}
context='project'
/>
)}
{form.isMediaType && (
<MediaSettingsSection
elementType={
currentElementType as 'video_player' | 'audio_player'
}
mediaUrl={form.state.mediaUrl}
mediaAutoplay={form.state.mediaAutoplay}
mediaLoop={form.state.mediaLoop}
mediaMuted={form.state.mediaMuted}
onChange={handleTypeChange}
context='project'
/>
)}
</>
)}
{/* CSS Styles Tab */}
{activeTab === 'css' && (
<CardBox className='border border-gray-200 dark:border-dark-700'>
<StyleSettingsSection
values={form.getStyleValues()}
onChange={handleStyleChange}
/>
</CardBox>
)}
{/* Effects Tab */}
{activeTab === 'effects' && (
<CardBox className='border border-gray-200 dark:border-dark-700'>
<EffectsSettingsSection
values={form.getEffectValues()}
onChange={handleEffectChange}
/>
</CardBox>
)}
</div>
</CardBox>
<div className='mt-4'>
<Link
href={`/project-element-defaults?projectId=${projectId}`}
className='text-sm text-blue-600 hover:underline'
>
Back to project element defaults list
</Link>
</div>
</SectionMain>
</>
);
};
ProjectElementDefaultDetailsPage.getLayout = function getLayout(
page: ReactElement,
) {
return (
<LayoutAuthenticated permission='UPDATE_PAGE_ELEMENTS'>
{page}
</LayoutAuthenticated>
);
};
export default ProjectElementDefaultDetailsPage;