Compare commits

..

3 Commits

Author SHA1 Message Date
41af501b51 update: layout
All checks were successful
Build and Release / release (push) Successful in 27s
2026-04-29 16:32:46 +07:00
65806d197f readme
All checks were successful
Build and Release / release (push) Successful in 28s
2026-04-29 16:11:22 +07:00
7ff68659ad update: project module
All checks were successful
Build and Release / release (push) Successful in 27s
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 16:09:07 +07:00
16 changed files with 1086 additions and 447 deletions

201
README.md
View File

@@ -1,200 +1 @@
# TailAdmin Next.js - Free Next.js Tailwind Admin Dashboard
TailAdmin is a free and open-source admin dashboard template built on **Next.js and Tailwind CSS** providing developers with everything they need to create a feature-rich and data-driven: back-end, dashboard, or admin panel solution for any sort of web project.
![TailAdmin - Next.js Dashboard Preview](./banner.png)
With TailAdmin Next.js, you get access to all the necessary dashboard UI components, elements, and pages required to build a high-quality and complete dashboard or admin panel. Whether you're building a dashboard or admin panel for a complex web application or a simple website.
TailAdmin utilizes the powerful features of **Next.js 16** and common features of Next.js such as server-side rendering (SSR), static site generation (SSG), and seamless API route integration. Combined with the advancements of **React 19** and the robustness of **TypeScript**, TailAdmin is the perfect solution to help get your project up and running quickly.
## Overview
TailAdmin provides essential UI components and layouts for building feature-rich, data-driven admin dashboards and control panels. It's built on:
* Next.js 16.x
* React 19
* TypeScript
* Tailwind CSS V4
### Quick Links
* [✨ Visit Website](https://tailadmin.com)
* [📄 Documentation](https://tailadmin.com/docs)
* [⬇️ Download](https://tailadmin.com/download)
* [🖌️ Figma Design File (Community Edition)](https://www.figma.com/community/file/1463141366275764364)
* [⚡ Get PRO Version](https://tailadmin.com/pricing)
### Demos
* [Free Version](https://nextjs-free-demo.tailadmin.com)
* [Pro Version](https://nextjs-demo.tailadmin.com)
### Other Versions
- [Next.js Version](https://github.com/TailAdmin/free-nextjs-admin-dashboard)
- [React.js Version](https://github.com/TailAdmin/free-react-tailwind-admin-dashboard)
- [Vue.js Version](https://github.com/TailAdmin/vue-tailwind-admin-dashboard)
- [Angular Version](https://github.com/TailAdmin/free-angular-tailwind-dashboard)
- [Laravel Version](https://github.com/TailAdmin/tailadmin-laravel)
## Installation
### Prerequisites
To get started with TailAdmin, ensure you have the following prerequisites installed and set up:
* Node.js 18.x or later (recommended to use Node.js 20.x or later)
### Cloning the Repository
Clone the repository using the following command:
```bash
git clone https://github.com/TailAdmin/free-nextjs-admin-dashboard.git
```
> Windows Users: place the repository near the root of your drive if you face issues while cloning.
1. Install dependencies:
```bash
npm install
# or
yarn install
```
> Use `--legacy-peer-deps` flag if you face peer-dependency error during installation.
2. Start the development server:
```bash
npm run dev
# or
yarn dev
```
## Components
TailAdmin is a pre-designed starting point for building a web-based dashboard using Next.js and Tailwind CSS. The template includes:
* Sophisticated and accessible sidebar
* Data visualization components
* Profile management and custom 404 page
* Tables and Charts(Line and Bar)
* Authentication forms and input elements
* Alerts, Dropdowns, Modals, Buttons and more
* Can't forget Dark Mode 🕶️
All components are built with React and styled using Tailwind CSS for easy customization.
## Feature Comparison
### Free Version
* 1 Unique Dashboard
* 30+ dashboard components
* 50+ UI elements
* Basic Figma design files
* Community support
### Pro Version
* 7 Unique Dashboards: Analytics, Ecommerce, Marketing, CRM, SaaS, Stocks, Logistics (more coming soon)
* 500+ dashboard components and UI elements
* Complete Figma design file
* Email support
To learn more about pro version features and pricing, visit our [pricing page](https://tailadmin.com/pricing).
## Changelog
### Version 2.2.3 - [March 15, 2026]
* update ESLint configuration and dependencies; upgrade Next.js to version 16.1.6
### Version 2.2.2 - [December 30, 2025]
* Fixed date picker positioning and functionality in Statistics Chart.
### Version 2.1.0 - [November 15, 2025]
* Updated to Next.js 16.x
* Fixed all reported minor bugs
### Version 2.0.2 - [March 25, 2025]
* Upgraded to Next.js 16.x for [CVE-2025-29927](https://nextjs.org/blog/cve-2025-29927) concerns
* Included overrides vectormap for packages to prevent peer dependency errors during installation.
* Migrated from react-flatpickr to flatpickr package for React 19 support
### Version 2.0.1 - [February 27, 2025]
#### Update Overview
* Upgraded to Tailwind CSS v4 for better performance and efficiency.
* Updated class usage to match the latest syntax and features.
* Replaced deprecated class and optimized styles.
#### Next Steps
* Run npm install or yarn install to update dependencies.
* Check for any style changes or compatibility issues.
* Refer to the Tailwind CSS v4 [Migration Guide](https://tailwindcss.com/docs/upgrade-guide) on this release. if needed.
* This update keeps the project up to date with the latest Tailwind improvements. 🚀
### v2.0.0 (February 2025)
A major update focused on Next.js 16 implementation and comprehensive redesign.
#### Major Improvements
* Complete redesign using Next.js 16 App Router and React Server Components
* Enhanced user interface with Next.js-optimized components
* Improved responsiveness and accessibility
* New features including collapsible sidebar, chat screens, and calendar
* Redesigned authentication using Next.js App Router and server actions
* Updated data visualization using ApexCharts for React
#### Breaking Changes
* Migrated from Next.js 14 to Next.js 16
* Chart components now use ApexCharts for React
* Authentication flow updated to use Server Actions and middleware
[Read more](https://tailadmin.com/docs/update-logs/nextjs) on this release.
### v1.3.4 (July 01, 2024)
* Fixed JSvectormap rendering issues
### v1.3.3 (June 20, 2024)
* Fixed build error related to Loader component
### v1.3.2 (June 19, 2024)
* Added ClickOutside component for dropdown menus
* Refactored sidebar components
* Updated Jsvectormap package
### v1.3.1 (Feb 12, 2024)
* Fixed layout naming consistency
* Updated styles
### v1.3.0 (Feb 05, 2024)
* Upgraded to Next.js 14
* Added Flatpickr integration
* Improved form elements
* Enhanced multiselect functionality
* Added default layout component
## License
TailAdmin Next.js Free Version is released under the MIT License.
## Support
If you find this project helpful, please consider giving it a star on GitHub. Your support helps us continue developing and maintaining this template.
xamluoccampuchia

7
src/app/loading.tsx Normal file
View File

@@ -0,0 +1,7 @@
export default function Loading() {
return (
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-white/40 backdrop-blur-sm dark:bg-black/40">
<div className="w-12 h-12 border-4 border-blue-200 border-t-blue-600 dark:border-blue-900 dark:border-t-blue-500 rounded-full animate-spin"></div>
</div>
);
}

View File

@@ -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 <div>Đang tải thông tin...</div>;
if (error) return <div>Bạn chưa đăng nhập (Lỗi 401)</div>;
return (
<div className="">
<h1>Thông tin người dùng hiện tại:</h1>
</div>
);
}

View File

@@ -8,7 +8,6 @@ import { apiGetCurrentUser } from "@/service/auth";
import { setUserData } from "@/store/features/userSlice";
import React, { useEffect } from "react";
import { useDispatch } from "react-redux";
import { usePathname } from "next/navigation";
export default function AdminLayout({
children,
@@ -17,8 +16,6 @@ export default function AdminLayout({
}) {
const { isExpanded, isHovered, isMobileOpen } = useSidebar();
const dispatch = useDispatch()
const pathname = usePathname();
const isHomePage = pathname === "/";
useEffect(() => {
const fetchUser = async () => {
@@ -34,19 +31,17 @@ export default function AdminLayout({
// Dynamic class for main content margin based on sidebar state
const mainContentMargin = isHomePage
const mainContentMargin = isMobileOpen
? "ml-0"
: isMobileOpen
? "ml-0"
: isExpanded || isHovered
? "lg:ml-[290px]"
: "lg:ml-[90px]";
: isExpanded || isHovered
? "lg:ml-[290px]"
: "lg:ml-[0px]";
return (
<div className="min-h-screen xl:flex">
{/* Sidebar and Backdrop */}
{!isHomePage && <AppSidebar />}
{!isHomePage && <Backdrop />}
<AppSidebar />
<Backdrop />
{/* Main Content Area */}
<div
className={`flex-1 transition-all duration-300 ease-in-out ${mainContentMargin}`}

View File

@@ -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<MediaDto | null>(null);
@@ -31,13 +32,7 @@ export default function LibraryPage() {
fetchLibraryContent();
}, []);
if (loading) {
return (
<div className="flex h-[50vh] items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-blue-500 border-t-transparent"></div>
</div>
);
}
if (loading) return <Loading />;
const hasNoData = (!mediaData?.data || mediaData.data.length === 0) && applications.length === 0;

View File

@@ -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<Project | null>(null);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<TabType>("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 <b>${memberName}</b>?<br/>Hành động này <b>không thể hoàn tác</b> 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 <Loading />;
if (!project)
return (
<div className="flex justify-center p-20 text-red-500">
Không tìm thấy dự án
</div>
);
// console.log(project)
return (
<div className="min-h-screen dark:bg-[#0d1117] text-gray-900 dark:text-[#c9d1d9] font-sans">
<PageBreadcrumb
pageTitle="Chi tiết dự án"
paths={[{ name: "Quản lý dự án", href: "/user/projects" }]}
/>
<div className="pt-8 border-b border-gray-200 dark:border-[#30363d] bg-gray-50 dark:bg-[#0d1117]">
<div className="px-6">
<div className="flex items-center gap-2 text-xl mb-6">
<div className="w-8 h-8 shrink-0 flex items-center justify-center">
{project.user?.avatar_url ? (
<div className="relative w-8 h-8 rounded-full overflow-hidden border border-gray-200 dark:border-gray-800">
<Image
src={project.user.avatar_url}
alt="avatar"
fill
className="object-cover rounded-full"
/>
</div>
) : (
<div className="w-8 h-8 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center border border-gray-300 dark:border-gray-600">
<span className="text-[10px] font-bold text-gray-500 dark:text-gray-300 leading-none">
{project.user?.display_name?.charAt(0)?.toUpperCase() ||
"U"}
</span>
</div>
)}
</div>
<span className="font-medium text-blue-600 dark:text-[#58a6ff] hover:underline cursor-pointer">
{project.user?.display_name}
</span>
<span className="text-gray-400">/</span>
<strong className="font-semibold text-blue-600 dark:text-[#58a6ff] hover:underline cursor-pointer">
{project.title}
</strong>
<span className="ml-2 px-2.5 py-0.5 text-xs font-medium rounded-full border border-gray-200 dark:border-[#30363d] text-gray-500 dark:text-[#8b949e]">
{project.project_status}
</span>
</div>
<div className="flex gap-4">
{[
{
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) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as TabType)}
className={`flex items-center gap-2 px-3 py-2.5 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.id
? "border-[#f78166] text-gray-900 dark:text-[#c9d1d9]"
: "border-transparent text-gray-500 hover:text-gray-700 dark:hover:text-[#8b949e]"
}`}
>
<svg
className="w-4 h-4 opacity-70"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d={tab.icon}
/>
</svg>
{tab.label}
{tab.count !== undefined && (
<span className="ml-1 px-2 py-0.5 text-xs rounded-full bg-gray-200/50 dark:bg-[#21262d] text-gray-600 dark:text-[#c9d1d9]">
{tab.count}
</span>
)}
</button>
))}
</div>
</div>
</div>
<div className="px-6 py-8">
{activeTab === "overview" && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
<div className="md:col-span-3">
<div className="border border-gray-200 dark:border-[#30363d] rounded-xl overflow-hidden ">
<div className="bg-gray-50 dark:bg-[#161b22] px-5 py-3 border-b border-gray-200 dark:border-[#30363d] font-semibold text-sm text-gray-800 dark:text-[#c9d1d9]">
About
</div>
<div className="p-6 bg-white dark:bg-[#0d1117] text-[15px] leading-relaxed text-gray-700 dark:text-[#8b949e]">
{project.description || (
<i className="text-gray-400">
Không tả cho dự án này.
</i>
)}
</div>
</div>
</div>
<div className="md:col-span-1 space-y-6">
<div>
<h3 className="font-semibold text-sm mb-4 border-b border-gray-200 dark:border-[#30363d] pb-2 text-gray-800 dark:text-[#c9d1d9]">
Owner
</h3>
<div className="flex items-center gap-3">
<div className="w-8 h-8 shrink-0 flex items-center justify-center">
{project.user?.avatar_url ? (
<div className="relative w-8 h-8 rounded-full overflow-hidden border border-gray-200 dark:border-gray-800">
<Image
src={project.user.avatar_url}
alt="avatar"
fill
className="object-cover rounded-full"
/>
</div>
) : (
<div className="w-8 h-8 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center border border-gray-300 dark:border-gray-600">
<span className="text-[10px] font-bold text-gray-500 dark:text-gray-300 leading-none">
{project.user?.display_name
?.charAt(0)
?.toUpperCase() || "U"}
</span>
</div>
)}
</div>
<div className="overflow-hidden">
<div className="font-semibold text-sm text-gray-800 dark:text-[#c9d1d9] truncate">
{project.user?.display_name}
</div>
<div className="text-xs text-gray-500 dark:text-[#8b949e] truncate">
{project.user?.email || "No email"}
</div>
</div>
</div>
</div>
</div>
</div>
)}
{activeTab === "members" && (
<div className="max-w-4xl">
<h2 className="text-2xl font-normal mb-6 pb-2 border-b border-gray-200 dark:border-[#30363d]">
Manage access
</h2>
<form
onSubmit={handleAddMember}
className="flex flex-col sm:flex-row gap-3 mb-8 p-5 border border-gray-200 dark:border-[#30363d] rounded-xl bg-gray-50 dark:bg-[#161b22] "
>
<input
type="text"
placeholder="User ID..."
value={newMember.user_id}
onChange={(e) =>
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"
/>
<div className="flex gap-3">
<select
value={newMember.role}
onChange={(e) =>
setNewMember({ ...newMember, role: e.target.value as any })
}
className="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 cursor-pointer"
>
<option value="EDITOR">Editor</option>
<option value="VIEWER">Viewer</option>
</select>
<button
type="submit"
className="px-5 py-2 text-sm font-medium text-white bg-[#238636] border border-transparent rounded-md hover:bg-[#2ea043] transition-colors "
>
Add member
</button>
</div>
</form>
<div className="border border-gray-200 dark:border-[#30363d] rounded-xl overflow-hidden ">
<div className="divide-y divide-gray-200 dark:divide-[#30363d]">
{project.members && project.members.length > 0 ? (
project.members.map((member) => (
<div
key={member.user_id}
className="flex items-center justify-between p-4 hover:bg-gray-50 dark:hover:bg-[#161b22]/50 transition-colors"
>
<div className="flex items-center gap-4">
<img
src={
member.avatar_url ||
"https://github.com/identicons/jasonlong.png"
}
alt={member.display_name}
className="w-10 h-10 rounded-full border border-gray-200 dark:border-gray-700"
/>
<div>
<div className="font-semibold text-sm text-gray-800 dark:text-[#c9d1d9]">
{member.display_name}
</div>
<div className="text-xs text-gray-500 dark:text-[#8b949e]">
ID: {member.user_id}
</div>
</div>
</div>
<div className="flex items-center gap-3">
<select
value={member.role}
onChange={(e) =>
handleUpdateRole(member.user_id, e.target.value)
}
className="text-sm px-3 py-1.5 rounded-md border border-gray-300 dark:border-[#30363d] bg-white dark:bg-[#21262d] outline-none hover:bg-gray-50 dark:hover:bg-[#30363d] cursor-pointer transition-colors"
>
<option value="EDITOR">Editor</option>
<option value="VIEWER">Viewer</option>
</select>
<button
onClick={() => handleRemoveMember(member.user_id)}
className="text-red-500 hover:text-red-600 p-2 rounded-md hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
title="Remove member"
>
<svg
className="w-5 h-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
))
) : (
<div className="p-8 text-center text-gray-500 dark:text-[#8b949e] text-sm italic">
Chưa thành viên nào.
</div>
)}
</div>
</div>
</div>
)}
{activeTab === "settings" && (
<div className="max-w-3xl space-y-10">
<section>
<h2 className="text-2xl font-normal mb-4 pb-2 border-b border-gray-200 dark:border-[#30363d]">
General
</h2>
<form onSubmit={handleUpdateInfo} className="space-y-5">
<div>
<label className="block text-sm font-semibold mb-1.5 text-gray-800 dark:text-[#c9d1d9]">
Project name
</label>
<input
type="text"
value={editForm.title}
onChange={(e) =>
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"
/>
</div>
<div>
<label className="block text-sm font-semibold mb-1.5 text-gray-800 dark:text-[#c9d1d9]">
Description
</label>
<textarea
rows={4}
value={editForm.description}
onChange={(e) =>
setEditForm({ ...editForm, description: e.target.value })
}
className="w-full 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"
/>
</div>
<div>
<label className="block text-sm font-semibold mb-1.5 text-gray-800 dark:text-[#c9d1d9]">
Status
</label>
<select
value={editForm.status}
onChange={(e) =>
setEditForm({
...editForm,
status: e.target.value as any,
})
}
className="w-48 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 cursor-pointer"
>
<option value="PUBLIC">Public</option>
<option value="PRIVATE">Private</option>
<option value="ARCHIVE">Archive</option>
</select>
</div>
<button
type="submit"
className="px-5 py-2 text-sm font-medium rounded-md bg-[#21262d] text-[#c9d1d9] border border-[#30363d] hover:bg-[#30363d] transition-colors "
>
Update settings
</button>
</form>
</section>
<section>
<h2 className="text-2xl font-normal text-red-500 mb-4 pb-2 border-b border-red-500/30">
Danger Zone
</h2>
<div className="border border-red-500/30 rounded-xl overflow-hidden">
<div className="p-5 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<div className="font-semibold text-sm text-gray-800 dark:text-[#c9d1d9]">
Transfer ownership
</div>
<div className="text-xs text-gray-500 dark:text-[#8b949e] mt-1">
Transfer this project to another member in the project.
</div>
</div>
<form
onSubmit={handleTransferOwnership}
className="flex gap-2"
>
<select
value={newOwnerId}
onChange={(e) => setNewOwnerId(e.target.value)}
className="w-full sm:w-56 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-red-500/30 focus:border-red-500 cursor-pointer"
required
>
<option value="" disabled>
-- Thành viên --
</option>
{project.members && project.members.length > 0 ? (
project.members.map((member) => (
<option key={member.user_id} value={member.user_id}>
{member.display_name}
</option>
))
) : (
<option value="" disabled>
Chưa thành viên nào
</option>
)}
</select>
<button
type="submit"
disabled={
!newOwnerId ||
!project.members ||
project.members.length === 0
}
className="shrink-0 px-4 py-2 text-sm font-medium text-red-500 bg-transparent border border-red-500/50 rounded-md hover:bg-red-500 hover:text-white dark:hover:bg-red-900/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-red-500 dark:disabled:hover:bg-transparent"
>
Transfer
</button>
</form>
</div>
<div className="p-5 flex flex-col sm:flex-row sm:items-center justify-between gap-4 hover:bg-red-50/30 dark:hover:bg-red-900/10 transition-colors">
<div>
<div className="font-semibold text-sm text-gray-800 dark:text-[#c9d1d9]">
Delete this project
</div>
<div className="text-xs text-gray-500 dark:text-[#8b949e] mt-1">
Once you delete a project, there is no going back. Please
be certain.
</div>
</div>
<button
type="button"
onClick={handleDeleteProject}
className="shrink-0 px-4 py-2 text-sm font-medium text-red-500 bg-transparent border border-red-500/50 rounded-md hover:bg-red-500 hover:text-white dark:hover:bg-red-900/30 transition-colors"
>
Delete project
</button>
</div>
</div>
</section>
</div>
)}
</div>
</div>
);
}

View File

@@ -1,24 +1,30 @@
"use client";
import React, { useEffect, useState } from "react";
import Link from "next/link";
import Image from "next/image";
import { useRouter } from "next/navigation";
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
import ComponentCard from "@/components/common/ComponentCard";
import { getCurrentProject, apiCreateProject, CreateProjectPayload } from "@/service/projectService";
import { toast } from "sonner";
import { useModal } from "@/hooks/useModal";
import { Modal } from "@/components/ui/modal";
import Button from "@/components/ui/button/Button";
import Label from "@/components/form/Label";
import { Table, TableBody, TableCell, TableHeader, TableRow } from "@/components/ui/table";
import Badge from "@/components/ui/badge/Badge";
import { Project } from "@/interface/project";
import { CreateProjectPayload, Project } from "@/interface/project";
import { apiCreateProject, getCurrentProject } from "@/service/projectService";
export type ProjectSortColumn = "created_at" | "updated_at" | "title";
export default function ProjectsPage() {
const router = useRouter();
const [projects, setProjects] = useState<Project[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [sortBy, setSortBy] = useState<ProjectSortColumn>("updated_at");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
const { isOpen, openModal, closeModal } = useModal();
const [formData, setFormData] = useState<CreateProjectPayload>({ title: "", description: "", project_status: "PRIVATE" });
@@ -39,7 +45,7 @@ export default function ProjectsPage() {
fetchProjects();
}, []);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};
@@ -56,7 +62,7 @@ export default function ProjectsPage() {
toast.success("Tạo dự án mới thành công!");
closeModal();
setFormData({ title: "", description: "", project_status: "PRIVATE" });
fetchProjects(); // Tải lại danh sách sau khi tạo
fetchProjects();
} catch (error) {
console.error("Lỗi tạo dự án:", error);
toast.error("Có lỗi xảy ra khi tạo dự án.");
@@ -65,6 +71,67 @@ export default function ProjectsPage() {
}
};
const handleSort = (column: ProjectSortColumn) => {
if (sortBy === column) {
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
} else {
setSortBy(column);
setSortOrder("desc");
}
};
const sortedProjects = [...projects].sort((a: any, b: any) => {
let valA = a[sortBy];
let valB = b[sortBy];
if (!valA) valA = "";
if (!valB) valB = "";
if (valA < valB) return sortOrder === "asc" ? -1 : 1;
if (valA > valB) return sortOrder === "asc" ? 1 : -1;
return 0;
});
// Helper format ngày
const formatDate = (dateString: string | null | undefined) => {
if (!dateString) return "-";
const date = new Date(dateString);
return `Updated on ${date.toLocaleDateString("vi-VN", {
day: "2-digit",
month: "short",
year: "numeric",
})}`;
};
const getStatusBadge = (status: string) => {
switch (status) {
case "PUBLIC":
return <Badge size="sm" variant="light" color="success">PUBLIC</Badge>;
case "PRIVATE":
return <Badge size="sm" variant="light" color="warning">PRIVATE</Badge>;
case "ARCHIVE":
return <Badge size="sm" variant="light" color="light">ARCHIVE</Badge>;
default:
return <Badge size="sm" variant="light" color="dark">{status}</Badge>;
}
};
const SortButton = ({ column, label }: { column: ProjectSortColumn; label: string }) => {
const isActive = sortBy === column;
return (
<button
onClick={() => handleSort(column)}
className={`w-20 text-sm font-medium text-left hover:text-blue-500 transition-colors ${
isActive ? "text-blue-600 dark:text-blue-400" : "text-gray-500 dark:text-gray-400"
}`}
>
{label} {isActive && (sortOrder === "asc" ? "↑" : "↓")}
</button>
);
};
console.log(projects);
return (
<div className="max-w-7xl mx-auto pb-10">
<PageBreadcrumb pageTitle="Quản lý dự án" />
@@ -85,53 +152,117 @@ export default function ProjectsPage() {
</div>
)}
{projects.length > 0 ? (
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-white/[0.05] dark:bg-white/[0.03]">
<div className="max-w-full overflow-x-auto">
<div className="min-w-[800px]">
<Table>
<TableHeader className="border-b border-gray-100 dark:border-white/[0.05]">
<TableRow>
<TableCell isHeader className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400">
Tên dự án
</TableCell>
<TableCell isHeader className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400">
tả
</TableCell>
<TableCell isHeader className="px-5 py-3 font-medium text-gray-500 text-center text-theme-xs dark:text-gray-400">
Trạng thái
</TableCell>
<TableCell isHeader className="px-5 py-3 font-medium text-gray-500 text-center text-theme-xs dark:text-gray-400">
Thao tác
</TableCell>
</TableRow>
</TableHeader>
<TableBody className="divide-y divide-gray-100 dark:divide-white/[0.05]">
{projects.map((project) => (
<TableRow key={project.id} className="hover:bg-gray-50/50 dark:hover:bg-white/[0.01] transition-colors">
<TableCell className="px-5 py-4 text-start text-theme-sm font-semibold text-gray-800 dark:text-white/90">
{!isLoading && sortedProjects.length > 0 ? (
<div className="max-w-full overflow-x-auto">
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-[#0d1117] min-w-[700px]">
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-200 dark:border-gray-800 bg-gray-50/50 dark:bg-[#161b22]">
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300 w-40"></span>
<div className="flex items-center gap-4 shrink-0">
<span className="text-sm text-gray-500 dark:text-gray-400 w-20">Sắp xếp:</span>
<SortButton column="title" label="Tên" />
<SortButton column="created_at" label="Ngày tạo" />
<SortButton column="updated_at" label="Cập nhật" />
</div>
</div>
<div className="flex flex-col divide-y divide-gray-200 dark:divide-gray-800">
{sortedProjects.map((project: any) => (
<div
key={project.id}
className="group flex flex-col p-5 md:flex-row md:items-center justify-between hover:bg-gray-50 dark:hover:bg-[#161b22] transition-colors"
>
<div className="flex-1 pr-4 max-w-full md:max-w-[75%]">
<div
onClick={() => router.push(`/user/projects/${project.id}`)}
className="flex items-center gap-2 mb-2 cursor-pointer hover:underline"
>
<div className="w-6 h-6 shrink-0 flex items-center justify-center">
{project.user?.avatar_url ? (
<div className="relative w-6 h-6 rounded-full overflow-hidden border border-gray-200 dark:border-gray-800">
<Image
src={project.user.avatar_url}
alt="avatar"
fill
className="object-cover rounded-full"
/>
</div>
) : (
<div className="w-6 h-6 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center border border-gray-300 dark:border-gray-600">
<span className="text-[10px] font-bold text-gray-500 dark:text-gray-300 leading-none">
{project.user?.display_name?.charAt(0)?.toUpperCase() || "U"}
</span>
</div>
)}
</div>
<div className="flex items-center max-w-[250px]">
<span className="text-[14px] font-medium text-gray-700 dark:text-gray-300 truncate">
{project.user?.display_name || "Unknown"}
</span>
</div>
<span className="text-[14px] text-gray-400 dark:text-gray-600 shrink-0">/</span>
<h3 className="text-[14px] font-semibold text-blue-600 dark:text-[#58a6ff] truncate max-w-[300px]">
{project.title}
</TableCell>
<TableCell className="px-5 py-4 text-start text-theme-sm text-gray-500 dark:text-gray-400 max-w-[300px]">
<p className="truncate">{project.description || "Chưa có mô tả cho dự án này..."}</p>
</TableCell>
<TableCell className="px-5 py-4 text-center">
<Badge size="sm" variant="light" color={project.project_status === "PUBLIC" ? "success" : project.project_status === "PRIVATE" ? "warning" : "light"}>
{project.project_status || "N/A"}
</Badge>
</TableCell>
<TableCell className="px-5 py-4 text-center">
<Link
href={`/user/projects/${project.id}`}
className="text-brand-500 hover:text-brand-600 font-medium text-theme-sm"
>
Thao tác
</Link>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</h3>
<div className="shrink-0 w-20 flex justify-start">
{getStatusBadge(project.project_status)}
</div>
</div>
<div className="flex flex-wrap items-center gap-4 text-xs text-gray-500 dark:text-[#8b949e] h-5">
<span>{formatDate(project.updated_at)}</span>
</div>
</div>
<div className="flex items-center mt-4 md:mt-0 w-[120px] justify-end shrink-0">
<div className="flex -space-x-2 overflow-hidden">
{project.members && project.members.length > 0 ? (
<>
{project.members.slice(0, 4).map((m: any, index: number) =>
m.avatar_url ? (
<Image
key={index}
src={m.avatar_url}
alt={m.display_name}
width={32}
height={32}
title={m.display_name}
className="inline-block w-8 h-8 rounded-full object-cover ring-2 ring-white group-hover:ring-gray-50 dark:ring-[#0d1117] dark:group-hover:ring-[#161b22] transition-colors"
/>
) : (
<div
key={index}
title={m.display_name}
className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 dark:bg-gray-700 ring-2 ring-white group-hover:ring-gray-50 dark:ring-[#0d1117] dark:group-hover:ring-[#161b22] transition-colors"
>
<span className="text-xs font-medium text-gray-500 dark:text-gray-300">
{m.display_name?.charAt(0)?.toUpperCase() || "U"}
</span>
</div>
)
)}
{project.members.length > 4 && (
<div
title="Những người khác"
className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-gray-100 dark:bg-gray-800 ring-2 ring-white group-hover:ring-gray-50 dark:ring-[#0d1117] dark:group-hover:ring-[#161b22] transition-colors z-10"
>
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">
+{project.members.length - 4}
</span>
</div>
)}
</>
) : (
<span className="text-sm text-gray-400 dark:text-gray-600 italic"></span>
)}
</div>
</div>
</div>
))}
</div>
</div>
</div>
@@ -145,7 +276,7 @@ export default function ProjectsPage() {
)}
</div>
</ComponentCard>
</div>
</div>
{/* Modal Tạo Dự án */}
<Modal isOpen={isOpen} onClose={closeModal} className="max-w-[500px] m-4">
@@ -166,7 +297,12 @@ export default function ProjectsPage() {
</div>
<div>
<Label>Trạng thái</Label>
<select name="status" value={formData.project_status} onChange={(e: any) => handleChange(e)} className="h-11 w-full rounded-xl border border-gray-200 bg-transparent px-4 py-2.5 text-sm text-gray-800 outline-none focus:border-brand-300 focus:ring-3 focus:ring-brand-500/10 dark:border-gray-800 dark:text-white/90 dark:focus:border-brand-800">
<select
name="project_status"
value={formData.project_status}
onChange={handleChange}
className="h-11 w-full rounded-xl border border-gray-200 bg-transparent px-4 py-2.5 text-sm text-gray-800 outline-none focus:border-brand-300 focus:ring-3 focus:ring-brand-500/10 dark:border-gray-800 dark:text-white/90 dark:focus:border-brand-800"
>
<option value="PRIVATE">Riêng (Private)</option>
<option value="PUBLIC">Công khai (Public)</option>
<option value="ARCHIVE">Lưu trữ (Archive)</option>
@@ -174,7 +310,14 @@ export default function ProjectsPage() {
</div>
<div>
<Label> tả dự án</Label>
<textarea name="description" value={formData.description} onChange={handleChange} rows={4} className="w-full rounded-xl border border-gray-200 bg-transparent px-4 py-3 text-sm text-gray-800 outline-none focus:border-brand-300 focus:ring-3 focus:ring-brand-500/10 dark:border-gray-800 dark:text-white/90 dark:focus:border-brand-800 custom-scrollbar" placeholder="Mô tả ngắn gọn về dự án..."></textarea>
<textarea
name="description"
value={formData.description}
onChange={handleChange}
rows={4}
className="w-full rounded-xl border border-gray-200 bg-transparent px-4 py-3 text-sm text-gray-800 outline-none focus:border-brand-300 focus:ring-3 focus:ring-brand-500/10 dark:border-gray-800 dark:text-white/90 dark:focus:border-brand-800 custom-scrollbar"
placeholder="Mô tả ngắn gọn về dự án..."
></textarea>
</div>
<div className="flex items-center justify-end gap-3 mt-4">
<Button size="sm" variant="outline" type="button" onClick={closeModal}>Hủy</Button>

View File

@@ -111,10 +111,10 @@ export default function SignInForm() {
<div>
<div className="grid grid-cols-1 gap-3 sm:gap-5">
<button
onClick={() => {
const redirectUrl = HOME_URL;
window.location.href = `${API.Auth.GOOGLE_LOGIN}?redirect=${redirectUrl}`;
}}
onClick={() => {
const redirectUrl = HOME_URL;
window.location.href = `${API.Auth.GOOGLE_LOGIN}?redirect=${redirectUrl}`;
}}
className="inline-flex items-center justify-center gap-3 py-3 text-sm font-normal text-gray-700 transition-colors bg-gray-100 rounded-lg px-7 hover:bg-gray-200 hover:text-gray-800 dark:bg-white/5 dark:text-white/90 dark:hover:bg-white/10"
>
<svg

View File

@@ -1,11 +1,17 @@
import Link from "next/link";
import React from "react";
interface BreadcrumbProps {
pageTitle: string;
interface BreadcrumbPath {
name: string;
href: string;
}
const PageBreadcrumb: React.FC<BreadcrumbProps> = ({ pageTitle }) => {
interface BreadcrumbProps {
pageTitle: string;
paths?: BreadcrumbPath[];
}
const PageBreadcrumb: React.FC<BreadcrumbProps> = ({ pageTitle, paths }) => {
return (
<div className="flex flex-wrap items-center justify-between gap-3 mb-6">
<h2
@@ -18,7 +24,7 @@ const PageBreadcrumb: React.FC<BreadcrumbProps> = ({ pageTitle }) => {
<ol className="flex items-center gap-1.5">
<li>
<Link
className="inline-flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400"
className="inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-800 dark:text-gray-400 dark:hover:text-white/90 transition-colors"
href="/"
>
Home
@@ -40,6 +46,33 @@ const PageBreadcrumb: React.FC<BreadcrumbProps> = ({ pageTitle }) => {
</svg>
</Link>
</li>
{paths &&
paths.map((path, index) => (
<li key={index} className="flex items-center gap-1.5">
<Link
className="inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-800 dark:text-gray-400 dark:hover:text-white/90 transition-colors"
href={path.href}
>
{path.name}
<svg
className="stroke-current"
width="17"
height="16"
viewBox="0 0 17 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.0765 12.667L10.2432 8.50033L6.0765 4.33366"
stroke=""
strokeWidth="1.2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</Link>
</li>
))}
<li className="text-sm text-gray-800 dark:text-white/90">
{pageTitle}
</li>

View File

@@ -159,6 +159,9 @@ export default function UserMetaCard({ data }: { data: UserMetaCardProps }) {
</>
)}
</div>
<span className="text-sm text-gray-500 dark:text-gray-400">
ID: {data.data?.id}
</span>
</div>
</div>
</div>

View File

@@ -27,7 +27,7 @@ export const useSidebar = () => {
export const SidebarProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const [isExpanded, setIsExpanded] = useState(true);
const [isExpanded, setIsExpanded] = useState(false);
const [isMobileOpen, setIsMobileOpen] = useState(false);
const [isMobile, setIsMobile] = useState(false);
const [isHovered, setIsHovered] = useState(false);

View File

@@ -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<T = Project> {
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",
}

View File

@@ -5,15 +5,12 @@ import UserDropdown from "@/components/header/UserDropdown";
import { useSidebar } from "@/context/SidebarContext";
import Image from "next/image";
import Link from "next/link";
import { usePathname } from "next/navigation";
import React, { useState ,useEffect,useRef} from "react";
const AppHeader: React.FC = () => {
const [isApplicationMenuOpen, setApplicationMenuOpen] = useState(false);
const { isMobileOpen, toggleSidebar, toggleMobileSidebar } = useSidebar();
const pathname = usePathname();
const isHomePage = pathname === "/";
const handleToggle = () => {
if (window.innerWidth >= 1024) {
@@ -47,46 +44,44 @@ const AppHeader: React.FC = () => {
<header className="sticky top-0 flex w-full bg-white border-gray-200 z-99 dark:border-gray-800 dark:bg-gray-900 lg:border-b">
<div className="flex flex-col items-center justify-between grow lg:flex-row lg:px-6">
<div className="flex items-center justify-between w-full gap-2 px-3 py-3 border-b border-gray-200 dark:border-gray-800 sm:gap-4 lg:justify-normal lg:border-b-0 lg:px-0 lg:py-4">
{!isHomePage && (
<button
className="items-center justify-center w-10 h-10 text-gray-500 border-gray-200 rounded-lg z-99999 dark:border-gray-800 lg:flex dark:text-gray-400 lg:h-11 lg:w-11 lg:border"
onClick={handleToggle}
aria-label="Toggle Sidebar"
>
{isMobileOpen ? (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M6.21967 7.28131C5.92678 6.98841 5.92678 6.51354 6.21967 6.22065C6.51256 5.92775 6.98744 5.92775 7.28033 6.22065L11.999 10.9393L16.7176 6.22078C17.0105 5.92789 17.4854 5.92788 17.7782 6.22078C18.0711 6.51367 18.0711 6.98855 17.7782 7.28144L13.0597 12L17.7782 16.7186C18.0711 17.0115 18.0711 17.4863 17.7782 17.7792C17.4854 18.0721 17.0105 18.0721 16.7176 17.7792L11.999 13.0607L7.28033 17.7794C6.98744 18.0722 6.51256 18.0722 6.21967 17.7794C5.92678 17.4865 5.92678 17.0116 6.21967 16.7187L10.9384 12L6.21967 7.28131Z"
fill="currentColor"
/>
</svg>
) : (
<svg
width="16"
height="12"
viewBox="0 0 16 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M0.583252 1C0.583252 0.585788 0.919038 0.25 1.33325 0.25H14.6666C15.0808 0.25 15.4166 0.585786 15.4166 1C15.4166 1.41421 15.0808 1.75 14.6666 1.75L1.33325 1.75C0.919038 1.75 0.583252 1.41422 0.583252 1ZM0.583252 11C0.583252 10.5858 0.919038 10.25 1.33325 10.25L14.6666 10.25C15.0808 10.25 15.4166 10.5858 15.4166 11C15.4166 11.4142 15.0808 11.75 14.6666 11.75L1.33325 11.75C0.919038 11.75 0.583252 11.4142 0.583252 11ZM1.33325 5.25C0.919038 5.25 0.583252 5.58579 0.583252 6C0.583252 6.41421 0.919038 6.75 1.33325 6.75L7.99992 6.75C8.41413 6.75 8.74992 6.41421 8.74992 6C8.74992 5.58579 8.41413 5.25 7.99992 5.25L1.33325 5.25Z"
fill="currentColor"
/>
</svg>
)}
{/* Cross Icon */}
</button>
)}
<button
className="items-center justify-center w-10 h-10 text-gray-500 border-gray-200 rounded-lg z-99999 dark:border-gray-800 lg:flex dark:text-gray-400 lg:h-11 lg:w-11 lg:border"
onClick={handleToggle}
aria-label="Toggle Sidebar"
>
{isMobileOpen ? (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M6.21967 7.28131C5.92678 6.98841 5.92678 6.51354 6.21967 6.22065C6.51256 5.92775 6.98744 5.92775 7.28033 6.22065L11.999 10.9393L16.7176 6.22078C17.0105 5.92789 17.4854 5.92788 17.7782 6.22078C18.0711 6.51367 18.0711 6.98855 17.7782 7.28144L13.0597 12L17.7782 16.7186C18.0711 17.0115 18.0711 17.4863 17.7782 17.7792C17.4854 18.0721 17.0105 18.0721 16.7176 17.7792L11.999 13.0607L7.28033 17.7794C6.98744 18.0722 6.51256 18.0722 6.21967 17.7794C5.92678 17.4865 5.92678 17.0116 6.21967 16.7187L10.9384 12L6.21967 7.28131Z"
fill="currentColor"
/>
</svg>
) : (
<svg
width="16"
height="12"
viewBox="0 0 16 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M0.583252 1C0.583252 0.585788 0.919038 0.25 1.33325 0.25H14.6666C15.0808 0.25 15.4166 0.585786 15.4166 1C15.4166 1.41421 15.0808 1.75 14.6666 1.75L1.33325 1.75C0.919038 1.75 0.583252 1.41422 0.583252 1ZM0.583252 11C0.583252 10.5858 0.919038 10.25 1.33325 10.25L14.6666 10.25C15.0808 10.25 15.4166 10.5858 15.4166 11C15.4166 11.4142 15.0808 11.75 14.6666 11.75L1.33325 11.75C0.919038 11.75 0.583252 11.4142 0.583252 11ZM1.33325 5.25C0.919038 5.25 0.583252 5.58579 0.583252 6C0.583252 6.41421 0.919038 6.75 1.33325 6.75L7.99992 6.75C8.41413 6.75 8.74992 6.41421 8.74992 6C8.74992 5.58579 8.41413 5.25 7.99992 5.25L1.33325 5.25Z"
fill="currentColor"
/>
</svg>
)}
{/* Cross Icon */}
</button>
<Link href="/" className="lg:hidden">
<Image

View File

@@ -90,23 +90,26 @@ const AppSidebar: React.FC = () => {
const { isExpanded, isMobileOpen, isHovered, setIsHovered } = useSidebar();
const pathname = usePathname();
const [openSubmenu, setOpenSubmenu] = useState<{
type: "main" | "others";
index: number;
} | null>(null);
const [subMenuHeight, setSubMenuHeight] = useState<Record<string, number>>({});
const [subMenuHeight, setSubMenuHeight] = useState<Record<string, number>>(
{},
);
const subMenuRefs = useRef<Record<string, HTMLDivElement | null>>({});
const isActive = useCallback((path: string) => path === pathname, [pathname]);
const handleSubmenuToggle = (index: number, menuType: "main" | "others") => {
setOpenSubmenu((prev) =>
prev?.type === menuType && prev?.index === index ? null : { type: menuType, index }
prev?.type === menuType && prev?.index === index
? null
: { type: menuType, index },
);
};
useEffect(() => {
useEffect(() => {
let submenuMatched = false;
[
{ items: ALL_NAV_ITEMS, type: "main" },
@@ -151,23 +154,41 @@ const AppSidebar: React.FC = () => {
onClick={() => handleSubmenuToggle(index, menuType)}
className={`menu-item group uppercase ${
openSubmenu?.type === menuType && openSubmenu?.index === index
? "menu-item-active" : "menu-item-inactive"
? "menu-item-active"
: "menu-item-inactive"
} cursor-pointer ${!isExpanded && !isHovered ? "lg:justify-center" : "lg:justify-start"}`}
>
<span className={openSubmenu?.type === menuType && openSubmenu?.index === index ? "menu-item-icon-active" : "menu-item-icon-inactive"}>
<span
className={
openSubmenu?.type === menuType && openSubmenu?.index === index
? "menu-item-icon-active"
: "menu-item-icon-inactive"
}
>
{nav.icon}
</span>
{(isExpanded || isHovered || isMobileOpen) && (
<>
<span className={`menu-item-text`}>{nav.name}</span>
<ChevronDownIcon className={`ml-auto w-5 h-5 transition-transform duration-200 ${openSubmenu?.type === menuType && openSubmenu?.index === index ? "rotate-180 text-brand-500" : ""}`} />
<ChevronDownIcon
className={`ml-auto w-5 h-5 transition-transform duration-200 ${openSubmenu?.type === menuType && openSubmenu?.index === index ? "rotate-180 text-brand-500" : ""}`}
/>
</>
)}
</button>
) : (
nav.path && (
<Link href={nav.path} className={`menu-item group ${isActive(nav.path) ? "menu-item-active" : "menu-item-inactive"}`}>
<span className={isActive(nav.path) ? "menu-item-icon-active" : "menu-item-icon-inactive"}>
<Link
href={nav.path}
className={`menu-item group ${isActive(nav.path) ? "menu-item-active" : "menu-item-inactive"}`}
>
<span
className={
isActive(nav.path)
? "menu-item-icon-active"
: "menu-item-icon-inactive"
}
>
{nav.icon}
</span>
{(isExpanded || isHovered || isMobileOpen) && (
@@ -178,18 +199,40 @@ const AppSidebar: React.FC = () => {
)}
{nav.subItems && (isExpanded || isHovered || isMobileOpen) && (
<div
ref={(el) => { subMenuRefs.current[`${menuType}-${index}`] = el; }}
ref={(el) => {
subMenuRefs.current[`${menuType}-${index}`] = el;
}}
className="overflow-hidden transition-all duration-300"
style={{ height: openSubmenu?.type === menuType && openSubmenu?.index === index ? `${subMenuHeight[`${menuType}-${index}`]}px` : "0px" }}
style={{
height:
openSubmenu?.type === menuType && openSubmenu?.index === index
? `${subMenuHeight[`${menuType}-${index}`]}px`
: "0px",
}}
>
<ul className="mt-2 space-y-1 ml-9">
{nav.subItems.map((subItem) => (
<li key={subItem.name}>
<Link href={subItem.path} className={`menu-dropdown-item ${isActive(subItem.path) ? "menu-dropdown-item-active" : "menu-dropdown-item-inactive"}`}>
<Link
href={subItem.path}
className={`menu-dropdown-item ${isActive(subItem.path) ? "menu-dropdown-item-active" : "menu-dropdown-item-inactive"}`}
>
{subItem.name}
<span className="flex items-center gap-1 ml-auto">
{subItem.new && <span className={`${isActive(subItem.path) ? "menu-dropdown-badge-active" : "menu-dropdown-badge-inactive"} menu-dropdown-badge`}>new</span>}
{subItem.pro && <span className={`${isActive(subItem.path) ? "menu-dropdown-badge-active" : "menu-dropdown-badge-inactive"} menu-dropdown-badge`}>pro</span>}
{subItem.new && (
<span
className={`${isActive(subItem.path) ? "menu-dropdown-badge-active" : "menu-dropdown-badge-inactive"} menu-dropdown-badge`}
>
new
</span>
)}
{subItem.pro && (
<span
className={`${isActive(subItem.path) ? "menu-dropdown-badge-active" : "menu-dropdown-badge-inactive"} menu-dropdown-badge`}
>
pro
</span>
)}
</span>
</Link>
</li>
@@ -204,13 +247,15 @@ const AppSidebar: React.FC = () => {
return (
<aside
className={`fixed mt-16 flex flex-col lg:mt-0 top-0 px-5 left-0 bg-white dark:bg-gray-900 dark:border-gray-800 text-gray-900 h-screen transition-all duration-300 ease-in-out z-50 border-r border-gray-200
${isExpanded || isMobileOpen ? "w-[290px]" : isHovered ? "w-[290px]" : "w-[90px]"}
${isMobileOpen ? "translate-x-0" : "-translate-x-full"} lg:translate-x-0`}
className={`fixed mt-16 flex flex-col lg:mt-0 top-0 left-0 bg-white dark:bg-gray-900 dark:border-gray-800 text-gray-900 h-screen transition-all duration-300 ease-in-out z-50 border-r border-gray-200
${isExpanded || isMobileOpen || isHovered ? "w-[290px] px-5" : "w-0 px-0 border-none overflow-hidden"}
${isMobileOpen ? "translate-x-0" : "-translate-x-full"} lg:translate-x-0`}
onMouseEnter={() => !isExpanded && setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div className={`py-8 flex ${!isExpanded && !isHovered ? "lg:justify-center" : "justify-start"}`}>
<div
className={`py-8 flex ${!isExpanded && !isHovered ? "lg:justify-center" : "justify-start"}`}
>
<Link href="/">
<Image
src="/images/logo/logo.svg"
@@ -224,8 +269,14 @@ const AppSidebar: React.FC = () => {
<nav className="mb-6">
<div className="flex flex-col gap-4">
<div>
<h2 className={`mb-4 text-xs uppercase flex leading-[20px] text-gray-400 ${!isExpanded && !isHovered ? "lg:justify-center" : "justify-start"}`}>
{isExpanded || isHovered || isMobileOpen ? "Menu" : <HorizontaLDots />}
<h2
className={`mb-4 text-xs uppercase flex leading-[20px] text-gray-400 ${!isExpanded && !isHovered ? "lg:justify-center" : "justify-start"}`}
>
{isExpanded || isHovered || isMobileOpen ? (
"Menu"
) : (
<HorizontaLDots />
)}
</h2>
{renderMenuItems(ALL_NAV_ITEMS, "main")}
</div>

View File

@@ -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<CommonResponse<Project>> => {
const response = await api.post(API.Project.CREATE, payload);
@@ -65,6 +24,11 @@ export const apiDeleteProject = async (id: string): Promise<CommonResponse> => {
return response?.data;
};
export const getProjects = async (params: GetProjectsParams): Promise<PaginatedResponse<Project>> => {
const response = await api.get(API.Project.GET_ALL, { params });
return response?.data;
};
// ==========================================
// 2. NHÓM: QUẢN LÝ THÀNH VIÊN (MEMBERS)
// ==========================================