big update new layout
Build and Release / release (push) Successful in 35s

This commit is contained in:
2026-05-19 18:00:19 +07:00
parent e7c8322c63
commit f5514b8fb5
46 changed files with 1032 additions and 702 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+33
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+10 -20
View File
@@ -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
</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></>
);
}
+4 -5
View File
@@ -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 ?? {}} />
+2 -8
View File
@@ -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>
+8 -8
View File
@@ -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 nội dung nào trong thư viện
</p>
</div>
@@ -66,5 +65,6 @@ export default function LibraryPage() {
</div>
)}
</div>
</div>
);
}
+1 -1
View File
@@ -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",
};
+9 -6
View File
@@ -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
View File
@@ -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 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 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 (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 (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>
+7 -4
View File
@@ -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>
);
}
+1 -1
View File
@@ -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"
+69
View File
@@ -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>
);
}
+1
View File
@@ -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"
>
+10 -17
View File
@@ -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ồ {" "}
<h3 className="text-xl font-bold text-gray-800 dark:text-white/90 items-center flex">
Hồ
<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 */}
+5 -8
View File
@@ -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"
+56 -82
View File
@@ -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>
+14 -17
View File
@@ -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
View File
@@ -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,
};
+7
View File
@@ -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

+7
View File
@@ -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

+4
View File
@@ -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

+1 -1
View File
@@ -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
View File
@@ -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>
);
};
+1 -1
View File
@@ -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}
/>
);
+2 -2
View File
@@ -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>