first commit
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
import React from "react";
|
||||
import { PropsBaseLayout, TContextBaseLayout } from "./interface";
|
||||
import clsx from "clsx";
|
||||
import Header from "./componets/Header";
|
||||
import Navbar from "./componets/Navbar";
|
||||
import RequireAuth from "@/components/protected/RequiredAuth";
|
||||
|
||||
export const ContextBaseLayout = React.createContext<TContextBaseLayout>({});
|
||||
|
||||
const BaseLayout = ({ children, title, breadcrumb }: PropsBaseLayout) => {
|
||||
const [showFull, setShowFull] = React.useState(false);
|
||||
const [openMenuMobile, setOpenMenuMobile] = React.useState(false);
|
||||
return (
|
||||
// <RequireAuth>
|
||||
<ContextBaseLayout
|
||||
value={{ showFull, setShowFull, openMenuMobile, setOpenMenuMobile }}
|
||||
>
|
||||
<div className="min-h-screen bg-[#f8f8f8]">
|
||||
{/* OVERLAY cho Mobile */}
|
||||
<div
|
||||
className={clsx(
|
||||
"fixed inset-0 bg-black/50 backdrop-blur-sm z-[20] transition-opacity duration-300 xl:hidden",
|
||||
openMenuMobile ? "opacity-100 visible" : "opacity-0 invisible",
|
||||
)}
|
||||
onClick={() => setOpenMenuMobile(false)}
|
||||
/>
|
||||
|
||||
{/* SIDEBAR (Dùng chung cho cả Desktop & Mobile) */}
|
||||
<nav
|
||||
className={clsx(
|
||||
"fixed top-0 left-0 h-full w-[240px] z-[21] bg-white transition-all duration-300 border-r border-[#f4f7fa]",
|
||||
// Desktop logic
|
||||
showFull ? "xl:translate-x-0" : "xl:-translate-x-full",
|
||||
// Mobile logic
|
||||
openMenuMobile ? "translate-x-0" : "-translate-x-full",
|
||||
)}
|
||||
>
|
||||
<Navbar />
|
||||
</nav>
|
||||
|
||||
{/* HEADER */}
|
||||
<header
|
||||
className={clsx(
|
||||
"fixed top-0 right-0 h-[68px] z-[11] bg-white transition-all duration-300 border-b border-[#f4f7fa]",
|
||||
showFull
|
||||
? "xl:left-[240px] xl:w-[calc(100%-240px)]"
|
||||
: "left-0 w-full",
|
||||
"left-0 w-full", // Mặc định full width trên mobile
|
||||
)}
|
||||
>
|
||||
<Header title={title} breadcrumb={breadcrumb} />
|
||||
</header>
|
||||
|
||||
{/* MAIN CONTENT */}
|
||||
<main
|
||||
className={clsx(
|
||||
"pt-[92px] pb-6 px-6 transition-all duration-300",
|
||||
showFull ? "xl:pl-[264px]" : "xl:pl-6",
|
||||
"pl-6", // Mặc định padding trên mobile
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</ContextBaseLayout>
|
||||
// </RequireAuth>
|
||||
);
|
||||
};
|
||||
|
||||
export default BaseLayout;
|
||||
@@ -0,0 +1,101 @@
|
||||
"use client";
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import { PropsHeader } from "./interface";
|
||||
import { usePathname } from "next/dist/client/components/navigation";
|
||||
import { useSelector } from "react-redux";
|
||||
import { RootState } from "@/redux/store";
|
||||
import { ContextBaseLayout } from "../../BaseLayout";
|
||||
import Image from "next/image";
|
||||
import icons from "@/constant/images/icons";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import clsx from "clsx";
|
||||
import MenuProfile from "../MenuProfile";
|
||||
|
||||
const Header = ({ title, breadcrumb }: PropsHeader) => {
|
||||
const pathname = usePathname();
|
||||
const { infoUser } = useSelector((state: RootState) => state.user);
|
||||
const context = useContext(ContextBaseLayout);
|
||||
|
||||
const [openProfile, setOpenProfile] = useState(false);
|
||||
|
||||
//Đóng menu mobile khi đổi route
|
||||
useEffect(() => {
|
||||
context.setOpenMenuMobile?.(false);
|
||||
}, [pathname, context]);
|
||||
|
||||
const toggleFullScreen = () => {
|
||||
if (!document.fullscreenElement) {
|
||||
document.documentElement.requestFullscreen();
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full w-full px-6 flex items-center justify-between bg-white relatives">
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Nút bấm Mobile: Mở menu mobile */}
|
||||
<div
|
||||
className="xl:hidden cursor-pointer"
|
||||
onClick={() => context?.setOpenMenuMobile?.(true)}
|
||||
>
|
||||
<Image src={icons.hamburger} alt="menu" width={20} height={20} />
|
||||
</div>
|
||||
{/* Nút bấm Desktop: Thu gọn/Mở rộng sidebar */}
|
||||
<div
|
||||
className="hidden xl:block cursor-pointer"
|
||||
onClick={() => context?.setShowFull?.(!context?.showFull)}
|
||||
>
|
||||
<Image src={icons.hamburger} alt="menu" width={20} height={20} />
|
||||
</div>
|
||||
{breadcrumb ? (
|
||||
breadcrumb
|
||||
) : (
|
||||
<h4 className="text-[18px] font-bold text-[#141416]">{title}</h4>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-7">
|
||||
<div className="cursor-pointer" onClick={toggleFullScreen}>
|
||||
<Image src={icons.full_screen} alt="" width={24} height={24} />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="relative flex items-center gap-2 cursor-pointer"
|
||||
onClick={() => setOpenProfile(!openProfile)}
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full border-2 border-blue-500 overflow-hidden">
|
||||
<Image
|
||||
src={
|
||||
infoUser?.avatar
|
||||
? `${process.env.NEXT_PUBLIC_IMAGE}/${infoUser.avatar}`
|
||||
: icons.avatar
|
||||
}
|
||||
alt="avatar"
|
||||
width={40}
|
||||
height={40}
|
||||
/>
|
||||
</div>
|
||||
<p className="hidden md:block text-sm font-semibold text-[#171832]">
|
||||
{infoUser?.fullname || "User admin"}
|
||||
</p>
|
||||
<ChevronDown
|
||||
size={16}
|
||||
className={clsx(
|
||||
"transition-transform",
|
||||
openProfile && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
|
||||
{openProfile && (
|
||||
<div className="absolute right-0 top-[120%] z-50">
|
||||
<MenuProfile onClose={() => setOpenProfile(false)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./Header";
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface PropsHeader {
|
||||
title: string;
|
||||
breadcrumb?: React.ReactNode;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import React, { use, useCallback, useState } from "react";
|
||||
import { PropsMenuProfile } from "./interface";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { httpRequest } from "@/services";
|
||||
import authServices from "@/services/authServices";
|
||||
import { store } from "@/redux/store";
|
||||
import { logout } from "@/redux/reducer/auth";
|
||||
import { setInfoUser } from "@/redux/reducer/user";
|
||||
import { PATH } from "@/constant/config";
|
||||
import Link from "next/link";
|
||||
import clsx from "clsx";
|
||||
import { LogOut, ShieldCog, ShieldUser } from "lucide-react";
|
||||
import CustomDialog from "@/components/customs/custom-dialog";
|
||||
|
||||
const MenuProfile = ({ onClose }: PropsMenuProfile) => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [openLogout, setOpenLogout] = useState(false);
|
||||
|
||||
/* ================== CHECK ACTIVE ================== */
|
||||
const checkActive = useCallback(
|
||||
(path: string) => {
|
||||
return pathname === path;
|
||||
},
|
||||
[pathname],
|
||||
);
|
||||
|
||||
/* ================== LOGOUT ================== */
|
||||
const logoutMutation = useMutation({
|
||||
mutationFn: () =>
|
||||
httpRequest({
|
||||
showMessageFailed: true,
|
||||
showMessageSuccess: false,
|
||||
http: authServices.logout(),
|
||||
}),
|
||||
onSuccess(data) {
|
||||
if (data) {
|
||||
store.dispatch(logout());
|
||||
store.dispatch(setInfoUser(null));
|
||||
router.push(PATH.LOGIN);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const handleLogout = () => {
|
||||
logoutMutation.mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-[260px] bg-white rounded-xl shadow-md p-2">
|
||||
{/* PROFILE */}
|
||||
<Link
|
||||
href={PATH.PROFILE}
|
||||
onClick={onClose}
|
||||
className={clsx(
|
||||
"flex items-center gap-3 p-3 rounded-lg hover:bg-gray-100 transition",
|
||||
checkActive(PATH.PROFILE) && "bg-gray-100",
|
||||
)}
|
||||
>
|
||||
<ShieldUser size={20} />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Thông tin cá nhân</p>
|
||||
<p className="text-xs text-gray-500">Chi tiết tài khoản</p>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="h-px bg-gray-200 my-1" />
|
||||
|
||||
{/* CHANGE PASSWORD */}
|
||||
<Link
|
||||
href={`${PATH.PROFILE}?_action=change-password`}
|
||||
onClick={onClose}
|
||||
className={clsx(
|
||||
"flex items-center gap-3 p-3 rounded-lg hover:bg-gray-100 transition",
|
||||
pathname === PATH.PROFILE &&
|
||||
searchParams.get("_action") === "change-password" &&
|
||||
"bg-gray-100",
|
||||
)}
|
||||
>
|
||||
<ShieldCog size={20} />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Đổi mật khẩu</p>
|
||||
<p className="text-xs text-gray-500">Thay đổi mật khẩu</p>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="h-px bg-gray-200 my-1" />
|
||||
|
||||
{/* LOGOUT */}
|
||||
<div
|
||||
onClick={() => {
|
||||
setOpenLogout(true);
|
||||
onClose();
|
||||
}}
|
||||
className="flex items-center gap-3 p-3 rounded-lg hover:bg-red-50 cursor-pointer transition"
|
||||
>
|
||||
<LogOut size={20} className="text-red-500" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-red-500">Đăng xuất</p>
|
||||
<p className="text-xs text-gray-500">Đăng xuất khỏi hệ thống</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* DIALOG */}
|
||||
<CustomDialog
|
||||
open={openLogout}
|
||||
onClose={() => setOpenLogout(false)}
|
||||
onSubmit={handleLogout}
|
||||
title="Đăng xuất"
|
||||
note="Bạn có muốn đăng xuất khỏi hệ thống không?"
|
||||
titleCancel="Không"
|
||||
titleSubmit="Đăng xuất"
|
||||
type="error"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MenuProfile;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./MenuProfile";
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface PropsMenuProfile {
|
||||
onClose: () => void;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Menus, PATH } from "@/constant/config";
|
||||
import Image from "next/image";
|
||||
import icons from "@/constant/images/icons";
|
||||
import clsx from "clsx";
|
||||
|
||||
const Navbar = () => {
|
||||
const pathname = usePathname();
|
||||
|
||||
const checkActive = useCallback(
|
||||
(pathActive: string) => {
|
||||
const currentRoute = pathname.split("/")[1];
|
||||
return pathActive === `/${currentRoute}`;
|
||||
},
|
||||
[pathname],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full w-full flex flex-col items-center p-3 bg-white">
|
||||
<Link href={PATH.HOME} className="flex items-center justify-center py-2">
|
||||
<Image alt="Logo" src={icons.avatar} width={40} height={40} />
|
||||
<h4 className="ml-2 text-sm font-semibold text-blue-900 select-none">
|
||||
Quản lý
|
||||
</h4>
|
||||
</Link>
|
||||
<div className="flex-1 w-full overflow-auto pt-3">
|
||||
{Menus.map((menu, i) => (
|
||||
<div key={i} className="mb-4">
|
||||
{/* TITLE */}
|
||||
<h5 className="text-xs font-semibold text-gray-400 uppercase mb-2">
|
||||
{menu.title}
|
||||
</h5>
|
||||
|
||||
<div className="space-y-1">
|
||||
{menu.group.map((tab, y) => {
|
||||
const isActive = checkActive(tab.pathActive);
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={y}
|
||||
href={tab.path}
|
||||
className={clsx(
|
||||
"flex items-center gap-2 px-3 py-2 rounded-lg transition",
|
||||
isActive
|
||||
? "bg-indigo-100 text-blue-700"
|
||||
: "text-gray-800 hover:bg-indigo-100 hover:text-blue-700",
|
||||
)}
|
||||
>
|
||||
<tab.icon
|
||||
size={22}
|
||||
className={clsx(
|
||||
isActive ? "text-blue-700" : "text-gray-800",
|
||||
)}
|
||||
/>
|
||||
<p className="text-base font-semibold">{tab.title}</p>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Navbar;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./Navbar";
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./BaseLayout";
|
||||
@@ -0,0 +1,12 @@
|
||||
export interface PropsBaseLayout {
|
||||
children: React.ReactNode;
|
||||
title: string;
|
||||
breadcrumb?: React.ReactNode;
|
||||
}
|
||||
|
||||
export interface TContextBaseLayout {
|
||||
showFull?: boolean;
|
||||
setShowFull?: (show: boolean) => void;
|
||||
openMenuMobile?: boolean;
|
||||
setOpenMenuMobile?: (show: boolean) => void;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client";
|
||||
import React from "react";
|
||||
import { PropsChartWrapper } from "./interface";
|
||||
import clsx from "clsx";
|
||||
import Image from "next/image";
|
||||
import icons from "@/constant/images/icons";
|
||||
|
||||
const ChartWrapper = ({
|
||||
children,
|
||||
isEmpty,
|
||||
message = "Không có dữ liệu",
|
||||
}: PropsChartWrapper) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"w-full min-h-[500px]",
|
||||
isEmpty && "flex items-center justify-center",
|
||||
)}
|
||||
>
|
||||
{isEmpty ? (
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<Image
|
||||
alt="Ảnh dữ liệu trống"
|
||||
src={icons.emptyTable}
|
||||
width={180}
|
||||
height={180}
|
||||
/>
|
||||
{/* nếu muốn hiện message thì bật dòng dưới */}
|
||||
{/* <p className="mt-3 text-sm text-gray-500">{message}</p> */}
|
||||
</div>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChartWrapper;
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface PropsChartWrapper {
|
||||
children: React.ReactNode;
|
||||
isEmpty?: boolean;
|
||||
message?: string;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { GridColumnProps } from "./interface";
|
||||
import clsx from "clsx";
|
||||
|
||||
const GridColumn = ({
|
||||
children,
|
||||
col = 3,
|
||||
sm,
|
||||
tabletCol3,
|
||||
mobile2,
|
||||
scroll20,
|
||||
scrollMobile85,
|
||||
className,
|
||||
}: GridColumnProps) => {
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"grid w-full",
|
||||
|
||||
/* ===== GAP ===== */
|
||||
sm ? "gap-2" : "gap-4",
|
||||
|
||||
/* ===== DEFAULT COL ===== */
|
||||
{
|
||||
"grid-cols-1": col === 1,
|
||||
"grid-cols-2": col === 2,
|
||||
"grid-cols-3": col === 3,
|
||||
"grid-cols-4": col === 4,
|
||||
"grid-cols-5": col === 5,
|
||||
"grid-cols-6": col === 6,
|
||||
"grid-cols-8": col === 8,
|
||||
"grid-cols-12": col === 12,
|
||||
},
|
||||
|
||||
/* ===== RESPONSIVE ===== */
|
||||
"xl:grid-cols-3", // giống default SCSS fallback
|
||||
"lg:grid-cols-2",
|
||||
"md:grid-cols-2",
|
||||
"sm:grid-cols-1",
|
||||
|
||||
/* ===== CUSTOM RULES ===== */
|
||||
tabletCol3 && "lg:grid-cols-3",
|
||||
|
||||
mobile2 && "sm:grid-cols-2",
|
||||
|
||||
/* ===== COL 4 SPECIAL ===== */
|
||||
col === 4 && clsx("xl:grid-cols-3", "lg:grid-cols-2", "sm:grid-cols-1"),
|
||||
|
||||
/* ===== COL 5 ===== */
|
||||
col === 5 && clsx("xl:grid-cols-3", "lg:grid-cols-2", "sm:grid-cols-1"),
|
||||
|
||||
/* ===== SCROLL ===== */
|
||||
scroll20 &&
|
||||
"grid-flow-col auto-cols-[20%] overflow-x-auto snap-x snap-mandatory gap-2",
|
||||
|
||||
scrollMobile85 &&
|
||||
"md:grid-cols-none md:grid-flow-col md:auto-cols-[85%] md:overflow-x-auto md:snap-x md:snap-mandatory",
|
||||
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GridColumn;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./GridColumn";
|
||||
@@ -0,0 +1,14 @@
|
||||
export interface GridColumnProps {
|
||||
children: React.ReactNode;
|
||||
|
||||
col?: 1 | 2 | 3 | 4 | 5 | 6 | 8 | 12;
|
||||
sm?: boolean;
|
||||
|
||||
tabletCol3?: boolean;
|
||||
mobile2?: boolean;
|
||||
|
||||
scroll20?: boolean;
|
||||
scrollMobile85?: boolean;
|
||||
|
||||
className?: string;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import RequiredLogout from "@/components/protected/RequiredLogout";
|
||||
import React from "react";
|
||||
|
||||
interface PropsLayoutAuth {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
const LayoutAuth = ({ children }: PropsLayoutAuth) => {
|
||||
return (
|
||||
<RequiredLogout>
|
||||
<div className="h-screen grid grid-cols-2 max-[1200px]:grid-cols-1">
|
||||
{/* BACKGROUND */}
|
||||
<div
|
||||
className="
|
||||
bg-no-repeat
|
||||
bg-cover
|
||||
bg-center
|
||||
max-[1200px]:hidden bg-[url('/static/images/background_auth.jpg')]
|
||||
"
|
||||
/>
|
||||
|
||||
{/* MAIN */}
|
||||
<main
|
||||
className="
|
||||
flex items-center bg-white
|
||||
px-[200px]
|
||||
max-[1600px]:px-[100px]
|
||||
max-[768px]:px-[40px]
|
||||
"
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</RequiredLogout>
|
||||
);
|
||||
};
|
||||
|
||||
export default LayoutAuth;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./LayoutAuth";
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
import React, { useCallback } from "react";
|
||||
import { PropsLayoutPages } from "./interface";
|
||||
import { usePathname } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import clsx from "clsx";
|
||||
|
||||
const LayoutPages = ({ children, listPages }: PropsLayoutPages) => {
|
||||
const pathname = usePathname();
|
||||
const checkActive = useCallback(
|
||||
(url: string) => {
|
||||
return pathname === url;
|
||||
},
|
||||
[pathname],
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<div className="overflow-x-auto whitespace-nowrap pb-1 mb-5 scrollbar-thin">
|
||||
{listPages.map((item, i) => {
|
||||
const isActive = checkActive(item.url);
|
||||
return (
|
||||
<Link
|
||||
key={i}
|
||||
href={item.url}
|
||||
onClick={(e) => {
|
||||
if (isActive) e.preventDefault();
|
||||
}}
|
||||
className={clsx(
|
||||
"inline-block min-w-[60px] text-center font-medium rounded-md px-6 py-3 border select-none cursor-pointer",
|
||||
"text-[16px] max-[1200px]:text-[14px] max-[768px]:text-[13px]",
|
||||
isActive
|
||||
? "bg-[#3772ff] text-white border-[#3772ff]"
|
||||
: "bg-white text-[#202939] border-[#eaedf2]",
|
||||
i !== 0 && "ml-2",
|
||||
)}
|
||||
>
|
||||
{" "}
|
||||
<div className="flex items-center gap-2 justify-center">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{
|
||||
background: isActive ? "#fff" : item.color,
|
||||
}}
|
||||
/>
|
||||
<p>{item.title}</p>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* MAIN */}
|
||||
<div>{children}</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default LayoutPages;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "./LayoutPages";
|
||||
@@ -0,0 +1,8 @@
|
||||
export interface PropsLayoutPages {
|
||||
children: React.ReactNode;
|
||||
listPages: {
|
||||
title: string;
|
||||
url: string;
|
||||
color: string;
|
||||
}[];
|
||||
}
|
||||
Reference in New Issue
Block a user