Compare commits

...

2 Commits

Author SHA1 Message Date
e76b1cf093 update
All checks were successful
Build and Release / release (push) Successful in 27s
2026-04-22 16:57:11 +07:00
8bc6b32e26 manage current user's media 2026-04-22 16:53:14 +07:00
16 changed files with 548 additions and 160 deletions

2
api.ts
View File

@@ -12,6 +12,8 @@ export const API = {
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`,

View File

@@ -1 +1,2 @@
export const LIMIT_ITEM_TABLE = 5;
export const LIMIT_ITEM_TABLE = 5;
export const INITIAL_LIMIT = 16;

View File

@@ -198,7 +198,7 @@ export default function RoleUpgrade() {
return (
<div className="max-w-4xl mx-auto pb-20">
<PageBreadcrumb pageTitle="ng cấp tài khoản Nhà sử học" />
<PageBreadcrumb pageTitle="Đăng ký trở thành Nhà sử học" />
<div className="flex items-center justify-between bg-white dark:bg-gray-900 p-2 rounded-xl border border-gray-200 dark:border-gray-800 mb-6">
<div className="flex gap-2">

View File

@@ -218,7 +218,6 @@ export default function HistorianApplicationPage() {
</button>
}
>
{/* Cập nhật Grid để chứa đủ các ô filter */}
<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>

View File

@@ -1,22 +1,16 @@
"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 { apiGetCurrentUserApplications, apiGetCurrentUserMedia } from "@/service/userService";
import { setUserData } from "@/store/features/userSlice";
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();
@@ -24,17 +18,7 @@ export default function Profile() {
const fetchUser = async () => {
try {
const userData = await apiGetCurrentUser();
const mediaResponse = await apiGetCurrentUserMedia();
const userApplications = await apiGetCurrentUserApplications();
console.log("user", userData);
if (userApplications?.data) {
setApplications(userApplications.data);
}
dispatch(setUserData(userData.data));
setMediaData(mediaResponse);
setUser(userData);
} catch (err) {
console.error("Lỗi:", err);
@@ -54,8 +38,6 @@ 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>

View File

@@ -0,0 +1,5 @@
export default function Page() {
return (
<div className=''>Page</div>
)
}

View File

@@ -0,0 +1,67 @@
"use client";
import { useEffect, useState } from "react";
import { useDispatch } from "react-redux";
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);
const dispatch = useDispatch();
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>
);
}
return (
<div className="min-h-screen bg-gray-50/50 p-4 dark:bg-zinc-950 lg:p-8">
<div className="mb-8">
<h1 className="text-3xl font-bold tracking-tight text-gray-900 dark:text-white">
Thư viện
</h1>
</div>
<div className="space-y-12">
{(mediaData?.data?.length ?? 0) > 0 && (
<section>
<MediaLibrary data={mediaData ?? {}} />
</section>
)}
{applications.length > 0 && (
<section>
<ApplicationLibrary applications={applications} />
</section>
)}
</div>
</div>
);
}

View File

@@ -8,6 +8,7 @@ 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";
export default function UserDropdown() {
const router = useRouter();
@@ -118,7 +119,7 @@ export default function UserDropdown() {
<DropdownItem
onItemClick={closeDropdown}
tag="a"
href="/profile"
href="/account"
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
@@ -136,35 +137,23 @@ export default function UserDropdown() {
fill=""
/>
</svg>
Edit profile
Tài Khoản
</DropdownItem>
</li>
{/* <li>
<li>
<DropdownItem
onItemClick={closeDropdown}
tag="a"
href="/profile"
href="/role-upgrade"
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
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
<span className="menu-item-icon">
<ListIcon />
</span>
Nhà Sử Học
</DropdownItem>
</li> */}
<li>
</li>
{/* <li>
<DropdownItem
onItemClick={closeDropdown}
tag="a"
@@ -186,9 +175,9 @@ export default function UserDropdown() {
fill=""
/>
</svg>
Support
Hỗ Trợ
</DropdownItem>
</li>
</li> */}
</ul>
<Link
onClick={handleLogout}
@@ -210,7 +199,7 @@ export default function UserDropdown() {
fill=""
/>
</svg>
Sign out
Đăng xuất
</Link>
</Dropdown>
</div>

View File

@@ -46,7 +46,7 @@ const processMedia = (mediaArray: any[]) => {
return { type: "empty" };
};
export default function ApplicationSquareCardList({
export default function ApplicationList({
applications,
}: {
applications: any[];
@@ -56,9 +56,9 @@ export default function ApplicationSquareCardList({
const handleViewDetail = (app: any) => {
dispatch(setSelectedApplication(app));
router.push(`/profile/applications`);
router.push(`/account/applications`);
};
// Tạo object mapping để render icon dựa trên status
const StatusIcons: Record<string, React.ReactNode> = {
APPROVED: (
<svg
@@ -111,12 +111,21 @@ export default function ApplicationSquareCardList({
};
return (
<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>
<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ồ {" "}
<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="flex flex-wrap gap-4">
{/* 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">
{applications?.map((app) => {
const mediaState = processMedia(app.media);
const config = statusConfig[app.status] || statusConfig.PENDING;
@@ -125,77 +134,69 @@ export default function ApplicationSquareCardList({
<div
key={app.id}
onClick={() => handleViewDetail(app)}
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"
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"
>
{/* Media Layer */}
<div className="absolute inset-0 z-0">
{mediaState.type === "image" ? (
<img
src={mediaState.src}
alt=""
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-700"
className="h-full w-full object-cover transition-transform duration-700 group-hover:scale-105"
/>
) : (
<div className="w-full h-full bg-zinc-100 dark:bg-zinc-900 flex items-center justify-center p-4">
<div className="flex h-full w-full items-center justify-center bg-gray-100 p-4 dark:bg-zinc-800/80">
{mediaState.type === "documents" ? (
<div className="flex flex-wrap gap-1 justify-center">
<div className="flex flex-wrap justify-center gap-2">
{mediaState.extensions?.slice(0, 3).map((ext, i) => (
<span
key={i}
className="text-[9px] font-black px-1.5 py-0.5 bg-white dark:bg-zinc-800 rounded border dark:border-zinc-700 uppercase"
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"
>
.{ext}
</span>
))}
</div>
) : (
<div className="w-full h-full bg-gradient-to-br from-zinc-400 to-zinc-600 opacity-20" />
<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>
)}
</div>
{/* 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 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" />
{/* TOP INFO: FILE COUNT & STATUS ICON */}
<div className="absolute top-2 left-2 right-2 z-20 flex justify-between items-center">
<div className="absolute left-3 right-3 top-3 z-20 flex items-start justify-between">
{app.media?.length > 0 ? (
<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 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>
) : (
<div />
)}
{/* Status Icon Wrapper */}
<div
className={`${config.container} rounded-full`}
>
<div className={`${config.container} rounded-full `}>
{StatusIcons[app.status] || StatusIcons.PENDING}
</div>
</div>
{/* 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="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>
<div className="flex items-center justify-between pt-1 border-t border-white/10">
<p className="text-[9px] font-bold text-white/70">
{app?.reviewer?.display_name && (
<p className="mb-3 truncate text-sm font-medium text-blue-300">
Người duyệt: {app.reviewer.display_name}
</p>
)}
<div className="flex items-center justify-between border-t border-white/20 pt-2">
<p className="text-xs font-semibold text-gray-300">
{formatFullDateTime(app.created_at)}
</p>
<div className="transform translate-x-2 opacity-0 group-hover:translate-x-0 group-hover:opacity-100 transition-all duration-300">
<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">
<svg
className="w-4 h-4 text-blue-400"
className="h-3 w-3 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
@@ -204,7 +205,7 @@ export default function ApplicationSquareCardList({
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13 7l5 5m0 0l-5 5m5-5H6"
d="M9 5l7 7-7 7"
/>
</svg>
</div>
@@ -216,4 +217,4 @@ export default function ApplicationSquareCardList({
</div>
</div>
);
}
}

View File

@@ -1,71 +1,396 @@
"use client";
import { useState } from "react";
import { useState, useEffect } 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";
export default function MediaCard({ data }: { data: MediaDto }) {
const [index, setIndex] = useState(-1);
const listMedia = data?.data || [];
import { deleteMedia } from "@/service/mediaService";
import { INITIAL_LIMIT } from "../../../constant";
const slides = listMedia.map((item) => ({
export default function MediaLibrary({
data,
onRefresh,
}: {
data: MediaDto;
onRefresh?: () => void;
}) {
const [index, setIndex] = useState(-1);
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) => ({
src: `${URL_MEDIA}${item.storage_key}`,
title: item.original_name,
description: `Size: ${(item.size / 1024).toFixed(2)} KB - Type: ${item.mime_type}`,
description: `Kích thước: ${(item.size / 1024).toFixed(2)} KB - Loại: ${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>
<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"
>
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 ? (
<>
<img
src={`${URL_MEDIA}${item.storage_key}`}
alt={item.original_name}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-110"
className={`h-full w-full object-cover transition-transform duration-500 ${isSelected ? "scale-105 opacity-80" : "group-hover:scale-110"}`}
/>
<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}
</p>
<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">
{item.original_name}
</span>
</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={slides}
slides={imageSlides}
plugins={[Zoom, Captions]}
zoom={{
maxZoomPixelRatio: 10,
zoomInMultiplier: 2,
doubleTapDelay: 300,
doubleClickDelay: 300,
doubleClickMaxStops: 2,
keyboardMoveDistance: 50,
wheelZoomDistanceFactor: 100,
pinchZoomDistanceFactor: 100,
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: 3 }}
animation={{ zoom: 200 }}
styles={{
root: {
zIndex: 999999999,
"--yarl__color_backdrop": "rgba(0, 0, 0, 0.85)",
},
root: { zIndex: 99, "--yarl__color_backdrop": "rgba(0, 0, 0, 0.9)" },
}}
/>
</div>

View File

@@ -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,3 +178,4 @@ const AppHeader: React.FC = () => {
};
export default AppHeader;

View File

@@ -10,6 +10,7 @@ import {
BoxCubeIcon,
CalenderIcon,
ChevronDownIcon,
FileIcon,
GridIcon,
HorizontaLDots,
ListIcon,
@@ -39,44 +40,51 @@ type NavItem = {
const ALL_NAV_ITEMS: NavItem[] = [
{
icon: <GridIcon />,
name: "Dashboard",
subItems: [{ name: "Ecommerce", path: "/", pro: false }],
name: "Trang Chủ",
// subItems: [{ name: "Ecommerce", path: "/", pro: false }],
path: "/",
},
// {
// icon: <CalenderIcon />,
// name: "Calendar",
// path: "/calendar",
// },
{
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 }
// ],
// },
{
icon: <UserCircleIcon />,
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",
name: "Quản lý",
icon: <TableIcon />,
subItems: [
// { name: "Basic Tables", path: "/basic-tables", pro: false },
{ name: "User Tables", path: "/user-table", pro: false, roles: ["ADMIN", "MOD"] },
{ name: "Applications Tables", path: "/applications-tables", pro: false, roles: ["ADMIN", "MOD"] },
{ 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"] },
],
},
{
name: "Pages",
icon: <PageIcon />,
subItems: [
{ name: "Blank Page", path: "/blank", pro: false },
{ name: "404 Error", path: "/error-404", pro: false },
],
icon: <UserCircleIcon />,
name: "Tài Khoản",
path: "/account",
},
// {
// name: "Pages",
// icon: <PageIcon />,
// subItems: [
// { name: "Blank Page", path: "/blank", pro: false },
// { name: "404 Error", path: "/error-404", pro: false },
// ],
// },
];
const OTHERS_ITEMS: NavItem[] = [
@@ -198,7 +206,7 @@ const AppSidebar: React.FC = () => {
{nav.subItems ? (
<button
onClick={() => handleSubmenuToggle(index, menuType)}
className={`menu-item group ${
className={`menu-item group uppercase ${
openSubmenu?.type === menuType && openSubmenu?.index === index
? "menu-item-active" : "menu-item-inactive"
} cursor-pointer ${!isExpanded && !isHovered ? "lg:justify-center" : "lg:justify-start"}`}
@@ -278,12 +286,12 @@ const AppSidebar: React.FC = () => {
</h2>
{renderMenuItems(filteredNavItems, "main")}
</div>
<div>
{/* <div>
<h2 className={`mb-4 text-xs uppercase flex leading-[20px] text-gray-400 ${!isExpanded && !isHovered ? "lg:justify-center" : "justify-start"}`}>
{isExpanded || isHovered || isMobileOpen ? "Others" : <HorizontaLDots />}
</h2>
{renderMenuItems(filteredOthersItems, "others")}
</div>
</div> */}
</div>
</nav>
</div>

View File

@@ -109,3 +109,11 @@ 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;
}