455 lines
15 KiB
TypeScript
455 lines
15 KiB
TypeScript
/**
|
||
* Edit Projects Page
|
||
* Cleaned up version with consolidated useEffect hooks
|
||
* Note: Preserves special logo loading logic for logo_url, favicon_url, og_image_url fields
|
||
*/
|
||
|
||
import { mdiChartTimelineVariant } from '@mdi/js';
|
||
import axios from 'axios';
|
||
import Head from 'next/head';
|
||
import React, { ReactElement, useEffect, useState } from 'react';
|
||
|
||
import CardBox from '../../components/CardBox';
|
||
import LayoutAuthenticated from '../../layouts/Authenticated';
|
||
import SectionMain from '../../components/SectionMain';
|
||
import SectionTitleLineWithButton from '../../components/SectionTitleLineWithButton';
|
||
import { getPageTitle } from '../../config';
|
||
|
||
import { Field, Form, Formik } from 'formik';
|
||
import FormField from '../../components/FormField';
|
||
import BaseDivider from '../../components/BaseDivider';
|
||
import BaseButtons from '../../components/BaseButtons';
|
||
import BaseButton from '../../components/BaseButton';
|
||
|
||
import { deleteItem, update, fetch } from '../../stores/projects/projectsSlice';
|
||
import { useAppDispatch, useAppSelector } from '../../stores/hooks';
|
||
import { useRouter } from 'next/router';
|
||
import { toast, ToastContainer } from 'react-toastify';
|
||
import type { Project } from '../../types/entities';
|
||
import { logger } from '../../lib/logger';
|
||
import { CANVAS_CONFIG } from '../../config/canvas.config';
|
||
|
||
const initVals = {
|
||
name: '',
|
||
slug: '',
|
||
description: '',
|
||
logo_url: '',
|
||
favicon_url: '',
|
||
og_image_url: '',
|
||
design_width: CANVAS_CONFIG.defaults.width as number,
|
||
design_height: CANVAS_CONFIG.defaults.height as number,
|
||
is_deleted: false,
|
||
deleted_at_time: new Date(),
|
||
};
|
||
|
||
const EditProjectsPage = () => {
|
||
const router = useRouter();
|
||
const dispatch = useAppDispatch();
|
||
const [initialValues, setInitialValues] = useState(initVals);
|
||
const [logoAssets, setLogoAssets] = useState<
|
||
{ id: string; cdn_url: string; storage_key?: string; name: string }[]
|
||
>([]);
|
||
const [isLoadingLogoAssets, setIsLoadingLogoAssets] = useState(false);
|
||
const [isCustomPreset, setIsCustomPreset] = useState(false);
|
||
|
||
const projectsState = useAppSelector((state) => state.projects);
|
||
const projects = projectsState.data;
|
||
const project = projects[0];
|
||
const { id } = router.query as { id?: string };
|
||
|
||
// Fetch entity data
|
||
useEffect(() => {
|
||
if (id) {
|
||
dispatch(fetch({ id: id as string }));
|
||
}
|
||
}, [id, dispatch]);
|
||
|
||
// Load logo assets for the project
|
||
const loadLogoAssets = async (projectId: string) => {
|
||
if (!projectId) {
|
||
setLogoAssets([]);
|
||
return;
|
||
}
|
||
|
||
setIsLoadingLogoAssets(true);
|
||
try {
|
||
const response = await axios.get(
|
||
`/assets?limit=500&page=0&sort=desc&field=createdAt&projectId=${projectId}`,
|
||
);
|
||
const rows = Array.isArray(response?.data?.rows)
|
||
? response.data.rows
|
||
: [];
|
||
const taggedLogoAssets = rows.filter(
|
||
(asset: {
|
||
cdn_url?: string;
|
||
asset_type?: string;
|
||
type?: string;
|
||
name?: string;
|
||
}) =>
|
||
typeof asset?.cdn_url === 'string' &&
|
||
asset.cdn_url &&
|
||
asset?.asset_type === 'image' &&
|
||
(asset?.type === 'logo' ||
|
||
(typeof asset?.name === 'string' &&
|
||
asset.name.startsWith('[LOGO] '))),
|
||
);
|
||
setLogoAssets(taggedLogoAssets);
|
||
} catch (error: unknown) {
|
||
const errorMessage =
|
||
error instanceof Error ? error.message : String(error);
|
||
logger.error(
|
||
'Failed to load logo assets for project edit:',
|
||
error instanceof Error ? error : { error: errorMessage },
|
||
);
|
||
setLogoAssets([]);
|
||
} finally {
|
||
setIsLoadingLogoAssets(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
setLogoAssets([]);
|
||
setIsLoadingLogoAssets(true);
|
||
|
||
if (typeof id === 'string' && id) {
|
||
void loadLogoAssets(id);
|
||
} else {
|
||
setIsLoadingLogoAssets(false);
|
||
}
|
||
}, [id]);
|
||
|
||
// Sync form values with fetched data (consolidated from redundant useEffects)
|
||
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;
|
||
|
||
// Check if dimensions match a preset
|
||
const matchesPreset = CANVAS_CONFIG.presets.some(
|
||
(p) => p.width === width && p.height === height,
|
||
);
|
||
setIsCustomPreset(!matchesPreset);
|
||
|
||
setInitialValues({
|
||
name: String(projectData.name || ''),
|
||
slug: String(projectData.slug || ''),
|
||
description: String(projectData.description || ''),
|
||
logo_url: String(projectData.logo_url || ''),
|
||
favicon_url: String(projectData.favicon_url || ''),
|
||
og_image_url: String(projectData.og_image_url || ''),
|
||
design_width: width,
|
||
design_height: height,
|
||
is_deleted: Boolean(projectData.is_deleted),
|
||
deleted_at_time: projectData.deleted_at_time
|
||
? new Date(projectData.deleted_at_time as string)
|
||
: new Date(),
|
||
});
|
||
}
|
||
}, [project]);
|
||
|
||
const handleSubmit = async (data: typeof initVals) => {
|
||
const apiData: Partial<Project> = {
|
||
name: data.name,
|
||
slug: data.slug,
|
||
description: data.description,
|
||
logo_url: data.logo_url,
|
||
favicon_url: data.favicon_url,
|
||
og_image_url: data.og_image_url,
|
||
design_width: data.design_width,
|
||
design_height: data.design_height,
|
||
};
|
||
|
||
try {
|
||
await dispatch(update({ id: id as string, data: apiData })).unwrap();
|
||
toast('Project settings saved', {
|
||
type: 'success',
|
||
position: 'bottom-center',
|
||
});
|
||
} catch (error: unknown) {
|
||
let errorMessage = 'Failed to save project settings';
|
||
if (typeof error === 'string') {
|
||
errorMessage = error;
|
||
} else if (error && typeof error === 'object' && 'message' in error) {
|
||
errorMessage = String((error as { message: string }).message);
|
||
}
|
||
toast(errorMessage, {
|
||
type: 'error',
|
||
position: 'bottom-center',
|
||
});
|
||
}
|
||
};
|
||
|
||
const handleDelete = async () => {
|
||
const projectId = typeof id === 'string' ? id : null;
|
||
|
||
if (!projectId) {
|
||
return;
|
||
}
|
||
|
||
if (!window.confirm('Are you sure you want to delete this project?')) {
|
||
return;
|
||
}
|
||
|
||
await dispatch(deleteItem(projectId));
|
||
await router.push('/projects/projects-list');
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<Head>
|
||
<title>{getPageTitle('Edit projects')}</title>
|
||
</Head>
|
||
<SectionMain>
|
||
<SectionTitleLineWithButton
|
||
icon={mdiChartTimelineVariant}
|
||
title='Edit projects'
|
||
main
|
||
>
|
||
{''}
|
||
</SectionTitleLineWithButton>
|
||
<CardBox>
|
||
<Formik
|
||
enableReinitialize
|
||
initialValues={initialValues}
|
||
onSubmit={(values) => handleSubmit(values)}
|
||
>
|
||
{({ values, setFieldValue }) => (
|
||
<Form>
|
||
<FormField label='Name'>
|
||
<Field name='name' placeholder='Name' />
|
||
</FormField>
|
||
|
||
<FormField label='Slug'>
|
||
<Field name='slug' placeholder='Slug' />
|
||
</FormField>
|
||
|
||
<FormField label='Description' hasTextareaHeight>
|
||
<Field
|
||
name='description'
|
||
id='description'
|
||
as='textarea'
|
||
rows={4}
|
||
placeholder='Project description'
|
||
/>
|
||
</FormField>
|
||
|
||
<FormField label='Logo'>
|
||
<Field name='logo_url' as='select'>
|
||
<option value=''>
|
||
{isLoadingLogoAssets
|
||
? 'Loading logo options...'
|
||
: 'Select logo from Assets'}
|
||
</option>
|
||
{logoAssets.map((asset) => (
|
||
<option
|
||
key={asset.id}
|
||
value={asset.storage_key || asset.cdn_url}
|
||
>
|
||
{(asset.name || '').replace(/^\[[^\]]+\]\s*/, '')}
|
||
</option>
|
||
))}
|
||
{values.logo_url &&
|
||
!logoAssets.some(
|
||
(asset) =>
|
||
(asset.storage_key || asset.cdn_url) ===
|
||
values.logo_url,
|
||
) && (
|
||
<option value={values.logo_url}>
|
||
{values.logo_url}
|
||
</option>
|
||
)}
|
||
</Field>
|
||
</FormField>
|
||
{values.logo_url && (
|
||
<a
|
||
className='text-xs underline mt-2 inline-block break-all'
|
||
href={values.logo_url}
|
||
target='_blank'
|
||
rel='noreferrer'
|
||
>
|
||
Preview selected logo
|
||
</a>
|
||
)}
|
||
|
||
<FormField label='Favicon'>
|
||
<Field name='favicon_url' as='select'>
|
||
<option value=''>
|
||
{isLoadingLogoAssets
|
||
? 'Loading logo options...'
|
||
: 'Select favicon from Assets logos'}
|
||
</option>
|
||
{logoAssets.map((asset) => (
|
||
<option
|
||
key={asset.id}
|
||
value={asset.storage_key || asset.cdn_url}
|
||
>
|
||
{(asset.name || '').replace(/^\[[^\]]+\]\s*/, '')}
|
||
</option>
|
||
))}
|
||
{values.favicon_url &&
|
||
!logoAssets.some(
|
||
(asset) =>
|
||
(asset.storage_key || asset.cdn_url) ===
|
||
values.favicon_url,
|
||
) && (
|
||
<option value={values.favicon_url}>
|
||
{values.favicon_url}
|
||
</option>
|
||
)}
|
||
</Field>
|
||
</FormField>
|
||
{values.favicon_url && (
|
||
<a
|
||
className='text-xs underline mt-2 inline-block break-all'
|
||
href={values.favicon_url}
|
||
target='_blank'
|
||
rel='noreferrer'
|
||
>
|
||
Preview selected favicon
|
||
</a>
|
||
)}
|
||
|
||
<FormField label='OG Image'>
|
||
<Field name='og_image_url' as='select'>
|
||
<option value=''>
|
||
{isLoadingLogoAssets
|
||
? 'Loading logo options...'
|
||
: 'Select OG image from Assets logos'}
|
||
</option>
|
||
{logoAssets.map((asset) => (
|
||
<option
|
||
key={asset.id}
|
||
value={asset.storage_key || asset.cdn_url}
|
||
>
|
||
{(asset.name || '').replace(/^\[[^\]]+\]\s*/, '')}
|
||
</option>
|
||
))}
|
||
{values.og_image_url &&
|
||
!logoAssets.some(
|
||
(asset) =>
|
||
(asset.storage_key || asset.cdn_url) ===
|
||
values.og_image_url,
|
||
) && (
|
||
<option value={values.og_image_url}>
|
||
{values.og_image_url}
|
||
</option>
|
||
)}
|
||
</Field>
|
||
</FormField>
|
||
{values.og_image_url && (
|
||
<a
|
||
className='text-xs underline mt-2 inline-block break-all'
|
||
href={values.og_image_url}
|
||
target='_blank'
|
||
rel='noreferrer'
|
||
>
|
||
Preview selected OG image
|
||
</a>
|
||
)}
|
||
|
||
<BaseDivider />
|
||
|
||
<FormField label='Design Canvas Preset'>
|
||
<Field
|
||
name='design_preset'
|
||
as='select'
|
||
value={
|
||
isCustomPreset
|
||
? 'custom'
|
||
: CANVAS_CONFIG.presets.find(
|
||
(p) =>
|
||
p.width === values.design_width &&
|
||
p.height === values.design_height,
|
||
)?.name || 'custom'
|
||
}
|
||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => {
|
||
if (e.target.value === 'custom') {
|
||
setIsCustomPreset(true);
|
||
} else {
|
||
setIsCustomPreset(false);
|
||
const preset = CANVAS_CONFIG.presets.find(
|
||
(p) => p.name === e.target.value,
|
||
);
|
||
if (preset) {
|
||
setFieldValue('design_width', preset.width);
|
||
setFieldValue('design_height', preset.height);
|
||
}
|
||
}
|
||
}}
|
||
>
|
||
{CANVAS_CONFIG.presets.map((p) => (
|
||
<option key={p.name} value={p.name}>
|
||
{p.name} ({p.width}×{p.height})
|
||
</option>
|
||
))}
|
||
<option value='custom'>Custom</option>
|
||
</Field>
|
||
</FormField>
|
||
|
||
<div className='flex gap-4'>
|
||
<FormField label='Design Width (px)'>
|
||
<Field
|
||
name='design_width'
|
||
type='number'
|
||
placeholder='1920'
|
||
min='320'
|
||
max='7680'
|
||
/>
|
||
</FormField>
|
||
<FormField label='Design Height (px)'>
|
||
<Field
|
||
name='design_height'
|
||
type='number'
|
||
placeholder='1080'
|
||
min='240'
|
||
max='4320'
|
||
/>
|
||
</FormField>
|
||
</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.
|
||
</p>
|
||
|
||
<BaseDivider />
|
||
<BaseButtons>
|
||
<BaseButton type='submit' color='info' label='Submit' />
|
||
<BaseButton type='reset' color='info' outline label='Reset' />
|
||
<BaseButton
|
||
type='button'
|
||
color='danger'
|
||
label='Delete'
|
||
onClick={handleDelete}
|
||
/>
|
||
<BaseButton
|
||
type='reset'
|
||
color='danger'
|
||
outline
|
||
label='Cancel'
|
||
onClick={() => router.push('/projects/projects-list')}
|
||
/>
|
||
</BaseButtons>
|
||
</Form>
|
||
)}
|
||
</Formik>
|
||
</CardBox>
|
||
<ToastContainer />
|
||
</SectionMain>
|
||
</>
|
||
);
|
||
};
|
||
|
||
EditProjectsPage.getLayout = function getLayout(page: ReactElement) {
|
||
return (
|
||
<LayoutAuthenticated permission='UPDATE_PROJECTS'>
|
||
{page}
|
||
</LayoutAuthenticated>
|
||
);
|
||
};
|
||
|
||
export default EditProjectsPage;
|