623 lines
20 KiB
TypeScript
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;
|