feat(projects): add Projects page and component modals + translations

This commit is contained in:
2026-03-15 02:06:41 +08:00
parent 0dddaa8185
commit 501e6c7ed2
7 changed files with 755 additions and 57 deletions

277
src/pages/Projects.tsx Normal file
View File

@@ -0,0 +1,277 @@
import React, { useState, useEffect } from "react";
import { useTranslation } from "../hooks/useTranslation";
import { getProjects, deleteProject, type Project } from "../api/projects";
import { useWorkspace } from "../context/WorkspaceContext";
import { ProjectCreateModal } from "../components/projects/ProjectCreateModal";
import { ProjectEditModal } from "../components/projects/ProjectEditModal";
import { Pagination } from "../components/Pagination";
import { Plus, Archive, Trash2, Pencil } from "lucide-react";
import FilterBar from "../components/FilterBar";
import { Button } from "../components/ui/button";
import { Card, CardHeader, CardTitle, CardContent } from "../components/ui/card";
import { Modal } from "../components/Modal";
import { toast } from "sonner";
import { Input } from "../components/ui/input";
export const Projects: React.FC = () => {
const { t } = useTranslation();
const { activeWorkspace } = useWorkspace();
const [projects, setProjects] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [editingProject, setEditingProject] = useState<any | null>(null);
const [search, setSearch] = useState("");
const [ordering, setOrdering] = useState("-created_at");
const [isArchived, setIsArchived] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [limit, setLimit] = useState(10);
const [totalItems, setTotalItems] = useState(0);
const [projectToDelete, setProjectToDelete] = useState<Project | null>(null);
const [deleteModal, setDeleteModal] = useState<{isOpen: boolean; project: Project | null}>({isOpen: false, project: null});
const [deleteInput, setDeleteInput] = useState('');
const orderingOptions = [
{ value: '-created_at', label: t.ordering?.createdAtDesc || 'Newest First' },
{ value: 'created_at', label: t.ordering?.createdAt || 'Oldest First' },
{ value: 'name', label: t.ordering?.name || 'Name (A-Z)' },
{ value: '-name', label: t.ordering?.nameDesc || 'Name (Z-A)' },
];
const fetchProjectList = async () => {
if (!activeWorkspace) return;
setLoading(true);
try {
const offset = (currentPage - 1) * limit;
const data = await getProjects(activeWorkspace.id, {
limit,
offset,
search,
is_archived: isArchived,
ordering
});
const items = data?.results || (Array.isArray(data) ? data : [])
const count = data?.count !== undefined ? data.count : items.length
setProjects(items);
setTotalItems(count)
} catch (error) {
console.error("Failed to fetch projects", error);
} finally {
setLoading(false);
}
};
useEffect(() => {
const delayDebounceFn = setTimeout(() => {
fetchProjectList();
}, 300);
return () => clearTimeout(delayDebounceFn);
}, [activeWorkspace, currentPage, limit, search, isArchived, ordering]);
useEffect(() => {
const handleCreated = () => fetchProjectList();
const handleUpdated = () => fetchProjectList();
window.addEventListener("project_created", handleCreated);
window.addEventListener("project_updated", handleUpdated);
return () => {
window.removeEventListener("project_created", handleCreated);
window.removeEventListener("project_updated", handleUpdated);
};
}, [activeWorkspace, currentPage, limit, search, isArchived, ordering]);
const handleDeleteClick = (project: Project) => {
setProjectToDelete(project);
};
const confirmDelete = async () => {
if (!deleteModal.project) return;
try {
const deletedId = deleteModal.project.id;
await deleteProject(deletedId);
fetchProjectList();
window.dispatchEvent(new CustomEvent('project_deleted', {
detail: { id: deletedId }
}));
toast.success(t.projects?.deleteSuccess || 'Project deleted successfully');
setDeleteModal({ isOpen: false, project: null });
setDeleteInput('');
} catch (error) {
toast.error(t.projects?.deleteError || 'Failed to delete project');
}
};
return (
<div className="flex flex-col p-6 min-h-[calc(100vh-73px)] bg-slate-50 dark:bg-slate-900">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-8 gap-4">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">{t.projects?.title || 'Projects'}</h1>
<p className="text-slate-500 dark:text-slate-400 mt-1">{t.projects?.description(activeWorkspace?.name || "-") || 'Manage your projects'}</p>
</div>
<div className="flex items-center gap-3 w-full sm:w-auto">
<Button
variant={isArchived ? "default" : "secondary"}
onClick={() => setIsArchived(!isArchived)}
className="gap-2 shadow-sm flex-1 sm:flex-none"
>
<Archive className="h-4 w-4" />
{isArchived ? (t.projects?.active || 'Active Projects') : (t.projects?.archived || 'Archived Projects')}
</Button>
<Button
onClick={() => setIsCreateModalOpen(true)}
className="gap-2 shadow-sm flex-1 sm:flex-none"
>
<Plus className="h-5 w-5" />
{t.projects?.createNew || 'Create New'}
</Button>
</div>
</div>
<FilterBar
searchQuery={search}
setSearchQuery={setSearch}
ordering={ordering}
setOrdering={setOrdering}
orderingOptions={orderingOptions}
searchPlaceholder={t.projects?.searchPlaceholder || 'Search projects...'}
/>
{loading ? (
<div className="p-12 flex justify-center text-slate-500">
<div className="animate-pulse">{t.projects?.loading || 'Loading...'}</div>
</div>
) : (
<div className="flex flex-col flex-1">
<div className="flex flex-col gap-4 mb-6">
{projects.map((project) => (
<Card key={project.id} className="flex flex-col text-slate-800 dark:text-slate-100 dark:bg-slate-800 dark:border-slate-700 shadow-sm">
<CardContent className="flex flex-col sm:flex-row items-start sm:items-center justify-between py-4 px-6 gap-4">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<CardTitle className="text-lg line-clamp-1">
{project.name}
</CardTitle>
</div>
<p className="text-sm text-slate-500 dark:text-slate-400 line-clamp-1">
{project.client ? `${t.projects?.client || "Client"}: ${project.client.name}` : t.projects?.noDescription || 'No description'}
</p>
</div>
<div className="flex items-center gap-2 shrink-0">
<Button
variant="ghost"
size="icon"
onClick={() => setEditingProject(project)}
className="h-8 w-8 text-slate-400 hover:text-blue-600 hover:bg-blue-50 dark:hover:text-blue-400 dark:hover:bg-blue-900/20"
title={t.actions?.edit || 'Edit'}
>
<Pencil className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => setDeleteModal({ isOpen: true, project })}
className="h-8 w-8 text-slate-400 hover:text-red-600 hover:bg-red-50 dark:hover:text-red-400 dark:hover:bg-red-900/20"
title={t.actions?.delete || 'Delete'}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</CardContent>
</Card>
))}
{projects.length === 0 && (
<div className="py-16 flex flex-col items-center justify-center border-2 border-dashed border-slate-200 dark:border-slate-800 rounded-2xl">
<p className="text-slate-500 dark:text-slate-400 font-medium">{t.projects?.emptyState || 'No projects found'}</p>
</div>
)}
</div>
<Pagination
currentPage={currentPage}
totalCount={totalItems}
limit={limit}
onPageChange={setCurrentPage}
onLimitChange={setLimit}
pageSizeOptions={[10, 20, 50]}
/>
</div>
)}
{/* Modals */}
{isCreateModalOpen && (
<ProjectCreateModal
isOpen={isCreateModalOpen}
onClose={() => setIsCreateModalOpen(false)}
/>
)}
{editingProject && (
<ProjectEditModal
project={editingProject}
isOpen={!!editingProject}
onClose={() => setEditingProject(null)}
/>
)}
{deleteModal.project && (
<Modal
isOpen={deleteModal.isOpen}
onClose={() => {
setDeleteModal({ isOpen: false, project: null });
setDeleteInput('');
}}
title={t.projects?.deleteTitle || 'Delete Project'}
maxWidth="max-w-md"
footer={
<>
<Button
variant="secondary"
onClick={() => {
setDeleteModal({ isOpen: false, project: null });
setDeleteInput('');
}}
className="rounded-xl font-semibold"
>
{t.actions?.cancel || 'Cancel'}
</Button>
<Button
variant="destructive"
disabled={deleteInput !== deleteModal.project.name}
onClick={confirmDelete}
className="rounded-xl font-semibold"
>
{t.actions?.delete || 'Delete'}
</Button>
</>
}
>
<div className="flex flex-col gap-4">
<p className="text-slate-600 dark:text-slate-400 text-sm leading-relaxed">
{t.projects?.deleteWarning || 'To confirm deletion, please type the project name:'} <strong className="text-slate-900 dark:text-white select-all">{deleteModal.project.name}</strong>
</p>
<Input
type="text"
value={deleteInput}
onChange={(e) => setDeleteInput(e.target.value)}
placeholder={deleteModal.project.name}
/>
</div>
</Modal>
)}
</div>
);
};