This commit is contained in:
2026-04-18 18:02:21 +07:00
parent 9d35fd3653
commit 300b35190d
14 changed files with 439 additions and 107 deletions

View File

@@ -7,7 +7,7 @@ import {
getPresignedUrl,
uploadFileToS3,
} from "@/service/mediaService";
import React, { useState } from "react";
import React, { useRef, useState } from "react";
import Image from "next/image";
import Lightbox from "yet-another-react-lightbox";
import Zoom from "yet-another-react-lightbox/plugins/zoom";
@@ -37,6 +37,37 @@ export default function RoleUpgrade() {
const [lightboxIndex, setLightboxIndex] = useState(-1);
const [isPreparingFiles, setIsPreparingFiles] = useState(false);
const [showPreview, setShowPreview] = useState(false);
const iframeRef = useRef<HTMLIFrameElement>(null);
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;
iframe.style.height = `${scrollHeight}px`;
}
};
// 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();
});
if (iframe.contentDocument.body) {
resizeObserver.observe(iframe.contentDocument.body);
}
};
const cleanHTMLContent = (rawHtml: string) => {
if (!rawHtml) return "";
@@ -198,13 +229,34 @@ export default function RoleUpgrade() {
) : (
<div className="bg-white border border-zinc-200 rounded-xl shadow-inner overflow-hidden min-h-[400px]">
{content ? (
<div className="preview-wrapper bg-white text-black">
<div
dangerouslySetInnerHTML={{
__html: cleanHTMLContent(content),
}}
/>
</div>
<iframe
ref={iframeRef}
onLoad={handleIframeLoad}
srcDoc={`
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body {
font-family: system-ui, sans-serif;
padding: 20px;
margin: 0;
color: black;
overflow-y: hidden;
}
img { max-width: 100%; height: auto; display: block; margin-top: 10px; }
</style>
</head>
<body>
${cleanHTMLContent(content)}
</body>
</html>
`}
title="Preview"
className="w-full border-none flex-1 transition-all duration-300"
sandbox="allow-same-origin"
/>
) : (
<div className="flex flex-col items-center justify-center h-[400px] text-zinc-400 italic bg-gray-50 dark:bg-gray-900">
<p>Chưa nội dung đ xem trước</p>

View File

@@ -187,6 +187,7 @@ export default function HistorianApplicationPage() {
const pagination = tableData?.pagination;
console.log(tableData)
// console.log("Pagination info:", pagination);
return (
<div>

View File

@@ -186,7 +186,7 @@ export default function UserTable() {
const pagination = tableData?.pagination;
console.log(pagination);
// console.log(pagination);
const handleOpenDetail = (user: fullDataUser) => {
setSelectedUser(user);

View File

@@ -7,8 +7,6 @@ import { useRouter } from "next/navigation";
import { useState } from "react";
import { apiDeleteHistorianCV } from "@/service/historianService";
import Swal from "sweetalert2";
// Import statusConfig để đồng bộ logic
import { statusConfig } from "@/service/handler";
import Lightbox from "yet-another-react-lightbox";
@@ -17,6 +15,7 @@ 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 { URL_MEDIA } from "../../../../../../api";
import Image from "next/image";
export default function ApplicationDetailPage() {
const application = useSelector(
@@ -24,6 +23,9 @@ export default function ApplicationDetailPage() {
);
const router = useRouter();
const [isDeleting, setIsDeleting] = useState(false);
const [errMessage, setErrMessage] = useState<string>(
"Không thể xóa đơn đăng ký này.",
);
const [index, setIndex] = useState(-1);
if (!application) {
@@ -82,6 +84,8 @@ export default function ApplicationDetailPage() {
try {
setIsDeleting(true);
await apiDeleteHistorianCV(application.id);
// Thành công (200 OK)
await Swal.fire({
title: "Đã xóa!",
icon: "success",
@@ -91,15 +95,31 @@ export default function ApplicationDetailPage() {
color: isDarkMode ? "#fff" : "#000",
});
router.push("/profile");
} catch (error) {
Swal.fire({ title: "Lỗi!", icon: "error" });
} catch (error: any) {
setErrMessage(
error.response?.data?.message || "Có lỗi xảy ra khi xóa!",
);
if (
error.response?.data?.message ===
"You don't have permission to access this resource."
) {
setErrMessage("Bạn không có quyền xóa đơn đăng ký này.");
}
Swal.fire({
title: "Lỗi!",
text: errMessage,
icon: "error",
background: isDarkMode ? "#18181b" : "#fff",
color: isDarkMode ? "#fff" : "#000",
});
} finally {
setIsDeleting(false);
}
}
};
console.log("Application Detail:", application); // Debug log để kiểm tra dữ liệu ứng dụng
// console.log("Application Detail:", application);
return (
<div className="max-w-5xl mx-auto p-6 bg-white dark:bg-zinc-900 rounded-2xl shadow-sm border dark:border-zinc-800">
@@ -179,19 +199,70 @@ export default function ApplicationDetailPage() {
</section>
)}
{application.status !== "APPROVED" && (
<div className="pt-8 border-t border-zinc-200 dark:border-zinc-800">
<div className="flex justify-end w-full">
<button
onClick={handleDelete}
disabled={isDeleting}
className="group flex items-center gap-2 px-8 py-3 bg-red-100 hover:bg-red-500 text-red-600 hover:text-white dark:bg-red-950/20 dark:hover:bg-red-600 dark:text-red-400 dark:hover:text-white text-sm font-black rounded-xl transition-all duration-300 disabled:opacity-50"
>
{isDeleting ? "ĐANG XỬ LÝ..." : "XÓA"}
</button>
{application.status !== "APPROVED" &&
application.status !== "REJECTED" && (
<div className="pt-8 border-t border-zinc-200 dark:border-zinc-800">
<div className="flex justify-end w-full">
<button
onClick={handleDelete}
disabled={isDeleting}
className="group flex items-center gap-2 px-8 py-3 bg-red-100 hover:bg-red-500 text-red-600 hover:text-white dark:bg-red-950/20 dark:hover:bg-red-600 dark:text-red-400 dark:hover:text-white text-sm font-black rounded-xl transition-all duration-300 disabled:opacity-50"
>
{isDeleting ? "ĐANG XỬ LÝ..." : "XÓA"}
</button>
</div>
</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>
<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">
{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 ghi chú bổ sung.</p>
)}
</div>
</div>
</div>
</section>
)}
</div>
<Lightbox

View File

@@ -23,7 +23,8 @@ export default function Profile() {
const userData = await apiGetCurrentUser();
const mediaResponse = await apiGetCurrentUserMedia();
const userApplications = await apiGetCurrentUserApplications();
// console.log("User Applications:", userApplications);
console.log(userData);
if (userApplications?.data) {
setApplications(userApplications.data);

View File

@@ -8,6 +8,7 @@ import Link from "next/link";
import { useState } from "react";
import { toast } from "sonner";
import { API, HOME_URL } from "../../../api";
import Swal from "sweetalert2";
export default function SignUpForm() {
const [showPassword, setShowPassword] = useState(false);
@@ -102,7 +103,7 @@ export default function SignUpForm() {
};
const signupRes = await apiSignUp(signupPayload);
alert("Đăng ký thành công! Đang chuyển hướng...");
Swal.fire("Đăng ký thành công! Đang chuyển hướng về trang đăng nhập!");
window.location.href = "/signin";
} catch (error) {

View File

@@ -11,6 +11,8 @@ 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 { IsolatedContent } from "@/components/ui/IsolatedContent";
import { apiDeleteHistorianCV } from "@/service/historianService";
interface Props {
isOpen: boolean;
@@ -57,7 +59,9 @@ export default function ApplicationDetailModal({
const handleMediaClick = (item: any) => {
const fileUrl = `${URL_MEDIA}${item.storage_key}`;
if (isImageFile(item)) {
const photoIndex = imageMediaOnly.findIndex((img: any) => img.id === item.id);
const photoIndex = imageMediaOnly.findIndex(
(img: any) => img.id === item.id,
);
setIndex(photoIndex);
} else {
const googleDocsUrl = `https://docs.google.com/viewer?url=${encodeURIComponent(fileUrl)}&embedded=true`;
@@ -87,6 +91,13 @@ export default function ApplicationDetailModal({
}
};
const handleDeleteApplication = async () => {
await apiDeleteHistorianCV(application.id);
Swal.fire("Thành công!", "Hồ sơ đã được xóa.", "success");
onRefresh();
onClose();
};
if (!isOpen || !application) return null;
const userData = application.user || {};
@@ -96,9 +107,22 @@ export default function ApplicationDetailModal({
<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">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-800">
<h3 className="text-xl font-semibold">Chi tiết yêu cầu nâng cấp</h3>
<button onClick={onClose} className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-full">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
<button
onClick={onClose}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-full"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
@@ -106,17 +130,32 @@ export default function ApplicationDetailModal({
<div className="flex-1 p-6 overflow-y-auto custom-scrollbar">
<div className="flex flex-col gap-8">
<div className="p-5 border border-gray-200 rounded-xl dark:border-gray-800 bg-gray-50/50 dark:bg-white/[0.02]">
<h4 className="mb-4 text-xs font-bold text-gray-500 uppercase tracking-wider">Thông tin ng viên</h4>
<h4 className="mb-4 text-xs font-bold text-gray-500 uppercase tracking-wider">
Thông tin ng viên
</h4>
<div className="flex items-center gap-4">
<div className="relative w-16 h-16 overflow-hidden border border-gray-200 rounded-full shrink-0">
<Image fill src={userData.avatar_url || "/images/no-images.jpg"} alt="avatar" className="object-cover" />
<Image
fill
src={userData.avatar_url || "/images/no-images.jpg"}
alt="avatar"
className="object-cover"
/>
</div>
<div>
<h4 className="text-lg font-semibold">{userData.display_name || "N/A"}</h4>
<p className="text-sm text-gray-500">{userData.email || "Không có email"}</p>
<h4 className="text-lg font-semibold">
{userData.display_name || "N/A"}
</h4>
<p className="text-sm text-gray-500">
{userData.email || "Không có email"}
</p>
<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={`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"}`}>
<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={`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>
@@ -125,15 +164,18 @@ export default function ApplicationDetailModal({
</div>
<div>
<h4 className="mb-3 text-xs font-bold text-gray-500 uppercase tracking-wider">Nội dung ng tuyển</h4>
<div
className="p-4 prose bg-white border border-gray-200 min-h-[100px] rounded-xl dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200 max-w-none"
dangerouslySetInnerHTML={{ __html: application.content || "<i>Không có nội dung.</i>" }}
/>
<h4 className="mb-3 text-xs font-bold text-gray-500 uppercase tracking-wider">
Nội dung ng tuyển
</h4>
<div className="p-4 prose bg-white border border-gray-200 min-h-[100px] rounded-xl dark:border-gray-800 dark:bg-gray-900 dark:text-gray-200 max-w-none">
<IsolatedContent html={application.content} />
</div>
</div>
<div>
<h4 className="mb-3 text-xs font-bold text-gray-500 uppercase tracking-wider">Tệp đính kèm ({mediaList.length})</h4>
<h4 className="mb-3 text-xs font-bold text-gray-500 uppercase tracking-wider">
Tệp đính kèm ({mediaList.length})
</h4>
{mediaList.length > 0 ? (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
{mediaList.map((media: any, idx: number) => {
@@ -145,11 +187,20 @@ export default function ApplicationDetailModal({
className="relative overflow-hidden border border-gray-200 group aspect-square rounded-xl dark:border-gray-700 bg-gray-50 dark:bg-gray-800 cursor-pointer"
>
{isImg ? (
<Image src={`${URL_MEDIA}${media.storage_key}`} alt="media" fill className="object-cover transition-transform duration-300 group-hover:scale-110" />
<Image
src={`${URL_MEDIA}${media.storage_key}`}
alt="media"
fill
className="object-cover transition-transform duration-300 group-hover:scale-110"
/>
) : (
<div className="flex flex-col items-center justify-center w-full h-full p-3 text-center">
<div className="w-10 h-10 mb-2 flex items-center justify-center bg-white dark:bg-zinc-800 rounded-lg shadow-sm text-xl">📄</div>
<span className="text-[10px] font-medium text-gray-600 line-clamp-2">{media.original_name}</span>
<div className="w-10 h-10 mb-2 flex items-center justify-center bg-white dark:bg-zinc-800 rounded-lg shadow-sm text-xl">
📄
</div>
<span className="text-[10px] font-medium text-gray-600 line-clamp-2">
{media.original_name}
</span>
</div>
)}
<div className="absolute inset-0 flex items-center justify-center transition-opacity bg-black/40 opacity-0 group-hover:opacity-100 z-10">
@@ -162,12 +213,16 @@ export default function ApplicationDetailModal({
})}
</div>
) : (
<p className="text-sm italic text-gray-400">Không tệp đính kèm.</p>
<p className="text-sm italic text-gray-400">
Không tệp đính kèm.
</p>
)}
</div>
<div>
<h4 className="mb-3 text-xs font-bold text-gray-500 uppercase tracking-wider">Ghi chú duyệt hồ </h4>
<h4 className="mb-3 text-xs font-bold text-gray-500 uppercase tracking-wider">
Ghi chú duyệt hồ
</h4>
<textarea
ref={textareaRef}
className="w-full p-4 text-sm bg-white border border-gray-200 rounded-xl dark:border-gray-800 dark:bg-gray-900 focus:ring-2 focus:ring-blue-500 outline-none h-[100px]"
@@ -181,13 +236,36 @@ export default function ApplicationDetailModal({
</div>
<div className="flex items-center justify-end gap-3 px-6 py-4 bg-gray-50 border-t border-gray-200 dark:bg-gray-900/50 dark:border-gray-800">
<button onClick={onClose} className="px-5 py-2 text-sm font-medium border border-gray-300 rounded-xl hover:bg-gray-100 transition-colors">Đóng</button>
{application.status === "PENDING" && (
<>
<button onClick={() => handleUpdateStatus("REJECTED")} disabled={isSubmitting} className="px-5 py-2 text-sm font-medium text-white bg-red-500 rounded-xl hover:bg-red-600">Từ chối</button>
<button onClick={() => handleUpdateStatus("APPROVED")} disabled={isSubmitting} className="px-5 py-2 text-sm font-medium text-white bg-green-500 rounded-xl hover:bg-green-600">Phê duyệt</button>
<button
onClick={() => handleUpdateStatus("REJECTED")}
disabled={isSubmitting}
className="px-5 py-2 text-sm font-medium text-white bg-red-500 rounded-xl hover:bg-red-600"
>
Từ chối
</button>
<button
onClick={() => handleUpdateStatus("APPROVED")}
disabled={isSubmitting}
className="px-5 py-2 text-sm font-medium text-white bg-green-500 rounded-xl hover:bg-green-600"
>
Phê duyệt
</button>
</>
)}
<button
onClick={handleDeleteApplication}
className="px-5 py-2 text-sm font-medium text-white bg-red-500 rounded-xl hover:bg-red-600"
>
Xóa
</button>
<button
onClick={onClose}
className="px-5 py-2 text-sm font-medium border border-gray-300 rounded-xl hover:bg-gray-100 transition-colors"
>
Đóng
</button>
</div>
</div>

View File

@@ -0,0 +1,41 @@
import { useEffect, useRef } from "react";
export function IsolatedContent({ html }: { html: string }) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (containerRef.current) {
let shadow = containerRef.current.shadowRoot;
if (!shadow) {
shadow = containerRef.current.attachShadow({ mode: "open" });
}
const styleTag = `
<style>
:host {
display: block;
font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
color: inherit;
line-height: 1.6;
}
.inner-content {
font-size: 14.5px;
color: currentColor;
}
p { margin-bottom: 1rem; }
h1, h2, h3 { margin-top: 1.5rem; margin-bottom: 0.5rem; font-weight: 700; line-height: 1.2; }
ul, ol { padding-left: 1.5rem; margin-bottom: 1rem; }
li { margin-bottom: 0.25rem; }
img { max-width: 100%; height: auto; border-radius: 8px; }
a { color: #3b82f6; text-decoration: underline; }
blockquote { border-left: 4px solid #e5e7eb; padding-left: 1rem; font-style: italic; color: #6b7280; }
</style>
`;
shadow.innerHTML = `${styleTag}<div class="inner-content">${html || "<i>Không có nội dung.</i>"}</div>`;
}
}, [html]);
return <div ref={containerRef} />;
}

View File

@@ -58,6 +58,57 @@ export default function ApplicationSquareCardList({
dispatch(setSelectedApplication(app));
router.push(`/profile/applications`);
};
// Tạo object mapping để render icon dựa trên status
const StatusIcons: Record<string, React.ReactNode> = {
APPROVED: (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
className="size-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
),
PENDING: (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
className="size-6 animate-pulse p-1 "
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 18 9 11.25l4.306 4.306a11.95 11.95 0 0 1 5.814-5.518l2.74-1.22m0 0-5.94-2.281m5.94 2.28-2.28 5.941"
/>
</svg>
),
REJECTED: (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth="1.5"
stroke="currentColor"
className="size-6"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m9.75 9.75 4.5 4.5m0-4.5-4.5 4.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"
/>
</svg>
),
};
return (
<div className="p-5 border rounded-xl dark:border-zinc-800 lg:p-6 bg-white dark:bg-zinc-950">
@@ -68,8 +119,6 @@ export default function ApplicationSquareCardList({
<div className="flex flex-wrap gap-4">
{applications?.map((app) => {
const mediaState = processMedia(app.media);
// --- LOGIC STATUS NẰM TRONG VÒNG LẶP ---
const config = statusConfig[app.status] || statusConfig.PENDING;
return (
@@ -78,7 +127,7 @@ export default function ApplicationSquareCardList({
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"
>
{/* BACKGROUND */}
{/* Media Layer */}
<div className="absolute inset-0 z-0">
{mediaState.type === "image" ? (
<img
@@ -106,32 +155,28 @@ export default function ApplicationSquareCardList({
)}
</div>
{/* OVERLAY */}
{/* 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" />
{/* TOP INFO: STATUS & FILE COUNT */}
<div className="absolute top-2 left-2 right-2 z-20 flex justify-between items-start">
<div
className={`flex items-center p-1 rounded-full border ${config.container}`}
>
<span
className={`w-2 h-2 rounded-full ${config.dot} ${
app.status === "PENDING" ? "animate-pulse" : ""
}`}
/>
{/* <span className="text-[9px] font-bold uppercase tracking-wider">
{app.status}
</span> */}
</div>
{app.media?.length > 0 && (
{/* 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="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 />
)}
{/* Status Icon Wrapper */}
<div
className={`${config.container} rounded-full`}
>
{StatusIcons[app.status] || StatusIcons.PENDING}
</div>
</div>
{/* BOTTOM INFO */}
{/* 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">

View File

@@ -7,7 +7,7 @@ import Button from "../ui/button/Button";
import Input from "../form/input/InputField";
import Label from "../form/Label";
import { Profile, UserMetaCardProps } from "@/interface/user";
import { apiUpdateUser } from "@/service/userService";
import { apiUpdateUser, apiUpdateUserCurrent } from "@/service/userService";
import { toast } from "sonner";
import Link from "next/link";
@@ -51,7 +51,7 @@ export default function UserInfoCard({ data }: { data: UserMetaCardProps }) {
if (!userId) return;
try {
const response = await apiUpdateUser(userId, formData);
const response = await apiUpdateUserCurrent(formData);
if (response && response.status === false) {
toast.error(response.message || "Cập nhật thất bại.");
@@ -90,14 +90,6 @@ export default function UserInfoCard({ data }: { data: UserMetaCardProps }) {
</h4>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:gap-7 2xl:gap-x-32">
{/* <div>
<p className="mb-2 text-xs leading-normal text-gray-500 dark:text-gray-400">
Avatar
</p>
<p className="text-sm font-medium text-gray-800 dark:text-white/90">
{data.data?.profile?.avatar_url || "avatar_url"}
</p>
</div> */}
<div>
<p className="mb-2 text-xs leading-normal text-gray-500 dark:text-gray-400">
Full Name

View File

@@ -4,10 +4,13 @@ import { useState, useRef, useEffect } from "react";
import Image from "next/image";
import { UserMetaCardProps } from "@/interface/user";
import { uploadMedia } from "@/service/mediaService";
import { toast } from "sonner";
import { apiUpdateUser, apiUpdateUserCurrent } from "@/service/userService";
import { URL_MEDIA } from "../../../api";
export default function UserMetaCard({ data }: { data: UserMetaCardProps }) {
const currentAvatar = data.data?.profile?.avatar_url || "/images/no-images.jpg";
const currentAvatar =
data.data?.profile?.avatar_url || "/images/no-images.jpg";
const [previewImage, setPreviewImage] = useState(currentAvatar);
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -24,11 +27,12 @@ export default function UserMetaCard({ data }: { data: UserMetaCardProps }) {
}
};
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const handleFileChange = async (
event: React.ChangeEvent<HTMLInputElement>,
) => {
const file = event.target.files?.[0];
if (!file) return;
const objectUrl = URL.createObjectURL(file);
const backupImage = previewImage;
setPreviewImage(objectUrl);
@@ -39,11 +43,24 @@ export default function UserMetaCard({ data }: { data: UserMetaCardProps }) {
// console.log("Upload thành công:", uploadedMedia);
if (uploadedMedia?.url) {
setPreviewImage(uploadedMedia.url);
if (uploadedMedia?.storage_key) {
const url = URL_MEDIA + uploadedMedia.storage_key;
setPreviewImage(url);
if (data.data) {
try {
await apiUpdateUserCurrent({ avatar_url: url });
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!",
);
}
}
}
} catch (error) {
console.error("Lỗi khi upload avatar:", error);
setPreviewImage(backupImage);
@@ -55,7 +72,6 @@ export default function UserMetaCard({ data }: { data: UserMetaCardProps }) {
}
}
};
return (
<div className="p-5 border border-gray-200 rounded-2xl dark:border-gray-800 lg:p-6">
<div className="flex flex-col gap-5 xl:flex-row xl:items-center xl:justify-between">
@@ -75,14 +91,44 @@ export default function UserMetaCard({ data }: { data: UserMetaCardProps }) {
<div className="absolute inset-0 flex items-center justify-center transition-opacity bg-black/50 opacity-0 group-hover:opacity-100">
{isUploading ? (
<svg className="w-6 h-6 text-white animate-spin" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
<svg
className="w-6 h-6 text-white animate-spin"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
fill="none"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
) : (
<svg className="w-6 h-6 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
<svg
className="w-6 h-6 text-white"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
)}
</div>
@@ -102,7 +148,8 @@ export default function UserMetaCard({ data }: { data: UserMetaCardProps }) {
</h4>
<div className="flex flex-col items-center gap-1 text-center xl:flex-row xl:gap-3 xl:text-left">
<p className="text-sm text-blue-500 dark:text-gray-400">
{data.data?.roles?.map((role) => role.name).join(", ") || "No roles available"}
{data.data?.roles?.map((role) => role.name).join(", ") ||
"No roles available"}
</p>
{data.data?.profile?.bio && (
<>

View File

@@ -38,7 +38,6 @@ export const apiUpdateApplicationStatus = async (id: string, payload: any) => {
};
export const apiGetUserById = async (userId: string) => {
// Thay đổi URL sao cho khớp với route Backend của bạn
const response = await api.get(API.Admin.GET_USER_BY_ID(userId));
return response?.data;
};

View File

@@ -12,8 +12,12 @@ export const apiGetCurrentUserApplications = async () => {
return response?.data;
};
export const apiUpdateUser = async (id: number | string, payload: Profile) => {
export const apiUpdateUser = async (id:string, payload: Profile) => {
const response = await api.put(API.User.Update(id), payload);
return response?.data;
};
export const apiUpdateUserCurrent = async (payload: Profile) => {
const response = await api.put(API.User.CURRENT, payload);
return response?.data;
};