Compare commits

..

18 Commits

Author SHA1 Message Date
ff70fc78f5 update: management project
All checks were successful
Build and Release / release (push) Successful in 29s
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 15:03:49 +07:00
a2bab73e50 update: projectsmanage
All checks were successful
Build and Release / release (push) Successful in 29s
2026-04-29 12:08:09 +07:00
d65a71ba1d project table 2026-04-28 17:52:51 +07:00
043ef08b87 remove upgrade role page
All checks were successful
Build and Release / release (push) Successful in 29s
2026-04-28 09:35:39 +07:00
226abba5ed update auto fill
All checks were successful
Build and Release / release (push) Successful in 27s
2026-04-23 18:30:28 +07:00
7013b93c53 update color
All checks were successful
Build and Release / release (push) Successful in 27s
2026-04-23 18:23:09 +07:00
d84352d848 fix bug
All checks were successful
Build and Release / release (push) Successful in 27s
2026-04-23 18:17:02 +07:00
2b40dd33dd fix: review image
All checks were successful
Build and Release / release (push) Successful in 27s
2026-04-23 18:11:27 +07:00
e273df1ee2 update media manage
All checks were successful
Build and Release / release (push) Successful in 29s
2026-04-23 15:52:18 +07:00
bf44502a24 update route
All checks were successful
Build and Release / release (push) Successful in 30s
2026-04-22 17:49:26 +07:00
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
2ee5c8da36 fix bug
All checks were successful
Build and Release / release (push) Successful in 27s
2026-04-20 11:22:13 +07:00
3dc7804b10 fix bug
All checks were successful
Build and Release / release (push) Successful in 27s
2026-04-20 08:17:29 +07:00
fc3d900871 fix bug
All checks were successful
Build and Release / release (push) Successful in 27s
2026-04-20 08:12:29 +07:00
4018d8e135 update: phan quyen
All checks were successful
Build and Release / release (push) Successful in 28s
2026-04-20 02:46:15 +07:00
1fe25c1944 Revert "pig update: decentralization, format date time"
All checks were successful
Build and Release / release (push) Successful in 27s
This reverts commit 49b99289bb.
2026-04-20 01:00:52 +07:00
49b99289bb pig update: decentralization, format date time
All checks were successful
Build and Release / release (push) Successful in 27s
2026-04-20 00:47:41 +07:00
34 changed files with 2919 additions and 483 deletions

23
api.ts
View File

@@ -7,11 +7,15 @@ export const API = {
MEDIA: `${API_URL_ROOT}/users/current/media`, MEDIA: `${API_URL_ROOT}/users/current/media`,
Update: `${API_URL_ROOT}/users/current`, Update: `${API_URL_ROOT}/users/current`,
CHANGE_PASSWORD: `${API_URL_ROOT}/users/current/password`, CHANGE_PASSWORD: `${API_URL_ROOT}/users/current/password`,
APPLICATION: `${API_URL_ROOT}/users/current/application` APPLICATION: `${API_URL_ROOT}/users/current/application`,
CURRENT_PROJECT: `${API_URL_ROOT}/users/current/project`
}, },
Media:{ Media:{
GET_MEDIA: `${API_URL_ROOT}/media`,
PRESIGNED: `${API_URL_ROOT}/media/presigned`, PRESIGNED: `${API_URL_ROOT}/media/presigned`,
GET_MEDIA_BY_ID: (Id: number | string) => `${API_URL_ROOT}/media/${Id}`, 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 : { Auth : {
LOGOUT: `${API_URL_ROOT}/auth/logout`, LOGOUT: `${API_URL_ROOT}/auth/logout`,
@@ -38,5 +42,20 @@ export const API = {
CREATE_CV: `${API_URL_ROOT}/historian/application`, CREATE_CV: `${API_URL_ROOT}/historian/application`,
APPLICATION: `${API_URL_ROOT}/historian/application`, APPLICATION: `${API_URL_ROOT}/historian/application`,
DELETE_CV: (Id: number | string) => `${API_URL_ROOT}/historian/application/${Id}`, DELETE_CV: (Id: number | string) => `${API_URL_ROOT}/historian/application/${Id}`,
} },
Project: {
GET_CURRENT_PROJECT: `${API_URL_ROOT}/users/current/project`,
CREATE: `${API_URL_ROOT}/projects`,
GET_ALL: `${API_URL_ROOT}/projects`,
GET_DETAIL: (id: number | string) => `${API_URL_ROOT}/projects/${id}`,
UPDATE: (id: number | string) => `${API_URL_ROOT}/projects/${id}`,
DELETE: (id: number | string) => `${API_URL_ROOT}/projects/${id}`,
ADD_MEMBER: (id: number | string) => `${API_URL_ROOT}/projects/${id}/members`,
UPDATE_MEMBER: (id: number | string, userId: number | string) => `${API_URL_ROOT}/projects/${id}/members/${userId}`,
REMOVE_MEMBER: (id: number | string, userId: number | string) => `${API_URL_ROOT}/projects/${id}/members/${userId}`,
CHANGE_OWNER: (id: number | string) => `${API_URL_ROOT}/projects/${id}/change-owner`,
CREATE_COMMIT: (id: number | string) => `${API_URL_ROOT}/projects/${id}/commits`,
GET_COMMITS: (id: number | string) => `${API_URL_ROOT}/projects/${id}/commits`,
RESTORE_COMMIT: (id: number | string) => `${API_URL_ROOT}/projects/${id}/commits/restore`,
},
} }

View File

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

View File

@@ -41,13 +41,11 @@ export default function RoleUpgrade() {
const handleIframeLoad = () => { const handleIframeLoad = () => {
const iframe = iframeRef.current; 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; if (!iframe || !iframe.contentDocument) return;
const updateHeight = () => { const updateHeight = () => {
if (iframe.contentDocument) { 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"; iframe.style.height = "auto";
const scrollHeight = const scrollHeight =
iframe.contentDocument.documentElement.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(); 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(() => { const resizeObserver = new ResizeObserver(() => {
updateHeight(); updateHeight();
}); });
@@ -198,7 +193,7 @@ export default function RoleUpgrade() {
return ( return (
<div className="max-w-4xl mx-auto pb-20"> <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 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"> <div className="flex gap-2">

View File

@@ -187,7 +187,7 @@ export default function HistorianApplicationPage() {
const pagination = tableData?.pagination; const pagination = tableData?.pagination;
console.log(tableData) // console.log(tableData)
// console.log("Pagination info:", pagination); // console.log("Pagination info:", pagination);
return ( return (
<div> <div>
@@ -218,7 +218,6 @@ export default function HistorianApplicationPage() {
</button> </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 className="grid grid-cols-1 gap-4 mb-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<div> <div>
<label className="block mb-2 text-sm font-medium">Tìm kiếm</label> <label className="block mb-2 text-sm font-medium">Tìm kiếm</label>
@@ -231,6 +230,7 @@ export default function HistorianApplicationPage() {
/> />
</div> </div>
{/* CẬP NHẬT: Loại xác minh */}
<div> <div>
<label className="block mb-2 text-sm font-medium"> <label className="block mb-2 text-sm font-medium">
Loại xác minh Loại xác minh
@@ -241,13 +241,14 @@ 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" 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="">Tất cả</option>
<option value="1">Thẻ nhận dạng nhà nghiên cứu</option> <option value="ID_CARD">Thẻ nhận dạng nhà nghiên cứu</option>
<option value="2">Bằng cấp</option> <option value="EDUCATION">Bằng cấp</option>
<option value="3">Chuyên gia</option> <option value="EXPERT">Chuyên gia</option>
<option value="4">Khác</option> <option value="OTHER">Khác</option>
</select> </select>
</div> </div>
{/* CẬP NHẬT: Trạng thái */}
<div> <div>
<label className="block mb-2 text-sm font-medium"> <label className="block mb-2 text-sm font-medium">
Trạng thái Trạng thái
@@ -258,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" 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="">Tất cả</option>
<option value="1">Đang chờ duyệt</option> <option value="PENDING">Đang chờ duyệt</option>
<option value="2">Đã duyệt</option> <option value="APPROVED">Đã duyệt</option>
<option value="3">Từ chối</option> <option value="REJECTED">Từ chối</option>
</select> </select>
</div> </div>
@@ -355,4 +356,4 @@ export default function HistorianApplicationPage() {
/> />
</div> </div>
); );
} }

View File

@@ -0,0 +1,481 @@
"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>
);
}

View File

@@ -0,0 +1,234 @@
"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 ProjectsTable, {
ProjectItem,
ProjectSortColumn,
} from "@/components/tables/ProjectsTable";
import {
getProjects,
updateProject,
deleteProject,
transferProjectOwnership,
} from "@/service/projectService";
import Swal from "sweetalert2";
import { useRouter } from "next/navigation";
import { LIMIT_ITEM_TABLE } from "../../../../../../constant";
import { ProjectsResponse } from "@/interface/project";
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 default function ProjectsPage(_props: {
params: unknown;
searchParams: unknown;
}) {
const router = useRouter();
const [page, setPage] = useState<number>(1);
const [limitInput, setLimitInput] = useState<string>(
LIMIT_ITEM_TABLE.toString(),
);
// Filters state
const [searchTerm, setSearchTerm] = useState<string>("");
const [statusFilter, setStatusFilter] = useState<string>("");
const [userIdsFilter, setUserIdsFilter] = 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,
statuses: "",
userIds: "",
fromDate: "",
fromTime: "",
toDate: "",
toTime: "",
});
const [tableData, setTableData] = useState<ProjectsResponse<ProjectItem> | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [sortBy, setSortBy] = useState<ProjectSortColumn>("created_at");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
const handleReset = () => {
setSearchTerm("");
setStatusFilter("");
setUserIdsFilter("");
setLimitInput(LIMIT_ITEM_TABLE.toString());
setFromDate("");
setFromTime("");
setToDate("");
setToTime("");
setPage(1);
};
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedParams({
search: searchTerm,
limit: parseInt(limitInput) || LIMIT_ITEM_TABLE,
statuses: statusFilter,
userIds: userIdsFilter,
fromDate,
fromTime,
toDate,
toTime,
});
setPage(1);
}, 100);
return () => clearTimeout(handler);
}, [
searchTerm,
limitInput,
statusFilter,
userIdsFilter,
fromDate,
fromTime,
toDate,
toTime,
]);
const fetchProjectsData = useCallback(async () => {
setLoading(true);
try {
const payload: any = {
page: page,
limit: debouncedParams.limit,
search: debouncedParams.search || undefined,
sort: sortBy,
order: sortOrder,
statuses: debouncedParams.statuses || undefined,
user_ids: debouncedParams.userIds || undefined,
};
const createdFrom = formatDateTimeToISO(debouncedParams.fromDate, debouncedParams.fromTime);
if (createdFrom) payload.created_from = createdFrom;
const createdTo = formatDateTimeToISO(debouncedParams.toDate, debouncedParams.toTime, true);
if (createdTo) payload.created_to = createdTo;
const response = await getProjects(payload);
if (response?.status) {
setTableData(response as unknown as ProjectsResponse<ProjectItem>);
}
} catch (err) {
toast.error("Lỗi lấy danh sách dự án");
console.error("Lỗi lấy danh sách dự án:", err);
setTableData(null);
} finally {
setLoading(false);
}
}, [page, debouncedParams, sortBy, sortOrder]);
useEffect(() => {
fetchProjectsData();
}, [fetchProjectsData]);
const handleSort = (column: ProjectSortColumn) => {
setPage(1);
if (sortBy === column) {
setSortOrder(sortOrder === "asc" ? "desc" : "asc");
} else {
setSortBy(column);
setSortOrder("desc");
}
};
const handleViewDetails = (id: string) => {
router.push(`/projects/${id}`);
};
const pagination = tableData?.pagination;
return (
<div>
<PageBreadcrumb pageTitle="Quản lý dự án" />
<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">
<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">
<input type="text" placeholder="Tên dự án, 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" />
<select value={statusFilter} onChange={(e) => setStatusFilter(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ả trạng thái</option>
<option value="PUBLIC">PUBLIC</option>
<option value="PRIVATE">PRIVATE</option>
<option value="ARCHIVE">ARCHIVE</option>
</select>
<input type="text" placeholder="IDs người dùng (cách nhau bởi dấu phẩy)" value={userIdsFilter} onChange={(e) => setUserIdsFilter(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 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 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>
<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>
</ComponentCard>
<ComponentCard
title="Danh sách dự án"
>
<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>
)}
<ProjectsTable
data={tableData?.data || []}
onSort={handleSort}
sortBy={sortBy}
sortOrder={sortOrder}
onViewDetails={handleViewDetails}
/>
</div>
<div className="flex items-center justify-between mt-6">
<p className="text-sm text-gray-500">
Hiển thị {pagination?.total_records || 0} dự án
</p>
{pagination && pagination.total_pages > 1 && (
<Pagination
currentPage={pagination.current_page}
totalPages={pagination.total_pages}
onPageChange={(newPage) => setPage(newPage)}
/>
)}
</div>
</ComponentCard>
</div>
</div>
);
}

View File

@@ -292,7 +292,7 @@ export default function UserTable() {
<div> <div>
<label className="block mb-2 text-sm font-medium"> <label className="block mb-2 text-sm font-medium">
Auth Provider Phương thức đăng nhập
</label> </label>
<select <select
value={authProvider} value={authProvider}
@@ -306,7 +306,7 @@ export default function UserTable() {
<div> <div>
<label className="block mb-2 text-sm font-medium"> <label className="block mb-2 text-sm font-medium">
Trạng thái xóa Trạng thái
</label> </label>
<select <select
onChange={(e) => onChange={(e) =>

View File

@@ -22,6 +22,7 @@ export default function ApplicationDetailPage() {
(state: RootState) => state.user.selectedApplication, (state: RootState) => state.user.selectedApplication,
); );
const router = useRouter(); const router = useRouter();
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
const [errMessage, setErrMessage] = useState<string>( const [errMessage, setErrMessage] = useState<string>(
"Không thể xóa đơn đăng ký này.", "Không thể xóa đơn đăng ký này.",
@@ -94,7 +95,7 @@ export default function ApplicationDetailPage() {
background: isDarkMode ? "#18181b" : "#fff", background: isDarkMode ? "#18181b" : "#fff",
color: isDarkMode ? "#fff" : "#000", color: isDarkMode ? "#fff" : "#000",
}); });
router.push("/profile"); router.push("/account");
} catch (error: any) { } catch (error: any) {
setErrMessage( setErrMessage(
error.response?.data?.message || "Có lỗi xảy ra khi xóa!", error.response?.data?.message || "Có lỗi xảy ra khi xóa!",
@@ -118,6 +119,17 @@ 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); // console.log("Application Detail:", application);
@@ -247,7 +259,7 @@ export default function ApplicationDetailPage() {
</div> </div>
</div> </div>
<span className="text-[11px] text-zinc-400 dark:text-zinc-500 tabular-nums"> <span className="text-[11px] text-zinc-400 dark:text-zinc-500 tabular-nums">
{application?.reviewed_at} {formatDate(application?.reviewed_at)}
</span> </span>
</div> </div>

View File

@@ -1,36 +1,24 @@
"use client"; "use client";
import AccountDetails from "@/components/user-profile/AccountDetails"; 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 UserInfoCard from "@/components/user-profile/UserInfoCard";
import UserMetaCard from "@/components/user-profile/UserMetaCard"; import UserMetaCard from "@/components/user-profile/UserMetaCard";
import { MediaDto } from "@/interface/media";
import { UserMetaCardProps } from "@/interface/user"; import { UserMetaCardProps } from "@/interface/user";
import { apiGetCurrentUser } from "@/service/auth"; import { apiGetCurrentUser } from "@/service/auth";
import { apiGetCurrentUserApplications, apiGetCurrentUserMedia } from "@/service/userService"; import { setUserData } from "@/store/features/userSlice";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useDispatch } from "react-redux";
export default function Profile() { export default function Profile() {
const [user, setUser] = useState<UserMetaCardProps | null>(null); 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 [loading, setLoading] = useState(true);
const dispatch = useDispatch();
useEffect(() => { useEffect(() => {
const fetchUser = async () => { const fetchUser = async () => {
try { try {
const userData = await apiGetCurrentUser(); const userData = await apiGetCurrentUser();
const mediaResponse = await apiGetCurrentUserMedia(); dispatch(setUserData(userData.data));
const userApplications = await apiGetCurrentUserApplications();
console.log(userData);
if (userApplications?.data) {
setApplications(userApplications.data);
}
setMediaData(mediaResponse);
setUser(userData); setUser(userData);
} catch (err) { } catch (err) {
console.error("Lỗi:", err); console.error("Lỗi:", err);
@@ -50,8 +38,6 @@ export default function Profile() {
<div className="space-y-6"> <div className="space-y-6">
<UserMetaCard data={user ?? {}} /> <UserMetaCard data={user ?? {}} />
<UserInfoCard data={{ ...user, openEdit: true }} /> <UserInfoCard data={{ ...user, openEdit: true }} />
{(mediaData?.data?.length ?? 0) > 0 && <MediaCard data={mediaData ?? {}} />}
<ApplicationList applications={applications} />
<AccountDetails data={user ?? {}} /> <AccountDetails data={user ?? {}} />
</div> </div>
</div> </div>

View File

@@ -0,0 +1,74 @@
"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 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>
);
}

View File

@@ -0,0 +1,622 @@
"use client";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import Image from "next/image";
import { toast } from "sonner";
import {
getProjectDetailByID,
updateProject,
transferProjectOwnership,
addProjectMember,
updateProjectMemberRole,
removeProjectMember,
deleteProject,
} from "@/service/projectService";
import { Project } from "@/interface/project";
import Swal from "sweetalert2";
import PageBreadcrumb from "@/components/common/PageBreadCrumb";
type TabType = "overview" | "members" | "settings";
export default function ProjectDetailsPage() {
const params = useParams();
const router = useRouter();
const id = params.id as string;
const [project, setProject] = useState<Project | null>(null);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<TabType>("overview");
const [editForm, setEditForm] = useState({
title: "",
description: "",
status: "PRIVATE" as any,
});
const [newOwnerId, setNewOwnerId] = useState("");
const [newMember, setNewMember] = useState({
user_id: "",
role: "EDITOR" as any,
});
const fetchProject = async () => {
setLoading(true);
try {
const res = await getProjectDetailByID(id);
if (res?.status && res.data) {
setProject(res.data);
setEditForm({
title: res.data.title,
description: res.data.description,
status: res.data.project_status,
});
}
} catch (error) {
toast.error("Lỗi khi tải dữ liệu dự án");
} finally {
setLoading(false);
}
};
useEffect(() => {
if (id) fetchProject();
}, [id]);
const handleUpdateInfo = async (e: React.FormEvent) => {
e.preventDefault();
try {
await updateProject(id, editForm);
toast.success("Cập nhật thông tin thành công!");
fetchProject();
} catch (error) {
toast.error("Cập nhật thất bại");
}
};
const handleTransferOwnership = async (e: React.FormEvent) => {
e.preventDefault();
const memberName =
project?.members?.find((m) => m.user_id === newOwnerId)?.display_name ||
"thành viên này";
const result = await Swal.fire({
title: "Chuyển quyền sở hữu?",
html: `Bạn có chắc chắn muốn chuyển dự án này cho <b>${memberName}</b>?<br/>Hành động này <b>không thể hoàn tác</b> và bạn sẽ không còn là chủ sở hữu nữa.`,
icon: "error",
showCancelButton: true,
confirmButtonColor: "#238636",
cancelButtonColor: "#30363d",
confirmButtonText: "Tôi hiểu, chuyển quyền sở hữu",
cancelButtonText: "Hủy bỏ",
color: "#333",
customClass: {
popup: "border border-[#30363d] rounded-xl",
},
});
if (result.isConfirmed) {
try {
const res = await transferProjectOwnership(id, {
new_owner_id: newOwnerId,
});
if (res?.status) {
toast.success("Đã chuyển quyền sở hữu thành công!");
setNewOwnerId("");
fetchProject();
} else {
toast.error(res?.message || "Chuyển quyền thất bại");
}
} catch (error) {
toast.error("Lỗi hệ thống khi chuyển quyền");
}
}
};
const handleAddMember = async (e: React.FormEvent) => {
e.preventDefault();
if (!newMember.user_id) return toast.error("Vui lòng nhập User ID");
try {
await addProjectMember(id, newMember);
toast.success("Thêm thành viên thành công");
setNewMember({ user_id: "", role: "EDITOR" });
fetchProject();
} catch (error) {
toast.error("Lỗi thêm thành viên");
}
};
const handleUpdateRole = async (userId: string, newRole: string) => {
try {
const res = await updateProjectMemberRole(id, userId, {
role: newRole as any,
});
if (res?.status) {
toast.success("Cập nhật quyền thành công");
fetchProject();
} else {
toast.error(res?.message || "Cập nhật quyền thất bại");
}
} catch (error: any) {
const errorMessage =
error.response?.data?.message || "Cập nhật quyền thất bại";
toast.error(errorMessage);
}
};
const handleRemoveMember = async (userId: string) => {
// 1. Hiển thị hộp thoại xác nhận bằng SweetAlert2
const result = await Swal.fire({
title: "Xác nhận xóa?",
text: "Bạn có chắc chắn muốn xóa thành viên này khỏi dự án?",
icon: "warning",
showCancelButton: true,
confirmButtonColor: "#3085d6",
cancelButtonColor: "#d33",
confirmButtonText: "Đồng ý",
cancelButtonText: "Hủy",
});
if (!result.isConfirmed) return;
try {
const res = await removeProjectMember(id, userId);
if (res?.status) {
toast.success("Đã xóa thành viên");
} else {
toast.error(res?.message || "Xóa thành viên thất bại");
}
} catch (error: any) {
const errorMessage =
error.response?.data?.message || "Xóa thành viên thất bại";
toast.error(errorMessage);
console.error("Remove Member Error:", error);
} finally {
fetchProject();
}
};
const handleDeleteProject = async () => {
const result = await Swal.fire({
title: "Xác nhận xóa dự án?",
text: "Hành động này sẽ xóa vĩnh viễn dự án. Bạn không thể hoàn tác sau khi xác nhận!",
icon: "warning",
showCancelButton: true,
confirmButtonColor: "#d33",
cancelButtonColor: "#30363d",
confirmButtonText: "Tôi hiểu, xóa dự án này",
cancelButtonText: "Hủy",
color: "#333",
customClass: {
popup: "border border-[#30363d] rounded-xl",
},
});
if (result.isConfirmed) {
try {
const res = await deleteProject(id);
if (res?.status) {
toast.success("Đã xóa dự án thành công");
router.push("/project");
} else {
toast.error(res?.message || "Xóa dự án thất bại");
}
} catch (error) {
toast.error("Đã xảy ra lỗi khi kết nối máy chủ");
console.error(error);
}
}
};
if (loading)
return (
<div className="flex justify-center p-20 text-gray-500">
Đang tải dữ liệu...
</div>
);
if (!project)
return (
<div className="flex justify-center p-20 text-red-500">
Không tìm thấy dự án
</div>
);
return (
<div className="min-h-screen dark:bg-[#0d1117] text-gray-900 dark:text-[#c9d1d9] font-sans">
<PageBreadcrumb pageTitle="Chi tiết dự án" />
<div className="pt-8 border-b border-gray-200 dark:border-[#30363d] bg-gray-50 dark:bg-[#0d1117]">
<div className="px-6">
<div className="flex items-center gap-2 text-xl mb-6">
<div className="w-8 h-8 shrink-0 flex items-center justify-center">
{project.user?.avatar_url ? (
<div className="relative w-8 h-8 rounded-full overflow-hidden border border-gray-200 dark:border-gray-800">
<Image
src={project.user.avatar_url}
alt="avatar"
fill
className="object-cover rounded-full"
/>
</div>
) : (
<div className="w-8 h-8 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center border border-gray-300 dark:border-gray-600">
<span className="text-[10px] font-bold text-gray-500 dark:text-gray-300 leading-none">
{project.user?.display_name?.charAt(0)?.toUpperCase() ||
"U"}
</span>
</div>
)}
</div>
<span className="font-medium text-blue-600 dark:text-[#58a6ff] hover:underline cursor-pointer">
{project.user?.display_name}
</span>
<span className="text-gray-400">/</span>
<strong className="font-semibold text-blue-600 dark:text-[#58a6ff] hover:underline cursor-pointer">
{project.title}
</strong>
<span className="ml-2 px-2.5 py-0.5 text-xs font-medium rounded-full border border-gray-200 dark:border-[#30363d] text-gray-500 dark:text-[#8b949e]">
{project.project_status}
</span>
</div>
<div className="flex gap-4">
{[
{
id: "overview",
label: "Overview",
icon: "M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z",
},
{
id: "members",
label: `Members`,
count: project.members?.length || 0,
icon: "M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z",
},
{
id: "settings",
label: "Settings",
icon: "M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z",
},
].map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as TabType)}
className={`flex items-center gap-2 px-3 py-2.5 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.id
? "border-[#f78166] text-gray-900 dark:text-[#c9d1d9]"
: "border-transparent text-gray-500 hover:text-gray-700 dark:hover:text-[#8b949e]"
}`}
>
<svg
className="w-4 h-4 opacity-70"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d={tab.icon}
/>
</svg>
{tab.label}
{tab.count !== undefined && (
<span className="ml-1 px-2 py-0.5 text-xs rounded-full bg-gray-200/50 dark:bg-[#21262d] text-gray-600 dark:text-[#c9d1d9]">
{tab.count}
</span>
)}
</button>
))}
</div>
</div>
</div>
<div className="px-6 py-8">
{activeTab === "overview" && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
<div className="md:col-span-3">
<div className="border border-gray-200 dark:border-[#30363d] rounded-xl overflow-hidden ">
<div className="bg-gray-50 dark:bg-[#161b22] px-5 py-3 border-b border-gray-200 dark:border-[#30363d] font-semibold text-sm text-gray-800 dark:text-[#c9d1d9]">
About
</div>
<div className="p-6 bg-white dark:bg-[#0d1117] text-[15px] leading-relaxed text-gray-700 dark:text-[#8b949e]">
{project.description || (
<i className="text-gray-400">
Không tả cho dự án này.
</i>
)}
</div>
</div>
</div>
<div className="md:col-span-1 space-y-6">
<div>
<h3 className="font-semibold text-sm mb-4 border-b border-gray-200 dark:border-[#30363d] pb-2 text-gray-800 dark:text-[#c9d1d9]">
Owner
</h3>
<div className="flex items-center gap-3">
<div className="w-8 h-8 shrink-0 flex items-center justify-center">
{project.user?.avatar_url ? (
<div className="relative w-8 h-8 rounded-full overflow-hidden border border-gray-200 dark:border-gray-800">
<Image
src={project.user.avatar_url}
alt="avatar"
fill
className="object-cover rounded-full"
/>
</div>
) : (
<div className="w-8 h-8 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center border border-gray-300 dark:border-gray-600">
<span className="text-[10px] font-bold text-gray-500 dark:text-gray-300 leading-none">
{project.user?.display_name
?.charAt(0)
?.toUpperCase() || "U"}
</span>
</div>
)}
</div>
<div className="overflow-hidden">
<div className="font-semibold text-sm text-gray-800 dark:text-[#c9d1d9] truncate">
{project.user?.display_name}
</div>
<div className="text-xs text-gray-500 dark:text-[#8b949e] truncate">
{project.user?.email || "No email"}
</div>
</div>
</div>
</div>
</div>
</div>
)}
{activeTab === "members" && (
<div className="max-w-4xl">
<h2 className="text-2xl font-normal mb-6 pb-2 border-b border-gray-200 dark:border-[#30363d]">
Manage access
</h2>
<form
onSubmit={handleAddMember}
className="flex flex-col sm:flex-row gap-3 mb-8 p-5 border border-gray-200 dark:border-[#30363d] rounded-xl bg-gray-50 dark:bg-[#161b22] "
>
<input
type="text"
placeholder="User ID..."
value={newMember.user_id}
onChange={(e) =>
setNewMember({ ...newMember, user_id: e.target.value })
}
className="flex-1 px-3 py-2 text-sm rounded-md border border-gray-300 dark:border-[#30363d] bg-white dark:bg-[#0d1117] outline-none focus:ring-2 focus:ring-blue-500/30 focus:border-blue-500 transition-shadow"
/>
<div className="flex gap-3">
<select
value={newMember.role}
onChange={(e) =>
setNewMember({ ...newMember, role: e.target.value as any })
}
className="px-3 py-2 text-sm rounded-md border border-gray-300 dark:border-[#30363d] bg-white dark:bg-[#0d1117] outline-none focus:ring-2 focus:ring-blue-500/30 focus:border-blue-500 cursor-pointer"
>
<option value="EDITOR">Editor</option>
<option value="VIEWER">Viewer</option>
</select>
<button
type="submit"
className="px-5 py-2 text-sm font-medium text-white bg-[#238636] border border-transparent rounded-md hover:bg-[#2ea043] transition-colors "
>
Add member
</button>
</div>
</form>
<div className="border border-gray-200 dark:border-[#30363d] rounded-xl overflow-hidden ">
<div className="divide-y divide-gray-200 dark:divide-[#30363d]">
{project.members && project.members.length > 0 ? (
project.members.map((member) => (
<div
key={member.user_id}
className="flex items-center justify-between p-4 hover:bg-gray-50 dark:hover:bg-[#161b22]/50 transition-colors"
>
<div className="flex items-center gap-4">
<img
src={
member.avatar_url ||
"https://github.com/identicons/jasonlong.png"
}
alt={member.display_name}
className="w-10 h-10 rounded-full border border-gray-200 dark:border-gray-700"
/>
<div>
<div className="font-semibold text-sm text-gray-800 dark:text-[#c9d1d9]">
{member.display_name}
</div>
<div className="text-xs text-gray-500 dark:text-[#8b949e]">
ID: {member.user_id}
</div>
</div>
</div>
<div className="flex items-center gap-3">
<select
value={member.role}
onChange={(e) =>
handleUpdateRole(member.user_id, e.target.value)
}
className="text-sm px-3 py-1.5 rounded-md border border-gray-300 dark:border-[#30363d] bg-white dark:bg-[#21262d] outline-none hover:bg-gray-50 dark:hover:bg-[#30363d] cursor-pointer transition-colors"
>
<option value="EDITOR">Editor</option>
<option value="VIEWER">Viewer</option>
</select>
<button
onClick={() => handleRemoveMember(member.user_id)}
className="text-red-500 hover:text-red-600 p-2 rounded-md hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
title="Remove member"
>
<svg
className="w-5 h-5"
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>
</div>
</div>
))
) : (
<div className="p-8 text-center text-gray-500 dark:text-[#8b949e] text-sm italic">
Chưa thành viên nào.
</div>
)}
</div>
</div>
</div>
)}
{activeTab === "settings" && (
<div className="max-w-3xl space-y-10">
<section>
<h2 className="text-2xl font-normal mb-4 pb-2 border-b border-gray-200 dark:border-[#30363d]">
General
</h2>
<form onSubmit={handleUpdateInfo} className="space-y-5">
<div>
<label className="block text-sm font-semibold mb-1.5 text-gray-800 dark:text-[#c9d1d9]">
Project name
</label>
<input
type="text"
value={editForm.title}
onChange={(e) =>
setEditForm({ ...editForm, title: e.target.value })
}
className="w-full max-w-md px-3 py-2 text-sm rounded-md border border-gray-300 dark:border-[#30363d] bg-white dark:bg-[#0d1117] outline-none focus:ring-2 focus:ring-blue-500/30 focus:border-blue-500 transition-shadow"
/>
</div>
<div>
<label className="block text-sm font-semibold mb-1.5 text-gray-800 dark:text-[#c9d1d9]">
Description
</label>
<textarea
rows={4}
value={editForm.description}
onChange={(e) =>
setEditForm({ ...editForm, description: e.target.value })
}
className="w-full px-3 py-2 text-sm rounded-md border border-gray-300 dark:border-[#30363d] bg-white dark:bg-[#0d1117] outline-none focus:ring-2 focus:ring-blue-500/30 focus:border-blue-500 transition-shadow"
/>
</div>
<div>
<label className="block text-sm font-semibold mb-1.5 text-gray-800 dark:text-[#c9d1d9]">
Status
</label>
<select
value={editForm.status}
onChange={(e) =>
setEditForm({
...editForm,
status: e.target.value as any,
})
}
className="w-48 px-3 py-2 text-sm rounded-md border border-gray-300 dark:border-[#30363d] bg-white dark:bg-[#0d1117] outline-none focus:ring-2 focus:ring-blue-500/30 focus:border-blue-500 cursor-pointer"
>
<option value="PUBLIC">Public</option>
<option value="PRIVATE">Private</option>
<option value="ARCHIVE">Archive</option>
</select>
</div>
<button
type="submit"
className="px-5 py-2 text-sm font-medium rounded-md bg-[#21262d] text-[#c9d1d9] border border-[#30363d] hover:bg-[#30363d] transition-colors "
>
Update settings
</button>
</form>
</section>
<section>
<h2 className="text-2xl font-normal text-red-500 mb-4 pb-2 border-b border-red-500/30">
Danger Zone
</h2>
<div className="border border-red-500/30 rounded-xl overflow-hidden">
<div className="p-5 flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<div className="font-semibold text-sm text-gray-800 dark:text-[#c9d1d9]">
Transfer ownership
</div>
<div className="text-xs text-gray-500 dark:text-[#8b949e] mt-1">
Transfer this project to another member in the project.
</div>
</div>
<form
onSubmit={handleTransferOwnership}
className="flex gap-2"
>
<select
value={newOwnerId}
onChange={(e) => setNewOwnerId(e.target.value)}
className="w-full sm:w-56 px-3 py-2 text-sm rounded-md border border-gray-300 dark:border-[#30363d] bg-white dark:bg-[#0d1117] outline-none focus:ring-2 focus:ring-red-500/30 focus:border-red-500 cursor-pointer"
required
>
<option value="" disabled>
-- Thành viên --
</option>
{project.members && project.members.length > 0 ? (
project.members.map((member) => (
<option key={member.user_id} value={member.user_id}>
{member.display_name}
</option>
))
) : (
<option value="" disabled>
Chưa thành viên nào
</option>
)}
</select>
<button
type="submit"
disabled={
!newOwnerId ||
!project.members ||
project.members.length === 0
}
className="shrink-0 px-4 py-2 text-sm font-medium text-red-500 bg-transparent border border-red-500/50 rounded-md hover:bg-red-500 hover:text-white dark:hover:bg-red-900/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-red-500 dark:disabled:hover:bg-transparent"
>
Transfer
</button>
</form>
</div>
<div className="p-5 flex flex-col sm:flex-row sm:items-center justify-between gap-4 hover:bg-red-50/30 dark:hover:bg-red-900/10 transition-colors">
<div>
<div className="font-semibold text-sm text-gray-800 dark:text-[#c9d1d9]">
Delete this project
</div>
<div className="text-xs text-gray-500 dark:text-[#8b949e] mt-1">
Once you delete a project, there is no going back. Please
be certain.
</div>
</div>
<button
type="button"
onClick={handleDeleteProject}
className="shrink-0 px-4 py-2 text-sm font-medium text-red-500 bg-transparent border border-red-500/50 rounded-md hover:bg-red-500 hover:text-white dark:hover:bg-red-900/30 transition-colors"
>
Delete project
</button>
</div>
</div>
</section>
</div>
)}
</div>
</div>
);
}

View File

@@ -4,7 +4,10 @@ import { useSidebar } from "@/context/SidebarContext";
import AppHeader from "@/layout/AppHeader"; import AppHeader from "@/layout/AppHeader";
import AppSidebar from "@/layout/AppSidebar"; import AppSidebar from "@/layout/AppSidebar";
import Backdrop from "@/layout/Backdrop"; import Backdrop from "@/layout/Backdrop";
import React from "react"; import { apiGetCurrentUser } from "@/service/auth";
import { setUserData } from "@/store/features/userSlice";
import React, { useEffect } from "react";
import { useDispatch } from "react-redux";
export default function AdminLayout({ export default function AdminLayout({
children, children,
@@ -12,13 +15,27 @@ export default function AdminLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const { isExpanded, isHovered, isMobileOpen } = useSidebar(); 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 // Dynamic class for main content margin based on sidebar state
const mainContentMargin = isMobileOpen const mainContentMargin = isMobileOpen
? "ml-0" ? "ml-0"
: isExpanded || isHovered : isExpanded || isHovered
? "lg:ml-[290px]" ? "lg:ml-[290px]"
: "lg:ml-[90px]"; : "lg:ml-[90px]";
return ( return (
<div className="min-h-screen xl:flex"> <div className="min-h-screen xl:flex">

View File

@@ -737,25 +737,48 @@ span.flatpickr-weekday,
box-shadow: 0 1px 3px 0 rgba(16, 24, 40, 0.1), box-shadow: 0 1px 3px 0 rgba(16, 24, 40, 0.1),
0 1px 2px 0 rgba(16, 24, 40, 0.06); 0 1px 2px 0 rgba(16, 24, 40, 0.06);
opacity: 0.8; opacity: 0.8;
cursor: grabbing; /* Changes the cursor to indicate dragging */ cursor: grabbing;
} }
html { html {
scrollbar-gutter: stable; scrollbar-gutter: stable;
overflow-y: scroll;
} }
html { html {
overflow-y: scroll; overflow-y: scroll;
} }
::-webkit-scrollbar { ::-webkit-scrollbar {
-webkit-appearance: none; width: 10px;
width: 10px; height: 8px;
}
::-webkit-scrollbar-track {
background: var(--color-gray-50);
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
border-radius: 5px; border-radius: 10px;
background-color: rgba(0, 0, 0, .5); background-color: var(--color-gray-300);
box-shadow: 0 0 1px rgba(255, 255, 255, .5); 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);
} }
.ql-editor pre.ql-syntax { .ql-editor pre.ql-syntax {

View File

@@ -7,7 +7,7 @@ export default function GetUser() {
const [user, setUser] = useState(null); const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
useEffect(() => { useEffect(() => {
const fetchUser = async () => { const fetchUser = async () => {
try { try {

View File

@@ -109,7 +109,7 @@ export default function SignInForm() {
</p> </p>
</div> </div>
<div> <div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-5"> <div className="grid grid-cols-1 gap-3 sm:gap-5">
<button <button
onClick={() => { onClick={() => {
const redirectUrl = HOME_URL; const redirectUrl = HOME_URL;
@@ -143,19 +143,6 @@ export default function SignInForm() {
</svg> </svg>
Sign in with Google Sign in with Google
</button> </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>
<div className="relative py-3 sm:py-5"> <div className="relative py-3 sm:py-5">
<div className="absolute inset-0 flex items-center"> <div className="absolute inset-0 flex items-center">
@@ -179,6 +166,7 @@ export default function SignInForm() {
type="email" type="email"
onChange={handleChange} onChange={handleChange}
defaultValue={formData.email} defaultValue={formData.email}
autoComplete="username"
/> />
</div> </div>
@@ -195,6 +183,7 @@ export default function SignInForm() {
placeholder="Min. 8 characters" placeholder="Min. 8 characters"
onChange={handleChange} onChange={handleChange}
defaultValue={formData.password} defaultValue={formData.password}
autoComplete="current-password"
/> />
<span <span
onClick={() => setShowPassword(!showPassword)} onClick={() => setShowPassword(!showPassword)}
@@ -205,7 +194,6 @@ export default function SignInForm() {
</div> </div>
</div> </div>
{/* Hiển thị thông báo lỗi nếu có */}
{errorMsg && ( {errorMsg && (
<p className="text-sm text-red-500 font-medium">{errorMsg}</p> <p className="text-sm text-red-500 font-medium">{errorMsg}</p>
)} )}

View File

@@ -9,11 +9,15 @@ import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { API, HOME_URL } from "../../../api"; import { API, HOME_URL } from "../../../api";
import Swal from "sweetalert2"; import Swal from "sweetalert2";
import { useRouter } from "next/navigation";
export default function SignUpForm() { export default function SignUpForm() {
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [isChecked, setIsChecked] = useState(false); const [isChecked, setIsChecked] = useState(false);
const router = useRouter();
const [step, setStep] = useState(1); const [step, setStep] = useState(1);
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
fname: "", fname: "",
@@ -103,9 +107,15 @@ export default function SignUpForm() {
}; };
const signupRes = await apiSignUp(signupPayload); const signupRes = await apiSignUp(signupPayload);
Swal.fire("Đăng ký thành công! Đang chuyển hướng về trang đăng nhập!");
window.location.href = "/signin"; 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");
} catch (error) { } catch (error) {
const errorMessage = const errorMessage =
error instanceof Error error instanceof Error
@@ -150,7 +160,7 @@ export default function SignUpForm() {
{step === 1 && ( {step === 1 && (
<> <>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 sm:gap-5"> <div className="grid grid-cols-1 gap-3 sm:gap-5">
<button <button
onClick={() => { onClick={() => {
const redirectUrl = HOME_URL; const redirectUrl = HOME_URL;
@@ -184,9 +194,6 @@ export default function SignUpForm() {
</svg> </svg>
Sign up with Google Sign up with Google
</button> </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>
<div className="relative py-3 sm:py-5"> <div className="relative py-3 sm:py-5">
<div className="absolute inset-0 flex items-center"> <div className="absolute inset-0 flex items-center">

View File

@@ -15,6 +15,7 @@ interface InputProps {
success?: boolean; success?: boolean;
error?: boolean; error?: boolean;
hint?: string; // Optional hint text hint?: string; // Optional hint text
autoComplete?: string;
} }
const Input: FC<InputProps> = ({ const Input: FC<InputProps> = ({
@@ -32,6 +33,7 @@ const Input: FC<InputProps> = ({
success = false, success = false,
error = false, error = false,
hint, hint,
autoComplete
}) => { }) => {
// Determine input styles based on state (disabled, success, error) // 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}`; 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}`;
@@ -61,6 +63,7 @@ const Input: FC<InputProps> = ({
step={step} step={step}
disabled={disabled} disabled={disabled}
className={inputClasses} className={inputClasses}
autoComplete={autoComplete}
/> />
{/* Optional Hint Text */} {/* Optional Hint Text */}

View File

@@ -8,6 +8,7 @@ import { fullDataUser } from "@/interface/admin";
import { UserMetaCardProps } from "@/interface/user"; import { UserMetaCardProps } from "@/interface/user";
import { apiGetCurrentUser, apiLogout } from "@/service/auth"; import { apiGetCurrentUser, apiLogout } from "@/service/auth";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { ListIcon } from "@/icons";
export default function UserDropdown() { export default function UserDropdown() {
const router = useRouter(); const router = useRouter();
@@ -118,7 +119,7 @@ export default function UserDropdown() {
<DropdownItem <DropdownItem
onItemClick={closeDropdown} onItemClick={closeDropdown}
tag="a" 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" 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 <svg
@@ -136,35 +137,23 @@ export default function UserDropdown() {
fill="" fill=""
/> />
</svg> </svg>
Edit profile Tài Khoản
</DropdownItem> </DropdownItem>
</li> </li>
{/* <li> {/* <li>
<DropdownItem <DropdownItem
onItemClick={closeDropdown} onItemClick={closeDropdown}
tag="a" 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" 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 <span className="menu-item-icon">
className="fill-gray-500 group-hover:fill-gray-700 dark:fill-gray-400 dark:group-hover:fill-gray-300" <ListIcon />
width="24" </span>
height="24" Nhà Sử Học
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> </DropdownItem>
</li> */} </li> */}
<li> {/* <li>
<DropdownItem <DropdownItem
onItemClick={closeDropdown} onItemClick={closeDropdown}
tag="a" tag="a"
@@ -186,9 +175,9 @@ export default function UserDropdown() {
fill="" fill=""
/> />
</svg> </svg>
Support Hỗ Trợ
</DropdownItem> </DropdownItem>
</li> </li> */}
</ul> </ul>
<Link <Link
onClick={handleLogout} onClick={handleLogout}
@@ -210,7 +199,7 @@ export default function UserDropdown() {
fill="" fill=""
/> />
</svg> </svg>
Sign out Đăng xuất
</Link> </Link>
</Dropdown> </Dropdown>
</div> </div>

View File

@@ -13,6 +13,7 @@ import "yet-another-react-lightbox/styles.css";
import "yet-another-react-lightbox/plugins/captions.css"; import "yet-another-react-lightbox/plugins/captions.css";
import { IsolatedContent } from "@/components/ui/IsolatedContent"; import { IsolatedContent } from "@/components/ui/IsolatedContent";
import { apiDeleteHistorianCV } from "@/service/historianService"; import { apiDeleteHistorianCV } from "@/service/historianService";
import { statusConfig } from "@/service/handler";
interface Props { interface Props {
isOpen: boolean; isOpen: boolean;
@@ -101,7 +102,9 @@ export default function ApplicationDetailModal({
if (!isOpen || !application) return null; if (!isOpen || !application) return null;
const userData = application.user || {}; const userData = application.user || {};
const currentStatus = statusConfig[application.status] || {
container: "bg-gray-50 border-gray-200 text-gray-600 shadow-sm",
};
return ( return (
<div className="fixed inset-0 z-999 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"> <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"> <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">
@@ -149,13 +152,18 @@ export default function ApplicationDetailModal({
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
{userData.email || "Không có email"} {userData.email || "Không có email"}
</p> </p>
<div className="mt-1 flex gap-2"> <div className="mt-1 flex gap-2 items-center">
<span className="px-2 py-0.5 text-[10px] font-bold uppercase text-blue-600 bg-blue-100 rounded-md"> <span className="px-3 py-1 text-[11px] font-bold uppercase text-blue-600 bg-blue-100 rounded-md ">
{application.verify_type} {application.verify_type}
</span> </span>
<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"}`} className={`
> inline-flex items-center px-3 py-1
rounded-md border text-[11px] font-semibold uppercase
transition-colors duration-200
${currentStatus.container}
`}
>
{application.status} {application.status}
</span> </span>
</div> </div>

View File

@@ -0,0 +1,237 @@
"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 max-w-[100px] truncate">
<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>
);
}

View File

@@ -9,7 +9,33 @@ const Pagination: React.FC<PaginationProps> = ({
totalPages, totalPages,
onPageChange, onPageChange,
}) => { }) => {
const allPages = Array.from({ length: totalPages }, (_, i) => i + 1); const getPaginationItems = () => {
const delta = 1;
const range = [];
for (let i = 1; i <= totalPages; i++) {
if (i === 1 || i === totalPages || (i >= currentPage - delta && i <= currentPage + delta)) {
range.push(i);
}
}
const rangeWithDots: (number | string)[] = [];
let l: number | undefined;
for (const i of range) {
if (l !== undefined) {
if (i - l === 2) {
rangeWithDots.push(l + 1);
} else if (i - l > 2) {
rangeWithDots.push('...');
}
}
rangeWithDots.push(i);
l = i;
}
return rangeWithDots;
};
const pages = getPaginationItems();
return ( return (
<div className="flex items-center "> <div className="flex items-center ">
@@ -21,21 +47,25 @@ const Pagination: React.FC<PaginationProps> = ({
Previous Previous
</button> </button>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{currentPage > 3 && <span className="px-2">...</span>} {pages.map((page, index) =>
{allPages.map((page) => ( page === '...' ? (
<button <span key={`dots-${index}`} className="flex h-10 w-10 items-center justify-center text-gray-500">
key={page} ...
onClick={() => onPageChange(page)} </span>
className={`px-4 py-2 rounded ${ ) : (
currentPage === page <button
? "bg-brand-500 text-white" key={page}
: "text-gray-700 dark:text-gray-400" onClick={() => onPageChange(page as number)}
} flex w-10 items-center justify-center h-10 rounded-lg text-sm font-medium hover:bg-blue-500/[0.08] hover:text-brand-500 dark:hover:text-brand-500`} className={`flex h-10 w-10 items-center justify-center rounded-lg text-sm font-medium hover:bg-blue-500/[0.08] hover:text-brand-500 dark:hover:text-brand-500 ${
> currentPage === page
{page} ? "bg-brand-500 text-white"
</button> : "text-gray-700 dark:text-gray-400"
))} }`}
{currentPage < totalPages - 2 && <span className="px-2">...</span>} >
{page}
</button>
),
)}
</div> </div>
<button <button

View File

@@ -0,0 +1,230 @@
"use client";
import Image from "next/image";
import Badge from "../ui/badge/Badge";
export type ProjectSortColumn = "created_at" | "updated_at" | "title";
export interface ProjectItem {
id: string;
title: string;
description: string;
project_status: "PRIVATE" | "PUBLIC" | "ARCHIVE";
owner_id: string;
created_at: string;
updated_at: string;
user: {
id: string;
display_name: string;
email: string;
avatar_url: string;
};
members?: {
user_id: string;
role: string;
display_name: string;
avatar_url: string;
}[];
}
interface ProjectsTableProps {
data: ProjectItem[];
onSort: (column: ProjectSortColumn) => void;
sortBy?: ProjectSortColumn;
sortOrder?: "asc" | "desc";
onViewDetails: (id: string) => void;
}
export default function ProjectsTable({
data,
onSort,
sortBy,
sortOrder,
onViewDetails,
}: ProjectsTableProps) {
const formatDate = (dateString: string | null | undefined) => {
if (!dateString) return "-";
const date = new Date(dateString);
return `Updated on ${date.toLocaleDateString("vi-VN", {
day: "2-digit",
month: "short",
year: "numeric",
})}`;
};
const getStatusBadge = (status: ProjectItem["project_status"]) => {
switch (status) {
case "PUBLIC":
return (
<Badge size="sm" variant="light" color="success">
PUBLIC
</Badge>
);
case "PRIVATE":
return (
<Badge size="sm" variant="light" color="warning">
PRIVATE
</Badge>
);
case "ARCHIVE":
return (
<Badge size="sm" variant="light" color="light">
ARCHIVE
</Badge>
);
default:
return (
<Badge size="sm" variant="light" color="dark">
{status}
</Badge>
);
}
};
const SortButton = ({
column,
label,
}: {
column: ProjectSortColumn;
label: string;
}) => {
const isActive = sortBy === column;
return (
<button
onClick={() => onSort(column)}
className={`w-20 text-sm font-medium text-left hover:text-blue-500 transition-colors ${
isActive
? "text-blue-600 dark:text-blue-400"
: "text-gray-500 dark:text-gray-400"
}`}
>
{label} {isActive && (sortOrder === "asc" ? "↑" : "↓")}
</button>
);
};
return (
<div className="overflow-hidden rounded-xl border border-gray-200 bg-white dark:border-gray-800 dark:bg-[#0d1117] min-w-[700px]">
<div className="flex items-center justify-between px-5 py-3 border-b border-gray-200 dark:border-gray-800 bg-gray-50/50 dark:bg-[#161b22]">
<span className="text-sm font-semibold text-gray-700 dark:text-gray-300 w-40">
</span>
<div className="flex items-center gap-4 shrink-0">
<span className="text-sm text-gray-500 dark:text-gray-400 w-20">
Sắp xếp:
</span>
<SortButton column="title" label="Tên" />
<SortButton column="created_at" label="Ngày tạo" />
<SortButton column="updated_at" label="Cập nhật" />
</div>
</div>
<div className="flex flex-col divide-y divide-gray-200 dark:divide-gray-800">
{data.length > 0 ? (
data.map((item) => (
<div
key={item.id}
className="group flex flex-col p-5 md:flex-row md:items-center justify-between hover:bg-gray-50 dark:hover:bg-[#161b22] transition-colors"
>
<div className="flex-1 pr-4 max-w-full md:max-w-[75%]">
<div
onClick={() => onViewDetails(item.id)}
className="flex items-center gap-2 mb-2 cursor-pointer hover:underline"
>
<div className="w-6 h-6 shrink-0 flex items-center justify-center">
{item.user?.avatar_url ? (
<div className="relative w-6 h-6 rounded-full overflow-hidden border border-gray-200 dark:border-gray-800">
<Image
src={item.user.avatar_url}
alt="avatar"
fill
className="object-cover rounded-full"
/>
</div>
) : (
<div className="w-6 h-6 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center border border-gray-300 dark:border-gray-600">
<span className="text-[10px] font-bold text-gray-500 dark:text-gray-300 leading-none">
{item.user?.display_name?.charAt(0)?.toUpperCase() ||
"U"}
</span>
</div>
)}
</div>
<div className="flex items-center max-w-[250px]">
<span className="text-[14px] font-medium text-gray-700 dark:text-gray-300 truncate">
{item.user?.display_name || "Unknown"}
</span>
</div>
<span className="text-[14px] text-gray-400 dark:text-gray-600 shrink-0">
/
</span>
<h3 className="text-[14px] font-semibold text-blue-600 dark:text-[#58a6ff] truncate max-w-[300px]">
{item.title}
</h3>
<div className="shrink-0 w-20 flex justify-start">
{getStatusBadge(item.project_status)}
</div>
</div>
<div className="flex flex-wrap items-center gap-4 text-xs text-gray-500 dark:text-[#8b949e] h-5">
<span>{formatDate(item.updated_at)}</span>
</div>
</div>
<div className="flex items-center mt-4 md:mt-0 w-[120px] justify-end shrink-0">
<div className="flex -space-x-2 overflow-hidden">
{item.members && item.members.length > 0 ? (
<>
{item.members.slice(0, 4).map((m, index) =>
m.avatar_url ? (
<Image
key={index}
src={m.avatar_url}
alt={m.display_name}
width={32}
height={32}
title={m.display_name}
className="inline-block w-8 h-8 rounded-full object-cover ring-2 ring-white group-hover:ring-gray-50 dark:ring-[#0d1117] dark:group-hover:ring-[#161b22] transition-colors"
/>
) : (
<div
key={index}
title={m.display_name}
className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-gray-200 dark:bg-gray-700 ring-2 ring-white group-hover:ring-gray-50 dark:ring-[#0d1117] dark:group-hover:ring-[#161b22] transition-colors"
>
<span className="text-xs font-medium text-gray-500 dark:text-gray-300">
{m.display_name?.charAt(0)?.toUpperCase() || "U"}
</span>
</div>
),
)}
{item.members.length > 4 && (
<div
title="Những người khác"
className="inline-flex items-center justify-center w-8 h-8 rounded-full bg-gray-100 dark:bg-gray-800 ring-2 ring-white group-hover:ring-gray-50 dark:ring-[#0d1117] dark:group-hover:ring-[#161b22] transition-colors z-10"
>
<span className="text-xs font-medium text-gray-600 dark:text-gray-400">
+{item.members.length - 4}
</span>
</div>
)}
</>
) : (
<span className="text-sm text-gray-400 dark:text-gray-600 italic"></span>
)}
</div>
</div>
</div>
))
) : (
<div className="p-10 text-center text-gray-500 dark:text-gray-400 italic">
Không tìm thấy dự án nào
</div>
)}
</div>
</div>
);
}

View File

@@ -22,6 +22,8 @@ export default function AccountDetails({ data }: { data: UserMetaCardProps }) {
}); });
const [showOldPass, setShowOldPass] = useState(false); const [showOldPass, setShowOldPass] = useState(false);
const [showNewPass, setShowNewPass] = useState(false);
const [showConfirmPass, setShowConfirmPass] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
useEffect(() => { useEffect(() => {
@@ -32,6 +34,8 @@ export default function AccountDetails({ data }: { data: UserMetaCardProps }) {
confirm_password: "", confirm_password: "",
}); });
setShowOldPass(false); setShowOldPass(false);
setShowNewPass(false);
setShowConfirmPass(false);
setError(""); setError("");
} }
}, [isOpen]); }, [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.` || `${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.", "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 ( return (
<> <>
<div className="p-5 border border-red-200 rounded-2xl dark:border-gray-800 lg:p-6"> <div className="p-5 border border-red-200 rounded-2xl dark:border-gray-800 lg:p-6">
@@ -149,7 +158,6 @@ export default function AccountDetails({ data }: { data: UserMetaCardProps }) {
/> />
</div> </div>
{/* 2. Current Password - CÓ NÚT HIỆN MẬT KHẨU */}
<div className="col-span-2 relative"> <div className="col-span-2 relative">
<Label>Current Password</Label> <Label>Current Password</Label>
<div className="relative"> <div className="relative">
@@ -174,28 +182,63 @@ export default function AccountDetails({ data }: { data: UserMetaCardProps }) {
</div> </div>
</div> </div>
{/* 3. New Password - LUÔN ẨN */} <div className="col-span-2 relative">
<div className="col-span-2 lg:col-span-1">
<Label>New Password</Label> <Label>New Password</Label>
<Input <div className="relative">
type="text" <Input
name="new_password" type={showNewPass ? "text" : "password"}
placeholder="Enter new password" name="new_password"
defaultValue={formValues.new_password} placeholder="Enter new password"
onChange={handleChange} 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>
</div> </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> <Label>Confirm New Password</Label>
<Input <div className="relative">
type="text" <Input
name="confirm_password" type={showConfirmPass ? "text" : "password"}
placeholder="Re-type new password" name="confirm_password"
defaultValue={formValues.confirm_password} placeholder="Confirm new password"
onChange={handleChange} 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>
)}
</div> </div>
</div> </div>
@@ -211,7 +254,11 @@ export default function AccountDetails({ data }: { data: UserMetaCardProps }) {
> >
Close Close
</Button> </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 Save Changes
</Button> </Button>
</div> </div>

View File

@@ -46,7 +46,7 @@ const processMedia = (mediaArray: any[]) => {
return { type: "empty" }; return { type: "empty" };
}; };
export default function ApplicationSquareCardList({ export default function ApplicationList({
applications, applications,
}: { }: {
applications: any[]; applications: any[];
@@ -56,9 +56,9 @@ export default function ApplicationSquareCardList({
const handleViewDetail = (app: any) => { const handleViewDetail = (app: any) => {
dispatch(setSelectedApplication(app)); 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> = { const StatusIcons: Record<string, React.ReactNode> = {
APPROVED: ( APPROVED: (
<svg <svg
@@ -111,12 +111,21 @@ export default function ApplicationSquareCardList({
}; };
return ( return (
<div className="p-5 border rounded-xl dark:border-zinc-800 lg:p-6 bg-white dark:bg-zinc-950"> <div className="rounded-2xl border border-gray-200 bg-white p-6 dark:border-zinc-800 dark:bg-zinc-900/50">
<h4 className="text-lg font-bold text-zinc-800 dark:text-white/90 mb-5 tracking-tight"> <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">
Applications CV <h3 className="text-xl font-bold text-gray-800 dark:text-white/90">
</h4> 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) => { {applications?.map((app) => {
const mediaState = processMedia(app.media); const mediaState = processMedia(app.media);
const config = statusConfig[app.status] || statusConfig.PENDING; const config = statusConfig[app.status] || statusConfig.PENDING;
@@ -125,77 +134,69 @@ export default function ApplicationSquareCardList({
<div <div
key={app.id} key={app.id}
onClick={() => handleViewDetail(app)} 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"> <div className="absolute inset-0 z-0">
{mediaState.type === "image" ? ( {mediaState.type === "image" ? (
<img <img
src={mediaState.src} src={mediaState.src}
alt="" 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" ? ( {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) => ( {mediaState.extensions?.slice(0, 3).map((ext, i) => (
<span <span
key={i} 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} .{ext}
</span> </span>
))} ))}
</div> </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>
)} )}
</div> </div>
{/* Overlay Gradient */} <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" />
<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: FILE COUNT & STATUS ICON */} <div className="absolute left-3 right-3 top-3 z-20 flex items-start justify-between">
<div className="absolute top-2 left-2 right-2 z-20 flex justify-between items-center">
{app.media?.length > 0 ? ( {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"> <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} FILE {app.media.length} TỆP
</span> </span>
) : ( ) : (
<div /> <div />
)} )}
<div className={`${config.container} rounded-full `}>
{/* Status Icon Wrapper */}
<div
className={`${config.container} rounded-full`}
>
{StatusIcons[app.status] || StatusIcons.PENDING} {StatusIcons[app.status] || StatusIcons.PENDING}
</div> </div>
</div> </div>
{/* Bottom Content */} <div className="absolute bottom-0 left-0 right-0 z-20 p-4 text-white">
<div className="absolute bottom-2 left-2 right-2 z-20 text-white"> <p className="mb-1 truncate text-xs font-bold uppercase tracking-wider text-gray-300">
<div className="mb-1"> {app.verify_type || "VERIFY"}
<p className="text-[10px] font-bold uppercase opacity-80 truncate"> </p>
{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 pt-1 border-t border-white/10"> {app?.reviewer?.display_name && (
<p className="text-[9px] font-bold text-white/70"> <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)} {formatFullDateTime(app.created_at)}
</p> </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 <svg
className="w-4 h-4 text-blue-400" className="h-3 w-3 text-white"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
@@ -204,7 +205,7 @@ export default function ApplicationSquareCardList({
<path <path
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
d="M13 7l5 5m0 0l-5 5m5-5H6" d="M9 5l7 7-7 7"
/> />
</svg> </svg>
</div> </div>
@@ -216,4 +217,4 @@ export default function ApplicationSquareCardList({
</div> </div>
</div> </div>
); );
} }

View File

@@ -1,71 +1,396 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useEffect } from "react";
import Lightbox from "yet-another-react-lightbox"; import Lightbox from "yet-another-react-lightbox";
import Zoom from "yet-another-react-lightbox/plugins/zoom"; import Zoom from "yet-another-react-lightbox/plugins/zoom";
import Captions from "yet-another-react-lightbox/plugins/captions"; import Captions from "yet-another-react-lightbox/plugins/captions";
import "yet-another-react-lightbox/styles.css"; import "yet-another-react-lightbox/styles.css";
import "yet-another-react-lightbox/plugins/captions.css"; import "yet-another-react-lightbox/plugins/captions.css";
import Swal from "sweetalert2";
import { MediaDto } from "@/interface/media"; import { MediaDto } from "@/interface/media";
import { URL_MEDIA } from "../../../api"; import { URL_MEDIA } from "../../../api";
export default function MediaCard({ data }: { data: MediaDto }) { import { deleteMedia } from "@/service/mediaService";
const [index, setIndex] = useState(-1); import { INITIAL_LIMIT } from "../../../constant";
const listMedia = data?.data || [];
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}`, src: `${URL_MEDIA}${item.storage_key}`,
title: item.original_name, 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"> const toggleSelectionMode = () => {
{listMedia.map((item, idx) => ( setIsSelectionMode(!isSelectionMode);
<div if (isSelectionMode) {
key={item.id} setSelectedIds([]);
onClick={() => setIndex(idx)} }
className="group relative min-w-[150px] h-[150px] cursor-pointer overflow-hidden rounded-lg border border-gray-200" };
>
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 <img
src={`${URL_MEDIA}${item.storage_key}`} src={`${URL_MEDIA}${item.storage_key}`}
alt={item.original_name} 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 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 inset-0 flex items-end bg-black/40 p-2 opacity-0 transition-opacity duration-300 group-hover:opacity-100"> <div className="absolute bottom-0 w-full p-3 text-white">
<p className="w-full truncate text-xs text-white"> <p className="truncate text-sm font-medium ">
{item.original_name} {item.original_name}
</p> </p>
<p className="text-xs text-gray-300">
{(item.size / 1024).toFixed(0)} KB
</p>
</div>
</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> </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> </div>
<Lightbox <Lightbox
index={index} index={index}
open={index >= 0} open={index >= 0}
close={() => setIndex(-1)} close={() => setIndex(-1)}
slides={slides} slides={imageSlides}
plugins={[Zoom, Captions]} plugins={[Zoom, Captions]}
zoom={{ toolbar={{
maxZoomPixelRatio: 10, buttons: [
zoomInMultiplier: 2, <button
doubleTapDelay: 300, key="delete"
doubleClickDelay: 300, type="button"
doubleClickMaxStops: 2, className="yarl__button text-red-500"
keyboardMoveDistance: 50, title="Xóa ảnh này"
wheelZoomDistanceFactor: 100, onClick={handleDeleteFromLightbox}
pinchZoomDistanceFactor: 100, >
<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 }} animation={{ zoom: 200 }}
styles={{ styles={{
root: { root: { zIndex: 99, "--yarl__color_backdrop": "rgba(0, 0, 0, 0.9)" },
zIndex: 999999999,
"--yarl__color_backdrop": "rgba(0, 0, 0, 0.85)",
},
}} }}
/> />
</div> </div>

View File

@@ -7,6 +7,7 @@ import { uploadMedia } from "@/service/mediaService";
import { toast } from "sonner"; import { toast } from "sonner";
import { apiUpdateUser } from "@/service/userService"; import { apiUpdateUser } from "@/service/userService";
import { URL_MEDIA } from "../../../api"; import { URL_MEDIA } from "../../../api";
import { useRouter } from "next/navigation";
export default function UserMetaCard({ data }: { data: UserMetaCardProps }) { export default function UserMetaCard({ data }: { data: UserMetaCardProps }) {
const currentAvatar = const currentAvatar =
@@ -15,6 +16,8 @@ export default function UserMetaCard({ data }: { data: UserMetaCardProps }) {
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const router = useRouter();
useEffect(() => { useEffect(() => {
if (data.data?.profile?.avatar_url) { if (data.data?.profile?.avatar_url) {
setPreviewImage(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) { if (data.data) {
try { try {
await apiUpdateUser({ avatar_url: url }); await apiUpdateUser({ avatar_url: url });
window.location.href = window.location.pathname;
toast.success("Cập nhật avatar thành công!"); toast.success("Cập nhật avatar thành công!");
setTimeout(() => {
window.location.reload();
}, 1000);
} catch (error) { } catch (error) {
console.error("Lỗi khi cập nhật avatar:", error); console.error("Lỗi khi cập nhật avatar:", error);
toast.warning( toast.warning("Lỗi khi cập nhật ảnh đại diện. Vui lòng thử lại!");
"Ảnh đã được tải lên nhưng không thể cập nhật hồ sơ. Vui lòng thử lại!",
);
} }
} }
} }
@@ -160,6 +159,9 @@ export default function UserMetaCard({ data }: { data: UserMetaCardProps }) {
</> </>
)} )}
</div> </div>
<span className="text-sm text-gray-500 dark:text-gray-400">
ID: {data.data?.id}
</span>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -51,6 +51,7 @@ export const SidebarProvider: React.FC<{ children: React.ReactNode }> = ({
}; };
}, []); }, []);
const toggleSidebar = () => { const toggleSidebar = () => {
setIsExpanded((prev) => !prev); setIsExpanded((prev) => !prev);
}; };

29
src/interface/common.ts Normal file
View File

@@ -0,0 +1,29 @@
export interface CommonResponse<T = any> {
status: boolean;
message: string;
data: T;
errors?: any; // Or a more specific error type
}
export interface PaginatedResponse<T> {
status: boolean;
message: string;
data: T[];
pagination: {
current_page: number;
page_size: number;
total_records: number;
total_pages: number;
};
errors?: any;
}
export interface CursorPaginatedResponse<T> {
status: boolean;
message: string;
data: {
items: T[];
next_cursor_id?: string;
};
errors?: any;
}

66
src/interface/project.ts Normal file
View File

@@ -0,0 +1,66 @@
export interface Project {
id: string;
title: string;
description: string;
project_status: "PRIVATE" | "PUBLIC" | "ARCHIVE";
created_at: string;
updated_at: string;
is_deleted?: boolean;
user_id?: string;
user?: {
id: string;
email: string;
display_name: string;
avatar_url: string;
};
commits?: any[];
submission_ids?: any[];
members?: ProjectMember[];
}
export interface ProjectsResponse<T = Project> {
status: boolean;
message: string;
data: T[];
pagination: {
current_page: number;
page_size: number;
total_records: number;
total_pages: number;
};
}
export interface UpdateProjectPayload {
title: string;
description: string;
status: "PRIVATE" | "PUBLIC" | "ARCHIVE";
}
export interface ChangeOwnerPayload {
new_owner_id: string;
}
export interface ProjectMemberPayload {
user_id?: string;
role: "EDITOR" | "VIEWER" | "ADMIN";
}
export interface ProjectMember {
user_id: string;
role: string;
display_name: string;
avatar_url: string;
}
export interface GetProjectsParams {
page?: number;
limit?: number;
search?: string;
sort?: "created_at" | "updated_at" | "title";
order?: "asc" | "desc";
statuses?: string; // comma-separated
user_ids?: string; // comma-separated
created_from?: string; // ISO date string
created_to?: string; // ISO date string
}
export interface CreateCommitPayload {
edit_summary: string;
snapshot_json: number[];
}
export interface RestoreCommitPayload {
commit_id: string;
}

View File

@@ -162,10 +162,10 @@ const AppHeader: React.FC = () => {
> >
<div className="flex items-center gap-2 2xsm:gap-3"> <div className="flex items-center gap-2 2xsm:gap-3">
{/* <!-- Dark Mode Toggler --> */} {/* <!-- Dark Mode Toggler --> */}
<ThemeToggleButton /> {/* <ThemeToggleButton /> */}
{/* <!-- Dark Mode Toggler --> */} {/* <!-- Dark Mode Toggler --> */}
<NotificationDropdown /> {/* <NotificationDropdown /> */}
{/* <!-- Notification Menu Area --> */} {/* <!-- Notification Menu Area --> */}
</div> </div>
{/* <!-- User Area --> */} {/* <!-- User Area --> */}
@@ -178,3 +178,4 @@ const AppHeader: React.FC = () => {
}; };
export default AppHeader; export default AppHeader;

View File

@@ -1,13 +1,16 @@
"use client"; "use client";
import React, { useEffect, useRef, useState, useCallback } from "react"; import React, { useEffect, useRef, useState, useCallback, useMemo } from "react";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useSelector } from "react-redux";
import { RootState } from "@/store/store";
import { useSidebar } from "../context/SidebarContext"; import { useSidebar } from "../context/SidebarContext";
import { import {
BoxCubeIcon, BoxCubeIcon,
CalenderIcon, CalenderIcon,
ChevronDownIcon, ChevronDownIcon,
FileIcon,
GridIcon, GridIcon,
HorizontaLDots, HorizontaLDots,
ListIcon, ListIcon,
@@ -17,57 +20,75 @@ import {
TableIcon, TableIcon,
UserCircleIcon, UserCircleIcon,
} from "../icons/index"; } from "../icons/index";
import SidebarWidget from "./SidebarWidget";
type RoleName = "ADMIN" | "MOD" | "USER" | "HISTORIAN";
type NavItem = { type NavItem = {
name: string; name: string;
icon: React.ReactNode; icon: React.ReactNode;
path?: string; path?: string;
subItems?: { name: string; path: string; pro?: boolean; new?: boolean }[]; roles?: RoleName[];
subItems?: {
name: string;
path: string;
pro?: boolean;
new?: boolean;
roles?: RoleName[];
}[];
}; };
const navItems: NavItem[] = [ const ALL_NAV_ITEMS: NavItem[] = [
{ {
icon: <GridIcon />, icon: <GridIcon />,
name: "Dashboard", name: "Trang Chủ",
subItems: [{ name: "Ecommerce", path: "/", pro: false }], // subItems: [{ name: "Ecommerce", path: "/", pro: false }],
path: "/",
}, },
// {
// icon: <CalenderIcon />,
// name: "Calendar",
// path: "/calendar",
// },
{ {
icon: <CalenderIcon />, icon: <FileIcon />,
name: "Calendar", name: "Thư Viện",
path: "/calendar", 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"] },
{ name: "Dự Án", path: "/project", pro: false, roles: ["ADMIN", "MOD"] },
],
}, },
{ {
icon: <UserCircleIcon />, icon: <UserCircleIcon />,
name: "User Profile", name: "Tài Khoản",
path: "/profile", path: "/account",
},
{
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 othersItems: NavItem[] = [ const OTHERS_ITEMS: NavItem[] = [
{ {
icon: <PieChartIcon />, icon: <PieChartIcon />,
name: "Charts", name: "Charts",
@@ -102,64 +123,109 @@ const AppSidebar: React.FC = () => {
const { isExpanded, isMobileOpen, isHovered, setIsHovered } = useSidebar(); const { isExpanded, isMobileOpen, isHovered, setIsHovered } = useSidebar();
const pathname = usePathname(); const pathname = usePathname();
const renderMenuItems = ( // Lấy data gốc từ Redux (không bị render lại vô cớ)
navItems: NavItem[], const rolesData = useSelector((state: RootState) => state.user.data?.roles);
menuType: "main" | "others",
) => ( 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 }
);
};
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;
}
});
});
});
if (!submenuMatched) {
setOpenSubmenu((prev) => (prev !== null ? null : prev));
}
}, [pathname, isActive, filteredNavItems, filteredOthersItems]);
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 renderMenuItems = (items: NavItem[], menuType: "main" | "others") => (
<ul className="flex flex-col gap-4"> <ul className="flex flex-col gap-4">
{navItems.map((nav, index) => ( {items.map((nav, index) => (
<li key={nav.name}> <li key={nav.name}>
{nav.subItems ? ( {nav.subItems ? (
<button <button
onClick={() => handleSubmenuToggle(index, menuType)} onClick={() => handleSubmenuToggle(index, menuType)}
className={`menu-item group ${ className={`menu-item group uppercase ${
openSubmenu?.type === menuType && openSubmenu?.index === index openSubmenu?.type === menuType && openSubmenu?.index === index
? "menu-item-active" ? "menu-item-active" : "menu-item-inactive"
: "menu-item-inactive" } cursor-pointer ${!isExpanded && !isHovered ? "lg:justify-center" : "lg:justify-start"}`}
} cursor-pointer ${
!isExpanded && !isHovered
? "lg:justify-center"
: "lg:justify-start"
}`}
> >
<span <span className={openSubmenu?.type === menuType && openSubmenu?.index === index ? "menu-item-icon-active" : "menu-item-icon-inactive"}>
className={` ${
openSubmenu?.type === menuType && openSubmenu?.index === index
? "menu-item-icon-active"
: "menu-item-icon-inactive"
}`}
>
{nav.icon} {nav.icon}
</span> </span>
{(isExpanded || isHovered || isMobileOpen) && ( {(isExpanded || isHovered || isMobileOpen) && (
<span className={`menu-item-text`}>{nav.name}</span> <>
)} <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" : ""}`} />
<ChevronDownIcon </>
className={`ml-auto w-5 h-5 transition-transform duration-200 ${
openSubmenu?.type === menuType &&
openSubmenu?.index === index
? "rotate-180 text-brand-500"
: ""
}`}
/>
)} )}
</button> </button>
) : ( ) : (
nav.path && ( nav.path && (
<Link <Link href={nav.path} className={`menu-item group ${isActive(nav.path) ? "menu-item-active" : "menu-item-inactive"}`}>
href={nav.path} <span className={isActive(nav.path) ? "menu-item-icon-active" : "menu-item-icon-inactive"}>
className={`menu-item group ${
isActive(nav.path) ? "menu-item-active" : "menu-item-inactive"
}`}
>
<span
className={`${
isActive(nav.path)
? "menu-item-icon-active"
: "menu-item-icon-inactive"
}`}
>
{nav.icon} {nav.icon}
</span> </span>
{(isExpanded || isHovered || isMobileOpen) && ( {(isExpanded || isHovered || isMobileOpen) && (
@@ -170,52 +236,18 @@ const AppSidebar: React.FC = () => {
)} )}
{nav.subItems && (isExpanded || isHovered || isMobileOpen) && ( {nav.subItems && (isExpanded || isHovered || isMobileOpen) && (
<div <div
ref={(el) => { ref={(el) => { subMenuRefs.current[`${menuType}-${index}`] = el; }}
subMenuRefs.current[`${menuType}-${index}`] = el;
}}
className="overflow-hidden transition-all duration-300" className="overflow-hidden transition-all duration-300"
style={{ style={{ height: openSubmenu?.type === menuType && openSubmenu?.index === index ? `${subMenuHeight[`${menuType}-${index}`]}px` : "0px" }}
height:
openSubmenu?.type === menuType && openSubmenu?.index === index
? `${subMenuHeight[`${menuType}-${index}`]}px`
: "0px",
}}
> >
<ul className="mt-2 space-y-1 ml-9"> <ul className="mt-2 space-y-1 ml-9">
{nav.subItems.map((subItem) => ( {nav.subItems.map((subItem) => (
<li key={subItem.name}> <li key={subItem.name}>
<Link <Link href={subItem.path} className={`menu-dropdown-item ${isActive(subItem.path) ? "menu-dropdown-item-active" : "menu-dropdown-item-inactive"}`}>
href={subItem.path}
className={`menu-dropdown-item ${
isActive(subItem.path)
? "menu-dropdown-item-active"
: "menu-dropdown-item-inactive"
}`}
>
{subItem.name} {subItem.name}
<span className="flex items-center gap-1 ml-auto"> <span className="flex items-center gap-1 ml-auto">
{subItem.new && ( {subItem.new && <span className={`${isActive(subItem.path) ? "menu-dropdown-badge-active" : "menu-dropdown-badge-inactive"} menu-dropdown-badge`}>new</span>}
<span {subItem.pro && <span className={`${isActive(subItem.path) ? "menu-dropdown-badge-active" : "menu-dropdown-badge-inactive"} menu-dropdown-badge`}>pro</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> </span>
</Link> </Link>
</li> </li>
@@ -228,160 +260,44 @@ const AppSidebar: React.FC = () => {
</ul> </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 ( return (
<aside <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 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]"}
isExpanded || isMobileOpen ${isMobileOpen ? "translate-x-0" : "-translate-x-full"} lg:translate-x-0`}
? "w-[290px]"
: isHovered
? "w-[290px]"
: "w-[90px]"
}
${isMobileOpen ? "translate-x-0" : "-translate-x-full"}
lg:translate-x-0`}
onMouseEnter={() => !isExpanded && setIsHovered(true)} onMouseEnter={() => !isExpanded && setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)} onMouseLeave={() => setIsHovered(false)}
> >
<div <div className={`py-8 flex ${!isExpanded && !isHovered ? "lg:justify-center" : "justify-start"}`}>
className={`py-8 flex ${
!isExpanded && !isHovered ? "lg:justify-center" : "justify-start"
}`}
>
<Link href="/"> <Link href="/">
{isExpanded || isHovered || isMobileOpen ? ( <Image
<> src="/images/logo/logo.svg"
<Image alt="Logo"
className="dark:hidden" width={isExpanded || isHovered || isMobileOpen ? 80 : 32}
src="/images/logo/logo.svg" height={isExpanded || isHovered || isMobileOpen ? 50 : 32}
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> </Link>
</div> </div>
<div className="flex flex-col overflow-y-auto duration-300 ease-linear no-scrollbar"> <div className="flex flex-col overflow-y-auto duration-300 ease-linear no-scrollbar">
<nav className="mb-6"> <nav className="mb-6">
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div> <div>
<h2 <h2 className={`mb-4 text-xs uppercase flex leading-[20px] text-gray-400 ${!isExpanded && !isHovered ? "lg:justify-center" : "justify-start"}`}>
className={`mb-4 text-xs uppercase flex leading-[20px] text-gray-400 ${ {isExpanded || isHovered || isMobileOpen ? "Menu" : <HorizontaLDots />}
!isExpanded && !isHovered
? "lg:justify-center"
: "justify-start"
}`}
>
{isExpanded || isHovered || isMobileOpen ? (
"Menu"
) : (
<HorizontaLDots />
)}
</h2> </h2>
{renderMenuItems(navItems, "main")} {renderMenuItems(filteredNavItems, "main")}
</div> </div>
{/* <div>
<div className=""> <h2 className={`mb-4 text-xs uppercase flex leading-[20px] text-gray-400 ${!isExpanded && !isHovered ? "lg:justify-center" : "justify-start"}`}>
<h2 {isExpanded || isHovered || isMobileOpen ? "Others" : <HorizontaLDots />}
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> </h2>
{renderMenuItems(othersItems, "others")} {renderMenuItems(filteredOthersItems, "others")}
</div> </div> */}
</div> </div>
</nav> </nav>
{/* {isExpanded || isHovered || isMobileOpen ? <SidebarWidget /> : null} */}
</div> </div>
</aside> </aside>
); );
}; };
export default AppSidebar; export default AppSidebar;

View File

@@ -109,3 +109,22 @@ export const getMediaById = async (mediaId: number | string) => {
return response?.data; 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;
}

View File

@@ -0,0 +1,72 @@
import api from "@/config/config";
import { API } from "../../api";
import { ProjectMemberPayload, ChangeOwnerPayload, CreateCommitPayload, GetProjectsParams, Project, RestoreCommitPayload, UpdateProjectPayload } from "@/interface/project";
import { CommonResponse, CursorPaginatedResponse, PaginatedResponse } from "@/interface/common";
export const getProjects = async (params: GetProjectsParams): Promise<PaginatedResponse<Project>> => {
const response = await api.get(API.Project.GET_ALL, { params });
return response?.data;
};
export const getProjectDetailByID = async (id: string): Promise<CommonResponse<Project>> => {
const response = await api.get(API.Project.GET_DETAIL(id));
return response?.data;
};
export const updateProject = async (id: string, payload: UpdateProjectPayload): Promise<CommonResponse<Project>> => {
const response = await api.put(API.Project.UPDATE(id), payload);
return response?.data;
};
export const deleteProject = async (id: string): Promise<CommonResponse> => {
const response = await api.delete(API.Project.DELETE(id));
return response?.data;
};
export const transferProjectOwnership = async (id: string, payload: ChangeOwnerPayload): Promise<CommonResponse> => {
const response = await api.put(API.Project.CHANGE_OWNER(id), payload);
return response?.data;
};
// ==========================================
// 2. NHÓM: QUẢN LÝ THÀNH VIÊN (MEMBERS)
// ==========================================
export const addProjectMember = async (id: string, payload: ProjectMemberPayload): Promise<CommonResponse> => {
const response = await api.post(API.Project.ADD_MEMBER(id), payload);
return response?.data;
};
export const updateProjectMemberRole = async (id: string, userId: string, payload: ProjectMemberPayload): Promise<CommonResponse> => {
const response = await api.put(API.Project.UPDATE_MEMBER(id, userId), payload);
return response?.data;
};
export const removeProjectMember = async (id: string, userId: string): Promise<CommonResponse> => {
const response = await api.delete(API.Project.REMOVE_MEMBER(id, userId));
return response?.data;
};
// ==========================================
// 3. NHÓM: LỊCH SỬ BẢN LƯU (COMMITS)
// ==========================================
export const createProjectCommit = async (id: string, payload: CreateCommitPayload): Promise<CommonResponse> => {
const response = await api.post(API.Project.CREATE_COMMIT(id), payload);
return response?.data;
};
export const getProjectCommits = async (id: string): Promise<CommonResponse> => {
const response = await api.get(API.Project.GET_COMMITS(id));
return response?.data;
};
export const restoreProjectCommit = async (id: string, payload: RestoreCommitPayload): Promise<CommonResponse> => {
const response = await api.post(API.Project.RESTORE_COMMIT(id), payload);
return response?.data;
};
export const getCurrentProject = async (params?: { cursor_id?: string; limit?: number }): Promise<CursorPaginatedResponse<Project>> => {
const response = await api.get(API.Project.GET_CURRENT_PROJECT, { params });
return response?.data;
};