39948-vm/frontend/src/pages/projects/projects-list.tsx
2026-03-28 08:51:47 +04:00

228 lines
7.0 KiB
TypeScript

import { mdiChartTimelineVariant } from '@mdi/js';
import axios from 'axios';
import Head from 'next/head';
import { useRouter } from 'next/router';
import React, { ReactElement, useEffect, useState } from 'react';
import { toast, ToastContainer } from 'react-toastify';
import BaseButton from '../../components/BaseButton';
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 { useAppDispatch, useAppSelector } from '../../stores/hooks';
import {
fetch as fetchProjects,
create as createProject,
} from '../../stores/projects/projectsSlice';
import type { Project } from '../../types/entities';
import { logger } from '../../lib/logger';
const ProjectsListPage = () => {
const router = useRouter();
const dispatch = useAppDispatch();
const projectsRaw = useAppSelector((state) => state.projects.projects) as
| Project[]
| Project
| undefined;
// Handle both array (from list fetch) and single object (after edit fetch)
const projects: Project[] = Array.isArray(projectsRaw)
? projectsRaw
: projectsRaw
? [projectsRaw]
: [];
const isLoading = useAppSelector((state) => state.projects.loading);
const [isCreating, setIsCreating] = useState(false);
const [isCloning, setIsCloning] = useState(false);
const [isCloneOpen, setIsCloneOpen] = useState(false);
const [cloneSourceId, setCloneSourceId] = useState('');
useEffect(() => {
dispatch(
fetchProjects({ query: '?limit=100&page=0&sort=desc&field=updatedAt' }),
);
}, [dispatch]);
useEffect(() => {
if (projects.length > 0 && !cloneSourceId) {
setCloneSourceId(projects[0].id);
}
}, [projects, cloneSourceId]);
const buildNewProjectDraft = (): Partial<Project> => {
const stamp = Date.now();
return {
name: `New Project ${new Date(stamp).toISOString().slice(0, 16).replace('T', ' ')}`,
slug: `project-${stamp}`,
description: '',
};
};
const handleOpenProjectAssets = async (projectId: string) => {
await router.push(`/projects/${projectId}`);
};
const handleCreateNewProject = async () => {
setIsCreating(true);
try {
const result = await dispatch(
createProject(buildNewProjectDraft()),
).unwrap();
const createdId = result?.id;
if (!createdId) {
throw new Error('Project was created but id is missing in response');
}
await router.push(`/projects/${createdId}`);
} catch (error: unknown) {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error';
logger.error(
'Failed to create project:',
error instanceof Error ? error : { error: errorMessage },
);
toast('Failed to create project', {
type: 'error',
position: 'bottom-center',
});
} finally {
setIsCreating(false);
}
};
const handleCloneProject = async () => {
if (!cloneSourceId) {
toast('Select source project first', {
type: 'warning',
position: 'bottom-center',
});
return;
}
setIsCloning(true);
try {
const response = await axios.post(`/projects/${cloneSourceId}/clone`);
const createdId = response?.data?.id;
if (!createdId) {
throw new Error('Cloned project id is missing in response');
}
await router.push(`/projects/${createdId}`);
} catch (error: unknown) {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error';
logger.error(
'Failed to clone project:',
error instanceof Error ? error : { error: errorMessage },
);
toast('Failed to clone project', {
type: 'error',
position: 'bottom-center',
});
} finally {
setIsCloning(false);
}
};
return (
<>
<Head>
<title>{getPageTitle('Projects')}</title>
</Head>
<SectionMain>
<SectionTitleLineWithButton
icon={mdiChartTimelineVariant}
title='Projects'
main
>
{''}
</SectionTitleLineWithButton>
<CardBox
className='mb-6'
cardBoxClassName='flex flex-wrap items-center gap-3'
>
<BaseButton
color='info'
label={isCreating ? 'Creating...' : 'New Project'}
onClick={handleCreateNewProject}
disabled={isCreating || isCloning}
/>
<BaseButton
color='lightDark'
label='Clone Project'
onClick={() => setIsCloneOpen((prev) => !prev)}
disabled={isCreating || isCloning || projects.length === 0}
/>
{isCloneOpen && (
<div className='flex items-center gap-2 ml-0 md:ml-2 w-full md:w-auto'>
<select
className='w-full md:w-80 border border-gray-300 rounded px-2 py-2 bg-white dark:bg-dark-800'
value={cloneSourceId}
onChange={(event) => setCloneSourceId(event.target.value)}
disabled={isCreating || isCloning || projects.length === 0}
>
{projects.map((project) => (
<option key={project.id} value={project.id}>
{project.name}
</option>
))}
</select>
<BaseButton
color='success'
label={isCloning ? 'Cloning...' : 'Create Clone'}
onClick={handleCloneProject}
disabled={isCreating || isCloning || !cloneSourceId}
/>
</div>
)}
</CardBox>
<div className='grid grid-cols-1 lg:grid-cols-2 2xl:grid-cols-3 gap-4'>
{!isLoading &&
projects.map((project) => (
<button
key={project.id}
type='button'
className='text-left'
onClick={() => handleOpenProjectAssets(project.id)}
>
<CardBox className='h-full hover:shadow-md transition-shadow'>
<h3 className='text-lg font-semibold'>{project.name}</h3>
<p className='text-sm text-gray-500 mt-1'>
Slug: {project.slug}
</p>
{!!project.description && (
<p className='text-sm text-gray-600 mt-3 line-clamp-3'>
{project.description}
</p>
)}
</CardBox>
</button>
))}
{!isLoading && projects.length === 0 && (
<CardBox>
<p>No projects found.</p>
</CardBox>
)}
</div>
</SectionMain>
<ToastContainer />
</>
);
};
ProjectsListPage.getLayout = function getLayout(page: ReactElement) {
return (
<LayoutAuthenticated permission='READ_PROJECTS'>{page}</LayoutAuthenticated>
);
};
export default ProjectsListPage;