From e273df1ee297275fab83401abcef54957d4a018a Mon Sep 17 00:00:00 2001 From: bokhonglo Date: Thu, 23 Apr 2026 15:52:18 +0700 Subject: [PATCH] update media manage --- api.ts | 1 + .../(forms)/role-upgrade/page.tsx | 7 +- .../(management)/assets/page.tsx | 438 ++++++++++++++++++ .../(management)/user-information/page.tsx | 2 +- .../account/applications/page.tsx | 1 + .../(admin)/(others-pages)/assets/page.tsx | 5 - .../(admin)/(others-pages)/library/page.tsx | 37 +- src/app/globals.css | 35 +- src/components/auth/SignUpForm.tsx | 14 +- src/components/tables/MediaTable.tsx | 237 ++++++++++ .../user-profile/AccountDetails.tsx | 93 +++- src/components/user-profile/Media.tsx | 4 +- src/components/user-profile/UserMetaCard.tsx | 11 +- src/service/mediaService.ts | 11 + 14 files changed, 830 insertions(+), 66 deletions(-) create mode 100644 src/app/(admin)/(others-pages)/(management)/assets/page.tsx delete mode 100644 src/app/(admin)/(others-pages)/assets/page.tsx create mode 100644 src/components/tables/MediaTable.tsx diff --git a/api.ts b/api.ts index 4ab55ed..5232437 100644 --- a/api.ts +++ b/api.ts @@ -10,6 +10,7 @@ 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}`, diff --git a/src/app/(admin)/(others-pages)/(forms)/role-upgrade/page.tsx b/src/app/(admin)/(others-pages)/(forms)/role-upgrade/page.tsx index 17d360e..48392e7 100644 --- a/src/app/(admin)/(others-pages)/(forms)/role-upgrade/page.tsx +++ b/src/app/(admin)/(others-pages)/(forms)/role-upgrade/page.tsx @@ -41,13 +41,11 @@ 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; @@ -55,11 +53,8 @@ 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(); }); diff --git a/src/app/(admin)/(others-pages)/(management)/assets/page.tsx b/src/app/(admin)/(others-pages)/(management)/assets/page.tsx new file mode 100644 index 0000000..c187126 --- /dev/null +++ b/src/app/(admin)/(others-pages)/(management)/assets/page.tsx @@ -0,0 +1,438 @@ +"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(1); + const [limitInput, setLimitInput] = useState( + LIMIT_ITEM_TABLE.toString(), + ); + + const [searchTerm, setSearchTerm] = useState(""); + const [mimeTypeFilter, setMimeTypeFilter] = useState(""); + + const [fromDate, setFromDate] = useState(""); + const [fromTime, setFromTime] = useState(""); + const [toDate, setToDate] = useState(""); + const [toTime, setToTime] = useState(""); + + const [debouncedParams, setDebouncedParams] = useState({ + search: "", + limit: LIMIT_ITEM_TABLE, + mimeType: "", + fromDate: "", + fromTime: "", + toDate: "", + toTime: "", + }); + + const [tableData, setTableData] = useState(null); + const [loading, setLoading] = useState(true); + + const [sortBy, setSortBy] = useState("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([]); + const [index, setIndex] = useState(-1); // Dùng cho thư viện xem ảnh + const [isSelectionMode, setIsSelectionMode] = useState(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 ( +
+ + +
+ + + + + + } + > +
+
+ + 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" + /> +
+
+ + +
+
+ +
+ 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" + /> + 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" + /> +
+
+
+ +
+ 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" + /> + 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" + /> +
+
+
+ + 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" + /> +
+
+
+ + 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})`} + + } + > +
+ {loading && ( +
+
+
+ )} + + +
+ +
+

+ Hiển thị {pagination?.total_records || 0} tệp tin +

+ + {pagination && pagination.total_pages > 1 && ( + setPage(newPage)} + /> + )} +
+
+
+
+ ); +} diff --git a/src/app/(admin)/(others-pages)/(management)/user-information/page.tsx b/src/app/(admin)/(others-pages)/(management)/user-information/page.tsx index 28659ee..1dd7f64 100644 --- a/src/app/(admin)/(others-pages)/(management)/user-information/page.tsx +++ b/src/app/(admin)/(others-pages)/(management)/user-information/page.tsx @@ -306,7 +306,7 @@ export default function UserTable() {
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" + /> + + +
onSort("original_name")}> + Tên tệp +
+
+ +
onSort("mime_type")}> + Định dạng +
+
+ +
onSort("size")}> + Kích thước +
+
+ +
onSort("created_at")}> + Ngày tải lên +
+
+ +
onSort("updated_at")}> + Cập nhật +
+
+ + Thao tác + + + + + + {data.length > 0 ? ( + data.map((item, idx) => ( + + {/* Yêu cầu 1: Cột 0 - Ô hình vuông chọn từng item */} + + 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" + /> + + + {item.original_name} + + +
+
{getMimeTypeBadge(item.mime_type)}
+ {item.mime_type} +
+
+ + {formatBytes(item.size)} + + + {formatDate(item.created_at)} + + + {formatDate(item.updated_at)} + + +
+ + +
+
+
+ )) + ) : ( + + + Không tìm thấy tệp tin nào + + + )} +
+ +
+ + + ); +} \ No newline at end of file diff --git a/src/components/user-profile/AccountDetails.tsx b/src/components/user-profile/AccountDetails.tsx index 2a415c7..bf8350b 100644 --- a/src/components/user-profile/AccountDetails.tsx +++ b/src/components/user-profile/AccountDetails.tsx @@ -22,6 +22,8 @@ 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(() => { @@ -32,6 +34,8 @@ export default function AccountDetails({ data }: { data: UserMetaCardProps }) { confirm_password: "", }); setShowOldPass(false); + setShowNewPass(false); + setShowConfirmPass(false); setError(""); } }, [isOpen]); @@ -74,10 +78,15 @@ 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 ( <>
@@ -149,7 +158,6 @@ export default function AccountDetails({ data }: { data: UserMetaCardProps }) { />
- {/* 2. Current Password - CÓ NÚT HIỆN MẬT KHẨU */}
@@ -174,28 +182,63 @@ export default function AccountDetails({ data }: { data: UserMetaCardProps }) {
- {/* 3. New Password - LUÔN ẨN */} -
+
- +
+ + +
- - {/* 4. Confirm New Password - LUÔN ẨN */} -
+
- +
+ + +
+ {/* Thêm một dòng text báo lỗi nhỏ ngay dưới ô input nếu muốn */} + {isPasswordMismatch && ( +

+ Mật khẩu không khớp! +

+ )}
@@ -211,7 +254,11 @@ export default function AccountDetails({ data }: { data: UserMetaCardProps }) { > Close -
diff --git a/src/components/user-profile/Media.tsx b/src/components/user-profile/Media.tsx index c592e6a..2f4ea57 100644 --- a/src/components/user-profile/Media.tsx +++ b/src/components/user-profile/Media.tsx @@ -258,7 +258,7 @@ export default function MediaLibrary({

- Media Assets{" "} + Media Assets ({localMedia.length} tệp) @@ -333,7 +333,7 @@ export default function MediaLibrary({ Tài liệu ({documentFiles.length})

- {" "} + {displayedDocs.map((item, idx) => renderItemCard(item, false, idx), )} diff --git a/src/components/user-profile/UserMetaCard.tsx b/src/components/user-profile/UserMetaCard.tsx index faf537d..0d0b20a 100644 --- a/src/components/user-profile/UserMetaCard.tsx +++ b/src/components/user-profile/UserMetaCard.tsx @@ -7,6 +7,7 @@ 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 = @@ -15,6 +16,8 @@ export default function UserMetaCard({ data }: { data: UserMetaCardProps }) { const [isUploading, setIsUploading] = useState(false); const fileInputRef = useRef(null); + const router = useRouter(); + useEffect(() => { if (data.data?.profile?.avatar_url) { setPreviewImage(data.data.profile.avatar_url); @@ -49,15 +52,11 @@ 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( - "Ảnh đã được tải lên nhưng không thể cập nhật hồ sơ. Vui lòng thử lại!", - ); + toast.warning("Lỗi khi cập nhật ảnh đại diện. Vui lòng thử lại!"); } } } diff --git a/src/service/mediaService.ts b/src/service/mediaService.ts index 3ff98d9..3a1358a 100644 --- a/src/service/mediaService.ts +++ b/src/service/mediaService.ts @@ -116,4 +116,15 @@ export const deleteMedia = async (mediaIds: string[]) => { } }); 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; } \ No newline at end of file