From 7ff68659ade31903b774575b711347bb242d94cd Mon Sep 17 00:00:00 2001 From: bokhonglo Date: Wed, 29 Apr 2026 16:09:07 +0700 Subject: [PATCH] update: project module Co-authored-by: Copilot --- src/app/loading.tsx | 7 + src/app/testing/page.tsx | 37 -- src/app/user/library/page.tsx | 9 +- src/app/user/projects/[id]/page.tsx | 616 +++++++++++++++++++ src/app/user/projects/page.tsx | 267 ++++++-- src/components/auth/SignInForm.tsx | 8 +- src/components/common/PageBreadCrumb.tsx | 41 +- src/components/user-profile/UserMetaCard.tsx | 3 + src/interface/project.ts | 75 ++- src/service/projectService.ts | 54 +- 10 files changed, 957 insertions(+), 160 deletions(-) create mode 100644 src/app/loading.tsx delete mode 100644 src/app/testing/page.tsx create mode 100644 src/app/user/projects/[id]/page.tsx diff --git a/src/app/loading.tsx b/src/app/loading.tsx new file mode 100644 index 0000000..07ab175 --- /dev/null +++ b/src/app/loading.tsx @@ -0,0 +1,7 @@ +export default function Loading() { + return ( +
+
+
+ ); +} diff --git a/src/app/testing/page.tsx b/src/app/testing/page.tsx deleted file mode 100644 index 7d02086..0000000 --- a/src/app/testing/page.tsx +++ /dev/null @@ -1,37 +0,0 @@ -'use client'; - -import { useState, useEffect } from 'react'; -import { apiGetCurrentUser } from "@/service/auth"; - -export default function GetUser() { - const [user, setUser] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - const fetchUser = async () => { - try { - setLoading(true); - const result = await apiGetCurrentUser(); - // console.log("Current User from useEffect:", result); - setUser(result); - } catch (err) { - // console.error("Lỗi 401 hoặc lỗi kết nối:", err); - // setError(err); - } finally { - setLoading(false); - } - }; - - fetchUser(); - }, []); - - if (loading) return
Đang tải thông tin...
; - if (error) return
Bạn chưa đăng nhập (Lỗi 401)
; - - return ( -
-

Thông tin người dùng hiện tại:

-
- ); -} \ No newline at end of file diff --git a/src/app/user/library/page.tsx b/src/app/user/library/page.tsx index 5b86b46..c8ff121 100644 --- a/src/app/user/library/page.tsx +++ b/src/app/user/library/page.tsx @@ -6,6 +6,7 @@ import { MediaDto } from "@/interface/media"; // Assuming this file will be crea import { apiGetCurrentUserApplications, apiGetCurrentUserMedia } from "@/service/userService"; import MediaLibrary from "@/components/user-profile/Media"; import { Application } from "@/interface/historian"; +import Loading from "@/app/loading"; export default function LibraryPage() { const [mediaData, setMediaData] = useState(null); @@ -31,13 +32,7 @@ export default function LibraryPage() { fetchLibraryContent(); }, []); - if (loading) { - return ( -
-
-
- ); - } + if (loading) return ; const hasNoData = (!mediaData?.data || mediaData.data.length === 0) && applications.length === 0; diff --git a/src/app/user/projects/[id]/page.tsx b/src/app/user/projects/[id]/page.tsx new file mode 100644 index 0000000..365de43 --- /dev/null +++ b/src/app/user/projects/[id]/page.tsx @@ -0,0 +1,616 @@ +"use client"; + +import { useParams, useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import Image from "next/image"; +import { toast } from "sonner"; + +import { Project } from "@/interface/project"; +import Swal from "sweetalert2"; +import PageBreadcrumb from "@/components/common/PageBreadCrumb"; +import { apiAddProjectMember, apiChangeProjectOwner, apiDeleteProject, apiGetProjectDetail, apiRemoveProjectMember, apiUpdateProject, apiUpdateProjectMemberRole } from "@/service/projectService"; +import Loading from "@/app/loading"; + +type TabType = "overview" | "members" | "settings"; + +export default function ProjectDetailsPage() { + const params = useParams(); + const router = useRouter(); + const id = params.id as string; + + const [project, setProject] = useState(null); + const [loading, setLoading] = useState(true); + const [activeTab, setActiveTab] = useState("overview"); + + const [editForm, setEditForm] = useState({ + title: "", + description: "", + status: "PRIVATE" as any, + }); + const [newOwnerId, setNewOwnerId] = useState(""); + const [newMember, setNewMember] = useState({ + user_id: "", + role: "EDITOR" as any, + }); + + const fetchProject = async () => { + setLoading(true); + try { + const res = await apiGetProjectDetail(id); + if (res?.status && res.data) { + setProject(res.data); + setEditForm({ + title: res.data.title, + description: res.data.description, + status: res.data.project_status, + }); + } + } catch (error) { + toast.error("Lỗi khi tải dữ liệu dự án"); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (id) fetchProject(); + }, [id]); + + const handleUpdateInfo = async (e: React.FormEvent) => { + e.preventDefault(); + try { + await apiUpdateProject(id, editForm); + toast.success("Cập nhật thông tin thành công!"); + fetchProject(); + } catch (error) { + toast.error("Cập nhật thất bại"); + } + }; + + const handleTransferOwnership = async (e: React.FormEvent) => { + e.preventDefault(); + + const memberName = + project?.members?.find((m) => m.user_id === newOwnerId)?.display_name || + "thành viên này"; + + const result = await Swal.fire({ + title: "Chuyển quyền sở hữu?", + html: `Bạn có chắc chắn muốn chuyển dự án này cho ${memberName}?
Hành động này không thể hoàn tác và bạn sẽ không còn là chủ sở hữu nữa.`, + icon: "error", + showCancelButton: true, + confirmButtonColor: "#238636", + cancelButtonColor: "#30363d", + confirmButtonText: "Tôi hiểu, chuyển quyền sở hữu", + cancelButtonText: "Hủy bỏ", + color: "#333", + customClass: { + popup: "border border-[#30363d] rounded-xl", + }, + }); + + if (result.isConfirmed) { + try { + const res = await apiChangeProjectOwner(id, { + new_owner_id: newOwnerId, + }); + if (res?.status) { + toast.success("Đã chuyển quyền sở hữu thành công!"); + setNewOwnerId(""); + fetchProject(); + } else { + toast.error(res?.message || "Chuyển quyền thất bại"); + } + } catch (error) { + toast.error("Lỗi hệ thống khi chuyển quyền"); + } + } + }; + + const handleAddMember = async (e: React.FormEvent) => { + e.preventDefault(); + if (!newMember.user_id) return toast.error("Vui lòng nhập User ID"); + try { + await apiAddProjectMember(id, newMember); + toast.success("Thêm thành viên thành công"); + setNewMember({ user_id: "", role: "EDITOR" }); + fetchProject(); + } catch (error) { + toast.error("Lỗi thêm thành viên"); + } + }; + + const handleUpdateRole = async (userId: string, newRole: string) => { + try { + const res = await apiUpdateProjectMemberRole(id, userId, { + role: newRole as any, + }); + + if (res?.status) { + toast.success("Cập nhật quyền thành công"); + fetchProject(); + } else { + toast.error(res?.message || "Cập nhật quyền thất bại"); + } + } catch (error: any) { + const errorMessage = + error.response?.data?.message || "Cập nhật quyền thất bại"; + toast.error(errorMessage); + } + }; + + const handleRemoveMember = async (userId: string) => { + // 1. Hiển thị hộp thoại xác nhận bằng SweetAlert2 + const result = await Swal.fire({ + title: "Xác nhận xóa?", + text: "Bạn có chắc chắn muốn xóa thành viên này khỏi dự án?", + icon: "warning", + showCancelButton: true, + confirmButtonColor: "#3085d6", + cancelButtonColor: "#d33", + confirmButtonText: "Đồng ý", + cancelButtonText: "Hủy", + }); + + if (!result.isConfirmed) return; + + try { + const res = await apiRemoveProjectMember(id, userId); + + if (res?.status) { + toast.success("Đã xóa thành viên"); + } else { + toast.error(res?.message || "Xóa thành viên thất bại"); + } + } catch (error: any) { + const errorMessage = + error.response?.data?.message || "Xóa thành viên thất bại"; + toast.error(errorMessage); + + console.error("Remove Member Error:", error); + } finally { + fetchProject(); + } + }; + + const handleDeleteProject = async () => { + const result = await Swal.fire({ + title: "Xác nhận xóa dự án?", + text: "Hành động này sẽ xóa vĩnh viễn dự án. Bạn không thể hoàn tác sau khi xác nhận!", + icon: "warning", + showCancelButton: true, + confirmButtonColor: "#d33", + cancelButtonColor: "#30363d", + confirmButtonText: "Tôi hiểu, xóa dự án này", + cancelButtonText: "Hủy", + color: "#333", + customClass: { + popup: "border border-[#30363d] rounded-xl", + }, + }); + + if (result.isConfirmed) { + try { + const res = await apiDeleteProject(id); + if (res?.status) { + toast.success("Đã xóa dự án thành công"); + router.push("/user/projects"); + } else { + toast.error(res?.message || "Xóa dự án thất bại"); + } + } catch (error : any) { + toast.error(error.response?.data?.message || "Xóa dự án thất bại"); + console.error(error); + } + } + }; + + if (loading) return ; + + if (!project) + return ( +
+ Không tìm thấy dự án +
+ ); + + // console.log(project) + return ( +
+ +
+
+
+
+ {project.user?.avatar_url ? ( +
+ avatar +
+ ) : ( +
+ + {project.user?.display_name?.charAt(0)?.toUpperCase() || + "U"} + +
+ )} +
+ + + {project.user?.display_name} + + / + + {project.title} + + + {project.project_status} + +
+ +
+ {[ + { + id: "overview", + label: "Overview", + icon: "M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z", + }, + { + id: "members", + label: `Members`, + count: project.members?.length || 0, + icon: "M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z", + }, + { + id: "settings", + label: "Settings", + icon: "M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z", + }, + ].map((tab) => ( + + ))} +
+
+
+ +
+ {activeTab === "overview" && ( +
+
+
+
+ About +
+
+ {project.description || ( + + Không có mô tả cho dự án này. + + )} +
+
+
+ +
+
+

+ Owner +

+
+
+ {project.user?.avatar_url ? ( +
+ avatar +
+ ) : ( +
+ + {project.user?.display_name + ?.charAt(0) + ?.toUpperCase() || "U"} + +
+ )} +
+
+
+ {project.user?.display_name} +
+
+ {project.user?.email || "No email"} +
+
+
+
+
+
+ )} + + {activeTab === "members" && ( +
+

+ Manage access +

+ +
+ + setNewMember({ ...newMember, user_id: e.target.value }) + } + className="flex-1 px-3 py-2 text-sm rounded-md border border-gray-300 dark:border-[#30363d] bg-white dark:bg-[#0d1117] outline-none focus:ring-2 focus:ring-blue-500/30 focus:border-blue-500 transition-shadow" + /> +
+ + +
+
+ +
+
+ {project.members && project.members.length > 0 ? ( + project.members.map((member) => ( +
+
+ {member.display_name} +
+
+ {member.display_name} +
+
+ ID: {member.user_id} +
+
+
+
+ + +
+
+ )) + ) : ( +
+ Chưa có thành viên nào. +
+ )} +
+
+
+ )} + + {activeTab === "settings" && ( +
+
+

+ General +

+
+
+ + + setEditForm({ ...editForm, title: e.target.value }) + } + className="w-full max-w-md px-3 py-2 text-sm rounded-md border border-gray-300 dark:border-[#30363d] bg-white dark:bg-[#0d1117] outline-none focus:ring-2 focus:ring-blue-500/30 focus:border-blue-500 transition-shadow" + /> +
+
+ + +
diff --git a/src/components/auth/SignInForm.tsx b/src/components/auth/SignInForm.tsx index 5579777..3bea40b 100644 --- a/src/components/auth/SignInForm.tsx +++ b/src/components/auth/SignInForm.tsx @@ -111,10 +111,10 @@ export default function SignInForm() {
diff --git a/src/interface/project.ts b/src/interface/project.ts index 2f81edb..d42e211 100644 --- a/src/interface/project.ts +++ b/src/interface/project.ts @@ -5,5 +5,78 @@ export interface Project { project_status: "PRIVATE" | "PUBLIC" | "ARCHIVE"; created_at: string; updated_at: string; - // You can add other fields like 'members' if they are part of the response + is_deleted?: boolean; + user_id?: string; + user?: { + id: string; + email: string; + display_name: string; + avatar_url: string; + }; + commits?: any[]; + submission_ids?: any[]; + members?: ProjectMember[]; +} +export interface ProjectsResponse { + status: boolean; + message: string; + data: T[]; + pagination: { + current_page: number; + page_size: number; + total_records: number; + total_pages: number; + }; +} +export interface UpdateProjectPayload { + title: string; + description: string; + status: "PRIVATE" | "PUBLIC" | "ARCHIVE"; +} +export interface ChangeOwnerPayload { + new_owner_id: string; +} +export interface ProjectMemberPayload { + user_id?: string; + role: "EDITOR" | "VIEWER" | "ADMIN"; +} +export interface ProjectMember { + user_id: string; + role: string; + display_name: string; + avatar_url: string; +} +export interface GetProjectsParams { + page?: number; + limit?: number; + search?: string; + sort?: "created_at" | "updated_at" | "title"; + order?: "asc" | "desc"; + statuses?: string; // comma-separated + user_ids?: string; // comma-separated + created_from?: string; // ISO date string + created_to?: string; // ISO date string +} +export interface CreateCommitPayload { + edit_summary: string; + snapshot_json: number[]; +} +export interface RestoreCommitPayload { + commit_id: string; +} + +export interface CreateProjectPayload +{ + description: string, + project_status: "PRIVATE" | "PUBLIC" | "ARCHIVE", + title: string +} + +export interface AddMemberPayload { + role: "PRIVATE" | "PUBLIC" | "ARCHIVE", + user_id: string +} + +export interface UpdateMemberRolePayload { + role: "PRIVATE" | "PUBLIC" | "ARCHIVE", } \ No newline at end of file diff --git a/src/service/projectService.ts b/src/service/projectService.ts index 50f7367..d585d56 100644 --- a/src/service/projectService.ts +++ b/src/service/projectService.ts @@ -1,49 +1,8 @@ + import api from "@/config/config"; import { API } from "../../api"; -import { Project } from "@/interface/project"; -import { CommonResponse, CursorPaginatedResponse } from "@/interface/common"; - -// ========================================== -// TYPES & INTERFACES (Cơ bản theo logic chuẩn) -// ========================================== - -export interface CreateProjectPayload { - title: string; - description?: string; - project_status?: "PRIVATE" | "PUBLIC" | "ARCHIVE"; -} - -export interface UpdateProjectPayload { - title?: string; - description?: string; - project_status?: "PRIVATE" | "PUBLIC" | "ARCHIVE"; -} - -export interface AddMemberPayload { - user_id: string; - role: "EDITOR" | "VIEWER"; -} - -export interface UpdateMemberRolePayload { - role: "EDITOR" | "VIEWER"; -} - -export interface ChangeOwnerPayload { - new_owner_id: string; -} - -export interface CreateCommitPayload { - edit_summary: string; - snapshot_json: number[]; -} - -export interface RestoreCommitPayload { - commit_id: string; -} - -// ========================================== -// 1. NHÓM: QUẢN LÝ DỰ ÁN (PROJECTS) -// ========================================== +import { Project, GetProjectsParams, UpdateProjectPayload, CreateProjectPayload, AddMemberPayload, UpdateMemberRolePayload, ChangeOwnerPayload, CreateCommitPayload, RestoreCommitPayload } from "@/interface/project"; +import { CommonResponse, CursorPaginatedResponse, PaginatedResponse } from "@/interface/common"; export const apiCreateProject = async (payload: CreateProjectPayload): Promise> => { const response = await api.post(API.Project.CREATE, payload); @@ -65,6 +24,11 @@ export const apiDeleteProject = async (id: string): Promise => { return response?.data; }; +export const getProjects = async (params: GetProjectsParams): Promise> => { + const response = await api.get(API.Project.GET_ALL, { params }); + return response?.data; +}; + // ========================================== // 2. NHÓM: QUẢN LÝ THÀNH VIÊN (MEMBERS) // ========================================== @@ -111,4 +75,4 @@ export const apiRestoreProjectCommit = async (id: string, payload: RestoreCommit export const getCurrentProject = async (params?: { cursor_id?: string; limit?: number }): Promise> => { const response = await api.get(API.Project.GET_CURRENT_PROJECT, { params }); return response?.data; -}; \ No newline at end of file +};