228 lines
7.0 KiB
TypeScript
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;
|