From 70fd3db19ff4c13c7595662cac6950b0d282a7c2 Mon Sep 17 00:00:00 2001 From: KodekaTeam Date: Wed, 10 Jun 2026 01:30:12 +0700 Subject: [PATCH] feat(projects): enable editing project info from project cards --- .../projects/components/ProjectFormModal.tsx | 31 +++++-- src/app/projects/components/ProjectsGrid.tsx | 25 ++++- src/app/projects/page.tsx | 67 ++++++++++++-- src/lib/api.ts | 39 ++++++++ src/server/routes/projects.ts | 91 ++++++++++++++++++- 5 files changed, 232 insertions(+), 21 deletions(-) diff --git a/src/app/projects/components/ProjectFormModal.tsx b/src/app/projects/components/ProjectFormModal.tsx index 53fd18e..f25709d 100644 --- a/src/app/projects/components/ProjectFormModal.tsx +++ b/src/app/projects/components/ProjectFormModal.tsx @@ -2,21 +2,24 @@ import { useMemo, useState } from "react"; import { FolderArchive, Loader2, LucideRocket, X } from "lucide-react"; -import type { ProjectCreateBody } from "@/lib/api"; +import type { ProjectCreateBody, ProjectRecord } from "@/lib/api"; interface ProjectFormModalProps { + project?: ProjectRecord; submitting: boolean; onClose: () => void; onSubmit: (payload: ProjectCreateBody) => Promise; } export default function ProjectFormModal({ + project, submitting, onClose, onSubmit, }: ProjectFormModalProps) { - const [name, setName] = useState(""); - const [description, setDescription] = useState(""); + const isEditing = Boolean(project); + const [name, setName] = useState(project?.name ?? ""); + const [description, setDescription] = useState(project?.description ?? ""); const [error, setError] = useState(""); const canSubmit = useMemo(() => name.trim().length >= 2, [name]); @@ -30,11 +33,13 @@ export default function ProjectFormModal({ } setError(""); - await onSubmit({ + const payload = { name: name.trim(), description: description.trim(), - environments: [], - }); + ...(isEditing ? {} : { environments: [] }), + }; + + await onSubmit(payload); }; return ( @@ -69,12 +74,14 @@ export default function ProjectFormModal({ }} > - Create New Project + {isEditing ? "Project Detail/Ops" : "Create New Project"}

- Create a new project to manage your environments and deployments. + {isEditing + ? "Update project information used across environments and deployments." + : "Create a new project to manage your environments and deployments."}

diff --git a/src/app/projects/components/ProjectsGrid.tsx b/src/app/projects/components/ProjectsGrid.tsx index 459de28..7f25790 100644 --- a/src/app/projects/components/ProjectsGrid.tsx +++ b/src/app/projects/components/ProjectsGrid.tsx @@ -1,16 +1,18 @@ import Link from "next/link"; -import { FolderTree, Layers3, Trash2 } from "lucide-react"; +import { FolderTree, Layers3, Trash2, Wrench } from "lucide-react"; import type { ProjectRecord } from "@/lib/api"; interface ProjectsGridProps { projects: ProjectRecord[]; deletingProjectId: string | null; + onEditProject: (project: ProjectRecord) => void; onDeleteProject: (project: ProjectRecord) => void; } export default function ProjectsGrid({ projects, deletingProjectId, + onEditProject, onDeleteProject, }: ProjectsGridProps) { if (projects.length === 0) { @@ -202,12 +204,25 @@ export default function ProjectsGrid({ ))} -
+
+ onDeleteProject(project)} disabled={deletingProjectId === project.id} - style={{ flex: 1, fontSize: 11, padding: "5px" }} + style={{ flex: "1 1 120px", fontSize: 11, padding: "5px" }} > {deletingProjectId === project.id ? "Deleting..." : "Remove"} diff --git a/src/app/projects/page.tsx b/src/app/projects/page.tsx index 7b0be00..28436fe 100644 --- a/src/app/projects/page.tsx +++ b/src/app/projects/page.tsx @@ -31,7 +31,11 @@ export default function ProjectsPage() { const [refreshing, setRefreshing] = useState(false); const [search, setSearch] = useState(""); const [showProjectModal, setShowProjectModal] = useState(false); + const [editingProject, setEditingProject] = useState( + null, + ); const [creatingProject, setCreatingProject] = useState(false); + const [updatingProject, setUpdatingProject] = useState(false); const [deletingProjectId, setDeletingProjectId] = useState( null, ); @@ -154,6 +158,46 @@ export default function ProjectsPage() { [pushToast], ); + const handleUpdateProject = useCallback( + async (payload: ProjectCreateBody) => { + if (!editingProject) { + return; + } + + setUpdatingProject(true); + try { + const response = await projectsApi.update(editingProject.id, { + name: payload.name, + description: payload.description, + }); + const updatedProject = response.data; + if (updatedProject) { + setProjects((current) => + current.map((project) => + project.id === updatedProject.id ? updatedProject : project, + ), + ); + } + setEditingProject(null); + pushToast({ + tone: "success", + title: "Project updated", + message: `Project ${payload.name} has been successfully updated.`, + }); + } catch (error) { + pushToast({ + tone: "error", + title: "Update failed", + message: + error instanceof Error ? error.message : "Project update failed.", + }); + } finally { + setUpdatingProject(false); + } + }, + [editingProject, pushToast], + ); + const performDeleteProject = useCallback( async (project: ProjectRecord) => { setDeletingProjectId(project.id); @@ -236,7 +280,10 @@ export default function ProjectsPage() { addLabel="Create Project" onSearchChange={setSearch} onRefresh={() => load(true)} - onAdd={() => setShowProjectModal(true)} + onAdd={() => { + setEditingProject(null); + setShowProjectModal(true); + }} /> setShowProjectModal(true)} + onAdd={() => { + setEditingProject(null); + setShowProjectModal(true); + }} /> {!loading && filteredProjects.length > 0 ? ( ) : null}
- {showProjectModal ? ( + {showProjectModal || editingProject ? ( setShowProjectModal(false)} - onSubmit={handleCreateProject} + project={editingProject ?? undefined} + submitting={editingProject ? updatingProject : creatingProject} + onClose={() => { + setShowProjectModal(false); + setEditingProject(null); + }} + onSubmit={editingProject ? handleUpdateProject : handleCreateProject} /> ) : null} diff --git a/src/lib/api.ts b/src/lib/api.ts index 26c49ed..98a8be6 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -251,6 +251,8 @@ export interface ProjectCreateBody { environments?: ProjectEnvironmentCreateBody[]; } +export type ProjectUpdateBody = Pick; + export interface ApiResponse { success: boolean; data?: T; @@ -383,6 +385,40 @@ export interface UpdateSettingsBody { }; } +export type PanelAccessProxy = "NGINX" | "CADDY" | "TRAEFIK"; + +export interface PanelProxyCapability { + type: PanelAccessProxy; + label: string; + installed: boolean; + active: boolean; + available: boolean; + supportsProvisioning: boolean; + reason: string | null; +} + +export interface PanelAccessCapabilities { + proxies: PanelProxyCapability[]; + autoSsl: { + installed: boolean; + available: boolean; + reason: string | null; + }; + defaultProxy: PanelAccessProxy | null; + upstream: string; +} + +export interface PanelAccessProvisionResult { + domain: string; + panelUrl: string; + proxy: PanelAccessProxy; + configPath: string; + enabledPath: string; + reloadTarget: string; + sslEnabled: boolean; + message: string; +} + export type S3StorageProvider = | "awsS3" | "alibabaOss" @@ -959,6 +995,9 @@ export const projectsApi = { create: (body: ProjectCreateBody) => post<{ success: boolean; data: ProjectRecord }>("/projects", body), + update: (id: string, body: ProjectUpdateBody) => + patch<{ success: boolean; data: ProjectRecord }>(`/projects/${id}`, body), + addEnvironment: (projectId: string, body: ProjectEnvironmentCreateBody) => post<{ success: boolean; data: ProjectEnvironmentRecord }>( `/projects/${projectId}/environments`, diff --git a/src/server/routes/projects.ts b/src/server/routes/projects.ts index dd70346..5e9b27f 100644 --- a/src/server/routes/projects.ts +++ b/src/server/routes/projects.ts @@ -28,6 +28,11 @@ const ProjectCreateSchema = z.object({ environments: z.array(ProjectEnvironmentInputSchema).max(12).default([]), }); +const ProjectUpdateSchema = ProjectCreateSchema.pick({ + name: true, + description: true, +}); + const EnvironmentCreateSchema = ProjectEnvironmentInputSchema; const EnvironmentUpdateSchema = ProjectEnvironmentInputSchema; @@ -54,6 +59,7 @@ async function generateUniqueProjectSlug( db: DbClient, organizationId: string, name: string, + excludeProjectId?: string, ) { const base = slugify(name); let slug = base; @@ -61,7 +67,11 @@ async function generateUniqueProjectSlug( while ( await db.project.findFirst({ - where: { organizationId, slug }, + where: { + organizationId, + slug, + ...(excludeProjectId ? { id: { not: excludeProjectId } } : {}), + }, select: { id: true }, }) ) { @@ -402,6 +412,85 @@ export async function projectsRoutes(app: FastifyInstance) { }, ); + app.patch( + "/:id", + { preHandler: [requireRole("DEVELOPER")] }, + async (req, reply) => { + const organizationId = req.organizationId; + if (!organizationId) { + return reply + .status(400) + .send({ success: false, error: "Active organization is required" }); + } + + const { id } = req.params as { id: string }; + const body = ProjectUpdateSchema.safeParse(req.body); + if (!body.success) { + return reply + .status(400) + .send({ success: false, error: body.error.flatten() }); + } + + const project = await prisma.project.findFirst({ + where: { id, organizationId }, + select: { id: true, name: true, slug: true }, + }); + + if (!project) { + return reply + .status(404) + .send({ success: false, error: "Project not found" }); + } + + try { + await prisma.project.update({ + where: { id: project.id }, + data: { + name: body.data.name, + slug: + body.data.name === project.name + ? project.slug + : await generateUniqueProjectSlug( + prisma, + organizationId, + body.data.name, + project.id, + ), + description: body.data.description || null, + }, + }); + + const freshProject = await getProjectByIdForOrganization( + project.id, + organizationId, + ); + if (!freshProject) { + throw new Error("Project updated but could not be loaded"); + } + + await auditLog({ + userId: req.userId, + organizationId, + action: "PROJECT_UPDATE", + category: "SYSTEM", + level: "INFO", + message: `Project \"${freshProject.name}\" updated`, + }); + + return reply.send({ + success: true, + data: serializeProject(freshProject), + }); + } catch (error) { + return reply.status(400).send({ + success: false, + error: + error instanceof Error ? error.message : "Failed to update project", + }); + } + }, + ); + app.post( "/:id/environments", { preHandler: [requireRole("DEVELOPER")] },