Compare commits
1 Commits
043ef08b87
...
staging
| Author | SHA1 | Date | |
|---|---|---|---|
| 4d2c121fd5 |
3
api.ts
3
api.ts
@@ -10,11 +10,8 @@ export const API = {
|
||||
APPLICATION: `${API_URL_ROOT}/users/current/application`
|
||||
},
|
||||
Media:{
|
||||
GET_MEDIA: `${API_URL_ROOT}/media`,
|
||||
PRESIGNED: `${API_URL_ROOT}/media/presigned`,
|
||||
GET_MEDIA_BY_ID: (Id: number | string) => `${API_URL_ROOT}/media/${Id}`,
|
||||
DELETE_MEDIA_BY_ID: (Id: number | string) => `${API_URL_ROOT}/media/${Id}`,
|
||||
DELETE_MEDIA: `${API_URL_ROOT}/media`,
|
||||
},
|
||||
Auth : {
|
||||
LOGOUT: `${API_URL_ROOT}/auth/logout`,
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
export const LIMIT_ITEM_TABLE = 5;
|
||||
export const INITIAL_LIMIT = 16;
|
||||
export const LIMIT_ITEM_TABLE = 5;
|
||||
73
middleware.ts
Normal file
73
middleware.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { PUBLIC_ROUTES, canAccessRoute, UserRole } from "./src/config/routes.config"
|
||||
|
||||
/**
|
||||
* Middleware để kiểm tra authentication và authorization
|
||||
* Chạy TRƯỚC khi render page
|
||||
*/
|
||||
export async function middleware(request: NextRequest) {
|
||||
const { pathname } = request.nextUrl
|
||||
|
||||
// 1. Kiểm tra nếu là public route
|
||||
if (PUBLIC_ROUTES.includes(pathname)) {
|
||||
// Nếu user đã login, không cho vào signin/signup
|
||||
const userDataCookie = request.cookies.get("userDataRedux")
|
||||
if (userDataCookie && (pathname === "/signin" || pathname === "/signup")) {
|
||||
return NextResponse.redirect(new URL("/", request.url))
|
||||
}
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
// 2. Kiểm tra user data cookie (prioritize này để tránh redirect loop)
|
||||
const userDataCookie = request.cookies.get("userDataRedux")
|
||||
|
||||
if (userDataCookie) {
|
||||
try {
|
||||
const userData = JSON.parse(userDataCookie.value)
|
||||
const userRoles: UserRole[] = userData.roles?.map((r: any) => r.name) || []
|
||||
|
||||
// Kiểm tra user có quyền truy cập route này không
|
||||
if (!canAccessRoute(userRoles, pathname)) {
|
||||
// Redirect về 403 page
|
||||
return NextResponse.redirect(new URL("/error-403", request.url))
|
||||
}
|
||||
|
||||
// User có quyền, cho qua
|
||||
return NextResponse.next()
|
||||
} catch (error) {
|
||||
console.error("Error parsing user data in middleware:", error)
|
||||
// Xóa cookie lỗi
|
||||
const response = NextResponse.redirect(new URL("/signin", request.url))
|
||||
response.cookies.delete("userDataRedux")
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Kiểm tra token từ backend (HTTP-only cookie)
|
||||
const token = request.cookies.get("token") || request.cookies.get("access_token")
|
||||
|
||||
// 4. Nếu không có token và không có user data, redirect về signin
|
||||
if (!token) {
|
||||
const signinUrl = new URL("/signin", request.url)
|
||||
signinUrl.searchParams.set("from", pathname)
|
||||
return NextResponse.redirect(signinUrl)
|
||||
}
|
||||
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
/**
|
||||
* Cấu hình matcher - middleware chỉ chạy cho những routes này
|
||||
*/
|
||||
export const config = {
|
||||
matcher: [
|
||||
/*
|
||||
* Chạy middleware cho tất cả paths ngoại trừ:
|
||||
* - _next/static (static files)
|
||||
* - _next/image (image optimization files)
|
||||
* - favicon.ico (favicon file)
|
||||
* - public folder
|
||||
*/
|
||||
"/((?!_next/static|_next/image|favicon.ico|public).*)",
|
||||
],
|
||||
}
|
||||
@@ -41,11 +41,13 @@ export default function RoleUpgrade() {
|
||||
|
||||
const handleIframeLoad = () => {
|
||||
const iframe = iframeRef.current;
|
||||
|
||||
// Đảm bảo iframe và nội dung bên trong đã sẵn sàng và cùng nguồn gốc (same-origin)
|
||||
if (!iframe || !iframe.contentDocument) return;
|
||||
|
||||
const updateHeight = () => {
|
||||
if (iframe.contentDocument) {
|
||||
// Mẹo: Reset height về 'auto' trước để lấy được chiều cao thực tế
|
||||
// (đặc biệt khi người dùng xóa bớt nội dung làm chiều cao ngắn lại)
|
||||
iframe.style.height = "auto";
|
||||
const scrollHeight =
|
||||
iframe.contentDocument.documentElement.scrollHeight;
|
||||
@@ -53,8 +55,11 @@ export default function RoleUpgrade() {
|
||||
}
|
||||
};
|
||||
|
||||
// 1. Cập nhật chiều cao ngay khi iframe load xong HTML
|
||||
updateHeight();
|
||||
|
||||
// 2. Dùng ResizeObserver để theo dõi những thay đổi sau khi load
|
||||
// (VD: ảnh bên trong tải xong làm nội dung dài ra)
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
updateHeight();
|
||||
});
|
||||
@@ -193,7 +198,7 @@ export default function RoleUpgrade() {
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto pb-20">
|
||||
<PageBreadcrumb pageTitle="Đăng ký trở thành Nhà sử học" />
|
||||
<PageBreadcrumb pageTitle="Nâng cấp tài khoản 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">
|
||||
|
||||
@@ -1,481 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import ComponentCard from "@/components/common/ComponentCard";
|
||||
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
|
||||
import Pagination from "@/components/tables/Pagination";
|
||||
import { toast } from "sonner";
|
||||
import MediaTable, {
|
||||
MediaItem,
|
||||
MediaSortColumn,
|
||||
} from "@/components/tables/MediaTable";
|
||||
import { LIMIT_ITEM_TABLE } from "../../../../../../constant";
|
||||
import { deleteMedia, deleteMediaById, getMedia } from "@/service/mediaService";
|
||||
import { URL_MEDIA } from "../../../../../../api";
|
||||
import Swal from "sweetalert2";
|
||||
|
||||
const formatDateTimeToISO = (
|
||||
dateStr: string,
|
||||
timeStr: string,
|
||||
isEndOfDay: boolean = false,
|
||||
): string | undefined => {
|
||||
if (!dateStr) return undefined;
|
||||
const time = timeStr || (isEndOfDay ? "23:59" : "00:00");
|
||||
return `${dateStr}T${time}:00.000000+07:00`;
|
||||
};
|
||||
|
||||
export interface MediaResponse {
|
||||
status: boolean;
|
||||
message: string;
|
||||
data: MediaItem[];
|
||||
pagination: {
|
||||
current_page: number;
|
||||
page_size: number;
|
||||
total_records: number;
|
||||
total_pages: number;
|
||||
};
|
||||
}
|
||||
|
||||
export default function AssetsPage() {
|
||||
const [page, setPage] = useState<number>(1);
|
||||
const [limitInput, setLimitInput] = useState<string>(
|
||||
LIMIT_ITEM_TABLE.toString(),
|
||||
);
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState<string>("");
|
||||
const [mimeTypeFilter, setMimeTypeFilter] = useState<string>("");
|
||||
|
||||
const [fromDate, setFromDate] = useState<string>("");
|
||||
const [fromTime, setFromTime] = useState<string>("");
|
||||
const [toDate, setToDate] = useState<string>("");
|
||||
const [toTime, setToTime] = useState<string>("");
|
||||
|
||||
const [debouncedParams, setDebouncedParams] = useState({
|
||||
search: "",
|
||||
limit: LIMIT_ITEM_TABLE,
|
||||
mimeType: "",
|
||||
fromDate: "",
|
||||
fromTime: "",
|
||||
toDate: "",
|
||||
toTime: "",
|
||||
});
|
||||
|
||||
const [tableData, setTableData] = useState<MediaResponse | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
|
||||
const [sortBy, setSortBy] = useState<MediaSortColumn>("created_at");
|
||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
|
||||
|
||||
// State quản lý checkbox & logic View (Yêu cầu 1 & 3)
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [index, setIndex] = useState<number>(-1); // Dùng cho thư viện xem ảnh
|
||||
const [isSelectionMode, setIsSelectionMode] = useState<boolean>(false);
|
||||
|
||||
const handleReset = () => {
|
||||
setSearchTerm("");
|
||||
setMimeTypeFilter("");
|
||||
setLimitInput(LIMIT_ITEM_TABLE.toString());
|
||||
setFromDate("");
|
||||
setFromTime("");
|
||||
setToDate("");
|
||||
setToTime("");
|
||||
setPage(1);
|
||||
setSelectedIds([]);
|
||||
|
||||
setDebouncedParams({
|
||||
search: "",
|
||||
limit: LIMIT_ITEM_TABLE,
|
||||
mimeType: "",
|
||||
fromDate: "",
|
||||
fromTime: "",
|
||||
toDate: "",
|
||||
toTime: "",
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedParams({
|
||||
search: searchTerm,
|
||||
limit: parseInt(limitInput) || LIMIT_ITEM_TABLE,
|
||||
mimeType: mimeTypeFilter,
|
||||
fromDate,
|
||||
fromTime,
|
||||
toDate,
|
||||
toTime,
|
||||
});
|
||||
setPage(1);
|
||||
}, 600);
|
||||
return () => clearTimeout(handler);
|
||||
}, [
|
||||
searchTerm,
|
||||
limitInput,
|
||||
mimeTypeFilter,
|
||||
fromDate,
|
||||
fromTime,
|
||||
toDate,
|
||||
toTime,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMediaData();
|
||||
}, [page, debouncedParams, sortBy, sortOrder]);
|
||||
|
||||
const fetchMediaData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const payload: any = {
|
||||
page: page,
|
||||
limit: debouncedParams.limit,
|
||||
search: debouncedParams.search || undefined,
|
||||
sort: sortBy,
|
||||
order: sortOrder,
|
||||
};
|
||||
|
||||
if (debouncedParams.mimeType)
|
||||
payload.mime_type = debouncedParams.mimeType;
|
||||
const createdFrom = formatDateTimeToISO(
|
||||
debouncedParams.fromDate,
|
||||
debouncedParams.fromTime,
|
||||
false,
|
||||
);
|
||||
if (createdFrom) payload.created_from = createdFrom;
|
||||
const createdTo = formatDateTimeToISO(
|
||||
debouncedParams.toDate,
|
||||
debouncedParams.toTime,
|
||||
true,
|
||||
);
|
||||
if (createdTo) payload.created_to = createdTo;
|
||||
|
||||
const response = await getMedia(payload);
|
||||
if (response?.status) {
|
||||
setTableData(response);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error("Lỗi lấy danh sách tệp tin");
|
||||
console.error("Lỗi lấy danh sách tệp tin:", err);
|
||||
setTableData(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [page, debouncedParams, sortBy, sortOrder]);
|
||||
|
||||
const handleSort = (column: MediaSortColumn) => {
|
||||
setPage(1);
|
||||
if (sortBy === column) {
|
||||
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
|
||||
} else {
|
||||
setSortBy(column);
|
||||
setSortOrder("desc");
|
||||
}
|
||||
};
|
||||
|
||||
// --- LOGIC YÊU CẦU 1: Chọn nhiều ---
|
||||
const handleToggleSelect = (id: string) => {
|
||||
setSelectedIds((prev) =>
|
||||
prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id],
|
||||
);
|
||||
};
|
||||
|
||||
const handleToggleSelectAll = (checked: boolean) => {
|
||||
if (checked && tableData) {
|
||||
setSelectedIds(tableData.data.map((i) => i.id));
|
||||
} else {
|
||||
setSelectedIds([]);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleItemSelection = (id: string) => {
|
||||
handleToggleSelect(id);
|
||||
};
|
||||
|
||||
const handleDeleteMulti = async () => {
|
||||
if (selectedIds.length === 0) return;
|
||||
|
||||
const result = await Swal.fire({
|
||||
title: "Xác nhận xóa?",
|
||||
text: `Bạn có chắc chắn muốn xóa ${selectedIds.length} tệp đã chọn? Hành động này không thể hoàn tác!`,
|
||||
icon: "warning",
|
||||
showCancelButton: true,
|
||||
confirmButtonColor: "#ef4444",
|
||||
cancelButtonColor: "#6b7280",
|
||||
confirmButtonText: "Xóa",
|
||||
cancelButtonText: "Hủy",
|
||||
reverseButtons: true,
|
||||
});
|
||||
|
||||
if (result.isConfirmed) {
|
||||
try {
|
||||
const response = await deleteMedia(selectedIds);
|
||||
if (response?.status) {
|
||||
await Swal.fire({
|
||||
title: "Đã xóa!",
|
||||
text: `Đã xóa thành công ${selectedIds.length} tệp tin.`,
|
||||
icon: "success",
|
||||
confirmButtonColor: "#3b82f6",
|
||||
timer: 2000,
|
||||
});
|
||||
setSelectedIds([]);
|
||||
fetchMediaData();
|
||||
} else {
|
||||
toast.error(response?.message || "Xóa tệp thất bại");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Đã xảy ra lỗi khi xóa");
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSingle = async (id: string) => {
|
||||
const result = await Swal.fire({
|
||||
title: "Xóa tệp tin?",
|
||||
text: "Bạn có chắc chắn muốn xóa tệp này không?",
|
||||
icon: "warning",
|
||||
showCancelButton: true,
|
||||
confirmButtonColor: "#ef4444",
|
||||
cancelButtonColor: "#6b7280",
|
||||
confirmButtonText: "Xóa ngay",
|
||||
cancelButtonText: "Quay lại",
|
||||
reverseButtons: true,
|
||||
});
|
||||
|
||||
if (result.isConfirmed) {
|
||||
try {
|
||||
const response = await deleteMediaById(id);
|
||||
if (response?.status) {
|
||||
await Swal.fire({
|
||||
title: "Thành công!",
|
||||
text: "Tệp tin đã được xóa bỏ.",
|
||||
icon: "success",
|
||||
confirmButtonColor: "#3b82f6",
|
||||
timer: 1500,
|
||||
});
|
||||
setSelectedIds((prev) => prev.filter((i) => i !== id));
|
||||
fetchMediaData();
|
||||
} else {
|
||||
toast.error(response?.message || "Không thể xóa tệp");
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error("Lỗi hệ thống khi xóa");
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleItemClick = (item: MediaItem, idx: number) => {
|
||||
const isImage = item.mime_type.includes("image");
|
||||
|
||||
if (isSelectionMode) {
|
||||
toggleItemSelection(item.id);
|
||||
} else {
|
||||
if (isImage) {
|
||||
setIndex(idx);
|
||||
} else {
|
||||
const fileUrl = `${URL_MEDIA}${item.storage_key}`;
|
||||
const googleDocsUrl = `https://docs.google.com/viewer?url=${encodeURIComponent(fileUrl)}&embedded=true`;
|
||||
window.open(googleDocsUrl, "_blank");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const pagination = tableData?.pagination;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageBreadcrumb pageTitle="Quản lý tệp tin (Assets)" />
|
||||
|
||||
<div className="space-y-6">
|
||||
<ComponentCard
|
||||
title="Bộ lọc tìm kiếm"
|
||||
headerAction={
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="flex items-center px-3 py-1.5 text-xs text-red-500 transition-colors border-red-100 dark:bg-red-500/10 dark:border-red-500/20 dark:hover:bg-red-500/20"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="w-7 h-7"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2.5}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-4 mb-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<div>
|
||||
<label className="block mb-2 text-sm font-medium">Tìm kiếm</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Tên tệp, ID..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 outline-none focus:border-brand-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block mb-2 text-sm font-medium">Loại tệp</label>
|
||||
<select
|
||||
value={mimeTypeFilter}
|
||||
onChange={(e) => setMimeTypeFilter(e.target.value)}
|
||||
className="w-full px-3 py-2 bg-white dark:bg-gray-900 border rounded-lg cursor-pointer outline-none focus:border-brand-500"
|
||||
>
|
||||
<option value="">Tất cả</option>
|
||||
<option value="image">Hình ảnh (webp, jpeg, png...)</option>
|
||||
<option value="application/pdf">PDF</option>
|
||||
<option value="application/msword">Word (doc, docx)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block mb-2 text-sm font-medium">Từ ngày</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="date"
|
||||
value={fromDate}
|
||||
onChange={(e) => setFromDate(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 outline-none focus:border-brand-500"
|
||||
/>
|
||||
<input
|
||||
type="time"
|
||||
value={fromTime}
|
||||
onChange={(e) => setFromTime(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 outline-none focus:border-brand-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block mb-2 text-sm font-medium">Đến ngày</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="date"
|
||||
value={toDate}
|
||||
onChange={(e) => setToDate(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 outline-none focus:border-brand-500"
|
||||
/>
|
||||
<input
|
||||
type="time"
|
||||
value={toTime}
|
||||
onChange={(e) => setToTime(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 outline-none focus:border-brand-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block mb-2 text-sm font-medium">
|
||||
Hiển thị (Limit)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={limitInput}
|
||||
onChange={(e) => setLimitInput(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 outline-none focus:border-brand-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ComponentCard>
|
||||
|
||||
<ComponentCard
|
||||
title="Danh sách tệp tin"
|
||||
headerAction={
|
||||
<button
|
||||
onClick={handleDeleteMulti}
|
||||
disabled={selectedIds.length === 0}
|
||||
className={`flex items-center px-4 py-2 text-sm font-medium rounded-lg transition-colors border ${
|
||||
selectedIds.length > 0
|
||||
? "bg-red-500 text-white border-red-500 hover:bg-red-600 shadow-sm cursor-pointer"
|
||||
: "bg-gray-100 text-gray-400 border-gray-200 cursor-not-allowed dark:bg-gray-800 dark:border-gray-700"
|
||||
}`}
|
||||
>
|
||||
Xóa {selectedIds.length > 0 && `(${selectedIds.length})`}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="relative min-h-[300px]">
|
||||
{loading && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/50 dark:bg-gray-900/50 rounded-xl">
|
||||
<div className="w-10 h-10 border-4 border-t-brand-500 rounded-full animate-spin"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MediaTable
|
||||
data={tableData?.data || []}
|
||||
onSort={handleSort}
|
||||
sortBy={sortBy}
|
||||
sortOrder={sortOrder}
|
||||
selectedIds={selectedIds}
|
||||
onToggleSelect={handleToggleSelect}
|
||||
onToggleSelectAll={handleToggleSelectAll}
|
||||
onViewSingle={handleItemClick}
|
||||
onDeleteSingle={handleDeleteSingle}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mt-6">
|
||||
<p className="text-sm text-gray-500">
|
||||
Hiển thị {pagination?.total_records || 0} tệp tin
|
||||
</p>
|
||||
|
||||
{pagination && pagination.total_pages > 1 && (
|
||||
<Pagination
|
||||
currentPage={pagination.current_page}
|
||||
totalPages={pagination.total_pages}
|
||||
onPageChange={(newPage) => setPage(newPage)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ComponentCard>
|
||||
</div>
|
||||
|
||||
{index >= 0 && tableData?.data && tableData.data[index] && (
|
||||
<div
|
||||
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/80 backdrop-blur-sm transition-opacity"
|
||||
onClick={() => setIndex(-1)} // Click ra vùng tối sẽ đóng
|
||||
>
|
||||
<div
|
||||
className="relative flex flex-col items-center justify-center p-4 max-w-[90vw] max-h-[90vh]"
|
||||
onClick={(e) => e.stopPropagation()} // Bấm vào ảnh không bị đóng
|
||||
>
|
||||
{/* Nút X đóng Modal */}
|
||||
<button
|
||||
onClick={() => setIndex(-1)}
|
||||
className="absolute -top-12 right-0 md:-right-12 text-white/70 hover:text-white transition-colors p-2"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="w-8 h-8"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<img
|
||||
src={`${URL_MEDIA}${tableData.data[index].storage_key}`}
|
||||
alt={tableData.data[index].original_name}
|
||||
className="max-w-full max-h-[80vh] object-contain rounded-lg shadow-2xl"
|
||||
/>
|
||||
|
||||
<p className="mt-4 text-sm font-medium text-white/90 truncate max-w-full">
|
||||
{tableData.data[index].original_name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import ComponentCard from "@/components/common/ComponentCard";
|
||||
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
|
||||
import Pagination from "@/components/tables/Pagination";
|
||||
import Swal from "sweetalert2";
|
||||
import { ProtectedRoute } from "@/components/auth/ProtectedRoute";
|
||||
|
||||
import ApplicationTable, {
|
||||
AppSortColumn,
|
||||
@@ -187,11 +188,12 @@ export default function HistorianApplicationPage() {
|
||||
|
||||
const pagination = tableData?.pagination;
|
||||
|
||||
// console.log(tableData)
|
||||
console.log(tableData)
|
||||
// console.log("Pagination info:", pagination);
|
||||
return (
|
||||
<div>
|
||||
<PageBreadcrumb pageTitle="Quản lý hồ sơ" />
|
||||
<ProtectedRoute requiredRoles={["ADMIN", "MOD", "HISTORIAN"]}>
|
||||
<div>
|
||||
<PageBreadcrumb pageTitle="Quản lý hồ sơ" />
|
||||
|
||||
<div className="space-y-6">
|
||||
<ComponentCard
|
||||
@@ -230,7 +232,6 @@ export default function HistorianApplicationPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* CẬP NHẬT: Loại xác minh */}
|
||||
<div>
|
||||
<label className="block mb-2 text-sm font-medium">
|
||||
Loại xác minh
|
||||
@@ -241,14 +242,13 @@ export default function HistorianApplicationPage() {
|
||||
className="w-full px-3 py-2 bg-white dark:bg-gray-900 border rounded-lg cursor-pointer outline-none focus:border-brand-500"
|
||||
>
|
||||
<option value="">Tất cả</option>
|
||||
<option value="ID_CARD">Thẻ nhận dạng nhà nghiên cứu</option>
|
||||
<option value="EDUCATION">Bằng cấp</option>
|
||||
<option value="EXPERT">Chuyên gia</option>
|
||||
<option value="OTHER">Khác</option>
|
||||
<option value="1">Thẻ nhận dạng nhà nghiên cứu</option>
|
||||
<option value="2">Bằng cấp</option>
|
||||
<option value="3">Chuyên gia</option>
|
||||
<option value="4">Khác</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* CẬP NHẬT: Trạng thái */}
|
||||
<div>
|
||||
<label className="block mb-2 text-sm font-medium">
|
||||
Trạng thái
|
||||
@@ -259,9 +259,9 @@ export default function HistorianApplicationPage() {
|
||||
className="w-full px-3 py-2 bg-white dark:bg-gray-900 border rounded-lg cursor-pointer outline-none focus:border-brand-500"
|
||||
>
|
||||
<option value="">Tất cả</option>
|
||||
<option value="PENDING">Đang chờ duyệt</option>
|
||||
<option value="APPROVED">Đã duyệt</option>
|
||||
<option value="REJECTED">Từ chối</option>
|
||||
<option value="1">Đang chờ duyệt</option>
|
||||
<option value="2">Đã duyệt</option>
|
||||
<option value="3">Từ chối</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -355,5 +355,6 @@ export default function HistorianApplicationPage() {
|
||||
onRefresh={fetchApplications}
|
||||
/>
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import Pagination from "@/components/tables/Pagination";
|
||||
import { LIMIT_ITEM_TABLE } from "../../../../../../constant";
|
||||
import { ProtectedRoute } from "@/components/auth/ProtectedRoute";
|
||||
|
||||
export type SortColumn = "created_at" | "updated_at" | "display_name" | "email";
|
||||
|
||||
@@ -32,7 +33,9 @@ const formatDateTimeToISO = (
|
||||
|
||||
export default function UserTable() {
|
||||
const [page, setPage] = useState<number>(1);
|
||||
const [limitInput, setLimitInput] = useState<string>(LIMIT_ITEM_TABLE.toString());
|
||||
const [limitInput, setLimitInput] = useState<string>(
|
||||
LIMIT_ITEM_TABLE.toString(),
|
||||
);
|
||||
|
||||
const [selectedRole, setSelectedRole] = useState<string>("");
|
||||
const [roles, setRoles] = useState<{ id: string; name: string }[]>([]);
|
||||
@@ -143,7 +146,6 @@ export default function UserTable() {
|
||||
role_ids: selectedRole ? [selectedRole] : undefined,
|
||||
};
|
||||
|
||||
// Thêm format ngày giờ vào payload
|
||||
const createdFrom = formatDateTimeToISO(
|
||||
debouncedParams.fromDate,
|
||||
debouncedParams.fromTime,
|
||||
@@ -185,7 +187,7 @@ export default function UserTable() {
|
||||
};
|
||||
|
||||
const pagination = tableData?.pagination;
|
||||
|
||||
|
||||
// console.log(pagination);
|
||||
|
||||
const handleOpenDetail = (user: fullDataUser) => {
|
||||
@@ -251,192 +253,197 @@ export default function UserTable() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageBreadcrumb pageTitle="Quản lý người dùng" />
|
||||
<div className="space-y-6">
|
||||
<ComponentCard
|
||||
title="Bộ lọc tìm kiếm"
|
||||
headerAction={
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="flex items-center px-3 py-1.5 text-xs text-red-500 transition-colors border-red-100 dark:bg-red-500/10 dark:border-red-500/20 dark:hover:bg-red-500/20"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="w-7 h-7"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
<ProtectedRoute requiredRoles={["ADMIN", "MOD"]}>
|
||||
<div>
|
||||
<PageBreadcrumb pageTitle="Quản lý người dùng" />
|
||||
<div className="space-y-6">
|
||||
<ComponentCard
|
||||
title="Bộ lọc tìm kiếm"
|
||||
headerAction={
|
||||
<button
|
||||
onClick={handleReset}
|
||||
className="flex items-center px-3 py-1.5 text-xs text-red-500 transition-colors border-red-100 dark:bg-red-500/10 dark:border-red-500/20 dark:hover:bg-red-500/20"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2.5}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="w-7 h-7"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2.5}
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-4 mb-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<div>
|
||||
<label className="block mb-2 text-sm font-medium">Search</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Name, email..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 outline-none focus:border-brand-500"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-4 mb-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
<div>
|
||||
<label className="block mb-2 text-sm font-medium">Search</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Name, email..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 outline-none focus:border-brand-500"
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block mb-2 text-sm font-medium">
|
||||
Auth Provider
|
||||
</label>
|
||||
<select
|
||||
value={authProvider}
|
||||
onChange={(e) => setAuthProvider(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 cursor-pointer bg-white outline-none focus:border-brand-500"
|
||||
>
|
||||
<option value="">Tất cả</option>
|
||||
<option value="google">Google</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block mb-2 text-sm font-medium">
|
||||
Trạng thái xóa
|
||||
</label>
|
||||
<select
|
||||
onChange={(e) =>
|
||||
setIsDeleted(
|
||||
e.target.value === ""
|
||||
? undefined
|
||||
: e.target.value === "true",
|
||||
)
|
||||
}
|
||||
value={
|
||||
isDeleted === undefined ? "" : isDeleted ? "true" : "false"
|
||||
}
|
||||
className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 outline-none focus:border-brand-500 cursor-pointer"
|
||||
>
|
||||
<option value="">Tất cả</option>
|
||||
<option value="false">Hoạt động</option>
|
||||
<option value="true">Đã xóa</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block mb-2 text-sm font-medium">
|
||||
Từ ngày
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="date"
|
||||
value={fromDate}
|
||||
onChange={(e) => setFromDate(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 outline-none focus:border-brand-500"
|
||||
/>
|
||||
<input
|
||||
type="time"
|
||||
value={fromTime}
|
||||
onChange={(e) => setFromTime(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 outline-none focus:border-brand-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block mb-2 text-sm font-medium">
|
||||
Đến ngày
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="date"
|
||||
value={toDate}
|
||||
onChange={(e) => setToDate(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 outline-none focus:border-brand-500"
|
||||
/>
|
||||
<input
|
||||
type="time"
|
||||
value={toTime}
|
||||
onChange={(e) => setToTime(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 outline-none focus:border-brand-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block mb-2 text-sm font-medium">
|
||||
Số lượng (Limit)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={limitInput}
|
||||
onChange={(e) => setLimitInput(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 outline-none focus:border-brand-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ComponentCard>
|
||||
|
||||
<ComponentCard title="Danh sách người dùng">
|
||||
<div className="relative min-h-[300px]">
|
||||
{loading && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/50 dark:bg-gray-900/50 rounded-xl">
|
||||
<div className="w-10 h-10 border-4 border-t-brand-500 rounded-full animate-spin"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<BasicTableOne
|
||||
data={tableData?.data || []}
|
||||
onSort={handleSort}
|
||||
sortBy={sortBy}
|
||||
sortOrder={sortOrder}
|
||||
onViewDetail={handleOpenDetail}
|
||||
roles={roles}
|
||||
selectedRole={selectedRole}
|
||||
onFilterRole={(role) => setSelectedRole(role)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block mb-2 text-sm font-medium">
|
||||
Phương thức đăng nhập
|
||||
</label>
|
||||
<select
|
||||
value={authProvider}
|
||||
onChange={(e) => setAuthProvider(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 cursor-pointer bg-white outline-none focus:border-brand-500"
|
||||
>
|
||||
<option value="">Tất cả</option>
|
||||
<option value="google">Google</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-6">
|
||||
<p className="text-sm text-gray-500">
|
||||
Hiển thị trang {pagination?.current_page || 1} /{" "}
|
||||
{pagination?.total_pages || 1} ({pagination?.total_records || 0}{" "}
|
||||
kết quả)
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label className="block mb-2 text-sm font-medium">
|
||||
Trạng thái
|
||||
</label>
|
||||
<select
|
||||
onChange={(e) =>
|
||||
setIsDeleted(
|
||||
e.target.value === ""
|
||||
? undefined
|
||||
: e.target.value === "true",
|
||||
)
|
||||
}
|
||||
value={isDeleted === undefined ? "" : isDeleted ? "true" : "false"}
|
||||
className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 outline-none focus:border-brand-500 cursor-pointer"
|
||||
>
|
||||
<option value="">Tất cả</option>
|
||||
<option value="false">Hoạt động</option>
|
||||
<option value="true">Đã xóa</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Từ ngày */}
|
||||
<div>
|
||||
<label className="block mb-2 text-sm font-medium">Từ ngày</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="date"
|
||||
value={fromDate}
|
||||
onChange={(e) => setFromDate(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 outline-none focus:border-brand-500"
|
||||
{pagination && pagination.total_pages > 1 && (
|
||||
<Pagination
|
||||
currentPage={pagination.current_page}
|
||||
totalPages={pagination.total_pages}
|
||||
onPageChange={(newPage) => setPage(newPage)}
|
||||
/>
|
||||
<input
|
||||
type="time"
|
||||
value={fromTime}
|
||||
onChange={(e) => setFromTime(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 outline-none focus:border-brand-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Đến ngày */}
|
||||
<div>
|
||||
<label className="block mb-2 text-sm font-medium">Đến ngày</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="date"
|
||||
value={toDate}
|
||||
onChange={(e) => setToDate(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 outline-none focus:border-brand-500"
|
||||
/>
|
||||
<input
|
||||
type="time"
|
||||
value={toTime}
|
||||
onChange={(e) => setToTime(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 outline-none focus:border-brand-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Limit */}
|
||||
<div>
|
||||
<label className="block mb-2 text-sm font-medium">
|
||||
Số lượng (Limit)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={limitInput}
|
||||
onChange={(e) => setLimitInput(e.target.value)}
|
||||
className="w-full px-3 py-2 border rounded-lg dark:bg-gray-800 dark:border-gray-700 outline-none focus:border-brand-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</ComponentCard>
|
||||
|
||||
<ComponentCard title="Danh sách người dùng">
|
||||
<div className="relative min-h-[300px]">
|
||||
{loading && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-white/50 dark:bg-gray-900/50 rounded-xl">
|
||||
<div className="w-10 h-10 border-4 border-t-brand-500 rounded-full animate-spin"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<BasicTableOne
|
||||
data={tableData?.data || []}
|
||||
onSort={handleSort}
|
||||
sortBy={sortBy}
|
||||
sortOrder={sortOrder}
|
||||
onViewDetail={handleOpenDetail}
|
||||
roles={roles}
|
||||
selectedRole={selectedRole}
|
||||
onFilterRole={(role) => setSelectedRole(role)}
|
||||
<UserDetailModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
user={selectedUser}
|
||||
onChangeRole={handleOpenRoleModal}
|
||||
onDelete={(u) => {
|
||||
handleDelete(u);
|
||||
setIsModalOpen(false);
|
||||
}}
|
||||
onRestore={(u) => {
|
||||
handleRestore(u);
|
||||
setIsModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mt-6">
|
||||
<p className="text-sm text-gray-500">
|
||||
Hiển thị trang {pagination?.current_page || 1} /{" "}
|
||||
{pagination?.total_pages || 1} ({pagination?.total_records || 0}{" "}
|
||||
kết quả)
|
||||
</p>
|
||||
|
||||
{pagination && pagination.total_pages > 1 && (
|
||||
<Pagination
|
||||
currentPage={pagination.current_page}
|
||||
totalPages={pagination.total_pages}
|
||||
onPageChange={(newPage) => setPage(newPage)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<UserDetailModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
user={selectedUser}
|
||||
onChangeRole={handleOpenRoleModal}
|
||||
onDelete={(u) => {
|
||||
handleDelete(u);
|
||||
setIsModalOpen(false);
|
||||
}}
|
||||
onRestore={(u) => {
|
||||
handleRestore(u);
|
||||
setIsModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ChangeRoleModal
|
||||
isOpen={isRoleModalOpen}
|
||||
onClose={() => setIsRoleModalOpen(false)}
|
||||
user={roleUser}
|
||||
onSuccess={fetchUsers}
|
||||
/>
|
||||
</ComponentCard>
|
||||
<ChangeRoleModal
|
||||
isOpen={isRoleModalOpen}
|
||||
onClose={() => setIsRoleModalOpen(false)}
|
||||
user={roleUser}
|
||||
onSuccess={fetchUsers}
|
||||
/>
|
||||
</ComponentCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ProtectedRoute>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import ApplicationLibrary from "@/components/user-profile/ApplicationList";
|
||||
import { MediaDto } from "@/interface/media";
|
||||
import { apiGetCurrentUserApplications, apiGetCurrentUserMedia } from "@/service/userService";
|
||||
import MediaLibrary from "@/components/user-profile/Media";
|
||||
|
||||
export default function LibraryPage() {
|
||||
const [mediaData, setMediaData] = useState<MediaDto | null>(null);
|
||||
const [applications, setApplications] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchLibraryContent = async () => {
|
||||
try {
|
||||
const [mediaResponse, userApplications] = await Promise.all([
|
||||
apiGetCurrentUserMedia(),
|
||||
apiGetCurrentUserApplications()
|
||||
]);
|
||||
|
||||
if (userApplications?.data) setApplications(userApplications.data);
|
||||
setMediaData(mediaResponse);
|
||||
} catch (err) {
|
||||
console.error("Lỗi khi tải thư viện:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchLibraryContent();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-[50vh] items-center justify-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-blue-500 border-t-transparent"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
{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">
|
||||
Chưa có nội dung nào trong thư viện
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-12">
|
||||
{(mediaData?.data?.length ?? 0) > 0 && (
|
||||
<section>
|
||||
<MediaLibrary data={mediaData ?? {}} />
|
||||
</section>
|
||||
)}
|
||||
|
||||
{applications.length > 0 && (
|
||||
<section>
|
||||
<ApplicationLibrary applications={applications} />
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,7 +22,6 @@ export default function ApplicationDetailPage() {
|
||||
(state: RootState) => state.user.selectedApplication,
|
||||
);
|
||||
const router = useRouter();
|
||||
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [errMessage, setErrMessage] = useState<string>(
|
||||
"Không thể xóa đơn đăng ký này.",
|
||||
@@ -39,6 +38,18 @@ export default function ApplicationDetailPage() {
|
||||
|
||||
const config = statusConfig[application.status] || statusConfig.PENDING;
|
||||
|
||||
const formatDate = (dateString: string | null | undefined) => {
|
||||
if (!dateString) return "-";
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString("vi-VN", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
const isImageFile = (file: any) => {
|
||||
const isImageMime = file.mime_type?.startsWith("image/");
|
||||
const isImageExt = /\.(jpg|jpeg|png|webp|gif)$/i.test(file.storage_key);
|
||||
@@ -95,7 +106,7 @@ export default function ApplicationDetailPage() {
|
||||
background: isDarkMode ? "#18181b" : "#fff",
|
||||
color: isDarkMode ? "#fff" : "#000",
|
||||
});
|
||||
router.push("/account");
|
||||
router.push("/profile");
|
||||
} catch (error: any) {
|
||||
setErrMessage(
|
||||
error.response?.data?.message || "Có lỗi xảy ra khi xóa!",
|
||||
@@ -119,17 +130,6 @@ export default function ApplicationDetailPage() {
|
||||
}
|
||||
}
|
||||
};
|
||||
const formatDate = (dateString: string | null | undefined) => {
|
||||
if (!dateString) return "-";
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString("vi-VN", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
// console.log("Application Detail:", application);
|
||||
|
||||
@@ -226,55 +226,57 @@ export default function ApplicationDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{application?.reviewer && (
|
||||
<section className="pt-8 border-t border-zinc-200 dark:border-zinc-800">
|
||||
<label className="text-[11px] font-bold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider mb-3 block">
|
||||
Phản hồi từ người kiểm duyệt
|
||||
</label>
|
||||
{application?.reviewer && (
|
||||
<section className="pt-8 border-t border-zinc-200 dark:border-zinc-800">
|
||||
<label className="text-[11px] font-bold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider mb-3 block">
|
||||
Phản hồi từ người kiểm duyệt
|
||||
</label>
|
||||
|
||||
<div className="p-5 rounded-xl border border-zinc-200 dark:border-zinc-800 bg-zinc-50/50 dark:bg-zinc-900/50">
|
||||
<div className="flex items-start justify-between mb-5">
|
||||
<div className="flex items-center gap-3">
|
||||
{application?.reviewer?.avatar_url ? (
|
||||
<Image
|
||||
src={application.reviewer.avatar_url}
|
||||
alt={application.reviewer.display_name || "Avatar"}
|
||||
width={36}
|
||||
height={36}
|
||||
className="w-9 h-9 rounded-full object-cover border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-9 h-9 rounded-full bg-zinc-200 dark:bg-zinc-800 flex items-center justify-center text-zinc-500 font-medium text-sm">
|
||||
{application?.reviewer?.display_name?.charAt(0) || "R"}
|
||||
<div className="p-5 rounded-xl border border-zinc-200 dark:border-zinc-800 bg-zinc-50/50 dark:bg-zinc-900/50">
|
||||
<div className="flex items-start justify-between mb-5">
|
||||
<div className="flex items-center gap-3">
|
||||
{application?.reviewer?.avatar_url ? (
|
||||
<Image
|
||||
src={application.reviewer.avatar_url}
|
||||
alt={application.reviewer.display_name || "Avatar"}
|
||||
width={36}
|
||||
height={36}
|
||||
className="w-9 h-9 rounded-full object-cover border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-800"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-9 h-9 rounded-full bg-zinc-200 dark:bg-zinc-800 flex items-center justify-center text-zinc-500 font-medium text-sm">
|
||||
{application?.reviewer?.display_name?.charAt(0) || "R"}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
{application?.reviewer?.display_name}
|
||||
</h4>
|
||||
<p className="text-[11px] text-zinc-500 dark:text-zinc-400">
|
||||
{application?.reviewer?.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[11px] text-zinc-400 dark:text-zinc-500 tabular-nums">
|
||||
{formatDate(application?.reviewed_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative ml-4 pl-4 border-l border-zinc-300 dark:border-zinc-700">
|
||||
<div className="text-sm text-zinc-700 dark:text-zinc-300 leading-relaxed">
|
||||
{application?.review_note ? (
|
||||
<p>{application.review_note}</p>
|
||||
) : (
|
||||
<p className="italic text-zinc-500">
|
||||
Không có ghi chú bổ sung.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
{application?.reviewer?.display_name}
|
||||
</h4>
|
||||
<p className="text-[11px] text-zinc-500 dark:text-zinc-400">
|
||||
{application?.reviewer?.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[11px] text-zinc-400 dark:text-zinc-500 tabular-nums">
|
||||
{formatDate(application?.reviewed_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="relative ml-4 pl-4 border-l border-zinc-300 dark:border-zinc-700">
|
||||
<div className="text-sm text-zinc-700 dark:text-zinc-300 leading-relaxed">
|
||||
{application?.review_note ? (
|
||||
<p>{application.review_note}</p>
|
||||
) : (
|
||||
<p className="italic text-zinc-500">Không có ghi chú bổ sung.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Lightbox
|
||||
@@ -1,24 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import AccountDetails from "@/components/user-profile/AccountDetails";
|
||||
import ApplicationList from "@/components/user-profile/ApplicationList";
|
||||
import MediaCard from "@/components/user-profile/Media";
|
||||
import UserInfoCard from "@/components/user-profile/UserInfoCard";
|
||||
import UserMetaCard from "@/components/user-profile/UserMetaCard";
|
||||
import { MediaDto } from "@/interface/media";
|
||||
import { UserMetaCardProps } from "@/interface/user";
|
||||
import { apiGetCurrentUser } from "@/service/auth";
|
||||
import { setUserData } from "@/store/features/userSlice";
|
||||
import { apiGetCurrentUserApplications, apiGetCurrentUserMedia } from "@/service/userService";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
|
||||
export default function Profile() {
|
||||
const [user, setUser] = useState<UserMetaCardProps | null>(null);
|
||||
const [mediaData, setMediaData] = useState<MediaDto | null>(null);
|
||||
const [applications, setApplications] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUser = async () => {
|
||||
try {
|
||||
const userData = await apiGetCurrentUser();
|
||||
dispatch(setUserData(userData.data));
|
||||
const mediaResponse = await apiGetCurrentUserMedia();
|
||||
const userApplications = await apiGetCurrentUserApplications();
|
||||
|
||||
console.log(userData);
|
||||
|
||||
if (userApplications?.data) {
|
||||
setApplications(userApplications.data);
|
||||
}
|
||||
|
||||
setMediaData(mediaResponse);
|
||||
setUser(userData);
|
||||
} catch (err) {
|
||||
console.error("Lỗi:", err);
|
||||
@@ -38,6 +50,8 @@ export default function Profile() {
|
||||
<div className="space-y-6">
|
||||
<UserMetaCard data={user ?? {}} />
|
||||
<UserInfoCard data={{ ...user, openEdit: true }} />
|
||||
{(mediaData?.data?.length ?? 0) > 0 && <MediaCard data={mediaData ?? {}} />}
|
||||
<ApplicationList applications={applications} />
|
||||
<AccountDetails data={user ?? {}} />
|
||||
</div>
|
||||
</div>
|
||||
@@ -4,10 +4,7 @@ import { useSidebar } from "@/context/SidebarContext";
|
||||
import AppHeader from "@/layout/AppHeader";
|
||||
import AppSidebar from "@/layout/AppSidebar";
|
||||
import Backdrop from "@/layout/Backdrop";
|
||||
import { apiGetCurrentUser } from "@/service/auth";
|
||||
import { setUserData } from "@/store/features/userSlice";
|
||||
import React, { useEffect } from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
import React from "react";
|
||||
|
||||
export default function AdminLayout({
|
||||
children,
|
||||
@@ -15,27 +12,13 @@ export default function AdminLayout({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { isExpanded, isHovered, isMobileOpen } = useSidebar();
|
||||
const dispatch = useDispatch()
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUser = async () => {
|
||||
try {
|
||||
const userData = await apiGetCurrentUser();
|
||||
dispatch(setUserData(userData.data));
|
||||
} catch (err) {
|
||||
console.error("Lỗi:", err);
|
||||
}
|
||||
};
|
||||
fetchUser();
|
||||
}, [])
|
||||
|
||||
|
||||
// Dynamic class for main content margin based on sidebar state
|
||||
const mainContentMargin = isMobileOpen
|
||||
? "ml-0"
|
||||
: isExpanded || isHovered
|
||||
? "lg:ml-[290px]"
|
||||
: "lg:ml-[90px]";
|
||||
? "lg:ml-[290px]"
|
||||
: "lg:ml-[90px]";
|
||||
|
||||
return (
|
||||
<div className="min-h-screen xl:flex">
|
||||
|
||||
53
src/app/(full-width-pages)/(error-pages)/error-403/page.tsx
Normal file
53
src/app/(full-width-pages)/(error-pages)/error-403/page.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import GridShape from "@/components/common/GridShape";
|
||||
import { Metadata } from "next";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Access Denied - 403 | Admin Dashboard",
|
||||
description:
|
||||
"This is Access Denied 403 page - you do not have permission to access this resource",
|
||||
};
|
||||
|
||||
export default function Error403() {
|
||||
return (
|
||||
<div className="relative flex flex-col items-center justify-center min-h-screen p-6 overflow-hidden z-1">
|
||||
<GridShape />
|
||||
<div className="mx-auto w-full max-w-[242px] text-center sm:max-w-[472px]">
|
||||
<h1 className="mb-8 font-bold text-gray-800 text-title-md dark:text-white/90 xl:text-title-2xl">
|
||||
403 - ACCESS DENIED
|
||||
</h1>
|
||||
|
||||
<Image
|
||||
src="/images/error/404.svg"
|
||||
alt="403"
|
||||
className="dark:hidden"
|
||||
width={472}
|
||||
height={152}
|
||||
/>
|
||||
<Image
|
||||
src="/images/error/404-dark.svg"
|
||||
alt="403"
|
||||
className="hidden dark:block"
|
||||
width={472}
|
||||
height={152}
|
||||
/>
|
||||
|
||||
<p className="mt-10 mb-6 text-base text-gray-700 dark:text-gray-400 sm:text-lg">
|
||||
You do not have permission to access this resource. Please contact your administrator if you believe this is a mistake.
|
||||
</p>
|
||||
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center justify-center rounded-lg border border-gray-300 bg-white px-5 py-3.5 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"
|
||||
>
|
||||
Back to Home Page
|
||||
</Link>
|
||||
</div>
|
||||
<p className="absolute text-sm text-center text-gray-500 -translate-x-1/2 bottom-6 left-1/2 dark:text-gray-400">
|
||||
© {new Date().getFullYear()} - Admin Dashboard
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -737,48 +737,25 @@ span.flatpickr-weekday,
|
||||
box-shadow: 0 1px 3px 0 rgba(16, 24, 40, 0.1),
|
||||
0 1px 2px 0 rgba(16, 24, 40, 0.06);
|
||||
opacity: 0.8;
|
||||
cursor: grabbing;
|
||||
cursor: grabbing; /* Changes the cursor to indicate dragging */
|
||||
}
|
||||
|
||||
html {
|
||||
scrollbar-gutter: stable;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
html {
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--color-gray-50);
|
||||
-webkit-appearance: none;
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
border-radius: 10px;
|
||||
background-color: var(--color-gray-300);
|
||||
border: 2px solid var(--color-gray-50);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--color-gray-400);
|
||||
}
|
||||
|
||||
.dark ::-webkit-scrollbar-track {
|
||||
background: var(--color-gray-950);
|
||||
}
|
||||
|
||||
.dark ::-webkit-scrollbar-thumb {
|
||||
background-color: var(--color-gray-700);
|
||||
border: 2px solid var(--color-gray-950);
|
||||
}
|
||||
|
||||
.dark ::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--color-gray-600);
|
||||
border-radius: 5px;
|
||||
background-color: rgba(0, 0, 0, .5);
|
||||
box-shadow: 0 0 1px rgba(255, 255, 255, .5);
|
||||
}
|
||||
|
||||
.ql-editor pre.ql-syntax {
|
||||
|
||||
@@ -7,7 +7,7 @@ export default function GetUser() {
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const fetchUser = async () => {
|
||||
try {
|
||||
|
||||
62
src/components/auth/ProtectedRoute.tsx
Normal file
62
src/components/auth/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
"use client"
|
||||
|
||||
import { ReactNode, useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useAuth } from "@/hooks/useAuth"
|
||||
import { UserRole } from "@/config/routes.config"
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: ReactNode
|
||||
requiredRoles?: UserRole[]
|
||||
fallback?: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Component để protect routes dựa trên role
|
||||
* Sử dụng ở client-side trong layouts hoặc pages
|
||||
*/
|
||||
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
|
||||
children,
|
||||
requiredRoles = [],
|
||||
fallback,
|
||||
}) => {
|
||||
const router = useRouter()
|
||||
const { isAuthenticated, userRoles, hasAnyRole } = useAuth()
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) {
|
||||
router.replace("/signin")
|
||||
}
|
||||
}, [isAuthenticated, router])
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (requiredRoles.length > 0 && !hasAnyRole(requiredRoles)) {
|
||||
if (fallback) {
|
||||
return <>{fallback}</>
|
||||
}
|
||||
router.replace("/error-403")
|
||||
return null
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper để render UI dựa trên role
|
||||
*/
|
||||
export const RoleGate: React.FC<{
|
||||
requiredRoles: UserRole[]
|
||||
children: ReactNode
|
||||
fallback?: ReactNode
|
||||
}> = ({ requiredRoles, children, fallback }) => {
|
||||
const { hasAnyRole } = useAuth()
|
||||
|
||||
if (!hasAnyRole(requiredRoles)) {
|
||||
return <>{fallback || null}</>
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { API, HOME_URL } from "../../../api";
|
||||
import { setUserData } from "@/store/features/userSlice";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { saveUserToCookie } from "@/lib/cookieStorage";
|
||||
|
||||
export default function SignInForm() {
|
||||
const router = useRouter();
|
||||
@@ -73,8 +74,15 @@ export default function SignInForm() {
|
||||
|
||||
// console.log("Current User Data:", data);
|
||||
if (data?.data) {
|
||||
// Lưu user data vào Redux và cookies
|
||||
dispatch(setUserData(data.data));
|
||||
router.push("/");
|
||||
saveUserToCookie(data.data);
|
||||
|
||||
// Sử dụng window.location để force reload, đảm bảo cookie được set
|
||||
// và middleware check được cookie trước khi render page
|
||||
setTimeout(() => {
|
||||
window.location.href = "/";
|
||||
}, 100);
|
||||
}
|
||||
} else {
|
||||
toast.error("Email hoặc mật khẩu không đúng.");
|
||||
@@ -109,7 +117,7 @@ export default function SignInForm() {
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="grid grid-cols-1 gap-3 sm:gap-5">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-5">
|
||||
<button
|
||||
onClick={() => {
|
||||
const redirectUrl = HOME_URL;
|
||||
@@ -143,6 +151,19 @@ export default function SignInForm() {
|
||||
</svg>
|
||||
Sign in with Google
|
||||
</button>
|
||||
{/* <button className="inline-flex items-center justify-center gap-3 py-3 text-sm font-normal text-gray-700 transition-colors bg-gray-100 rounded-lg px-7 hover:bg-gray-200 hover:text-gray-800 dark:bg-white/5 dark:text-white/90 dark:hover:bg-white/10">
|
||||
<svg
|
||||
width="21"
|
||||
className="fill-current"
|
||||
height="20"
|
||||
viewBox="0 0 21 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M15.6705 1.875H18.4272L12.4047 8.75833L19.4897 18.125H13.9422L9.59717 12.4442L4.62554 18.125H1.86721L8.30887 10.7625L1.51221 1.875H7.20054L11.128 7.0675L15.6705 1.875ZM14.703 16.475H16.2305L6.37054 3.43833H4.73137L14.703 16.475Z" />
|
||||
</svg>
|
||||
Sign in with X
|
||||
</button> */}
|
||||
</div>
|
||||
<div className="relative py-3 sm:py-5">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
@@ -166,7 +187,6 @@ export default function SignInForm() {
|
||||
type="email"
|
||||
onChange={handleChange}
|
||||
defaultValue={formData.email}
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -183,7 +203,6 @@ export default function SignInForm() {
|
||||
placeholder="Min. 8 characters"
|
||||
onChange={handleChange}
|
||||
defaultValue={formData.password}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<span
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
@@ -194,6 +213,7 @@ export default function SignInForm() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hiển thị thông báo lỗi nếu có */}
|
||||
{errorMsg && (
|
||||
<p className="text-sm text-red-500 font-medium">{errorMsg}</p>
|
||||
)}
|
||||
|
||||
@@ -9,15 +9,11 @@ import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { API, HOME_URL } from "../../../api";
|
||||
import Swal from "sweetalert2";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
|
||||
export default function SignUpForm() {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isChecked, setIsChecked] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const [step, setStep] = useState(1);
|
||||
const [formData, setFormData] = useState({
|
||||
fname: "",
|
||||
@@ -107,15 +103,9 @@ export default function SignUpForm() {
|
||||
};
|
||||
|
||||
const signupRes = await apiSignUp(signupPayload);
|
||||
Swal.fire("Đăng ký thành công! Đang chuyển hướng về trang đăng nhập!");
|
||||
|
||||
await Swal.fire({
|
||||
title: "Tạo tài khoản thành công!. Quay về trang đăng nhập để tiếp tục",
|
||||
icon: "success",
|
||||
timer: 1500,
|
||||
showConfirmButton: false,
|
||||
});
|
||||
router.push("/signin");
|
||||
|
||||
window.location.href = "/signin";
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error
|
||||
@@ -160,7 +150,7 @@ export default function SignUpForm() {
|
||||
|
||||
{step === 1 && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-3 sm:gap-5">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-5">
|
||||
<button
|
||||
onClick={() => {
|
||||
const redirectUrl = HOME_URL;
|
||||
@@ -194,6 +184,9 @@ export default function SignUpForm() {
|
||||
</svg>
|
||||
Sign up with Google
|
||||
</button>
|
||||
{/* <button className="inline-flex items-center justify-center gap-3 py-3 text-sm font-normal text-gray-700 transition-colors bg-gray-100 rounded-lg px-7 hover:bg-gray-200 hover:text-gray-800 dark:bg-white/5 dark:text-white/90 dark:hover:bg-white/10">
|
||||
Sign up with X
|
||||
</button> */}
|
||||
</div>
|
||||
<div className="relative py-3 sm:py-5">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
|
||||
@@ -15,7 +15,6 @@ interface InputProps {
|
||||
success?: boolean;
|
||||
error?: boolean;
|
||||
hint?: string; // Optional hint text
|
||||
autoComplete?: string;
|
||||
}
|
||||
|
||||
const Input: FC<InputProps> = ({
|
||||
@@ -33,7 +32,6 @@ const Input: FC<InputProps> = ({
|
||||
success = false,
|
||||
error = false,
|
||||
hint,
|
||||
autoComplete
|
||||
}) => {
|
||||
// Determine input styles based on state (disabled, success, error)
|
||||
let inputClasses = `h-11 w-full rounded-lg border appearance-none px-4 py-2.5 text-sm shadow-theme-xs placeholder:text-gray-400 focus:outline-hidden focus:ring-3 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800 ${className}`;
|
||||
@@ -63,7 +61,6 @@ const Input: FC<InputProps> = ({
|
||||
step={step}
|
||||
disabled={disabled}
|
||||
className={inputClasses}
|
||||
autoComplete={autoComplete}
|
||||
/>
|
||||
|
||||
{/* Optional Hint Text */}
|
||||
|
||||
@@ -4,15 +4,11 @@ import Link from "next/link";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Dropdown } from "../ui/dropdown/Dropdown";
|
||||
import { DropdownItem } from "../ui/dropdown/DropdownItem";
|
||||
import { fullDataUser } from "@/interface/admin";
|
||||
import { UserMetaCardProps } from "@/interface/user";
|
||||
import { apiGetCurrentUser, apiLogout } from "@/service/auth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ListIcon } from "@/icons";
|
||||
import { removeUserFromCookie } from "@/lib/cookieStorage";
|
||||
|
||||
export default function UserDropdown() {
|
||||
const router = useRouter();
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [user, setUser] = useState<UserMetaCardProps | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
@@ -48,6 +44,9 @@ export default function UserDropdown() {
|
||||
} catch (error) {
|
||||
console.error("Logout failed", error);
|
||||
} finally {
|
||||
// Xóa user data từ cookies
|
||||
removeUserFromCookie();
|
||||
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
|
||||
@@ -119,7 +118,7 @@ export default function UserDropdown() {
|
||||
<DropdownItem
|
||||
onItemClick={closeDropdown}
|
||||
tag="a"
|
||||
href="/account"
|
||||
href="/profile"
|
||||
className="flex items-center gap-3 px-3 py-2 font-medium text-gray-700 rounded-lg group text-theme-sm hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300"
|
||||
>
|
||||
<svg
|
||||
@@ -137,23 +136,35 @@ export default function UserDropdown() {
|
||||
fill=""
|
||||
/>
|
||||
</svg>
|
||||
Tài Khoản
|
||||
Edit profile
|
||||
</DropdownItem>
|
||||
</li>
|
||||
{/* <li>
|
||||
<DropdownItem
|
||||
onItemClick={closeDropdown}
|
||||
tag="a"
|
||||
href="/role-upgrade"
|
||||
href="/profile"
|
||||
className="flex items-center gap-3 px-3 py-2 font-medium text-gray-700 rounded-lg group text-theme-sm hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-white/5 dark:hover:text-gray-300"
|
||||
>
|
||||
<span className="menu-item-icon">
|
||||
<ListIcon />
|
||||
</span>
|
||||
Nhà Sử Học
|
||||
<svg
|
||||
className="fill-gray-500 group-hover:fill-gray-700 dark:fill-gray-400 dark:group-hover:fill-gray-300"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M10.4858 3.5L13.5182 3.5C13.9233 3.5 14.2518 3.82851 14.2518 4.23377C14.2518 5.9529 16.1129 7.02795 17.602 6.1682C17.9528 5.96567 18.4014 6.08586 18.6039 6.43667L20.1203 9.0631C20.3229 9.41407 20.2027 9.86286 19.8517 10.0655C18.3625 10.9253 18.3625 13.0747 19.8517 13.9345C20.2026 14.1372 20.3229 14.5859 20.1203 14.9369L18.6039 17.5634C18.4013 17.9142 17.9528 18.0344 17.602 17.8318C16.1129 16.9721 14.2518 18.0471 14.2518 19.7663C14.2518 20.1715 13.9233 20.5 13.5182 20.5H10.4858C10.0804 20.5 9.75182 20.1714 9.75182 19.766C9.75182 18.0461 7.88983 16.9717 6.40067 17.8314C6.04945 18.0342 5.60037 17.9139 5.39767 17.5628L3.88167 14.937C3.67903 14.586 3.79928 14.1372 4.15026 13.9346C5.63949 13.0748 5.63946 10.9253 4.15025 10.0655C3.79926 9.86282 3.67901 9.41401 3.88165 9.06303L5.39764 6.43725C5.60034 6.08617 6.04943 5.96581 6.40065 6.16858C7.88982 7.02836 9.75182 5.9539 9.75182 4.23399C9.75182 3.82862 10.0804 3.5 10.4858 3.5ZM13.5182 2L10.4858 2C9.25201 2 8.25182 3.00019 8.25182 4.23399C8.25182 4.79884 7.64013 5.15215 7.15065 4.86955C6.08213 4.25263 4.71559 4.61859 4.0986 5.68725L2.58261 8.31303C1.96575 9.38146 2.33183 10.7477 3.40025 11.3645C3.88948 11.647 3.88947 12.3531 3.40026 12.6355C2.33184 13.2524 1.96578 14.6186 2.58263 15.687L4.09863 18.3128C4.71562 19.3814 6.08215 19.7474 7.15067 19.1305C7.64015 18.8479 8.25182 19.2012 8.25182 19.766C8.25182 20.9998 9.25201 22 10.4858 22H13.5182C14.7519 22 15.7518 20.9998 15.7518 19.7663C15.7518 19.2015 16.3632 18.8487 16.852 19.1309C17.9202 19.7476 19.2862 19.3816 19.9029 18.3134L21.4193 15.6869C22.0361 14.6185 21.6701 13.2523 20.6017 12.6355C20.1125 12.3531 20.1125 11.647 20.6017 11.3645C21.6701 10.7477 22.0362 9.38152 21.4193 8.3131L19.903 5.68667C19.2862 4.61842 17.9202 4.25241 16.852 4.86917C16.3632 5.15138 15.7518 4.79856 15.7518 4.23377C15.7518 3.00024 14.7519 2 13.5182 2ZM9.6659 11.9999C9.6659 10.7103 10.7113 9.66493 12.0009 9.66493C13.2905 9.66493 14.3359 10.7103 14.3359 11.9999C14.3359 13.2895 13.2905 14.3349 12.0009 14.3349C10.7113 14.3349 9.6659 13.2895 9.6659 11.9999ZM12.0009 8.16493C9.88289 8.16493 8.1659 9.88191 8.1659 11.9999C8.1659 14.1179 9.88289 15.8349 12.0009 15.8349C14.1189 15.8349 15.8359 14.1179 15.8359 11.9999C15.8359 9.88191 14.1189 8.16493 12.0009 8.16493Z"
|
||||
fill=""
|
||||
/>
|
||||
</svg>
|
||||
Account settings
|
||||
</DropdownItem>
|
||||
</li> */}
|
||||
{/* <li>
|
||||
<li>
|
||||
<DropdownItem
|
||||
onItemClick={closeDropdown}
|
||||
tag="a"
|
||||
@@ -175,9 +186,9 @@ export default function UserDropdown() {
|
||||
fill=""
|
||||
/>
|
||||
</svg>
|
||||
Hỗ Trợ
|
||||
Support
|
||||
</DropdownItem>
|
||||
</li> */}
|
||||
</li>
|
||||
</ul>
|
||||
<Link
|
||||
onClick={handleLogout}
|
||||
@@ -199,7 +210,7 @@ export default function UserDropdown() {
|
||||
fill=""
|
||||
/>
|
||||
</svg>
|
||||
Đăng xuất
|
||||
Sign out
|
||||
</Link>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,6 @@ import "yet-another-react-lightbox/styles.css";
|
||||
import "yet-another-react-lightbox/plugins/captions.css";
|
||||
import { IsolatedContent } from "@/components/ui/IsolatedContent";
|
||||
import { apiDeleteHistorianCV } from "@/service/historianService";
|
||||
import { statusConfig } from "@/service/handler";
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
@@ -102,9 +101,7 @@ export default function ApplicationDetailModal({
|
||||
if (!isOpen || !application) return null;
|
||||
|
||||
const userData = application.user || {};
|
||||
const currentStatus = statusConfig[application.status] || {
|
||||
container: "bg-gray-50 border-gray-200 text-gray-600 shadow-sm",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-999 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
|
||||
<div className="w-full max-w-4xl max-h-[90vh] bg-white rounded-2xl dark:bg-gray-900 flex flex-col overflow-hidden text-gray-800 dark:text-gray-200">
|
||||
@@ -152,18 +149,13 @@ export default function ApplicationDetailModal({
|
||||
<p className="text-sm text-gray-500">
|
||||
{userData.email || "Không có email"}
|
||||
</p>
|
||||
<div className="mt-1 flex gap-2 items-center">
|
||||
<span className="px-3 py-1 text-[11px] font-bold uppercase text-blue-600 bg-blue-100 rounded-md ">
|
||||
<div className="mt-1 flex gap-2">
|
||||
<span className="px-2 py-0.5 text-[10px] font-bold uppercase text-blue-600 bg-blue-100 rounded-md">
|
||||
{application.verify_type}
|
||||
</span>
|
||||
<span
|
||||
className={`
|
||||
inline-flex items-center px-3 py-1
|
||||
rounded-md border text-[11px] font-semibold uppercase
|
||||
transition-colors duration-200
|
||||
${currentStatus.container}
|
||||
`}
|
||||
>
|
||||
className={`px-2 py-0.5 text-[10px] font-semibold uppercase rounded-md ${application.status === "PENDING" ? "bg-yellow-100 text-yellow-600" : "bg-red-100 text-red-500"}`}
|
||||
>
|
||||
{application.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,237 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../ui/table";
|
||||
import Badge from "../ui/badge/Badge";
|
||||
|
||||
export type MediaSortColumn =
|
||||
| "created_at"
|
||||
| "updated_at"
|
||||
| "size"
|
||||
| "original_name"
|
||||
| "mime_type";
|
||||
|
||||
export interface MediaItem {
|
||||
id: string;
|
||||
user_id: string;
|
||||
storage_key: string;
|
||||
original_name: string;
|
||||
mime_type: string;
|
||||
size: number;
|
||||
file_metadata: any;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface MediaTableProps {
|
||||
data: MediaItem[];
|
||||
onSort: (column: MediaSortColumn) => void;
|
||||
sortBy?: MediaSortColumn;
|
||||
sortOrder?: "asc" | "desc";
|
||||
// Các props mới thêm cho yêu cầu chọn, xem, xóa
|
||||
selectedIds: string[];
|
||||
onToggleSelect: (id: string) => void;
|
||||
onToggleSelectAll: (checked: boolean) => void;
|
||||
onViewSingle: (item: MediaItem, index: number) => void;
|
||||
onDeleteSingle: (id: string) => void;
|
||||
}
|
||||
|
||||
export default function MediaTable({
|
||||
data,
|
||||
onSort,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
selectedIds,
|
||||
onToggleSelect,
|
||||
onToggleSelectAll,
|
||||
onViewSingle,
|
||||
onDeleteSingle,
|
||||
}: MediaTableProps) {
|
||||
const formatDate = (dateString: string | null | undefined) => {
|
||||
if (!dateString) return "-";
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString("vi-VN", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) return "0 Bytes";
|
||||
const k = 1024;
|
||||
const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
|
||||
};
|
||||
|
||||
const SortIcon = ({ column }: { column: MediaSortColumn }) => {
|
||||
const isActive = sortBy === column;
|
||||
return (
|
||||
<div className="flex flex-col ml-2 opacity-50 cursor-pointer hover:opacity-100">
|
||||
<svg
|
||||
className={`w-3 h-3 ${isActive && sortOrder === "asc" ? "text-blue-700 opacity-100" : "text-gray-400"}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
<svg
|
||||
className={`w-3 h-3 -mt-1 ${isActive && sortOrder === "desc" ? "text-blue-700 opacity-100" : "text-gray-400"}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getMimeTypeBadge = (mimeType: string) => {
|
||||
if (mimeType.includes("image")) {
|
||||
return (
|
||||
<Badge size="sm" variant="light" color="success">
|
||||
Hình ảnh
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (mimeType.includes("pdf") || mimeType.includes("word") || mimeType.includes("document")) {
|
||||
return (
|
||||
<Badge size="sm" variant="light" color="warning">
|
||||
Tài liệu
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge size="sm" variant="light" color="light">
|
||||
Khác
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const isAllSelected = data.length > 0 && selectedIds.length === data.length;
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-white/[0.05] dark:bg-white/[0.03]">
|
||||
<div className="max-w-full overflow-x-auto">
|
||||
<div className="min-w-[1000px]">
|
||||
<Table>
|
||||
<TableHeader className="border-b border-gray-100 dark:border-white/[0.05]">
|
||||
<TableRow>
|
||||
<TableCell isHeader className="px-5 py-3 w-12 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isAllSelected}
|
||||
onChange={(e) => onToggleSelectAll(e.target.checked)}
|
||||
className="w-4 h-4 text-brand-500 border-gray-300 rounded cursor-pointer focus:ring-brand-500 dark:bg-gray-800 dark:border-gray-600"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell isHeader className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400">
|
||||
<div className="flex items-center cursor-pointer select-none" onClick={() => onSort("original_name")}>
|
||||
Tên tệp <SortIcon column="original_name" />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell isHeader className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400">
|
||||
<div className="flex items-center cursor-pointer select-none" onClick={() => onSort("mime_type")}>
|
||||
Định dạng <SortIcon column="mime_type" />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell isHeader className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400">
|
||||
<div className="flex items-center cursor-pointer select-none" onClick={() => onSort("size")}>
|
||||
Kích thước <SortIcon column="size" />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell isHeader className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400">
|
||||
<div className="flex items-center cursor-pointer select-none" onClick={() => onSort("created_at")}>
|
||||
Ngày tải lên <SortIcon column="created_at" />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell isHeader className="px-5 py-3 font-medium text-gray-500 text-start text-theme-xs dark:text-gray-400">
|
||||
<div className="flex items-center cursor-pointer select-none" onClick={() => onSort("updated_at")}>
|
||||
Cập nhật <SortIcon column="updated_at" />
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell isHeader className="px-5 py-3 font-medium text-center text-gray-500 text-theme-xs dark:text-gray-400 min-w-[120px]">
|
||||
Thao tác
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody className="divide-y divide-gray-100 dark:divide-white/[0.05]">
|
||||
{data.length > 0 ? (
|
||||
data.map((item, idx) => (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className="hover:bg-gray-50/50 dark:hover:bg-white/[0.01] transition-colors"
|
||||
>
|
||||
{/* Yêu cầu 1: Cột 0 - Ô hình vuông chọn từng item */}
|
||||
<TableCell className="px-5 py-4 text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.includes(item.id)}
|
||||
onChange={() => onToggleSelect(item.id)}
|
||||
className="w-4 h-4 text-brand-500 border-gray-300 rounded cursor-pointer focus:ring-brand-500 dark:bg-gray-800 dark:border-gray-600"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="px-5 py-4 text-start font-mono text-theme-xs truncate max-w-[250px]">
|
||||
{item.original_name}
|
||||
</TableCell>
|
||||
<TableCell className="px-5 py-4 text-start">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div>{getMimeTypeBadge(item.mime_type)}</div>
|
||||
<span className="text-[10px] text-gray-400">{item.mime_type}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="px-5 py-4 text-start text-theme-sm font-medium">
|
||||
{formatBytes(item.size)}
|
||||
</TableCell>
|
||||
<TableCell className="px-5 py-4 text-gray-600 text-theme-sm dark:text-gray-400">
|
||||
{formatDate(item.created_at)}
|
||||
</TableCell>
|
||||
<TableCell className="px-5 py-4 text-gray-600 text-theme-sm dark:text-gray-400">
|
||||
{formatDate(item.updated_at)}
|
||||
</TableCell>
|
||||
<TableCell className="px-5 py-4 text-center">
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<button
|
||||
onClick={() => onViewSingle(item, idx)}
|
||||
className="text-brand-500 hover:text-brand-600 font-medium text-theme-sm"
|
||||
>
|
||||
Xem
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDeleteSingle(item.id)}
|
||||
className="text-red-500 hover:text-red-600 font-medium text-theme-sm"
|
||||
>
|
||||
Xóa
|
||||
</button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={7}
|
||||
className="px-5 py-20 text-center text-gray-500 italic"
|
||||
>
|
||||
Không tìm thấy tệp tin nào
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,8 +22,6 @@ export default function AccountDetails({ data }: { data: UserMetaCardProps }) {
|
||||
});
|
||||
|
||||
const [showOldPass, setShowOldPass] = useState(false);
|
||||
const [showNewPass, setShowNewPass] = useState(false);
|
||||
const [showConfirmPass, setShowConfirmPass] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
@@ -34,8 +32,6 @@ export default function AccountDetails({ data }: { data: UserMetaCardProps }) {
|
||||
confirm_password: "",
|
||||
});
|
||||
setShowOldPass(false);
|
||||
setShowNewPass(false);
|
||||
setShowConfirmPass(false);
|
||||
setError("");
|
||||
}
|
||||
}, [isOpen]);
|
||||
@@ -78,15 +74,10 @@ export default function AccountDetails({ data }: { data: UserMetaCardProps }) {
|
||||
`${err?.response?.data?.message}, Cập nhật thất bại, vui lòng kiểm tra lại thông tin. Mật khẩu tối thiểu 8 ký tự, 1 in hoa, 1 số và 1 ký tự đặc biệt.` ||
|
||||
"Failed to update password. Please check your current password.",
|
||||
);
|
||||
toast.error(
|
||||
err?.response?.data?.message ||
|
||||
"Cập nhật thất bại, vui lòng kiểm tra lại thông tin!",
|
||||
);
|
||||
toast.error(err?.response?.data?.message || "Cập nhật thất bại, vui lòng kiểm tra lại thông tin!");
|
||||
}
|
||||
};
|
||||
const isPasswordMismatch =
|
||||
formValues.confirm_password.length > 0 &&
|
||||
formValues.new_password !== formValues.confirm_password;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="p-5 border border-red-200 rounded-2xl dark:border-gray-800 lg:p-6">
|
||||
@@ -158,6 +149,7 @@ export default function AccountDetails({ data }: { data: UserMetaCardProps }) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 2. Current Password - CÓ NÚT HIỆN MẬT KHẨU */}
|
||||
<div className="col-span-2 relative">
|
||||
<Label>Current Password</Label>
|
||||
<div className="relative">
|
||||
@@ -182,63 +174,28 @@ export default function AccountDetails({ data }: { data: UserMetaCardProps }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2 relative">
|
||||
{/* 3. New Password - LUÔN ẨN */}
|
||||
<div className="col-span-2 lg:col-span-1">
|
||||
<Label>New Password</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showNewPass ? "text" : "password"}
|
||||
name="new_password"
|
||||
placeholder="Enter new password"
|
||||
defaultValue={formValues.new_password}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowNewPass(!showNewPass)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
{showNewPass ? (
|
||||
<EyeCloseIcon className="w-5 h-5" />
|
||||
) : (
|
||||
<EyeIcon className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<Input
|
||||
type="text"
|
||||
name="new_password"
|
||||
placeholder="Enter new password"
|
||||
defaultValue={formValues.new_password}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 relative">
|
||||
|
||||
{/* 4. Confirm New Password - LUÔN ẨN */}
|
||||
<div className="col-span-2 lg:col-span-1">
|
||||
<Label>Confirm New Password</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showConfirmPass ? "text" : "password"}
|
||||
name="confirm_password"
|
||||
placeholder="Confirm new password"
|
||||
defaultValue={formValues.confirm_password}
|
||||
onChange={handleChange}
|
||||
// Thêm class viền đỏ ở đây
|
||||
className={
|
||||
isPasswordMismatch
|
||||
? "border-red-500 focus:border-red-500 dark:border-red-500"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmPass(!showConfirmPass)}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
{showConfirmPass ? (
|
||||
<EyeCloseIcon className="w-5 h-5" />
|
||||
) : (
|
||||
<EyeIcon className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{/* Thêm một dòng text báo lỗi nhỏ ngay dưới ô input nếu muốn */}
|
||||
{isPasswordMismatch && (
|
||||
<p className="mt-1 text-xs text-red-500">
|
||||
Mật khẩu không khớp!
|
||||
</p>
|
||||
)}
|
||||
<Input
|
||||
type="text"
|
||||
name="confirm_password"
|
||||
placeholder="Re-type new password"
|
||||
defaultValue={formValues.confirm_password}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -254,11 +211,7 @@ export default function AccountDetails({ data }: { data: UserMetaCardProps }) {
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
type="submit"
|
||||
className="bg-red-500 hover:bg-red-600 "
|
||||
>
|
||||
<Button size="sm" type="submit" className="bg-red-500 hover:bg-red-600 ">
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -46,7 +46,7 @@ const processMedia = (mediaArray: any[]) => {
|
||||
return { type: "empty" };
|
||||
};
|
||||
|
||||
export default function ApplicationList({
|
||||
export default function ApplicationSquareCardList({
|
||||
applications,
|
||||
}: {
|
||||
applications: any[];
|
||||
@@ -56,9 +56,9 @@ export default function ApplicationList({
|
||||
|
||||
const handleViewDetail = (app: any) => {
|
||||
dispatch(setSelectedApplication(app));
|
||||
router.push(`/account/applications`);
|
||||
router.push(`/profile/applications`);
|
||||
};
|
||||
|
||||
// Tạo object mapping để render icon dựa trên status
|
||||
const StatusIcons: Record<string, React.ReactNode> = {
|
||||
APPROVED: (
|
||||
<svg
|
||||
@@ -111,21 +111,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ơ{" "}
|
||||
<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>
|
||||
<div className="p-5 border rounded-xl dark:border-zinc-800 lg:p-6 bg-white dark:bg-zinc-950">
|
||||
<h4 className="text-lg font-bold text-zinc-800 dark:text-white/90 mb-5 tracking-tight">
|
||||
Applications CV
|
||||
</h4>
|
||||
|
||||
{/* CHỈ SỬA DÒNG NÀY: Tăng số lượng cột (grid-cols) để các thẻ nhỏ lại */}
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{applications?.map((app) => {
|
||||
const mediaState = processMedia(app.media);
|
||||
const config = statusConfig[app.status] || statusConfig.PENDING;
|
||||
@@ -134,69 +125,77 @@ export default function ApplicationList({
|
||||
<div
|
||||
key={app.id}
|
||||
onClick={() => handleViewDetail(app)}
|
||||
className="group relative flex aspect-square w-full cursor-pointer flex-col overflow-hidden rounded-xl border border-gray-200 bg-gray-50 transition-all duration-300 hover:ring-2 hover:ring-blue-500/50 dark:border-zinc-700 dark:bg-zinc-800"
|
||||
className="group relative h-60 aspect-square border dark:border-zinc-800 rounded-xl cursor-pointer overflow-hidden transition-all duration-300 hover:ring-2 hover:ring-blue-500/50"
|
||||
>
|
||||
{/* Media Layer */}
|
||||
<div className="absolute inset-0 z-0">
|
||||
{mediaState.type === "image" ? (
|
||||
<img
|
||||
src={mediaState.src}
|
||||
alt=""
|
||||
className="h-full w-full object-cover transition-transform duration-700 group-hover:scale-105"
|
||||
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-700"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center bg-gray-100 p-4 dark:bg-zinc-800/80">
|
||||
<div className="w-full h-full bg-zinc-100 dark:bg-zinc-900 flex items-center justify-center p-4">
|
||||
{mediaState.type === "documents" ? (
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
<div className="flex flex-wrap gap-1 justify-center">
|
||||
{mediaState.extensions?.slice(0, 3).map((ext, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="rounded bg-white px-2 py-1 text-xs font-bold uppercase text-gray-600 border border-gray-200 dark:border-zinc-600 dark:bg-zinc-700 dark:text-gray-200"
|
||||
className="text-[9px] font-black px-1.5 py-0.5 bg-white dark:bg-zinc-800 rounded border dark:border-zinc-700 uppercase"
|
||||
>
|
||||
.{ext}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-full w-full bg-gradient-to-br from-gray-200 to-gray-300 opacity-50 dark:from-zinc-600 dark:to-zinc-800" />
|
||||
<div className="w-full h-full bg-gradient-to-br from-zinc-400 to-zinc-600 opacity-20" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 z-10 bg-gradient-to-t from-black/90 via-black/40 to-transparent opacity-80 transition-opacity duration-300 group-hover:opacity-100" />
|
||||
{/* Overlay Gradient */}
|
||||
<div className="absolute inset-0 z-10 bg-gradient-to-t from-black/90 via-black/20 to-transparent transition-opacity group-hover:opacity-90" />
|
||||
|
||||
<div className="absolute left-3 right-3 top-3 z-20 flex items-start justify-between">
|
||||
{/* TOP INFO: FILE COUNT & STATUS ICON */}
|
||||
<div className="absolute top-2 left-2 right-2 z-20 flex justify-between items-center">
|
||||
{app.media?.length > 0 ? (
|
||||
<span className="rounded-md bg-black/60 px-2 py-1 text-[10px] font-bold tracking-wider text-white backdrop-blur-md">
|
||||
{app.media.length} TỆP
|
||||
<span className="text-[9px] font-bold text-white/60 bg-black/40 px-1.5 py-0.5 rounded-md backdrop-blur-sm">
|
||||
{app.media.length} FILE
|
||||
</span>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<div className={`${config.container} rounded-full `}>
|
||||
|
||||
{/* Status Icon Wrapper */}
|
||||
<div
|
||||
className={`${config.container} rounded-full`}
|
||||
>
|
||||
{StatusIcons[app.status] || StatusIcons.PENDING}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-0 left-0 right-0 z-20 p-4 text-white">
|
||||
<p className="mb-1 truncate text-xs font-bold uppercase tracking-wider text-gray-300">
|
||||
{app.verify_type || "VERIFY"}
|
||||
</p>
|
||||
|
||||
{app?.reviewer?.display_name && (
|
||||
<p className="mb-3 truncate text-sm font-medium text-blue-300">
|
||||
Người duyệt: {app.reviewer.display_name}
|
||||
{/* Bottom Content */}
|
||||
<div className="absolute bottom-2 left-2 right-2 z-20 text-white">
|
||||
<div className="mb-1">
|
||||
<p className="text-[10px] font-bold uppercase opacity-80 truncate">
|
||||
{app.verify_type || "VERIFY"}
|
||||
</p>
|
||||
)}
|
||||
{app?.reviewer?.display_name && (
|
||||
<p className="text-[9px] font-medium text-blue-400 truncate">
|
||||
By: {app.reviewer.display_name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-white/20 pt-2">
|
||||
<p className="text-xs font-semibold text-gray-300">
|
||||
<div className="flex items-center justify-between pt-1 border-t border-white/10">
|
||||
<p className="text-[9px] font-bold text-white/70">
|
||||
{formatFullDateTime(app.created_at)}
|
||||
</p>
|
||||
|
||||
<div className="flex h-6 w-6 -translate-x-2 items-center justify-center rounded-full bg-white/20 opacity-0 backdrop-blur-sm transition-all duration-300 group-hover:translate-x-0 group-hover:opacity-100">
|
||||
<div className="transform translate-x-2 opacity-0 group-hover:translate-x-0 group-hover:opacity-100 transition-all duration-300">
|
||||
<svg
|
||||
className="h-3 w-3 text-white"
|
||||
className="w-4 h-4 text-blue-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -205,7 +204,7 @@ export default function ApplicationList({
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M9 5l7 7-7 7"
|
||||
d="M13 7l5 5m0 0l-5 5m5-5H6"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
@@ -217,4 +216,4 @@ export default function ApplicationList({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,396 +1,71 @@
|
||||
"use client";
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
import Lightbox from "yet-another-react-lightbox";
|
||||
import Zoom from "yet-another-react-lightbox/plugins/zoom";
|
||||
import Captions from "yet-another-react-lightbox/plugins/captions";
|
||||
import "yet-another-react-lightbox/styles.css";
|
||||
import "yet-another-react-lightbox/plugins/captions.css";
|
||||
import Swal from "sweetalert2";
|
||||
import { MediaDto } from "@/interface/media";
|
||||
import { URL_MEDIA } from "../../../api";
|
||||
|
||||
import { deleteMedia } from "@/service/mediaService";
|
||||
import { INITIAL_LIMIT } from "../../../constant";
|
||||
|
||||
export default function MediaLibrary({
|
||||
data,
|
||||
onRefresh,
|
||||
}: {
|
||||
data: MediaDto;
|
||||
onRefresh?: () => void;
|
||||
}) {
|
||||
export default function MediaCard({ data }: { data: MediaDto }) {
|
||||
const [index, setIndex] = useState(-1);
|
||||
const listMedia = data?.data || [];
|
||||
|
||||
const [showAllImages, setShowAllImages] = useState(false);
|
||||
const [showAllDocs, setShowAllDocs] = useState(false);
|
||||
|
||||
const [localMedia, setLocalMedia] = useState(data?.data || []);
|
||||
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalMedia(data?.data || []);
|
||||
}, [data]);
|
||||
|
||||
const isImageFile = (file: any) => {
|
||||
const isImageMime = file.mime_type?.startsWith("image/");
|
||||
const isImageExt = /\.(jpg|jpeg|png|webp|gif)$/i.test(file.storage_key);
|
||||
return isImageMime || isImageExt;
|
||||
};
|
||||
|
||||
const imageFiles = localMedia.filter(isImageFile);
|
||||
const documentFiles = localMedia.filter((file) => !isImageFile(file));
|
||||
|
||||
const displayedImages = showAllImages
|
||||
? imageFiles
|
||||
: imageFiles.slice(0, INITIAL_LIMIT);
|
||||
const displayedDocs = showAllDocs
|
||||
? documentFiles
|
||||
: documentFiles.slice(0, INITIAL_LIMIT);
|
||||
|
||||
const imageSlides = imageFiles.map((item) => ({
|
||||
const slides = listMedia.map((item) => ({
|
||||
src: `${URL_MEDIA}${item.storage_key}`,
|
||||
title: item.original_name,
|
||||
description: `Kích thước: ${(item.size / 1024).toFixed(2)} KB - Loại: ${item.mime_type}`,
|
||||
description: `Size: ${(item.size / 1024).toFixed(2)} KB - Type: ${item.mime_type}`,
|
||||
}));
|
||||
// console.log("slides", listMedia);
|
||||
return (
|
||||
<div className="p-5 border border-gray-200 rounded-2xl dark:border-gray-800 lg:p-6">
|
||||
<h3 className="text-lg font-semibold text-gray-800 dark:text-white/90 lg:mb-6">Media Assets</h3>
|
||||
|
||||
const toggleSelectionMode = () => {
|
||||
setIsSelectionMode(!isSelectionMode);
|
||||
if (isSelectionMode) {
|
||||
setSelectedIds([]);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleItemSelection = (id: string, e?: React.MouseEvent) => {
|
||||
if (e) e.stopPropagation();
|
||||
|
||||
if (!isSelectionMode) {
|
||||
setIsSelectionMode(true);
|
||||
setSelectedIds([id]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedIds.includes(id)) {
|
||||
setSelectedIds(selectedIds.filter((selectedId) => selectedId !== id));
|
||||
} else {
|
||||
setSelectedIds([...selectedIds, id]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleItemClick = (item: any, idx: number, isImage: boolean) => {
|
||||
if (isSelectionMode) {
|
||||
toggleItemSelection(item.id);
|
||||
} else {
|
||||
if (isImage) {
|
||||
setIndex(idx);
|
||||
} else {
|
||||
const fileUrl = `${URL_MEDIA}${item.storage_key}`;
|
||||
const googleDocsUrl = `https://docs.google.com/viewer?url=${encodeURIComponent(fileUrl)}&embedded=true`;
|
||||
window.open(googleDocsUrl, "_blank");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSelected = async () => {
|
||||
const result = await Swal.fire({
|
||||
title: "Xóa tệp đính kèm?",
|
||||
text: `Bạn chuẩn bị xóa ${selectedIds.length} tệp. Hành động này không thể hoàn tác!`,
|
||||
icon: "warning",
|
||||
showCancelButton: true,
|
||||
confirmButtonColor: "#ef4444",
|
||||
cancelButtonColor: "#6b7280",
|
||||
confirmButtonText: "Xóa vĩnh viễn",
|
||||
cancelButtonText: "Hủy",
|
||||
});
|
||||
|
||||
if (result.isConfirmed) {
|
||||
try {
|
||||
await deleteMedia(selectedIds);
|
||||
setLocalMedia((prev) =>
|
||||
prev.filter((item) => !selectedIds.includes(item.id)),
|
||||
);
|
||||
setIsSelectionMode(false);
|
||||
setSelectedIds([]);
|
||||
Swal.fire("Thành công!", "Các tệp đã được xóa.", "success");
|
||||
if (onRefresh) onRefresh();
|
||||
} catch (error) {
|
||||
Swal.fire("Lỗi!", "Không thể xóa tệp, vui lòng thử lại.", "error");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteFromLightbox = async () => {
|
||||
const currentImage = imageFiles[index];
|
||||
if (!currentImage) return;
|
||||
|
||||
const result = await Swal.fire({
|
||||
title: "Xóa ảnh này?",
|
||||
text: "Bạn có chắc chắn muốn xóa ảnh này không?",
|
||||
icon: "warning",
|
||||
showCancelButton: true,
|
||||
confirmButtonColor: "#ef4444",
|
||||
cancelButtonColor: "#6b7280",
|
||||
confirmButtonText: "Xóa",
|
||||
cancelButtonText: "Hủy",
|
||||
// customClass: { container: 'z-99999999999999999999999' }
|
||||
});
|
||||
|
||||
if (result.isConfirmed) {
|
||||
try {
|
||||
await deleteMedia([currentImage.id]);
|
||||
setLocalMedia((prev) =>
|
||||
prev.filter((item) => item.id !== currentImage.id),
|
||||
);
|
||||
|
||||
if (imageFiles.length === 1) {
|
||||
setIndex(-1);
|
||||
} else if (index >= imageFiles.length - 1) {
|
||||
setIndex(index - 1);
|
||||
}
|
||||
|
||||
Swal.fire({
|
||||
title: "Thành công!",
|
||||
text: "Ảnh đã được xóa.",
|
||||
icon: "success",
|
||||
customClass: { container: "z-[9999999999]" },
|
||||
});
|
||||
if (onRefresh) onRefresh();
|
||||
} catch (error) {
|
||||
Swal.fire({
|
||||
title: "Lỗi!",
|
||||
text: "Không thể xóa ảnh.",
|
||||
icon: "error",
|
||||
customClass: { container: "z-[9999999999]" },
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const renderItemCard = (item: any, isImage: boolean, idx: number) => {
|
||||
const isSelected = selectedIds.includes(item.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
onClick={() => handleItemClick(item, idx, isImage)}
|
||||
className={`group relative aspect-square cursor-pointer overflow-hidden rounded-xl border-2 transition-all ${
|
||||
isSelected
|
||||
? "border-blue-500 ring-2 ring-blue-500/30"
|
||||
: "border-gray-200 hover:ring-2 hover:ring-blue-500 hover:ring-offset-2 dark:border-zinc-700 dark:hover:ring-offset-zinc-900"
|
||||
} ${isImage ? "bg-gray-100 dark:bg-zinc-800" : "bg-gray-50 dark:bg-zinc-800"}`}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => toggleItemSelection(item.id, e)}
|
||||
className={`absolute right-2 top-2 z-30 flex h-6 w-6 items-center justify-center rounded-full border-2 transition-all duration-200 ${
|
||||
isSelected
|
||||
? "border-blue-500 bg-blue-500"
|
||||
: "border-white bg-black/40 opacity-0 group-hover:opacity-100"
|
||||
} ${isSelectionMode && !isSelected ? "opacity-100" : ""}`}
|
||||
>
|
||||
{isSelected && (
|
||||
<svg
|
||||
className="h-3.5 w-3.5 text-white"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={3}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isImage ? (
|
||||
<>
|
||||
<div className="flex flex-row gap-4 overflow-x-auto pb-4 scrollbar-hide">
|
||||
{listMedia.map((item, idx) => (
|
||||
<div
|
||||
key={item.id}
|
||||
onClick={() => setIndex(idx)}
|
||||
className="group relative min-w-[150px] h-[150px] cursor-pointer overflow-hidden rounded-lg border border-gray-200"
|
||||
>
|
||||
<img
|
||||
src={`${URL_MEDIA}${item.storage_key}`}
|
||||
alt={item.original_name}
|
||||
className={`h-full w-full object-cover transition-transform duration-500 ${isSelected ? "scale-105 opacity-80" : "group-hover:scale-110"}`}
|
||||
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-110"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent opacity-0 transition-opacity duration-300 group-hover:opacity-100">
|
||||
<div className="absolute bottom-0 w-full p-3 text-white">
|
||||
<p className="truncate text-sm font-medium ">
|
||||
{item.original_name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-300">
|
||||
{(item.size / 1024).toFixed(0)} KB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{!isSelectionMode && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-black/40 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<span className="rounded-full border border-white/30 bg-white/20 px-3 py-1 text-xs font-bold text-white backdrop-blur-md">
|
||||
Xem ảnh
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className={`flex h-full w-full flex-col items-center justify-center p-3 text-center transition-opacity ${isSelected ? "opacity-80" : ""}`}
|
||||
>
|
||||
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-lg bg-white text-2xl dark:bg-zinc-700">
|
||||
📄
|
||||
</div>
|
||||
<span className="line-clamp-2 px-2 text-xs font-medium text-gray-600 dark:text-gray-300">
|
||||
|
||||
<div className="absolute inset-0 flex items-end bg-black/40 p-2 opacity-0 transition-opacity duration-300 group-hover:opacity-100">
|
||||
<p className="w-full truncate text-xs text-white">
|
||||
{item.original_name}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
{!isSelectionMode && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-black/40 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<span className="rounded-full border border-white/30 bg-white/20 px-3 py-1 text-xs font-bold text-white backdrop-blur-md">
|
||||
Xem file
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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">
|
||||
<h3 className="text-xl font-bold text-gray-800 dark:text-white/90">
|
||||
Media Assets
|
||||
<span className="ml-2 text-sm font-normal text-gray-500">
|
||||
({localMedia.length} tệp)
|
||||
</span>
|
||||
</h3>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{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"
|
||||
>
|
||||
<svg
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
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 ${
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
{isSelectionMode ? "Hủy" : "Chọn"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-10">
|
||||
{imageFiles.length > 0 && (
|
||||
<div>
|
||||
<h4 className="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-500">
|
||||
Hình ảnh ({imageFiles.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">
|
||||
{" "}
|
||||
{displayedImages.map((item, idx) =>
|
||||
renderItemCard(item, true, idx),
|
||||
)}
|
||||
</div>
|
||||
{imageFiles.length > INITIAL_LIMIT && (
|
||||
<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"
|
||||
>
|
||||
{showAllImages
|
||||
? "Thu gọn"
|
||||
: `Xem thêm ${imageFiles.length - INITIAL_LIMIT} hình ảnh`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{documentFiles.length > 0 && (
|
||||
<div>
|
||||
<h4 className="mb-4 text-sm font-semibold uppercase tracking-wider text-gray-500">
|
||||
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),
|
||||
)}
|
||||
</div>
|
||||
{documentFiles.length > INITIAL_LIMIT && (
|
||||
<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"
|
||||
>
|
||||
{showAllDocs
|
||||
? "Thu gọn"
|
||||
: `Xem thêm ${documentFiles.length - INITIAL_LIMIT} tài liệu`}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Lightbox
|
||||
index={index}
|
||||
open={index >= 0}
|
||||
close={() => setIndex(-1)}
|
||||
slides={imageSlides}
|
||||
slides={slides}
|
||||
plugins={[Zoom, Captions]}
|
||||
toolbar={{
|
||||
buttons: [
|
||||
<button
|
||||
key="delete"
|
||||
type="button"
|
||||
className="yarl__button text-red-500"
|
||||
title="Xóa ảnh này"
|
||||
onClick={handleDeleteFromLightbox}
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>,
|
||||
"zoom",
|
||||
"close",
|
||||
],
|
||||
zoom={{
|
||||
maxZoomPixelRatio: 10,
|
||||
zoomInMultiplier: 2,
|
||||
doubleTapDelay: 300,
|
||||
doubleClickDelay: 300,
|
||||
doubleClickMaxStops: 2,
|
||||
keyboardMoveDistance: 50,
|
||||
wheelZoomDistanceFactor: 100,
|
||||
pinchZoomDistanceFactor: 100,
|
||||
}}
|
||||
zoom={{ maxZoomPixelRatio: 3 }}
|
||||
animation={{ zoom: 200 }}
|
||||
styles={{
|
||||
root: { zIndex: 99, "--yarl__color_backdrop": "rgba(0, 0, 0, 0.9)" },
|
||||
root: {
|
||||
zIndex: 999999999,
|
||||
"--yarl__color_backdrop": "rgba(0, 0, 0, 0.85)",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,6 @@ import { uploadMedia } from "@/service/mediaService";
|
||||
import { toast } from "sonner";
|
||||
import { apiUpdateUser } from "@/service/userService";
|
||||
import { URL_MEDIA } from "../../../api";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function UserMetaCard({ data }: { data: UserMetaCardProps }) {
|
||||
const currentAvatar =
|
||||
@@ -16,8 +15,6 @@ export default function UserMetaCard({ data }: { data: UserMetaCardProps }) {
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (data.data?.profile?.avatar_url) {
|
||||
setPreviewImage(data.data.profile.avatar_url);
|
||||
@@ -52,11 +49,15 @@ export default function UserMetaCard({ data }: { data: UserMetaCardProps }) {
|
||||
if (data.data) {
|
||||
try {
|
||||
await apiUpdateUser({ avatar_url: url });
|
||||
window.location.href = window.location.pathname;
|
||||
toast.success("Cập nhật avatar thành công!");
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
} catch (error) {
|
||||
console.error("Lỗi khi cập nhật avatar:", error);
|
||||
toast.warning("Lỗi khi cập nhật ảnh đại diện. Vui lòng thử lại!");
|
||||
toast.warning(
|
||||
"Ảnh đã được tải lên nhưng không thể cập nhật hồ sơ. Vui lòng thử lại!",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
144
src/config/routes.config.ts
Normal file
144
src/config/routes.config.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Role-Based Access Control Configuration
|
||||
* Định nghĩa routes theo role và quyền truy cập
|
||||
*/
|
||||
|
||||
export type UserRole = "ADMIN" | "MOD" | "HISTORIAN" | "USER"
|
||||
|
||||
export interface RouteConfig {
|
||||
path: string
|
||||
allowedRoles: UserRole[]
|
||||
label?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Public routes - không cần authentication
|
||||
*/
|
||||
export const PUBLIC_ROUTES = [
|
||||
"/signin",
|
||||
"/signup",
|
||||
"/reset-password",
|
||||
"/error-403",
|
||||
"/error-404",
|
||||
"/error-500",
|
||||
]
|
||||
|
||||
|
||||
export const PROTECTED_ROUTES: RouteConfig[] = [
|
||||
// Dashboard
|
||||
{
|
||||
path: "/",
|
||||
allowedRoles: ["ADMIN", "MOD", "HISTORIAN", "USER"],
|
||||
label: "Dashboard",
|
||||
},
|
||||
|
||||
// Admin/MOD + Historian routes
|
||||
{
|
||||
path: "/user-table",
|
||||
allowedRoles: ["ADMIN", "MOD"],
|
||||
label: "User Management",
|
||||
},
|
||||
{
|
||||
path: "/applications-tables",
|
||||
allowedRoles: ["ADMIN", "MOD"],
|
||||
label: "Applications",
|
||||
},
|
||||
|
||||
// All authenticated users
|
||||
{
|
||||
path: "/profile",
|
||||
allowedRoles: ["ADMIN", "MOD", "HISTORIAN", "USER"],
|
||||
label: "User Profile",
|
||||
},
|
||||
{
|
||||
path: "/calendar",
|
||||
allowedRoles: ["ADMIN", "MOD", "HISTORIAN", "USER"],
|
||||
label: "Calendar",
|
||||
},
|
||||
{
|
||||
path: "/line-chart",
|
||||
allowedRoles: ["ADMIN", "MOD", "HISTORIAN", "USER"],
|
||||
label: "Line Chart",
|
||||
},
|
||||
{
|
||||
path: "/bar-chart",
|
||||
allowedRoles: ["ADMIN", "MOD", "HISTORIAN", "USER"],
|
||||
label: "Bar Chart",
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Kiểm tra user role có được phép truy cập route không
|
||||
*/
|
||||
export const canAccessRoute = (
|
||||
userRoles: UserRole[] | undefined,
|
||||
routePath: string
|
||||
): boolean => {
|
||||
// Nếu không có role, không được truy cập
|
||||
if (!userRoles || userRoles.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Public routes không cần check
|
||||
if (PUBLIC_ROUTES.includes(routePath)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Tìm route config theo độ dài path giảm dần để tránh route '/' khớp với tất cả
|
||||
const routeConfig = PROTECTED_ROUTES
|
||||
.slice()
|
||||
.sort((a, b) => b.path.length - a.path.length)
|
||||
.find(
|
||||
(route) =>
|
||||
routePath === route.path ||
|
||||
(route.path !== "/" && routePath.startsWith(route.path + "/"))
|
||||
)
|
||||
|
||||
// Nếu route không được định nghĩa, cho phép (mặc định)
|
||||
if (!routeConfig) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check xem user có role được phép không
|
||||
return userRoles.some((role) => routeConfig.allowedRoles.includes(role))
|
||||
}
|
||||
|
||||
/**
|
||||
* Lấy danh sách routes cho user role
|
||||
*/
|
||||
export const getAvailableRoutes = (userRoles: UserRole[] | undefined): RouteConfig[] => {
|
||||
if (!userRoles || userRoles.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
return PROTECTED_ROUTES.filter((route) =>
|
||||
userRoles.some((role) => route.allowedRoles.includes(role))
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Kiểm tra user có role nào không
|
||||
*/
|
||||
export const hasRole = (userRoles: UserRole[] | undefined, role: UserRole): boolean => {
|
||||
return userRoles?.includes(role) ?? false
|
||||
}
|
||||
|
||||
/**
|
||||
* Kiểm tra user có ít nhất một trong các role không
|
||||
*/
|
||||
export const hasAnyRole = (
|
||||
userRoles: UserRole[] | undefined,
|
||||
roles: UserRole[]
|
||||
): boolean => {
|
||||
return userRoles?.some((role) => roles.includes(role)) ?? false
|
||||
}
|
||||
|
||||
/**
|
||||
* Kiểm tra user có tất cả các role không
|
||||
*/
|
||||
export const hasAllRoles = (
|
||||
userRoles: UserRole[] | undefined,
|
||||
roles: UserRole[]
|
||||
): boolean => {
|
||||
return roles.every((role) => userRoles?.includes(role))
|
||||
}
|
||||
@@ -51,7 +51,6 @@ export const SidebarProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
const toggleSidebar = () => {
|
||||
setIsExpanded((prev) => !prev);
|
||||
};
|
||||
|
||||
66
src/hooks/useAuth.ts
Normal file
66
src/hooks/useAuth.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useAppSelector } from '@/store/store';
|
||||
|
||||
import {
|
||||
canAccessRoute,
|
||||
hasRole,
|
||||
hasAnyRole,
|
||||
hasAllRoles,
|
||||
getAvailableRoutes,
|
||||
UserRole,
|
||||
} from "@/config/routes.config"
|
||||
|
||||
/**
|
||||
* Hook để kiểm tra authentication và authorization
|
||||
*/
|
||||
export const useAuth = () => {
|
||||
const user = useAppSelector((state) => state.user.data)
|
||||
const isAuthenticated = useAppSelector((state) => state.user.isAuthenticated)
|
||||
|
||||
const userRoles = user?.roles?.map((r) => r.name) as UserRole[] | undefined
|
||||
|
||||
return {
|
||||
user,
|
||||
isAuthenticated,
|
||||
userRoles,
|
||||
|
||||
/**
|
||||
* Kiểm tra user có role cụ thể không
|
||||
*/
|
||||
hasRole: (role: UserRole) => hasRole(userRoles, role),
|
||||
|
||||
/**
|
||||
* Kiểm tra user có ít nhất một trong các role không
|
||||
*/
|
||||
hasAnyRole: (roles: UserRole[]) => hasAnyRole(userRoles, roles),
|
||||
|
||||
/**
|
||||
* Kiểm tra user có tất cả các role không
|
||||
*/
|
||||
hasAllRoles: (roles: UserRole[]) => hasAllRoles(userRoles, roles),
|
||||
|
||||
/**
|
||||
* Kiểm tra user có quyền truy cập route không
|
||||
*/
|
||||
canAccessRoute: (path: string) => canAccessRoute(userRoles, path),
|
||||
|
||||
/**
|
||||
* Kiểm tra user là admin
|
||||
*/
|
||||
isAdmin: () => hasRole(userRoles, "ADMIN"),
|
||||
|
||||
/**
|
||||
* Kiểm tra user là moderator
|
||||
*/
|
||||
isModerator: () => hasRole(userRoles, "MOD"),
|
||||
|
||||
/**
|
||||
* Kiểm tra user là historian
|
||||
*/
|
||||
isHistorian: () => hasRole(userRoles, "HISTORIAN"),
|
||||
|
||||
/**
|
||||
* Lấy danh sách routes user có thể truy cập
|
||||
*/
|
||||
getAvailableRoutes: () => getAvailableRoutes(userRoles),
|
||||
}
|
||||
}
|
||||
19
src/hooks/useRestoreUserData.ts
Normal file
19
src/hooks/useRestoreUserData.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { useDispatch } from "react-redux"
|
||||
import { setUserData, clearUserData } from "@/store/features/userSlice"
|
||||
import { getUserFromCookie } from "@/lib/cookieStorage"
|
||||
|
||||
export const useRestoreUserData = () => {
|
||||
const dispatch = useDispatch()
|
||||
|
||||
useEffect(() => {
|
||||
const userData = getUserFromCookie()
|
||||
if (userData) {
|
||||
dispatch(setUserData(userData))
|
||||
} else {
|
||||
dispatch(clearUserData())
|
||||
}
|
||||
}, [dispatch])
|
||||
}
|
||||
@@ -162,10 +162,10 @@ const AppHeader: React.FC = () => {
|
||||
>
|
||||
<div className="flex items-center gap-2 2xsm:gap-3">
|
||||
{/* <!-- Dark Mode Toggler --> */}
|
||||
{/* <ThemeToggleButton /> */}
|
||||
<ThemeToggleButton />
|
||||
{/* <!-- Dark Mode Toggler --> */}
|
||||
|
||||
{/* <NotificationDropdown /> */}
|
||||
<NotificationDropdown />
|
||||
{/* <!-- Notification Menu Area --> */}
|
||||
</div>
|
||||
{/* <!-- User Area --> */}
|
||||
@@ -178,4 +178,3 @@ const AppHeader: React.FC = () => {
|
||||
};
|
||||
|
||||
export default AppHeader;
|
||||
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
"use client";
|
||||
import React, { useEffect, useRef, useState, useCallback, useMemo } from "react";
|
||||
import React, { useEffect, useRef, useState, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useSelector } from "react-redux";
|
||||
import { RootState } from "@/store/store";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import {
|
||||
BoxCubeIcon,
|
||||
CalenderIcon,
|
||||
ChevronDownIcon,
|
||||
FileIcon,
|
||||
GridIcon,
|
||||
HorizontaLDots,
|
||||
ListIcon,
|
||||
@@ -20,74 +17,59 @@ import {
|
||||
TableIcon,
|
||||
UserCircleIcon,
|
||||
} from "../icons/index";
|
||||
|
||||
type RoleName = "ADMIN" | "MOD" | "USER" | "HISTORIAN";
|
||||
import SidebarWidget from "./SidebarWidget";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { canAccessRoute, PUBLIC_ROUTES } from "@/config/routes.config";
|
||||
|
||||
type NavItem = {
|
||||
name: string;
|
||||
icon: React.ReactNode;
|
||||
path?: string;
|
||||
roles?: RoleName[];
|
||||
subItems?: {
|
||||
name: string;
|
||||
path: string;
|
||||
pro?: boolean;
|
||||
new?: boolean;
|
||||
roles?: RoleName[];
|
||||
}[];
|
||||
subItems?: { name: string; path: string; pro?: boolean; new?: boolean }[];
|
||||
};
|
||||
|
||||
const ALL_NAV_ITEMS: NavItem[] = [
|
||||
const navItems: NavItem[] = [
|
||||
{
|
||||
icon: <GridIcon />,
|
||||
name: "Trang Chủ",
|
||||
// subItems: [{ name: "Ecommerce", path: "/", pro: false }],
|
||||
path: "/",
|
||||
name: "Dashboard",
|
||||
subItems: [{ name: "Ecommerce", path: "/", pro: false }],
|
||||
},
|
||||
// {
|
||||
// icon: <CalenderIcon />,
|
||||
// name: "Calendar",
|
||||
// path: "/calendar",
|
||||
// },
|
||||
{
|
||||
icon: <FileIcon />,
|
||||
name: "Thư Viện",
|
||||
path: "/library",
|
||||
},
|
||||
// {
|
||||
// name: "Forms",
|
||||
// icon: <ListIcon />,
|
||||
// subItems: [
|
||||
// // { name: "Form Elements", path: "/form-elements", pro: false },
|
||||
// { name: "Role Upgrade", path: "/role-upgrade", pro: false }
|
||||
// ],
|
||||
// },
|
||||
{
|
||||
name: "Quản lý",
|
||||
icon: <TableIcon />,
|
||||
subItems: [
|
||||
// { name: "Basic Tables", path: "/basic-tables", pro: false },
|
||||
{ name: "Tài Khoản", path: "/user-information", pro: false, roles: ["ADMIN", "MOD"] },
|
||||
{ name: "Hồ Sơ Nhà Sử Học", path: "/applications", pro: false, roles: ["ADMIN", "MOD"] },
|
||||
{ name: "Tệp Đăng Tải", path: "/assets", pro: false, roles: ["ADMIN", "MOD"] },
|
||||
],
|
||||
icon: <CalenderIcon />,
|
||||
name: "Calendar",
|
||||
path: "/calendar",
|
||||
},
|
||||
{
|
||||
icon: <UserCircleIcon />,
|
||||
name: "Tài Khoản",
|
||||
path: "/account",
|
||||
name: "User Profile",
|
||||
path: "/profile",
|
||||
},
|
||||
|
||||
{
|
||||
name: "Forms",
|
||||
icon: <ListIcon />,
|
||||
subItems: [{ name: "Form Elements", path: "/form-elements", pro: false }, { name: "Role Upgrade", path: "/role-upgrade", pro: false }],
|
||||
},
|
||||
{
|
||||
name: "Tables",
|
||||
icon: <TableIcon />,
|
||||
subItems: [
|
||||
{ name: "Basic Tables", path: "/basic-tables", pro: false },
|
||||
{ name: "User Tables", path: "/user-table", pro: false },
|
||||
{ name: "Applications Tables", path: "/applications-tables", pro: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Pages",
|
||||
icon: <PageIcon />,
|
||||
subItems: [
|
||||
{ name: "Blank Page", path: "/blank", pro: false },
|
||||
{ name: "404 Error", path: "/error-404", pro: false },
|
||||
],
|
||||
},
|
||||
// {
|
||||
// name: "Pages",
|
||||
// icon: <PageIcon />,
|
||||
// subItems: [
|
||||
// { name: "Blank Page", path: "/blank", pro: false },
|
||||
// { name: "404 Error", path: "/error-404", pro: false },
|
||||
// ],
|
||||
// },
|
||||
];
|
||||
|
||||
const OTHERS_ITEMS: NavItem[] = [
|
||||
const othersItems: NavItem[] = [
|
||||
{
|
||||
icon: <PieChartIcon />,
|
||||
name: "Charts",
|
||||
@@ -121,110 +103,98 @@ const OTHERS_ITEMS: NavItem[] = [
|
||||
const AppSidebar: React.FC = () => {
|
||||
const { isExpanded, isMobileOpen, isHovered, setIsHovered } = useSidebar();
|
||||
const pathname = usePathname();
|
||||
const { userRoles } = useAuth();
|
||||
|
||||
// Lấy data gốc từ Redux (không bị render lại vô cớ)
|
||||
const rolesData = useSelector((state: RootState) => state.user.data?.roles);
|
||||
|
||||
const userRoles = useMemo(() => {
|
||||
return rolesData?.map((r: any) => r.name) || [];
|
||||
}, [rolesData]);
|
||||
|
||||
const filterMenuByRole = useCallback((items: NavItem[]) => {
|
||||
return items
|
||||
.map((item) => ({
|
||||
...item,
|
||||
subItems: item.subItems?.filter((sub) => {
|
||||
if (!sub.roles) return true;
|
||||
return sub.roles.some((role) => userRoles.includes(role));
|
||||
}),
|
||||
}))
|
||||
.filter((item) => {
|
||||
if (item.roles && !item.roles.some((role) => userRoles.includes(role))) return false;
|
||||
if (item.subItems && item.subItems.length === 0 && !item.path) return false;
|
||||
return true;
|
||||
});
|
||||
}, [userRoles]);
|
||||
|
||||
const filteredNavItems = useMemo(() => filterMenuByRole(ALL_NAV_ITEMS), [filterMenuByRole]);
|
||||
const filteredOthersItems = useMemo(() => filterMenuByRole(OTHERS_ITEMS), [filterMenuByRole]);
|
||||
|
||||
const [openSubmenu, setOpenSubmenu] = useState<{
|
||||
type: "main" | "others";
|
||||
index: number;
|
||||
} | null>(null);
|
||||
const [subMenuHeight, setSubMenuHeight] = useState<Record<string, number>>({});
|
||||
const subMenuRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
|
||||
const isActive = useCallback((path: string) => path === pathname, [pathname]);
|
||||
|
||||
const handleSubmenuToggle = (index: number, menuType: "main" | "others") => {
|
||||
setOpenSubmenu((prev) =>
|
||||
prev?.type === menuType && prev?.index === index ? null : { type: menuType, index }
|
||||
);
|
||||
const hasAccess = (path?: string) => {
|
||||
if (!path) return true;
|
||||
if (!userRoles || userRoles.length === 0) {
|
||||
return PUBLIC_ROUTES.includes(path) || ["/", "/profile", "/calendar"].includes(path);
|
||||
}
|
||||
return canAccessRoute(userRoles, path);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let submenuMatched = false;
|
||||
[
|
||||
{ items: filteredNavItems, type: "main" },
|
||||
{ items: filteredOthersItems, type: "others" },
|
||||
].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 };
|
||||
});
|
||||
submenuMatched = true;
|
||||
const buildNavItems = (items: NavItem[]) =>
|
||||
items
|
||||
.map((nav) => {
|
||||
if (nav.path && !hasAccess(nav.path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (nav.subItems) {
|
||||
const filteredItems = nav.subItems.filter((subItem) =>
|
||||
hasAccess(subItem.path),
|
||||
);
|
||||
if (filteredItems.length === 0) {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
return { ...nav, subItems: filteredItems };
|
||||
}
|
||||
|
||||
if (!submenuMatched) {
|
||||
setOpenSubmenu((prev) => (prev !== null ? null : prev));
|
||||
}
|
||||
}, [pathname, isActive, filteredNavItems, filteredOthersItems]);
|
||||
return nav;
|
||||
})
|
||||
.filter((item): item is NavItem => item !== null);
|
||||
|
||||
useEffect(() => {
|
||||
if (openSubmenu !== null) {
|
||||
const key = `${openSubmenu.type}-${openSubmenu.index}`;
|
||||
if (subMenuRefs.current[key]) {
|
||||
setSubMenuHeight((prev) => ({
|
||||
...prev,
|
||||
[key]: subMenuRefs.current[key]?.scrollHeight || 0,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}, [openSubmenu]);
|
||||
const filteredMainNavItems = buildNavItems(navItems);
|
||||
const filteredOthersNavItems = buildNavItems(othersItems);
|
||||
|
||||
const renderMenuItems = (items: NavItem[], menuType: "main" | "others") => (
|
||||
const renderMenuItems = (
|
||||
navItems: NavItem[],
|
||||
menuType: "main" | "others",
|
||||
) => (
|
||||
<ul className="flex flex-col gap-4">
|
||||
{items.map((nav, index) => (
|
||||
{navItems.map((nav, index) => (
|
||||
<li key={nav.name}>
|
||||
{nav.subItems ? (
|
||||
<button
|
||||
onClick={() => handleSubmenuToggle(index, menuType)}
|
||||
className={`menu-item group uppercase ${
|
||||
className={`menu-item group ${
|
||||
openSubmenu?.type === menuType && openSubmenu?.index === index
|
||||
? "menu-item-active" : "menu-item-inactive"
|
||||
} cursor-pointer ${!isExpanded && !isHovered ? "lg:justify-center" : "lg:justify-start"}`}
|
||||
? "menu-item-active"
|
||||
: "menu-item-inactive"
|
||||
} cursor-pointer ${
|
||||
!isExpanded && !isHovered
|
||||
? "lg:justify-center"
|
||||
: "lg:justify-start"
|
||||
}`}
|
||||
>
|
||||
<span className={openSubmenu?.type === menuType && openSubmenu?.index === index ? "menu-item-icon-active" : "menu-item-icon-inactive"}>
|
||||
<span
|
||||
className={` ${
|
||||
openSubmenu?.type === menuType && openSubmenu?.index === index
|
||||
? "menu-item-icon-active"
|
||||
: "menu-item-icon-inactive"
|
||||
}`}
|
||||
>
|
||||
{nav.icon}
|
||||
</span>
|
||||
{(isExpanded || isHovered || isMobileOpen) && (
|
||||
<>
|
||||
<span className={`menu-item-text`}>{nav.name}</span>
|
||||
<ChevronDownIcon className={`ml-auto w-5 h-5 transition-transform duration-200 ${openSubmenu?.type === menuType && openSubmenu?.index === index ? "rotate-180 text-brand-500" : ""}`} />
|
||||
</>
|
||||
<span className={`menu-item-text`}>{nav.name}</span>
|
||||
)}
|
||||
{(isExpanded || isHovered || isMobileOpen) && (
|
||||
<ChevronDownIcon
|
||||
className={`ml-auto w-5 h-5 transition-transform duration-200 ${
|
||||
openSubmenu?.type === menuType &&
|
||||
openSubmenu?.index === index
|
||||
? "rotate-180 text-brand-500"
|
||||
: ""
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
nav.path && (
|
||||
<Link href={nav.path} className={`menu-item group ${isActive(nav.path) ? "menu-item-active" : "menu-item-inactive"}`}>
|
||||
<span className={isActive(nav.path) ? "menu-item-icon-active" : "menu-item-icon-inactive"}>
|
||||
<Link
|
||||
href={nav.path}
|
||||
className={`menu-item group ${
|
||||
isActive(nav.path) ? "menu-item-active" : "menu-item-inactive"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`${
|
||||
isActive(nav.path)
|
||||
? "menu-item-icon-active"
|
||||
: "menu-item-icon-inactive"
|
||||
}`}
|
||||
>
|
||||
{nav.icon}
|
||||
</span>
|
||||
{(isExpanded || isHovered || isMobileOpen) && (
|
||||
@@ -235,18 +205,52 @@ const AppSidebar: React.FC = () => {
|
||||
)}
|
||||
{nav.subItems && (isExpanded || isHovered || isMobileOpen) && (
|
||||
<div
|
||||
ref={(el) => { subMenuRefs.current[`${menuType}-${index}`] = el; }}
|
||||
ref={(el) => {
|
||||
subMenuRefs.current[`${menuType}-${index}`] = el;
|
||||
}}
|
||||
className="overflow-hidden transition-all duration-300"
|
||||
style={{ height: openSubmenu?.type === menuType && openSubmenu?.index === index ? `${subMenuHeight[`${menuType}-${index}`]}px` : "0px" }}
|
||||
style={{
|
||||
height:
|
||||
openSubmenu?.type === menuType && openSubmenu?.index === index
|
||||
? `${subMenuHeight[`${menuType}-${index}`]}px`
|
||||
: "0px",
|
||||
}}
|
||||
>
|
||||
<ul className="mt-2 space-y-1 ml-9">
|
||||
{nav.subItems.map((subItem) => (
|
||||
<li key={subItem.name}>
|
||||
<Link href={subItem.path} className={`menu-dropdown-item ${isActive(subItem.path) ? "menu-dropdown-item-active" : "menu-dropdown-item-inactive"}`}>
|
||||
<Link
|
||||
href={subItem.path}
|
||||
className={`menu-dropdown-item ${
|
||||
isActive(subItem.path)
|
||||
? "menu-dropdown-item-active"
|
||||
: "menu-dropdown-item-inactive"
|
||||
}`}
|
||||
>
|
||||
{subItem.name}
|
||||
<span className="flex items-center gap-1 ml-auto">
|
||||
{subItem.new && <span className={`${isActive(subItem.path) ? "menu-dropdown-badge-active" : "menu-dropdown-badge-inactive"} menu-dropdown-badge`}>new</span>}
|
||||
{subItem.pro && <span className={`${isActive(subItem.path) ? "menu-dropdown-badge-active" : "menu-dropdown-badge-inactive"} menu-dropdown-badge`}>pro</span>}
|
||||
{subItem.new && (
|
||||
<span
|
||||
className={`ml-auto ${
|
||||
isActive(subItem.path)
|
||||
? "menu-dropdown-badge-active"
|
||||
: "menu-dropdown-badge-inactive"
|
||||
} menu-dropdown-badge `}
|
||||
>
|
||||
new
|
||||
</span>
|
||||
)}
|
||||
{subItem.pro && (
|
||||
<span
|
||||
className={`ml-auto ${
|
||||
isActive(subItem.path)
|
||||
? "menu-dropdown-badge-active"
|
||||
: "menu-dropdown-badge-inactive"
|
||||
} menu-dropdown-badge `}
|
||||
>
|
||||
pro
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
@@ -259,44 +263,160 @@ const AppSidebar: React.FC = () => {
|
||||
</ul>
|
||||
);
|
||||
|
||||
const [openSubmenu, setOpenSubmenu] = useState<{
|
||||
type: "main" | "others";
|
||||
index: number;
|
||||
} | null>(null);
|
||||
const [subMenuHeight, setSubMenuHeight] = useState<Record<string, number>>(
|
||||
{},
|
||||
);
|
||||
const subMenuRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
|
||||
// const isActive = (path: string) => path === pathname;
|
||||
const isActive = useCallback((path: string) => path === pathname, [pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if the current path matches any submenu item
|
||||
let submenuMatched = false;
|
||||
["main", "others"].forEach((menuType) => {
|
||||
const items = menuType === "main" ? navItems : othersItems;
|
||||
items.forEach((nav, index) => {
|
||||
if (nav.subItems) {
|
||||
nav.subItems.forEach((subItem) => {
|
||||
if (isActive(subItem.path)) {
|
||||
setOpenSubmenu({
|
||||
type: menuType as "main" | "others",
|
||||
index,
|
||||
});
|
||||
submenuMatched = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// If no submenu item matches, close the open submenu
|
||||
if (!submenuMatched) {
|
||||
setOpenSubmenu(null);
|
||||
}
|
||||
}, [pathname, isActive]);
|
||||
|
||||
useEffect(() => {
|
||||
// Set the height of the submenu items when the submenu is opened
|
||||
if (openSubmenu !== null) {
|
||||
const key = `${openSubmenu.type}-${openSubmenu.index}`;
|
||||
if (subMenuRefs.current[key]) {
|
||||
setSubMenuHeight((prevHeights) => ({
|
||||
...prevHeights,
|
||||
[key]: subMenuRefs.current[key]?.scrollHeight || 0,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}, [openSubmenu]);
|
||||
|
||||
const handleSubmenuToggle = (index: number, menuType: "main" | "others") => {
|
||||
setOpenSubmenu((prevOpenSubmenu) => {
|
||||
if (
|
||||
prevOpenSubmenu &&
|
||||
prevOpenSubmenu.type === menuType &&
|
||||
prevOpenSubmenu.index === index
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { type: menuType, index };
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`fixed mt-16 flex flex-col lg:mt-0 top-0 px-5 left-0 bg-white dark:bg-gray-900 dark:border-gray-800 text-gray-900 h-screen transition-all duration-300 ease-in-out z-50 border-r border-gray-200
|
||||
${isExpanded || isMobileOpen ? "w-[290px]" : isHovered ? "w-[290px]" : "w-[90px]"}
|
||||
${isMobileOpen ? "translate-x-0" : "-translate-x-full"} lg:translate-x-0`}
|
||||
${
|
||||
isExpanded || isMobileOpen
|
||||
? "w-[290px]"
|
||||
: isHovered
|
||||
? "w-[290px]"
|
||||
: "w-[90px]"
|
||||
}
|
||||
${isMobileOpen ? "translate-x-0" : "-translate-x-full"}
|
||||
lg:translate-x-0`}
|
||||
onMouseEnter={() => !isExpanded && setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<div className={`py-8 flex ${!isExpanded && !isHovered ? "lg:justify-center" : "justify-start"}`}>
|
||||
<div
|
||||
className={`py-8 flex ${
|
||||
!isExpanded && !isHovered ? "lg:justify-center" : "justify-start"
|
||||
}`}
|
||||
>
|
||||
<Link href="/">
|
||||
<Image
|
||||
src="/images/logo/logo.svg"
|
||||
alt="Logo"
|
||||
width={isExpanded || isHovered || isMobileOpen ? 80 : 32}
|
||||
height={isExpanded || isHovered || isMobileOpen ? 50 : 32}
|
||||
/>
|
||||
{isExpanded || isHovered || isMobileOpen ? (
|
||||
<>
|
||||
<Image
|
||||
className="dark:hidden"
|
||||
src="/images/logo/logo.svg"
|
||||
alt="Logo"
|
||||
width={80}
|
||||
height={50}
|
||||
/>
|
||||
<Image
|
||||
className="hidden dark:block"
|
||||
src="/images/logo/logo.svg"
|
||||
alt="Logo"
|
||||
width={150}
|
||||
height={40}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Image
|
||||
src="/images/logo/logo.svg"
|
||||
alt="Logo"
|
||||
width={32}
|
||||
height={32}
|
||||
/>
|
||||
)}
|
||||
</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"}`}>
|
||||
{isExpanded || isHovered || isMobileOpen ? "Menu" : <HorizontaLDots />}
|
||||
<h2
|
||||
className={`mb-4 text-xs uppercase flex leading-[20px] text-gray-400 ${
|
||||
!isExpanded && !isHovered
|
||||
? "lg:justify-center"
|
||||
: "justify-start"
|
||||
}`}
|
||||
>
|
||||
{isExpanded || isHovered || isMobileOpen ? (
|
||||
"Menu"
|
||||
) : (
|
||||
<HorizontaLDots />
|
||||
)}
|
||||
</h2>
|
||||
{renderMenuItems(filteredNavItems, "main")}
|
||||
{renderMenuItems(filteredMainNavItems, "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 />}
|
||||
|
||||
<div className="">
|
||||
<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>
|
||||
{renderMenuItems(filteredOthersItems, "others")}
|
||||
</div> */}
|
||||
{renderMenuItems(filteredOthersNavItems, "others")}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
{/* {isExpanded || isHovered || isMobileOpen ? <SidebarWidget /> : null} */}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppSidebar;
|
||||
export default AppSidebar;
|
||||
|
||||
37
src/lib/cookieStorage.ts
Normal file
37
src/lib/cookieStorage.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { UserData } from "@/interface/user"
|
||||
|
||||
export const saveUserToCookie = (userData: UserData) => {
|
||||
if (typeof document === "undefined") return
|
||||
|
||||
const userDataJson = JSON.stringify(userData)
|
||||
document.cookie = `userDataRedux=${encodeURIComponent(userDataJson)}; path=/; max-age=86400`
|
||||
}
|
||||
|
||||
|
||||
export const removeUserFromCookie = () => {
|
||||
if (typeof document === "undefined") return
|
||||
|
||||
document.cookie = "userDataRedux=; path=/; max-age=0"
|
||||
}
|
||||
|
||||
export const getUserFromCookie = (): UserData | null => {
|
||||
if (typeof document === "undefined") return null
|
||||
|
||||
const name = "userDataRedux="
|
||||
const decodedCookie = decodeURIComponent(document.cookie)
|
||||
const cookieArray = decodedCookie.split(";")
|
||||
|
||||
for (let cookie of cookieArray) {
|
||||
cookie = cookie.trim()
|
||||
if (cookie.indexOf(name) === 0) {
|
||||
try {
|
||||
const userData = JSON.parse(cookie.substring(name.length))
|
||||
return userData
|
||||
} catch (error) {
|
||||
console.error("Error parsing user cookie:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -109,22 +109,3 @@ export const getMediaById = async (mediaId: number | string) => {
|
||||
return response?.data;
|
||||
}
|
||||
|
||||
export const deleteMedia = async (mediaIds: string[]) => {
|
||||
const response = await api.delete(API.Media.DELETE_MEDIA, {
|
||||
data: {
|
||||
media_ids: mediaIds
|
||||
}
|
||||
});
|
||||
return response?.data;
|
||||
}
|
||||
export const deleteMediaById = async (mediaId: string) => {
|
||||
const response = await api.delete(API.Media.DELETE_MEDIA_BY_ID(mediaId));
|
||||
return response?.data;
|
||||
}
|
||||
|
||||
export const getMedia = async (payload: any) => {
|
||||
const response = await api.get(API.Media.GET_MEDIA, {
|
||||
params: payload,
|
||||
});
|
||||
return response?.data;
|
||||
}
|
||||
@@ -3,6 +3,13 @@
|
||||
import { useRef } from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { store } from './store';
|
||||
import { useRestoreUserData } from '@/hooks/useRestoreUserData';
|
||||
|
||||
|
||||
function RestoreUserDataComponent() {
|
||||
useRestoreUserData();
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function StoreProvider({
|
||||
children,
|
||||
@@ -10,5 +17,10 @@ export default function StoreProvider({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const storeRef = useRef(store);
|
||||
return <Provider store={storeRef.current}>{children}</Provider>;
|
||||
return (
|
||||
<Provider store={storeRef.current}>
|
||||
<RestoreUserDataComponent />
|
||||
{children}
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { UserData } from '@/interface/user';
|
||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||
import { getUserFromCookie } from '@/lib/cookieStorage';
|
||||
|
||||
const getStoredApplication = () => {
|
||||
if (typeof window !== "undefined") {
|
||||
@@ -9,15 +10,24 @@ const getStoredApplication = () => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const getStoredUserData = (): UserData | null => {
|
||||
if (typeof window === "undefined") {
|
||||
return null;
|
||||
}
|
||||
return getUserFromCookie();
|
||||
};
|
||||
|
||||
interface UserState {
|
||||
data: UserData | null;
|
||||
isAuthenticated: boolean;
|
||||
selectedApplication: any | null;
|
||||
}
|
||||
|
||||
const storedUserData = getStoredUserData();
|
||||
|
||||
const initialState: UserState = {
|
||||
data: null,
|
||||
isAuthenticated: false,
|
||||
data: storedUserData,
|
||||
isAuthenticated: Boolean(storedUserData),
|
||||
selectedApplication: getStoredApplication(),
|
||||
};
|
||||
|
||||
@@ -29,6 +39,10 @@ const userSlice = createSlice({
|
||||
state.data = action.payload;
|
||||
state.isAuthenticated = true;
|
||||
},
|
||||
clearUserData: (state) => {
|
||||
state.data = null;
|
||||
state.isAuthenticated = false;
|
||||
},
|
||||
setSelectedApplication: (state, action: PayloadAction<any>) => {
|
||||
state.selectedApplication = action.payload;
|
||||
if (typeof window !== "undefined") {
|
||||
@@ -44,5 +58,5 @@ const userSlice = createSlice({
|
||||
},
|
||||
});
|
||||
|
||||
export const { setUserData, setSelectedApplication, clearSelectedApplication } = userSlice.actions;
|
||||
export const { setUserData, clearUserData, setSelectedApplication, clearSelectedApplication } = userSlice.actions;
|
||||
export default userSlice.reducer;
|
||||
@@ -1,4 +1,5 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { useDispatch, useSelector, TypedUseSelectorHook } from 'react-redux'; // Thêm dòng này
|
||||
import userReducer from './features/userSlice';
|
||||
|
||||
export const store = configureStore({
|
||||
@@ -8,4 +9,7 @@ export const store = configureStore({
|
||||
});
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
|
||||
export const useAppDispatch = () => useDispatch<AppDispatch>();
|
||||
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
|
||||
Reference in New Issue
Block a user