39948-vm/frontend/src/pages/projects/projects-edit.tsx
2026-04-11 14:54:10 +04:00

455 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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;