This commit is contained in:
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,33 @@
|
||||
|
||||
|
||||
@font-face {
|
||||
font-family: 'SF Pro Display';
|
||||
src: url('/font/SF-Pro-Display/SF-Pro-Display-Regular.otf') format('otf');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'SF Pro Display';
|
||||
src: url('/font/SF-Pro-Display/SF-Pro-Display-Medium.otf') format('otf');
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'SF Pro Display';
|
||||
src: url('/font/SF-Pro-Display/SF-Pro-Display-Semibold.otf') format('otf');
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'SF Pro Display';
|
||||
src: url('/font/SF-Pro-Display/SF-Pro-Display-Bold.otf') format('otf');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
+3
-1
@@ -1,11 +1,13 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@import './fonts.css';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme {
|
||||
--font-*: initial;
|
||||
--font-outfit: Outfit, sans-serif;
|
||||
--font-inter: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-inter: "SF Pro Display", ui-sans-serif, system-ui, sans-serif;
|
||||
|
||||
--breakpoint-*: initial;
|
||||
--breakpoint-2xsm: 375px;
|
||||
|
||||
+26
-6
@@ -1,4 +1,4 @@
|
||||
import { Inter } from 'next/font/google';
|
||||
import localFont from 'next/font/local';
|
||||
import './globals.css';
|
||||
import "flatpickr/dist/flatpickr.css";
|
||||
import { SidebarProvider } from '@/context/SidebarContext';
|
||||
@@ -6,10 +6,30 @@ import { ThemeProvider } from '@/context/ThemeContext';
|
||||
import { Toaster } from 'sonner';
|
||||
import StoreProvider from '@/store/StoreProvider';
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const sfPro = localFont({
|
||||
src: [
|
||||
{
|
||||
path: '../../public/font/SF-Pro-Display/SF-Pro-Display-Regular.otf',
|
||||
weight: '400',
|
||||
style: 'normal',
|
||||
},
|
||||
{
|
||||
path: '../../public/font/SF-Pro-Display/SF-Pro-Display-Medium.otf',
|
||||
weight: '500',
|
||||
style: 'normal',
|
||||
},
|
||||
{
|
||||
path: '../../public/font/SF-Pro-Display/SF-Pro-Display-Semibold.otf',
|
||||
weight: '600',
|
||||
style: 'normal',
|
||||
},
|
||||
{
|
||||
path: '../../public/font/SF-Pro-Display/SF-Pro-Display-Bold.otf',
|
||||
weight: '700',
|
||||
style: 'normal',
|
||||
},
|
||||
],
|
||||
})
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
@@ -17,7 +37,7 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={`${inter.className} dark:bg-gray-900`}>
|
||||
<body className={`${sfPro.className} dark:bg-gray-900`}>
|
||||
<StoreProvider>
|
||||
<ThemeProvider>
|
||||
<SidebarProvider>{children} <Toaster closeButton richColors position="top-right" /> </SidebarProvider>
|
||||
|
||||
@@ -17,6 +17,7 @@ import "yet-another-react-lightbox/plugins/captions.css";
|
||||
import Image from "next/image";
|
||||
import { URL_MEDIA } from "../../../../../api";
|
||||
import { MediaItem } from "@/components/tables/MediaTable";
|
||||
import StickyHeader from "@/components/ui/StickyHeader";
|
||||
|
||||
export default function ApplicationDetailPage() {
|
||||
const application = useSelector(
|
||||
@@ -132,26 +133,14 @@ export default function ApplicationDetailPage() {
|
||||
});
|
||||
};
|
||||
|
||||
// console.log("Application Detail:", application);
|
||||
const path =[
|
||||
{name: "Thư viện", href:"/user/library/"}
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto p-6 bg-white dark:bg-zinc-900 rounded-2xl shadow-sm border dark:border-zinc-800">
|
||||
<div className="flex justify-between items-center mb-8 border-b dark:border-zinc-800 pb-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold dark:text-zinc-50 tracking-tight">
|
||||
Chi tiết đơn đăng ký
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`flex items-center gap-1.5 px-2 py-1 rounded-md backdrop-blur-md border ${config.container}`}
|
||||
>
|
||||
<span className={`text-[12px] font-bold uppercase tracking-wider`}>
|
||||
{application.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<>
|
||||
<StickyHeader header={`Chi tiết đơn đăng ký`} paths={path} />
|
||||
<div className="max-w-5xl mx-auto mb-8 p-6 bg-white dark:bg-zinc-900 rounded-2xl shadow-sm border dark:border-zinc-800">
|
||||
<div className="space-y-10">
|
||||
<section>
|
||||
<label className="text-[11px] font-black text-zinc-400 uppercase tracking-widest mb-4 block">
|
||||
@@ -219,7 +208,7 @@ export default function ApplicationDetailPage() {
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className="group flex items-center gap-2 px-8 py-3 bg-red-100 hover:bg-red-500 text-red-600 hover:text-white dark:bg-red-950/20 dark:hover:bg-red-600 dark:text-red-400 dark:hover:text-white text-sm font-black rounded-xl transition-all duration-300 disabled:opacity-50"
|
||||
className="group flex items-center gap-2 px-8 py-3 bg-red-100 hover:bg-red-500 text-red-600 hover:text-white text-sm font-black rounded-4xl transition-all duration-300 disabled:opacity-50"
|
||||
>
|
||||
{isDeleting ? "ĐANG XỬ LÝ..." : "XÓA"}
|
||||
</button>
|
||||
@@ -291,6 +280,7 @@ export default function ApplicationDetailPage() {
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div></>
|
||||
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import StickyHeader from "@/components/ui/StickyHeader";
|
||||
import AccountDetails from "@/components/user-profile/AccountDetails";
|
||||
import UserInfoCard from "@/components/user-profile/UserInfoCard";
|
||||
import UserMetaCard from "@/components/user-profile/UserMetaCard";
|
||||
@@ -31,11 +32,9 @@ export default function Profile() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] lg:p-6">
|
||||
<h3 className="mb-5 text-lg font-semibold text-gray-800 dark:text-white/90 lg:mb-7">
|
||||
Profile
|
||||
</h3>
|
||||
<div className="space-y-6">
|
||||
<StickyHeader header={`Thông tin tài khoản`} />
|
||||
<div className="p-6 my-8 flex mx-auto ">
|
||||
<div className="max-w-82 pr-4 border-r border-gray-300">
|
||||
<UserMetaCard data={user ?? {}} />
|
||||
<UserInfoCard data={{ ...user, openEdit: true }} />
|
||||
<AccountDetails data={user ?? {}} />
|
||||
|
||||
@@ -9,7 +9,6 @@ import { setUserData } from "@/store/features/userSlice";
|
||||
import React, { useEffect } from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
import ChatbotWidget from "@/components/ui/chat/ChatbotWidget";
|
||||
|
||||
export default function AdminLayout({
|
||||
children,
|
||||
}: {
|
||||
@@ -31,7 +30,6 @@ export default function AdminLayout({
|
||||
}, [])
|
||||
|
||||
|
||||
// Dynamic class for main content margin based on sidebar state
|
||||
const mainContentMargin = isMobileOpen
|
||||
? "ml-0"
|
||||
: isExpanded || isHovered
|
||||
@@ -40,17 +38,13 @@ export default function AdminLayout({
|
||||
|
||||
return (
|
||||
<div className="min-h-screen xl:flex">
|
||||
{/* Sidebar and Backdrop */}
|
||||
<AppSidebar />
|
||||
<Backdrop />
|
||||
{/* Main Content Area */}
|
||||
<div
|
||||
className={`flex-1 transition-all duration-300 ease-in-out ${mainContentMargin}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<AppHeader />
|
||||
{/* Page Content */}
|
||||
<div className="p-4 mx-auto max-w-(--breakpoint-2xl) md:p-6">{children}</div>
|
||||
{/* <AppHeader /> */}
|
||||
<div className="mx-auto max-w-(--breakpoint-2xl)">{children}</div>
|
||||
</div>
|
||||
<ChatbotWidget />
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { apiGetCurrentUserApplications, apiGetCurrentUserMedia } from "@/service
|
||||
import MediaLibrary from "@/components/user-profile/Media";
|
||||
import { Application } from "@/interface/historian";
|
||||
import Loading from "@/app/loading";
|
||||
import StickyHeader from "@/components/ui/StickyHeader";
|
||||
|
||||
export default function LibraryPage() {
|
||||
const [mediaData, setMediaData] = useState<MediaDto | null>(null);
|
||||
@@ -37,16 +38,14 @@ export default function LibraryPage() {
|
||||
const hasNoData = (!mediaData?.data || mediaData.data.length === 0) && applications.length === 0;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50/50 p-4 dark:bg-zinc-950 lg:p-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold tracking-tight text-gray-900 dark:text-white">
|
||||
Thư viện
|
||||
</h1>
|
||||
</div>
|
||||
<div className="min-h-screen bg-gray-50/50 dark:bg-zinc-950">
|
||||
|
||||
<StickyHeader header="Thư viện của tôi" />
|
||||
|
||||
<div className="px-4 pb-4 lg:px-8 lg:pb-8">
|
||||
{hasNoData ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-gray-200 py-24 text-center dark:border-zinc-800">
|
||||
<p className="text-lg font-medium text-gray-500 dark:text-gray-400">
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-gray-200 py-24 text-center">
|
||||
<p className="text-lg font-medium text-gray-500">
|
||||
Chưa có nội dung nào trong thư viện
|
||||
</p>
|
||||
</div>
|
||||
@@ -66,5 +65,6 @@ export default function LibraryPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import DemographicCard from "@/components/ecommerce/DemographicCard";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title:
|
||||
"Admin Dashboard",
|
||||
"Home Page",
|
||||
description: "This is Dashboard Home for History Web",
|
||||
};
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ import PageBreadcrumb from "@/components/common/PageBreadCrumb";
|
||||
import { apiAddProjectMember, apiChangeProjectOwner, apiDeleteProject, apiGetProjectDetail, apiRemoveProjectMember, apiUpdateProject, apiUpdateProjectMemberRole } from "@/service/projectService";
|
||||
import Loading from "@/app/loading";
|
||||
import Button from "@/components/ui/button/Button";
|
||||
import StickyHeader from "@/components/ui/StickyHeader";
|
||||
import { PencilIcon } from "@/icons";
|
||||
|
||||
type TabType = "overview" | "members" | "settings";
|
||||
|
||||
@@ -216,12 +218,12 @@ export default function ProjectDetailsPage() {
|
||||
);
|
||||
|
||||
// console.log(project)
|
||||
const path =[
|
||||
{name: "Quản lý dự án", href:"/user/projects/"}
|
||||
]
|
||||
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" }]}
|
||||
/>
|
||||
<StickyHeader header={`Chi tiết dự án`} paths={path} />
|
||||
<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">
|
||||
@@ -308,8 +310,9 @@ export default function ProjectDetailsPage() {
|
||||
))}
|
||||
|
||||
<div className="flex-1" />
|
||||
<Button size="sm" variant="outline" onClick={() => router.push(`/editor/${id}`)}>
|
||||
Mo editor
|
||||
<Button size="sm" variant="primary" className="rounded-4xl!" onClick={() => router.push(`/editor/${id}`)}>
|
||||
<PencilIcon />
|
||||
Editor
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+286
-137
@@ -12,9 +12,15 @@ import Button from "@/components/ui/button/Button";
|
||||
import Label from "@/components/form/Label";
|
||||
import Badge from "@/components/ui/badge/Badge";
|
||||
import { CreateProjectPayload, Project } from "@/interface/project";
|
||||
import { apiCreateProject, apiCreateProjectCommit, apiGetProjectCommits, getCurrentProject } from "@/service/projectService";
|
||||
import {
|
||||
apiCreateProject,
|
||||
apiCreateProjectCommit,
|
||||
apiGetProjectCommits,
|
||||
getCurrentProject,
|
||||
} from "@/service/projectService";
|
||||
import { normalizeEditorSnapshot } from "@/uhm/lib/editor/snapshot/editorSnapshot";
|
||||
import type { EditorSnapshot } from "@/uhm/types/projects";
|
||||
import StickyHeader from "@/components/ui/StickyHeader";
|
||||
|
||||
export type ProjectSortColumn = "created_at" | "updated_at" | "title";
|
||||
|
||||
@@ -23,16 +29,26 @@ export default function ProjectsPage() {
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isExportingProjectId, setIsExportingProjectId] = useState<string | null>(null);
|
||||
const [isExportingProjectId, setIsExportingProjectId] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
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" });
|
||||
const [formData, setFormData] = useState<CreateProjectPayload>({
|
||||
title: "",
|
||||
description: "",
|
||||
status: "PRIVATE",
|
||||
});
|
||||
const importJsonInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [importSnapshot, setImportSnapshot] = useState<EditorSnapshot | null>(null);
|
||||
const [importSnapshotName, setImportSnapshotName] = useState<string | null>(null);
|
||||
const [importSnapshot, setImportSnapshot] = useState<EditorSnapshot | null>(
|
||||
null,
|
||||
);
|
||||
const [importSnapshotName, setImportSnapshotName] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const fetchProjects = async () => {
|
||||
try {
|
||||
@@ -51,9 +67,17 @@ export default function ProjectsPage() {
|
||||
fetchProjects();
|
||||
}, []);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
// 1. Cập nhật lại hàm handleChange để đảm bảo bắt giá trị chính xác từ thẻ select
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<
|
||||
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
|
||||
>,
|
||||
) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[name]: value, // Đảm bảo value từ select được gán chính xác vào key status
|
||||
}));
|
||||
};
|
||||
|
||||
const handleCreateProject = async (e: React.FormEvent) => {
|
||||
@@ -62,17 +86,38 @@ export default function ProjectsPage() {
|
||||
toast.warning("Vui lòng nhập tên dự án!");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
// Bước 1: Luôn tạo project trước
|
||||
const created = await apiCreateProject(formData);
|
||||
const projectId = created?.data?.id;
|
||||
|
||||
if (!projectId) {
|
||||
toast.error("Tạo dự án thất bại: không nhận được ID dự án.");
|
||||
setIsSubmitting(false); // Dừng sớm nếu không có ID
|
||||
return;
|
||||
}
|
||||
|
||||
// Bước 2: Nếu có snapshot, tạo commit ban đầu từ JSON
|
||||
if (importSnapshot) {
|
||||
await apiCreateProjectCommit(projectId, {
|
||||
edit_summary: `Init project from ${importSnapshotName || "JSON"}`,
|
||||
snapshot_json: importSnapshot as any,
|
||||
} as any);
|
||||
toast.success("Tạo dự án từ JSON thành công!");
|
||||
} else {
|
||||
toast.success("Tạo dự án mới thành công!");
|
||||
}
|
||||
|
||||
// Bước 3: Dọn dẹp state và chuyển hướng
|
||||
closeModal();
|
||||
setFormData({ title: "", description: "", project_status: "PRIVATE" });
|
||||
setFormData({ title: "", description: "", status: "PRIVATE" });
|
||||
setImportSnapshot(null);
|
||||
setImportSnapshotName(null);
|
||||
if (importJsonInputRef.current) importJsonInputRef.current.value = "";
|
||||
fetchProjects();
|
||||
if (projectId) router.push(`/editor/${projectId}`);
|
||||
router.push(`/editor/${projectId}`);
|
||||
} catch (error) {
|
||||
console.error("Lỗi tạo dự án:", error);
|
||||
toast.error("Có lỗi xảy ra khi tạo dự án.");
|
||||
@@ -81,6 +126,48 @@ export default function ProjectsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportHeadSnapshot = async (project: Project) => {
|
||||
const projectId = String(project.id || "").trim();
|
||||
if (!projectId) return;
|
||||
const headCommitId = project.latest_commit_id
|
||||
? String(project.latest_commit_id)
|
||||
: "";
|
||||
if (!headCommitId) {
|
||||
toast.warning("Dự án chưa có head commit để export.");
|
||||
return;
|
||||
}
|
||||
setIsExportingProjectId(projectId);
|
||||
try {
|
||||
const res: any = await apiGetProjectCommits(projectId);
|
||||
const rawList = res?.data?.items ?? res?.data ?? res?.items ?? [];
|
||||
const commits = Array.isArray(rawList) ? rawList : [];
|
||||
const head =
|
||||
commits.find((c: any) => String(c?.id || "") === headCommitId) || null;
|
||||
const snapshot = head?.snapshot_json ?? null;
|
||||
if (!snapshot) {
|
||||
toast.error("Không tìm thấy snapshot_json của head commit.");
|
||||
return;
|
||||
}
|
||||
const blob = new Blob([JSON.stringify(snapshot, null, 2)], {
|
||||
type: "application/json;charset=utf-8",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `project-${projectId}-head-${headCommitId}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success("Đã export JSON snapshot.");
|
||||
} catch (err) {
|
||||
console.error("Export snapshot failed", err);
|
||||
toast.error("Export thất bại.");
|
||||
} finally {
|
||||
setIsExportingProjectId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePickImportJson = () => {
|
||||
importJsonInputRef.current?.click();
|
||||
};
|
||||
@@ -97,88 +184,13 @@ export default function ProjectsPage() {
|
||||
}
|
||||
setImportSnapshot(normalized);
|
||||
setImportSnapshotName(file.name);
|
||||
toast.success("Đã nạp JSON snapshot. Bấm 'Tạo với JSON' để khởi tạo dự án.");
|
||||
toast.success("Đã nạp JSON snapshot. Bấm 'Tạo dự án' để hoàn tất.");
|
||||
} catch (err) {
|
||||
console.error("Import JSON failed", err);
|
||||
toast.error("Không đọc được file JSON.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateProjectWithJson = async () => {
|
||||
if (!formData.title.trim()) {
|
||||
toast.warning("Vui lòng nhập tên dự án!");
|
||||
return;
|
||||
}
|
||||
if (!importSnapshot) {
|
||||
toast.warning("Chưa chọn JSON snapshot.");
|
||||
handlePickImportJson();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
const created = await apiCreateProject(formData);
|
||||
const projectId = created?.data?.id;
|
||||
if (!projectId) {
|
||||
toast.error("Tạo dự án thất bại: thiếu project id.");
|
||||
return;
|
||||
}
|
||||
await apiCreateProjectCommit(projectId, {
|
||||
edit_summary: "Init project from JSON",
|
||||
snapshot_json: importSnapshot as any,
|
||||
} as any);
|
||||
toast.success("Tạo dự án (kèm JSON) thành công!");
|
||||
closeModal();
|
||||
setFormData({ title: "", description: "", project_status: "PRIVATE" });
|
||||
setImportSnapshot(null);
|
||||
setImportSnapshotName(null);
|
||||
if (importJsonInputRef.current) importJsonInputRef.current.value = "";
|
||||
fetchProjects();
|
||||
router.push(`/editor/${projectId}`);
|
||||
} catch (error) {
|
||||
console.error("Lỗi tạo dự án với JSON:", error);
|
||||
toast.error("Có lỗi xảy ra khi tạo dự án với JSON.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportHeadSnapshot = async (project: Project) => {
|
||||
const projectId = String(project.id || "").trim();
|
||||
if (!projectId) return;
|
||||
const headCommitId = project.latest_commit_id ? String(project.latest_commit_id) : "";
|
||||
if (!headCommitId) {
|
||||
toast.warning("Dự án chưa có head commit để export.");
|
||||
return;
|
||||
}
|
||||
setIsExportingProjectId(projectId);
|
||||
try {
|
||||
const res: any = await apiGetProjectCommits(projectId);
|
||||
const rawList = res?.data?.items ?? res?.data ?? res?.items ?? [];
|
||||
const commits = Array.isArray(rawList) ? rawList : [];
|
||||
const head = commits.find((c: any) => String(c?.id || "") === headCommitId) || null;
|
||||
const snapshot = head?.snapshot_json ?? null;
|
||||
if (!snapshot) {
|
||||
toast.error("Không tìm thấy snapshot_json của head commit.");
|
||||
return;
|
||||
}
|
||||
const blob = new Blob([JSON.stringify(snapshot, null, 2)], { type: "application/json;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `project-${projectId}-head-${headCommitId}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
toast.success("Đã export JSON snapshot.");
|
||||
} catch (err) {
|
||||
console.error("Export snapshot failed", err);
|
||||
toast.error("Export thất bại.");
|
||||
} finally {
|
||||
setIsExportingProjectId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSort = (column: ProjectSortColumn) => {
|
||||
if (sortBy === column) {
|
||||
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
|
||||
@@ -216,23 +228,47 @@ export default function ProjectsPage() {
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case "PUBLIC":
|
||||
return <Badge size="sm" variant="light" color="success">PUBLIC</Badge>;
|
||||
return (
|
||||
<Badge size="sm" variant="light" color="success">
|
||||
PUBLIC
|
||||
</Badge>
|
||||
);
|
||||
case "PRIVATE":
|
||||
return <Badge size="sm" variant="light" color="warning">PRIVATE</Badge>;
|
||||
return (
|
||||
<Badge size="sm" variant="light" color="warning">
|
||||
PRIVATE
|
||||
</Badge>
|
||||
);
|
||||
case "ARCHIVE":
|
||||
return <Badge size="sm" variant="light" color="light">ARCHIVE</Badge>;
|
||||
return (
|
||||
<Badge size="sm" variant="light" color="light">
|
||||
ARCHIVE
|
||||
</Badge>
|
||||
);
|
||||
default:
|
||||
return <Badge size="sm" variant="light" color="dark">{status}</Badge>;
|
||||
return (
|
||||
<Badge size="sm" variant="light" color="dark">
|
||||
{status}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const SortButton = ({ column, label }: { column: ProjectSortColumn; label: string }) => {
|
||||
const SortButton = ({
|
||||
column,
|
||||
label,
|
||||
}: {
|
||||
column: ProjectSortColumn;
|
||||
label: string;
|
||||
}) => {
|
||||
const isActive = sortBy === column;
|
||||
return (
|
||||
<button
|
||||
onClick={() => handleSort(column)}
|
||||
className={`flex items-center gap-1 text-sm font-medium hover:text-blue-500 transition-colors ${
|
||||
isActive ? "text-blue-600 dark:text-blue-400" : "text-gray-500 dark:text-gray-400"
|
||||
isActive
|
||||
? "text-blue-600 dark:text-blue-400"
|
||||
: "text-gray-500 dark:text-gray-400"
|
||||
}`}
|
||||
>
|
||||
<span>{label}</span>
|
||||
@@ -246,15 +282,22 @@ export default function ProjectsPage() {
|
||||
return `JSON: ${importSnapshotName}`;
|
||||
}, [importSnapshotName]);
|
||||
|
||||
// const path =[
|
||||
// {name: "Thư viện", href:"/user/library/"}
|
||||
// ]
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto pb-10">
|
||||
<PageBreadcrumb pageTitle="Quản lý dự án" />
|
||||
|
||||
<div className="mx-auto pb-10">
|
||||
{/* <PageBreadcrumb pageTitle="Quản lý dự án" /> */}
|
||||
<StickyHeader header={`Quản lý dự án`} />
|
||||
<div className="mt-6">
|
||||
<ComponentCard
|
||||
title="Danh sách dự án"
|
||||
headerAction={
|
||||
<Button size="sm" onClick={openModal} className="bg-brand-500 hover:bg-brand-600 text-white">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={openModal}
|
||||
className="bg-brand-500 hover:bg-brand-600 text-white rounded-4xl! "
|
||||
>
|
||||
+ Tạo dự án mới
|
||||
</Button>
|
||||
}
|
||||
@@ -273,12 +316,18 @@ export default function ProjectsPage() {
|
||||
<div className="flex-1 pr-4">
|
||||
<SortButton column="title" label="Tên dự án" />
|
||||
</div>
|
||||
<div className="w-48 px-4 text-sm font-medium text-gray-500 dark:text-gray-400">Trạng thái</div>
|
||||
<div className="w-48 px-4 text-sm font-medium text-gray-500 dark:text-gray-400">Thành viên</div>
|
||||
<div className="w-48 px-4 text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
Trạng thái
|
||||
</div>
|
||||
<div className="w-48 px-4 text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||
Thành viên
|
||||
</div>
|
||||
<div className="w-32 px-4">
|
||||
<SortButton column="updated_at" label="Cập nhật" />
|
||||
</div>
|
||||
<div className="w-48 px-4 text-sm font-medium text-gray-500 dark:text-gray-400 text-right">Thao tác</div>
|
||||
<div className="w-48 px-4 text-sm font-medium text-gray-500 dark:text-gray-400 text-right">
|
||||
Thao tác
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col divide-y divide-gray-200 dark:divide-gray-800">
|
||||
@@ -290,25 +339,36 @@ export default function ProjectsPage() {
|
||||
<div className="flex-1 pr-4 min-w-0">
|
||||
<div className="items-center gap-3 mb-1.5">
|
||||
<h3
|
||||
onClick={() => router.push(`/user/projects/${project.id}`)}
|
||||
onClick={() =>
|
||||
router.push(`/user/projects/${project.id}`)
|
||||
}
|
||||
className="font-semibold text-blue-600 dark:text-[#58a6ff] truncate cursor-pointer hover:underline"
|
||||
>
|
||||
{project.title}
|
||||
</h3>
|
||||
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-[#8b949e]">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{project.user?.avatar_url ? (
|
||||
<Image src={project.user.avatar_url} alt="avatar" width={16} height={16} className="rounded-full object-cover" />
|
||||
<Image
|
||||
src={project.user.avatar_url}
|
||||
alt="avatar"
|
||||
width={16}
|
||||
height={16}
|
||||
className="rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-4 h-4 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
|
||||
<span className="text-[8px] font-bold text-gray-500 dark:text-gray-300">
|
||||
{project.user?.display_name?.charAt(0)?.toUpperCase() || "U"}
|
||||
{project.user?.display_name
|
||||
?.charAt(0)
|
||||
?.toUpperCase() || "U"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<span className="truncate max-w-[150px]">{project.user?.display_name || "Unknown"}</span>
|
||||
<span className="truncate max-w-[150px]">
|
||||
{project.user?.display_name || "Unknown"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -321,18 +381,41 @@ export default function ProjectsPage() {
|
||||
<div className="flex -space-x-2 overflow-hidden">
|
||||
{project.members && project.members.length > 0 ? (
|
||||
<>
|
||||
{project.members.slice(0, 4).map((m: any, index: number) =>
|
||||
{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 dark:ring-[#0d1117]" />
|
||||
<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 dark:ring-[#0d1117]"
|
||||
/>
|
||||
) : (
|
||||
<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 dark:ring-[#0d1117]">
|
||||
<span className="text-xs font-medium text-gray-500 dark:text-gray-300">{m.display_name?.charAt(0)?.toUpperCase() || "U"}</span>
|
||||
<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 dark:ring-[#0d1117]"
|
||||
>
|
||||
<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 dark:ring-[#0d1117] z-10">
|
||||
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">+{project.members.length - 4}</span>
|
||||
<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 dark:ring-[#0d1117] z-10"
|
||||
>
|
||||
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
+{project.members.length - 4}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -352,10 +435,23 @@ export default function ProjectsPage() {
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="!p-0 w-9 h-9 flex items-center justify-center"
|
||||
onClick={() => router.push(`/editor/${project.id}`)}
|
||||
onClick={() =>
|
||||
router.push(`/editor/${project.id}`)
|
||||
}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-4 h-4">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
<span className="absolute -top-8 left-1/2 -translate-x-1/2 scale-0 rounded bg-gray-900 px-2 py-1 text-[11px] font-medium text-white opacity-0 transition-all group-hover/btn1:scale-100 group-hover/btn1:opacity-100 z-50 pointer-events-none whitespace-nowrap shadow-sm dark:bg-gray-700">
|
||||
@@ -368,31 +464,51 @@ export default function ProjectsPage() {
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="!p-0 w-9 h-9 flex items-center justify-center"
|
||||
disabled={isExportingProjectId === String(project.id)}
|
||||
disabled={
|
||||
isExportingProjectId === String(project.id)
|
||||
}
|
||||
onClick={() => handleExportHeadSnapshot(project)}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-4 h-4">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m.75 12l3 3m0 0l3-3m-3 3v-6m-1.5-9H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
className="w-4 h-4"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m.75 12l3 3m0 0l3-3m-3 3v-6m-1.5-9H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
|
||||
/>
|
||||
</svg>
|
||||
</Button>
|
||||
<span className="absolute -top-8 left-1/2 -translate-x-1/2 scale-0 rounded bg-gray-900 px-2 py-1 text-[11px] font-medium text-white opacity-0 transition-all group-hover/btn2:scale-100 group-hover/btn2:opacity-100 z-50 pointer-events-none whitespace-nowrap shadow-sm dark:bg-gray-700">
|
||||
Export JSON
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : !isLoading && (
|
||||
) : (
|
||||
!isLoading && (
|
||||
<div className="py-20 text-center">
|
||||
<p className="text-gray-500 dark:text-gray-400">Bạn chưa có dự án nào.</p>
|
||||
<Button size="sm" onClick={openModal} className="mt-4 bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300 hover:bg-gray-200">
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Bạn chưa có dự án nào.
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={openModal}
|
||||
className="mt-4 bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300 hover:bg-gray-200"
|
||||
>
|
||||
Tạo dự án đầu tiên
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</ComponentCard>
|
||||
@@ -400,10 +516,14 @@ export default function ProjectsPage() {
|
||||
|
||||
<Modal isOpen={isOpen} onClose={closeModal} className="max-w-[500px] m-4">
|
||||
<div className="p-6 bg-white rounded-3xl dark:bg-gray-900">
|
||||
<h3 className="mb-5 text-xl font-bold text-gray-800 dark:text-white/90">Tạo dự án mới</h3>
|
||||
<h3 className="mb-5 text-xl font-bold text-gray-800 dark:text-white/90">
|
||||
Tạo dự án mới
|
||||
</h3>
|
||||
<form onSubmit={handleCreateProject} className="flex flex-col gap-5">
|
||||
<div>
|
||||
<Label>Tên dự án <span className="text-red-500">*</span></Label>
|
||||
<Label>
|
||||
Tên dự án <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<input
|
||||
type="text"
|
||||
name="title"
|
||||
@@ -417,14 +537,29 @@ export default function ProjectsPage() {
|
||||
<div>
|
||||
<Label>Trạng thái</Label>
|
||||
<select
|
||||
name="project_status"
|
||||
value={formData.project_status}
|
||||
name="status"
|
||||
value={formData.status || "PRIVATE"} // Fallback về PRIVATE nếu undefined
|
||||
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"
|
||||
className="h-11 w-full rounded-xl border border-gray-200 bg-white 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:bg-gray-900 dark:focus:border-brand-800"
|
||||
>
|
||||
<option value="PRIVATE">Riêng tư (Private)</option>
|
||||
<option value="PUBLIC">Công khai (Public)</option>
|
||||
<option value="ARCHIVE">Lưu trữ (Archive)</option>
|
||||
<option
|
||||
value="PRIVATE"
|
||||
className="text-gray-800 dark:text-white bg-white dark:bg-gray-900"
|
||||
>
|
||||
Riêng tư (Private)
|
||||
</option>
|
||||
<option
|
||||
value="PUBLIC"
|
||||
className="text-gray-800 dark:text-white bg-white dark:bg-gray-900"
|
||||
>
|
||||
Công khai (Public)
|
||||
</option>
|
||||
<option
|
||||
value="ARCHIVE"
|
||||
className="text-gray-800 dark:text-white bg-white dark:bg-gray-900"
|
||||
>
|
||||
Lưu trữ (Archive)
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
@@ -441,7 +576,12 @@ export default function ProjectsPage() {
|
||||
<div>
|
||||
<Label>Khởi tạo từ JSON</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="outline" type="button" onClick={handlePickImportJson}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={handlePickImportJson}
|
||||
>
|
||||
Chọn JSON
|
||||
</Button>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||
@@ -453,22 +593,31 @@ export default function ProjectsPage() {
|
||||
type="file"
|
||||
accept="application/json"
|
||||
className="hidden"
|
||||
onChange={(e) => handleImportJsonFile(e.target.files?.[0] || null)}
|
||||
onChange={(e) =>
|
||||
handleImportJsonFile(e.target.files?.[0] || null)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 mt-4">
|
||||
<Button size="sm" variant="outline" type="button" onClick={closeModal}>Hủy</Button>
|
||||
<Button size="sm" type="submit" disabled={isSubmitting} className="bg-brand-500 hover:bg-brand-600 text-white">
|
||||
{isSubmitting ? "Đang tạo..." : "Khởi tạo"}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
>
|
||||
Hủy
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
type="button"
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="bg-gray-900 hover:bg-gray-800 text-white"
|
||||
onClick={handleCreateProjectWithJson}
|
||||
className="bg-indigo-600 hover:bg-indigo-700 text-white"
|
||||
>
|
||||
Tạo với JSON
|
||||
{isSubmitting
|
||||
? "Đang xử lý..."
|
||||
: importSnapshot
|
||||
? "Tạo với JSON"
|
||||
: "Tạo dự án"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -19,6 +19,7 @@ import { toast } from "sonner";
|
||||
import { newId } from "@/uhm/lib/utils/id";
|
||||
import Swal from "sweetalert2";
|
||||
import { PresignedUrlResponse } from "@/interface/media";
|
||||
import StickyHeader from "@/components/ui/StickyHeader";
|
||||
|
||||
type PendingFile = {
|
||||
id: string;
|
||||
@@ -197,9 +198,9 @@ export default function RoleUpgrade() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto">
|
||||
<StickyHeader header="Đăng ký trở thành Nhà sử học" />
|
||||
<div className="max-w-4xl mx-auto pb-20">
|
||||
<PageBreadcrumb pageTitle="Đăng ký trở thành Nhà sử học" />
|
||||
|
||||
<div className="flex items-center justify-between bg-white dark:bg-gray-900 p-2 rounded-xl border border-gray-200 dark:border-gray-800 mb-6">
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
@@ -221,7 +222,6 @@ export default function RoleUpgrade() {
|
||||
{showPreview ? "Preview Mode" : "Edit Mode"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="relative min-h-[400px]">
|
||||
{!showPreview ? (
|
||||
@@ -318,7 +318,9 @@ export default function RoleUpgrade() {
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center h-full ${docStyle.bg} p-2`}
|
||||
>
|
||||
<span className={`text-xs font-bold ${docStyle.color}`}>
|
||||
<span
|
||||
className={`text-xs font-bold ${docStyle.color}`}
|
||||
>
|
||||
{item.extension.toUpperCase()}
|
||||
</span>
|
||||
<span className="text-[10px] truncate w-full text-center mt-1">
|
||||
@@ -363,5 +365,6 @@ export default function RoleUpgrade() {
|
||||
plugins={[Zoom, Captions]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ interface BreadcrumbProps {
|
||||
|
||||
const PageBreadcrumb: React.FC<BreadcrumbProps> = ({ pageTitle, paths }) => {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-6">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-6 sticky top-0 z-40 bg-linear-to-b from-gray-50 via-gray-50/60 to-transparent backdrop-blur-xs pb-8 pt-8">
|
||||
<h2
|
||||
className="text-xl font-semibold text-gray-800 dark:text-white/90"
|
||||
x-text="pageName"
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
|
||||
interface BreadcrumbPath {
|
||||
name: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
interface StickyHeaderProps {
|
||||
header: string;
|
||||
paths?: BreadcrumbPath[];
|
||||
}
|
||||
|
||||
export default function StickyHeader({ header, paths }: StickyHeaderProps) {
|
||||
return (
|
||||
<div className="sticky top-0 z-40 bg-gradient-to-b from-gray-50 via-gray-50/60 to-transparent pb-8 pt-8 backdrop-blur-xs dark:from-zinc-950 dark:via-zinc-950/60">
|
||||
<div className="flex justify-between items-end">
|
||||
<h1 className="text-3xl font-bold tracking-tight text-gray-900 dark:text-white">
|
||||
{header}
|
||||
</h1>
|
||||
{paths && paths.length > 0 && (
|
||||
<nav className="mb-3">
|
||||
<ol className="flex flex-wrap items-center gap-1.5 text-sm">
|
||||
<li>
|
||||
<Link
|
||||
className="inline-flex items-center gap-1.5 text-gray-500 transition-colors hover:text-gray-800 dark:text-gray-400 dark:hover:text-white/90"
|
||||
href="/"
|
||||
>
|
||||
Home
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
<span className="text-gray-400 dark:text-zinc-600">
|
||||
<svg width="12" height="12" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg" className="stroke-current" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M6.0765 12.667L10.2432 8.50033L6.0765 4.33366" />
|
||||
</svg>
|
||||
</span>
|
||||
|
||||
{paths.map((path, index) => (
|
||||
<React.Fragment key={index}>
|
||||
<li>
|
||||
<Link
|
||||
className="inline-flex items-center gap-1.5 text-gray-500 transition-colors hover:text-gray-800 dark:text-gray-400 dark:hover:text-white/90"
|
||||
href={path.href}
|
||||
>
|
||||
{path.name}
|
||||
</Link>
|
||||
</li>
|
||||
<span className="text-gray-400 dark:text-zinc-600">
|
||||
<svg width="12" height="12" viewBox="0 0 17 16" fill="none" xmlns="http://www.w3.org/2000/svg" className="stroke-current" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M6.0765 12.667L10.2432 8.50033L6.0765 4.33366" />
|
||||
</svg>
|
||||
</span>
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
<li className="font-medium text-gray-800 dark:text-white/90 truncate max-w-[200px]">
|
||||
{header}
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -95,6 +95,7 @@ export default function ChatbotWidget({
|
||||
<div className="fixed bottom-8 right-8 z-50">
|
||||
{!isOpen && (
|
||||
<button
|
||||
name="AI chat"
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="w-14 h-14 bg-brand-500 hover:bg-brand-600 text-white rounded-full flex items-center justify-center shadow-[0_4px_14px_rgba(0,0,0,0.25)] transition-transform hover:scale-105"
|
||||
>
|
||||
|
||||
@@ -7,7 +7,7 @@ import Input from "../form/input/InputField";
|
||||
import Label from "../form/Label";
|
||||
import { UserMetaCardProps } from "@/interface/user";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { EyeCloseIcon, EyeIcon } from "@/icons";
|
||||
import { EyeCloseIcon, EyeIcon, LockIcon, MailIcon } from "@/icons";
|
||||
import { apiChangePassword } from "@/service/auth";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -89,26 +89,19 @@ export default function AccountDetails({ data }: { data: UserMetaCardProps }) {
|
||||
formValues.new_password !== formValues.confirm_password;
|
||||
return (
|
||||
<>
|
||||
<div className="p-5 border border-red-200 rounded-2xl dark:border-gray-800 lg:p-6">
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="border-t border-red-200 py-6">
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold text-gray-800 dark:text-white/90 lg:mb-6">
|
||||
Account Details
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:gap-7 2xl:gap-x-32">
|
||||
<div>
|
||||
<p className="mb-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
Email Address
|
||||
</p>
|
||||
<p className="text-sm font-medium text-gray-800 dark:text-white/90">
|
||||
<div className="">
|
||||
<div className="flex items-center gap-2">
|
||||
<MailIcon className="leading-normal text-gray-500"/>
|
||||
<p className="text-sm font-medium text-gray-600 dark:text-white/90">
|
||||
{data?.data?.email || "example@mail.com"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
Password
|
||||
</p>
|
||||
<p className="text-sm font-medium text-gray-800 dark:text-white/90">
|
||||
<div className="flex items-center gap-3 mt-2">
|
||||
<LockIcon className="leading-normal text-gray-500"/>
|
||||
<p className="text-sm font-medium text-gray-600 dark:text-white/90">
|
||||
••••••••
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -117,15 +117,12 @@ export default function ApplicationList({
|
||||
return (
|
||||
<div className="rounded-2xl border border-gray-200 bg-white p-6 dark:border-zinc-800 dark:bg-zinc-900/50">
|
||||
<div className="mb-8 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between border-b border-gray-100 pb-5 dark:border-zinc-800">
|
||||
<h3 className="text-xl font-bold text-gray-800 dark:text-white/90">
|
||||
Hồ sơ{" "}
|
||||
<h3 className="text-xl font-bold text-gray-800 dark:text-white/90 items-center flex">
|
||||
Hồ sơ
|
||||
<span className="ml-2 text-sm font-normal text-gray-500">
|
||||
({applications.length} tệp)
|
||||
</span>
|
||||
</h3>
|
||||
{/* <div className="text-sm text-gray-500">
|
||||
Cập nhật lần cuối: {new Date().toLocaleDateString("vi-VN")}
|
||||
</div> */}
|
||||
</div>
|
||||
|
||||
{/* CHỈ SỬA DÒNG NÀY: Tăng số lượng cột (grid-cols) để các thẻ nhỏ lại */}
|
||||
|
||||
@@ -13,7 +13,6 @@ import { deleteMedia } from "@/service/mediaService";
|
||||
import { INITIAL_LIMIT } from "../../../constant";
|
||||
import { MediaItem } from "../tables/MediaTable";
|
||||
|
||||
|
||||
export default function MediaLibrary({
|
||||
data,
|
||||
onRefresh,
|
||||
@@ -256,7 +255,6 @@ export default function MediaLibrary({
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-gray-200 bg-white p-6 dark:border-zinc-800 dark:bg-zinc-900/50">
|
||||
<div className="mb-6 flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
|
||||
@@ -271,7 +269,7 @@ export default function MediaLibrary({
|
||||
{selectedIds.length > 0 && (
|
||||
<button
|
||||
onClick={handleDeleteSelected}
|
||||
className="flex items-center gap-1 rounded-lg bg-red-50 px-4 py-2 text-sm font-medium text-red-600 transition-colors hover:bg-red-100 dark:bg-red-500/10 dark:hover:bg-red-500/20"
|
||||
className="flex items-center gap-1 h-10 rounded-4xl bg-red-50 px-4 py-2 text-sm font-medium text-red-600 transition-colors hover:bg-red-100 dark:bg-red-500/10 dark:hover:bg-red-500/20"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
@@ -286,13 +284,13 @@ export default function MediaLibrary({
|
||||
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>
|
||||
Xóa ({selectedIds.length})
|
||||
Xóa {selectedIds.length}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={toggleSelectionMode}
|
||||
className={`rounded-lg px-4 py-2 text-sm font-medium transition-all w-17.5 h-10 ${
|
||||
className={`rounded-4xl px-4 py-2 text-sm font-medium transition-all w-17.5 h-10 ${
|
||||
isSelectionMode
|
||||
? "bg-blue-500 text-white shadow-md hover:bg-blue-600"
|
||||
: "border border-gray-200 bg-gray-50 text-gray-700 hover:bg-gray-100 dark:border-zinc-700 dark:bg-zinc-800 dark:text-gray-200 dark:hover:bg-zinc-700"
|
||||
@@ -319,7 +317,7 @@ export default function MediaLibrary({
|
||||
<div className="mt-6 flex justify-center">
|
||||
<button
|
||||
onClick={() => setShowAllImages(!showAllImages)}
|
||||
className="rounded-lg border border-gray-200 bg-gray-50 px-5 py-2 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 dark:border-zinc-700 dark:bg-zinc-800 dark:text-gray-300"
|
||||
className="rounded-4xl border border-gray-200 bg-gray-50 px-5 py-2 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 dark:border-zinc-700 dark:bg-zinc-800 dark:text-gray-300"
|
||||
>
|
||||
{showAllImages
|
||||
? "Thu gọn"
|
||||
@@ -336,7 +334,6 @@ export default function MediaLibrary({
|
||||
Tài liệu ({documentFiles.length})
|
||||
</h4>
|
||||
<div className="grid grid-cols-3 gap-3 sm:grid-cols-4 md:grid-cols-5 lg:grid-cols-6 xl:grid-cols-8">
|
||||
|
||||
{displayedDocs.map((item, idx) =>
|
||||
renderItemCard(item, false, idx),
|
||||
)}
|
||||
@@ -345,7 +342,7 @@ export default function MediaLibrary({
|
||||
<div className="mt-6 flex justify-center">
|
||||
<button
|
||||
onClick={() => setShowAllDocs(!showAllDocs)}
|
||||
className="rounded-lg border border-gray-200 bg-gray-50 px-5 py-2 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 dark:border-zinc-700 dark:bg-zinc-800 dark:text-gray-300"
|
||||
className="rounded-4xl border border-gray-200 bg-gray-50 px-5 py-2 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 dark:border-zinc-700 dark:bg-zinc-800 dark:text-gray-300"
|
||||
>
|
||||
{showAllDocs
|
||||
? "Thu gọn"
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"use client";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useModal } from "../../hooks/useModal";
|
||||
import { Modal } from "../ui/modal";
|
||||
import Button from "../ui/button/Button";
|
||||
@@ -11,10 +10,9 @@ import { apiUpdateUser } from "@/service/userService";
|
||||
import { toast } from "sonner";
|
||||
import Link from "next/link";
|
||||
import { AxiosError } from "axios";
|
||||
|
||||
import { LinkIcon, LocationIcon, MailIcon, PhoneIcon } from "@/icons";
|
||||
|
||||
export default function UserInfoCard({ data }: { data: UserMetaCardProps }) {
|
||||
const router = useRouter();
|
||||
const { isOpen, openModal, closeModal } = useModal();
|
||||
const [formData, setFormData] = useState<Profile>({
|
||||
display_name: "",
|
||||
@@ -65,7 +63,10 @@ export default function UserInfoCard({ data }: { data: UserMetaCardProps }) {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError<{ status: boolean; message: string }>;
|
||||
const axiosError = error as AxiosError<{
|
||||
status: boolean;
|
||||
message: string;
|
||||
}>;
|
||||
const serverResponse = axiosError.response?.data;
|
||||
|
||||
if (serverResponse && serverResponse.status === false) {
|
||||
@@ -82,88 +83,15 @@ export default function UserInfoCard({ data }: { data: UserMetaCardProps }) {
|
||||
|
||||
console.error("Lỗi chi tiết:", axiosError);
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-5 border border-gray-200 rounded-2xl dark:border-gray-800 lg:p-6">
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold text-gray-800 dark:text-white/90 lg:mb-6">
|
||||
Personal Information
|
||||
</h4>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:gap-7 2xl:gap-x-32">
|
||||
<div>
|
||||
<p className="mb-2 text-xs leading-normal text-gray-500 dark:text-gray-400">
|
||||
Full Name
|
||||
</p>
|
||||
<p className="text-sm font-medium text-gray-800 dark:text-white/90">
|
||||
{data.data?.profile?.display_name || "Full Name"}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 text-xs leading-normal text-gray-500 dark:text-gray-400">
|
||||
Email address
|
||||
</p>
|
||||
<p className="text-sm font-medium text-gray-800 dark:text-white/90">
|
||||
{data.data?.email || "Email address"}
|
||||
</p>
|
||||
</div>
|
||||
{data.data?.profile?.phone && (
|
||||
<div>
|
||||
<p className="mb-2 text-xs leading-normal text-gray-500 dark:text-gray-400">
|
||||
Phone
|
||||
</p>
|
||||
<p className="text-sm font-medium text-gray-800 dark:text-white/90">
|
||||
{data.data?.profile?.phone || "+XXX XXX XXX"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.data?.profile?.bio && (
|
||||
<div>
|
||||
<p className="mb-2 text-xs leading-normal text-gray-500 dark:text-gray-400">
|
||||
Bio
|
||||
</p>
|
||||
<p className="text-sm font-medium text-gray-800 dark:text-white/90">
|
||||
{data.data?.profile?.bio || "No bio available"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.data?.profile?.location && (
|
||||
<div>
|
||||
<p className="mb-2 text-xs leading-normal text-gray-500 dark:text-gray-400">
|
||||
Address
|
||||
</p>
|
||||
<p className="text-sm font-medium text-gray-800 dark:text-white/90">
|
||||
{data.data?.profile?.location || "No location available"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{data.data?.profile?.website && (
|
||||
<div>
|
||||
<p className="mb-2 text-xs leading-normal text-gray-500 dark:text-gray-400">
|
||||
Website
|
||||
</p>
|
||||
<Link
|
||||
href={data.data?.profile?.website || "#"}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm font-medium text-gray-800 dark:text-white/90 hover:text-blue-500 dark:hover:text-blue-400"
|
||||
>
|
||||
{data.data?.profile?.website || "No website"}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="py-6">
|
||||
<div className="flex flex-col gap-6">
|
||||
{data.openEdit && (
|
||||
<button
|
||||
onClick={openModal}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-full border border-gray-300 bg-white px-4 py-3 text-sm font-medium text-gray-700 shadow-theme-xs hover:bg-gray-50 hover:text-gray-800 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-white/[0.03] dark:hover:text-gray-200 lg:inline-flex lg:w-auto"
|
||||
className="flex w-full items-center justify-center gap-2 rounded-full border border-gray-300 bg-white px-4 py-3 text-sm font-medium text-gray-700 shadow-theme-xs hover:bg-gray-50 hover:text-gray-600 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-white/[0.03] dark:hover:text-gray-200 lg:inline-flex lg:w-auto"
|
||||
>
|
||||
<svg
|
||||
className="fill-current"
|
||||
@@ -181,19 +109,65 @@ export default function UserInfoCard({ data }: { data: UserMetaCardProps }) {
|
||||
Edit
|
||||
</button>
|
||||
)}
|
||||
<div>
|
||||
<div className=" gap-4 lg:grid-cols-2 lg:gap-7 2xl:gap-x-32">
|
||||
<div className="flex items-center gap-2 py-2">
|
||||
<MailIcon className="leading-normal text-gray-500" />
|
||||
|
||||
<p className="text-sm font-medium text-gray-600 ">
|
||||
{data.data?.email || "Email address"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{data.data?.profile?.phone && (
|
||||
<div className="flex items-center gap-2 py-2">
|
||||
<PhoneIcon className="w-5 h-5 opacity-60" />
|
||||
|
||||
<p className="text-sm font-medium text-gray-600 ">
|
||||
{data.data?.profile?.phone || "+XXX XXX XXX"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.data?.profile?.location && (
|
||||
<div className="flex items-center gap-2 py-2">
|
||||
<LocationIcon className="w-5 h-5 opacity-60" />
|
||||
|
||||
<p className="text-sm font-medium text-gray-600 ">
|
||||
{data.data?.profile?.location || "No location available"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.data?.profile?.website && (
|
||||
<div className="flex items-center gap-2 py-2">
|
||||
<LinkIcon className="w-5 h-5 opacity-60" />
|
||||
|
||||
<Link
|
||||
href={data.data?.profile?.website || "#"}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm font-medium text-gray-600 hover:text-blue-500 dark:hover:text-blue-400"
|
||||
>
|
||||
{data.data?.profile?.website || "No website"}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal isOpen={isOpen} onClose={closeModal} className="max-w-[700px] m-4">
|
||||
<div className="no-scrollbar relative w-full max-w-[700px] overflow-y-auto rounded-3xl bg-white p-4 dark:bg-gray-900 lg:p-11">
|
||||
<div className="px-2 pr-14">
|
||||
<h4 className="mb-2 text-2xl font-semibold text-gray-800 dark:text-white/90">
|
||||
<h4 className="mb-2 text-2xl font-semibold text-gray-600 ">
|
||||
Edit Personal Information
|
||||
</h4>
|
||||
</div>
|
||||
<form className="flex flex-col" onSubmit={handleSave}>
|
||||
<div className="custom-scrollbar h-[450px] overflow-y-auto px-2 pb-3">
|
||||
<div className="mt-7">
|
||||
<h5 className="mb-5 text-lg font-medium text-gray-800 dark:text-white/90 lg:mb-6">
|
||||
<h5 className="mb-5 text-lg font-medium text-gray-600 lg:mb-6">
|
||||
Personal Information
|
||||
</h5>
|
||||
|
||||
|
||||
@@ -72,16 +72,16 @@ export default function UserMetaCard({ data }: { data: UserMetaCardProps }) {
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="p-5 border border-gray-200 rounded-2xl dark:border-gray-800 lg:p-6">
|
||||
<div className="flex flex-col gap-5 xl:flex-row xl:items-center xl:justify-between">
|
||||
<div className="flex flex-col items-center w-full gap-6 xl:flex-row">
|
||||
<div className="">
|
||||
<div className="flex flex-col gap-5 ">
|
||||
<div className="flex flex-col items-center w-full gap-6">
|
||||
<div
|
||||
onClick={handleAvatarClick}
|
||||
className="relative w-20 h-20 overflow-hidden border border-gray-200 rounded-full cursor-pointer dark:border-gray-800 group shrink-0"
|
||||
className="relative w-[296px] h-[296px] overflow-hidden border border-gray-200 rounded-full cursor-pointer dark:border-gray-800 group shrink-0"
|
||||
>
|
||||
<Image
|
||||
width={80}
|
||||
height={80}
|
||||
width={296}
|
||||
height={296}
|
||||
src={previewImage}
|
||||
alt="avatar"
|
||||
className="object-cover w-full h-full"
|
||||
@@ -142,26 +142,23 @@ export default function UserMetaCard({ data }: { data: UserMetaCardProps }) {
|
||||
</div>
|
||||
|
||||
<div className="order-3 xl:order-2">
|
||||
<h4 className="mb-2 text-lg font-semibold text-center text-gray-800 dark:text-white/90 xl:text-left">
|
||||
{data.data?.profile?.display_name || "Full Name"}
|
||||
</h4>
|
||||
<div className="flex flex-col items-center gap-1 text-center xl:flex-row xl:gap-3 xl:text-left">
|
||||
<p className="text-sm text-blue-500 dark:text-gray-400">
|
||||
{data.data?.roles?.map((role) => role.name).join(", ") ||
|
||||
"No roles available"}
|
||||
</p>
|
||||
</div>
|
||||
<h4 className="mb-2 text-2xl font-semibold text-gray-700 text-left">
|
||||
{data.data?.profile?.display_name || "Full Name"}
|
||||
</h4>
|
||||
{data.data?.profile?.bio && (
|
||||
<>
|
||||
<div className="hidden h-3.5 w-px bg-gray-300 dark:bg-gray-700 xl:block"></div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 max-w-[450px] truncate">
|
||||
<p className="text-sm text-gray-500">
|
||||
{data.data.profile.bio}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
ID: {data.data?.id}
|
||||
</span>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
ID: <span>{data.data?.id}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+6
-1
@@ -51,7 +51,9 @@ import HorizontaLDots from "./horizontal-dots.svg";
|
||||
import ChatIcon from "./chat.svg";
|
||||
import MoreDotIcon from "./more-dot.svg";
|
||||
import BellIcon from "./bell.svg";
|
||||
|
||||
import PhoneIcon from "./phone.svg";
|
||||
import LocationIcon from "./location.svg";
|
||||
import LinkIcon from "./link.svg";
|
||||
export {
|
||||
DownloadIcon,
|
||||
BellIcon,
|
||||
@@ -106,4 +108,7 @@ export {
|
||||
HorizontaLDots,
|
||||
ChevronUpIcon,
|
||||
ChatIcon,
|
||||
PhoneIcon,
|
||||
LocationIcon,
|
||||
LinkIcon,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
|
||||
<svg width="64px" height="64px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
|
||||
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
|
||||
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,7 @@
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Transformed by: SVG Repo Mixer Tools -->
|
||||
<svg width="64px" height="64px" viewBox="0 0 32.00 32.00" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sketch="http://www.bohemiancoding.com/sketch/ns" fill="#000000" transform="matrix(-1, 0, 0, 1, 0, 0)rotate(0)" stroke="#000000">
|
||||
|
||||
<g id="SVGRepo_bgCarrier" stroke-width="0"/>
|
||||
|
||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14.05 6C15.0268 6.19057 15.9244 6.66826 16.6281 7.37194C17.3318 8.07561 17.8095 8.97326 18 9.95M14.05 2C16.0793 2.22544 17.9716 3.13417 19.4163 4.57701C20.8609 6.01984 21.7721 7.91101 22 9.94M18.5 21C9.93959 21 3 14.0604 3 5.5C3 5.11378 3.01413 4.73086 3.04189 4.35173C3.07375 3.91662 3.08968 3.69907 3.2037 3.50103C3.29814 3.33701 3.4655 3.18146 3.63598 3.09925C3.84181 3 4.08188 3 4.56201 3H7.37932C7.78308 3 7.98496 3 8.15802 3.06645C8.31089 3.12515 8.44701 3.22049 8.55442 3.3441C8.67601 3.48403 8.745 3.67376 8.88299 4.05321L10.0491 7.26005C10.2096 7.70153 10.2899 7.92227 10.2763 8.1317C10.2643 8.31637 10.2012 8.49408 10.0942 8.64506C9.97286 8.81628 9.77145 8.93713 9.36863 9.17882L8 10C9.2019 12.6489 11.3501 14.7999 14 16L14.8212 14.6314C15.0629 14.2285 15.1837 14.0271 15.3549 13.9058C15.5059 13.7988 15.6836 13.7357 15.8683 13.7237C16.0777 13.7101 16.2985 13.7904 16.74 13.9509L19.9468 15.117C20.3262 15.255 20.516 15.324 20.6559 15.4456C20.7795 15.553 20.8749 15.6891 20.9335 15.842C21 16.015 21 16.2169 21 16.6207V19.438C21 19.9181 21 20.1582 20.9007 20.364C20.8185 20.5345 20.663 20.7019 20.499 20.7963C20.3009 20.9103 20.0834 20.9262 19.6483 20.9581C19.2691 20.9859 18.8862 21 18.5 21Z" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -72,7 +72,7 @@ export interface RestoreCommitPayload {
|
||||
export interface CreateProjectPayload
|
||||
{
|
||||
description: string,
|
||||
project_status: "PRIVATE" | "PUBLIC" | "ARCHIVE",
|
||||
status: "PRIVATE" | "PUBLIC" | "ARCHIVE",
|
||||
title: string
|
||||
}
|
||||
|
||||
|
||||
+236
-150
@@ -1,4 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
@@ -6,20 +7,17 @@ import { usePathname } from "next/navigation";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import {
|
||||
BoxCubeIcon,
|
||||
CalenderIcon,
|
||||
ChevronDownIcon,
|
||||
FileIcon,
|
||||
GridIcon,
|
||||
HorizontaLDots,
|
||||
ListIcon,
|
||||
PageIcon,
|
||||
PieChartIcon,
|
||||
PlugInIcon,
|
||||
ShootingStarIcon,
|
||||
TableIcon,
|
||||
UserCircleIcon,
|
||||
} from "../icons/index";
|
||||
|
||||
import { apiGetCurrentUser, apiLogout } from "@/service/auth";
|
||||
import { UserMetaCardProps } from "@/interface/user";
|
||||
|
||||
type NavItem = {
|
||||
name: string;
|
||||
icon: React.ReactNode;
|
||||
@@ -33,85 +31,76 @@ type NavItem = {
|
||||
};
|
||||
|
||||
const ALL_NAV_ITEMS: NavItem[] = [
|
||||
{
|
||||
icon: <GridIcon />,
|
||||
name: "Trang Chủ",
|
||||
// subItems: [{ name: "Ecommerce", path: "/", pro: false }],
|
||||
path: "/",
|
||||
},
|
||||
{
|
||||
icon: <BoxCubeIcon />,
|
||||
name: "Dự Án",
|
||||
path: "/user/projects",
|
||||
},
|
||||
{
|
||||
icon: <FileIcon />,
|
||||
name: "Thư Viện",
|
||||
path: "/user/library",
|
||||
},
|
||||
{
|
||||
icon: <UserCircleIcon />,
|
||||
name: "Tài Khoản",
|
||||
path: "/user/account",
|
||||
},
|
||||
|
||||
{ icon: <GridIcon />, name: "Trang Chủ", path: "/" },
|
||||
{ icon: <BoxCubeIcon />, name: "Dự Án", path: "/user/projects" },
|
||||
{ icon: <FileIcon />, name: "Thư Viện", path: "/user/library" },
|
||||
];
|
||||
|
||||
const OTHERS_ITEMS: NavItem[] = [
|
||||
// {
|
||||
// icon: <PieChartIcon />,
|
||||
// name: "Charts",
|
||||
// subItems: [
|
||||
// { name: "Line Chart", path: "/line-chart", pro: false },
|
||||
// { name: "Bar Chart", path: "/bar-chart", pro: false },
|
||||
// ],
|
||||
// },
|
||||
// {
|
||||
// icon: <BoxCubeIcon />,
|
||||
// name: "UI Elements",
|
||||
// subItems: [
|
||||
// { name: "Alerts", path: "/alerts", pro: false },
|
||||
// { name: "Avatar", path: "/avatars", pro: false },
|
||||
// { name: "Badge", path: "/badge", pro: false },
|
||||
// { name: "Buttons", path: "/buttons", pro: false },
|
||||
// { name: "Images", path: "/images", pro: false },
|
||||
// { name: "Videos", path: "/videos", pro: false },
|
||||
// ],
|
||||
// },
|
||||
// {
|
||||
// icon: <PlugInIcon />,
|
||||
// name: "Authentication",
|
||||
// subItems: [
|
||||
// { name: "Sign In", path: "/signin", pro: false },
|
||||
// { name: "Sign Up", path: "/signup", pro: false },
|
||||
// ],
|
||||
// },
|
||||
{
|
||||
icon: <ShootingStarIcon />,
|
||||
name: "Về Chúng Tôi",
|
||||
path: "/user/about-us",
|
||||
},
|
||||
{
|
||||
icon: <ShootingStarIcon />,
|
||||
name: "Hỗ trợ",
|
||||
path: "/user/quick-qa",
|
||||
},
|
||||
{ icon: <ShootingStarIcon />, name: "Hỗ trợ", path: "/user/quick-qa" },
|
||||
];
|
||||
|
||||
const AppSidebar: React.FC = () => {
|
||||
const { isExpanded, isMobileOpen, isHovered, setIsHovered } = useSidebar();
|
||||
const { isExpanded, isMobileOpen, toggleSidebar, toggleMobileSidebar } = useSidebar();
|
||||
const pathname = usePathname();
|
||||
const [user, setUser] = useState<UserMetaCardProps | null>(null);
|
||||
|
||||
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 isSidebarVisible = isExpanded || isMobileOpen;
|
||||
|
||||
const handleToggle = () => {
|
||||
if (window.innerWidth >= 1024) {
|
||||
toggleSidebar();
|
||||
} else {
|
||||
toggleMobileSidebar();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true;
|
||||
const fetchUser = async () => {
|
||||
try {
|
||||
const userData = await apiGetCurrentUser();
|
||||
if (isMounted) setUser(userData);
|
||||
} catch (err) {
|
||||
console.error("Lỗi fetch user:", err);
|
||||
}
|
||||
};
|
||||
fetchUser();
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const clearAllCookies = () => {
|
||||
document.cookie.split(";").forEach((c) => {
|
||||
document.cookie = c
|
||||
.replace(/^ +/, "")
|
||||
.replace(/=.*/, "=;expires=" + new Date().toUTCString() + ";path=/");
|
||||
});
|
||||
};
|
||||
|
||||
const handleLogout = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await apiLogout();
|
||||
} catch (error) {
|
||||
console.error("Logout failed", error);
|
||||
} finally {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
clearAllCookies();
|
||||
window.location.href = "/signin";
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmenuToggle = (index: number, menuType: "main" | "others") => {
|
||||
setOpenSubmenu((prev) =>
|
||||
@@ -121,18 +110,21 @@ const AppSidebar: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// Tự động mở submenu nếu route hiện tại nằm trong subItems
|
||||
useEffect(() => {
|
||||
let submenuMatched = false;
|
||||
[
|
||||
{ items: ALL_NAV_ITEMS, type: "main" },
|
||||
{ items: OTHERS_ITEMS, type: "others" },
|
||||
].forEach(({ items, type }) => {
|
||||
const menuGroups = [
|
||||
{ items: ALL_NAV_ITEMS, type: "main" as const },
|
||||
{ items: OTHERS_ITEMS, type: "others" as const },
|
||||
];
|
||||
|
||||
menuGroups.forEach(({ items, type }) => {
|
||||
items.forEach((nav, index) => {
|
||||
nav.subItems?.forEach((sub) => {
|
||||
if (isActive(sub.path)) {
|
||||
setOpenSubmenu((prev) => {
|
||||
if (prev?.type === type && prev?.index === index) return prev;
|
||||
return { type: type as "main" | "others", index };
|
||||
return { type, index };
|
||||
});
|
||||
submenuMatched = true;
|
||||
}
|
||||
@@ -145,44 +137,46 @@ const AppSidebar: React.FC = () => {
|
||||
}
|
||||
}, [pathname, isActive]);
|
||||
|
||||
// Tính toán chiều cao cho hiệu ứng mượt mà của submenu
|
||||
useEffect(() => {
|
||||
if (openSubmenu !== null) {
|
||||
const key = `${openSubmenu.type}-${openSubmenu.index}`;
|
||||
if (subMenuRefs.current[key]) {
|
||||
requestAnimationFrame(() => {
|
||||
setSubMenuHeight((prev) => ({
|
||||
...prev,
|
||||
[key]: subMenuRefs.current[key]?.scrollHeight || 0,
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [openSubmenu]);
|
||||
|
||||
const renderMenuItems = (items: NavItem[], menuType: "main" | "others") => (
|
||||
<ul className="flex flex-col gap-4">
|
||||
{items.map((nav, index) => (
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{items.map((nav, index) => {
|
||||
const isOpen = openSubmenu?.type === menuType && openSubmenu?.index === index;
|
||||
const refKey = `${menuType}-${index}`;
|
||||
|
||||
return (
|
||||
<li key={nav.name}>
|
||||
{nav.subItems ? (
|
||||
<button
|
||||
onClick={() => handleSubmenuToggle(index, menuType)}
|
||||
className={`menu-item group uppercase ${openSubmenu?.type === menuType && openSubmenu?.index === index
|
||||
? "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"
|
||||
}
|
||||
className={`menu-item group capitalize ${
|
||||
isOpen ? "menu-item-active" : "menu-item-icon-inactive"
|
||||
} ${!isExpanded ? "lg:justify-center px-2" : "lg:justify-start"}`}
|
||||
>
|
||||
<span className={`flex h-5 w-5 shrink-0 items-center justify-center ${isOpen ? "text-gray-900" : "text-gray-400"}`}>
|
||||
{nav.icon}
|
||||
</span>
|
||||
{(isExpanded || isHovered || isMobileOpen) && (
|
||||
{isSidebarVisible && (
|
||||
<>
|
||||
<span className={`menu-item-text`}>{nav.name}</span>
|
||||
<span className="block truncate text-[14px] font-medium">
|
||||
{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" : ""}`}
|
||||
className={`ml-auto h-4 w-4 transition-transform duration-200 ${isOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -191,60 +185,43 @@ const AppSidebar: React.FC = () => {
|
||||
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"
|
||||
}
|
||||
className={`menu-item group ${
|
||||
isActive(nav.path) ? "menu-item-active" : "menu-item-icon-inactive"
|
||||
} ${!isExpanded ? "lg:justify-center px-2" : "lg:justify-start"}`}
|
||||
>
|
||||
<span className={`menu-item-icon ${isActive(nav.path) ? "text-gray-950" : "text-gray-400"}`}>
|
||||
{nav.icon}
|
||||
</span>
|
||||
{(isExpanded || isHovered || isMobileOpen) && (
|
||||
<span className={`menu-item-text`}>{nav.name}</span>
|
||||
{isSidebarVisible && (
|
||||
<span className="block truncate text-[14px]">{nav.name}</span>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
)}
|
||||
{nav.subItems && (isExpanded || isHovered || isMobileOpen) && (
|
||||
|
||||
{/* Submenu Area */}
|
||||
{nav.subItems && isSidebarVisible && (
|
||||
<div
|
||||
ref={(el) => {
|
||||
subMenuRefs.current[`${menuType}-${index}`] = el;
|
||||
subMenuRefs.current[refKey] = el;
|
||||
}}
|
||||
className="overflow-hidden transition-all duration-300"
|
||||
className="overflow-hidden ease-in-out transition-all duration-300"
|
||||
style={{
|
||||
height:
|
||||
openSubmenu?.type === menuType && openSubmenu?.index === index
|
||||
? `${subMenuHeight[`${menuType}-${index}`]}px`
|
||||
: "0px",
|
||||
height: isOpen ? `${subMenuHeight[refKey] || 0}px` : "0px",
|
||||
}}
|
||||
>
|
||||
<ul className="mt-2 space-y-1 ml-9">
|
||||
<ul className="ml-4 mt-1 space-y-1 border-l border-gray-200 pl-4">
|
||||
{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"}`}
|
||||
className={`block rounded-xl py-2 text-[13px] transition-colors ${
|
||||
isActive(subItem.path)
|
||||
? "font-medium text-gray-950"
|
||||
: "text-gray-400 hover:text-gray-900"
|
||||
}`}
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
@@ -252,54 +229,163 @@ const AppSidebar: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
|
||||
return (
|
||||
<aside
|
||||
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)}
|
||||
className={`fixed left-0 top-0 z-50 flex h-screen flex-col border-r border-gray-200 bg-white text-gray-900 will-change-[width]
|
||||
${isSidebarVisible ? "w-[290px] px-5" : "w-0 border-none px-0 overflow-hidden sm:w-[88px] sm:border-solid sm:px-4"}
|
||||
${isMobileOpen ? "translate-x-0" : "-translate-x-full"} lg:translate-x-0 transition-all duration-300`}
|
||||
>
|
||||
{/* HEADER LOGO & TOGGLE */}
|
||||
<div
|
||||
className={`py-8 flex ${!isExpanded && !isHovered ? "lg:justify-center" : "justify-start"}`}
|
||||
className={`mb-2 flex w-full shrink-0 items-center ${
|
||||
!isSidebarVisible
|
||||
? "h-auto flex-col justify-center gap-3 py-4"
|
||||
: "h-24 flex-row justify-between py-8"
|
||||
}`}
|
||||
>
|
||||
<Link href="/">
|
||||
<Link href="/" className="flex shrink-0 items-center justify-center" aria-label="Go to homepage">
|
||||
<Image
|
||||
src="/images/logo/logo.svg"
|
||||
alt="Logo"
|
||||
width={isExpanded || isHovered || isMobileOpen ? 80 : 32}
|
||||
height={isExpanded || isHovered || isMobileOpen ? 50 : 32}
|
||||
width={isSidebarVisible ? 80 : 36}
|
||||
height={isSidebarVisible ? 45 : 36}
|
||||
priority
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-col overflow-y-auto duration-300 ease-linear no-scrollbar">
|
||||
<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"}`}
|
||||
|
||||
{isSidebarVisible && (
|
||||
<button
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-gray-300 bg-white/80 text-gray-400 hover:bg-white"
|
||||
onClick={handleToggle}
|
||||
aria-label="Collapse Sidebar"
|
||||
>
|
||||
{isExpanded || isHovered || isMobileOpen ? (
|
||||
"Menu"
|
||||
) : (
|
||||
<HorizontaLDots />
|
||||
<svg width="15" height="15" 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>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!isSidebarVisible && (
|
||||
<button
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-gray-300 bg-white/80 text-gray-400 hover:bg-white"
|
||||
onClick={handleToggle}
|
||||
aria-label="Expand Sidebar"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 16 12" fill="none" xmlns="http://www.w3.org/2000/svg" className="rotate-180">
|
||||
<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>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* USER PROFILE & NAVIGATION */}
|
||||
<div className="no-scrollbar flex flex-1 flex-col overflow-y-auto duration-200">
|
||||
<div className={`my-4 flex items-center gap-3 p-2 ${!isExpanded ? "justify-center px-2" : "rounded-4xl border border-gray-200"}`}>
|
||||
<div className="h-10 w-10 shrink-0 overflow-hidden rounded-full border-2 border-white bg-gray-100 shadow-sm">
|
||||
<Image
|
||||
width={40}
|
||||
height={40}
|
||||
src={user?.data?.profile?.avatar_url || "/images/no-images.jpg"}
|
||||
alt="User"
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
{isSidebarVisible && (
|
||||
<div className="overflow-hidden">
|
||||
<span className="block truncate text-[14px] font-semibold text-gray-950">
|
||||
{user?.data?.profile?.display_name || "User"}
|
||||
</span>
|
||||
<span className="mt-0.5 block truncate text-[11px] font-medium text-gray-400">
|
||||
{user?.data?.email || "email"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<nav className="flex flex-col gap-5">
|
||||
<div>
|
||||
<h2 className={`mb-3 flex text-[11px] font-bold capitalize tracking-wider text-gray-400/80 ${!isExpanded ? "lg:justify-center" : "justify-start pl-2"}`}>
|
||||
{isSidebarVisible ? "Menu" : <HorizontaLDots />}
|
||||
</h2>
|
||||
{renderMenuItems(ALL_NAV_ITEMS, "main")}
|
||||
</div>
|
||||
<div>
|
||||
<h2 className={`mb-4 text-xs uppercase flex leading-[20px] text-gray-400 ${!isExpanded && !isHovered ? "lg:justify-center" : "justify-start"}`}>
|
||||
{isExpanded || isHovered || isMobileOpen ? "Others" : <HorizontaLDots />}
|
||||
<h2 className={`mb-3 flex text-[11px] font-bold capitalize tracking-wider text-gray-400/80 ${!isExpanded ? "lg:justify-center" : "justify-start pl-2"}`}>
|
||||
{isSidebarVisible ? "Others" : <HorizontaLDots />}
|
||||
</h2>
|
||||
{renderMenuItems(OTHERS_ITEMS, "others")}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* FOOTER ACTIONS - Được đồng bộ style */}
|
||||
<div className="mt-auto shrink-0 border-t border-gray-200/50 py-4">
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
<li>
|
||||
<Link
|
||||
href="/user/account"
|
||||
className={`menu-item group ${
|
||||
isActive("/user/account") ? "menu-item-active" : "menu-item-icon-inactive"
|
||||
} ${!isExpanded ? "lg:justify-center px-2" : "lg:justify-start"}`}
|
||||
>
|
||||
<span className={`menu-item-icon ${isActive("/user/account") ? "text-gray-950" : "text-gray-400"}`}>
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M12 3.5C7.30558 3.5 3.5 7.30558 3.5 12C3.5 14.1526 4.3002 16.1184 5.61936 17.616C6.17279 15.3096 8.24852 13.5955 10.7246 13.5955H13.2746C15.7509 13.5955 17.8268 15.31 18.38 17.6167C19.6996 16.119 20.5 14.153 20.5 12C20.5 7.30558 16.6944 3.5 12 3.5ZM17.0246 18.8566V18.8455C17.0246 16.7744 15.3457 15.0955 13.2746 15.0955H10.7246C8.65354 15.0955 6.97461 16.7744 6.97461 18.8455V18.856C8.38223 19.8895 10.1198 20.5 12 20.5C13.8798 20.5 15.6171 19.8898 17.0246 18.8566ZM2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12ZM11.9991 7.25C10.8847 7.25 9.98126 8.15342 9.98126 9.26784C9.98126 10.3823 10.8847 11.2857 11.9991 11.2857C13.1135 11.2857 14.0169 10.3823 14.0169 9.26784C14.0169 8.15342 13.1135 7.25 11.9991 7.25ZM8.48126 9.26784C8.48126 7.32499 10.0563 5.75 11.9991 5.75C13.9419 5.75 15.5169 7.32499 15.5169 9.26784C15.5169 11.2107 13.9419 12.7857 11.9991 12.7857C10.0563 12.7857 8.48126 11.2107 8.48126 9.26784Z" fill="currentColor" />
|
||||
</svg>
|
||||
</span>
|
||||
{isSidebarVisible && <span className="block truncate text-[14px]">Tài Khoản</span>}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/user/role-upgrade"
|
||||
className={`menu-item group ${
|
||||
isActive("/user/role-upgrade") ? "menu-item-active" : "menu-item-icon-inactive"
|
||||
} ${!isExpanded ? "lg:justify-center px-2" : "lg:justify-start"}`}
|
||||
>
|
||||
<span className={`menu-item-icon ${isActive("/user/role-upgrade") ? "text-gray-950" : "text-gray-400"}`}>
|
||||
<ListIcon />
|
||||
</span>
|
||||
{isSidebarVisible && <span className="block truncate text-[14px]">Nhà Sử Học</span>}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/user/about-us"
|
||||
className={`menu-item group ${
|
||||
isActive("/user/about-us") ? "menu-item-active" : "menu-item-icon-inactive"
|
||||
} ${!isExpanded ? "lg:justify-center px-2" : "lg:justify-start"}`}
|
||||
>
|
||||
<span className={`menu-item-icon ${isActive("/user/about-us") ? "text-gray-950" : "text-gray-400"}`}>
|
||||
<ShootingStarIcon />
|
||||
</span>
|
||||
{isSidebarVisible && <span className="block truncate text-[14px]">Về chúng tôi</span>}
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
<li className="mt-2 border-t border-gray-200/40 pt-2">
|
||||
<button
|
||||
name="logout"
|
||||
onClick={handleLogout}
|
||||
className={`menu-item group w-full transition-all duration-200 hover:bg-red-50/50 ${
|
||||
!isExpanded ? "lg:justify-center px-2" : "lg:justify-start"
|
||||
}`}
|
||||
>
|
||||
<span className="menu-item-icon text-red-500">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M15.1007 19.247C14.6865 19.247 14.3507 18.9112 14.3507 18.497L14.3507 14.245H12.8507V18.497C12.8507 19.7396 13.8581 20.747 15.1007 20.747H18.5007C19.7434 20.747 20.7507 19.7396 20.7507 18.497L20.7507 5.49609C20.7507 4.25345 19.7433 3.24609 18.5007 3.24609H15.1007C13.8581 3.24609 12.8507 4.25345 12.8507 5.49609V9.74501L14.3507 9.74501V5.49609C14.3507 5.08188 14.6865 4.74609 15.1007 4.74609L18.5007 4.74609C18.9149 4.74609 19.2507 5.08188 19.2507 5.49609L19.2507 18.497C19.2507 18.9112 18.9149 19.247 18.5007 19.247H15.1007ZM3.25073 11.9984C3.25073 12.2144 3.34204 12.4091 3.48817 12.546L8.09483 17.1556C8.38763 17.4485 8.86251 17.4487 9.15549 17.1559C9.44848 16.8631 9.44863 16.3882 9.15583 16.0952L5.81116 12.7484L16.0007 12.7484C16.4149 12.7484 16.7507 12.4127 16.7507 11.9984C16.7507 11.5842 16.4149 11.2484 16.0007 11.2484L5.81528 11.2484L9.15585 7.90554C9.44864 7.61255 9.44847 7.13767 9.15547 6.84488C8.86248 6.55209 8.3876 6.55226 8.09481 6.84525L3.52309 11.4202C3.35673 11.5577 3.25073 11.7657 3.25073 11.9984Z" fill="currentColor" />
|
||||
</svg>
|
||||
</span>
|
||||
{isSidebarVisible && <span className="block truncate text-[14px] font-medium text-red-500">Đăng xuất</span>}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -8,7 +8,7 @@ const Backdrop: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-gray-900/50 lg:hidden"
|
||||
className="fixed inset-0 z-40 bg-gray-900/30 backdrop-blur-sm dark:bg-black/50 lg:hidden"
|
||||
onClick={toggleMobileSidebar}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,7 @@ export default function SidebarWidget() {
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
mx-auto mb-10 w-full max-w-60 rounded-2xl bg-gray-50 px-4 py-5 text-center dark:bg-white/[0.03]`}
|
||||
mx-auto mb-10 w-full max-w-60 rounded-2xl bg-gray-100 px-4 py-5 text-center dark:bg-gray-800`}
|
||||
>
|
||||
<h3 className="mb-2 font-semibold text-gray-900 dark:text-white">
|
||||
#1 Tailwind CSS Dashboard
|
||||
@@ -16,7 +16,7 @@ export default function SidebarWidget() {
|
||||
href="https://tailadmin.com/pricing"
|
||||
target="_blank"
|
||||
rel="nofollow"
|
||||
className="flex items-center justify-center p-3 font-medium text-white rounded-lg bg-brand-500 text-theme-sm hover:bg-brand-600"
|
||||
className="flex items-center justify-center p-3 font-medium text-white rounded-lg bg-indigo-600 text-theme-sm hover:bg-indigo-700"
|
||||
>
|
||||
Upgrade To Pro
|
||||
</a>
|
||||
|
||||
Reference in New Issue
Block a user