feat(backoffice): Create a nested/dropdown sidebar list
- Create a dropdown sidebar list - Show back the old navigation and group them - Make the sidebar responsive for mobile view
This commit is contained in:
@@ -1,13 +1,51 @@
|
|||||||
import { FC, ReactElement } from 'react';
|
import { FC, ReactElement, useState } from 'react';
|
||||||
import { Outlet } from 'react-router-dom';
|
import { Outlet } from 'react-router-dom';
|
||||||
import { BackofficeSidebar } from '@imphnen-frontend-service/ui/organisms';
|
import { BackofficeSidebar } from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
|
||||||
export const AppLayout: FC = (): ReactElement => {
|
export const AppLayout: FC = (): ReactElement => {
|
||||||
|
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-primary-50 min-h-screen flex justify-center">
|
<div className="bg-primary-50 min-h-screen flex justify-center">
|
||||||
<div className="bg-primary-50 min-h-screen w-full flex">
|
<div className="bg-primary-50 min-h-screen w-full flex">
|
||||||
<BackofficeSidebar />
|
<BackofficeSidebar
|
||||||
|
isOpen={mobileSidebarOpen}
|
||||||
|
onClose={() => setMobileSidebarOpen(false)}
|
||||||
|
/>
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
|
{/* Sticky top header */}
|
||||||
|
<header
|
||||||
|
className={
|
||||||
|
'sticky top-0 bg-white border-b border-primary-200 px-4 py-3 flex items-center gap-3 ' +
|
||||||
|
(mobileSidebarOpen ? 'z-0' : 'z-30')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* Mobile menu button (shown on small screens) */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="md:hidden p-2 rounded-md hover:bg-gray-100 text-gray-700"
|
||||||
|
onClick={() => setMobileSidebarOpen(true)}
|
||||||
|
aria-label="Open sidebar"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 6h16M4 12h16M4 18h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<h1 className="text-p3 font-semibold text-primary-700">
|
||||||
|
IMPHNEN Backoffice
|
||||||
|
</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -130,7 +130,7 @@
|
|||||||
html {
|
html {
|
||||||
font-family: 'Bai Jamjuree', sans-serif;
|
font-family: 'Bai Jamjuree', sans-serif;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-size: 12px;
|
font-size: 14px;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,91 +3,257 @@ import {
|
|||||||
AuditOutlined,
|
AuditOutlined,
|
||||||
BookOutlined,
|
BookOutlined,
|
||||||
CommentOutlined,
|
CommentOutlined,
|
||||||
|
DownOutlined,
|
||||||
InboxOutlined,
|
InboxOutlined,
|
||||||
LogoutOutlined,
|
LogoutOutlined,
|
||||||
|
ReadOutlined,
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
|
RightOutlined,
|
||||||
ScheduleOutlined,
|
ScheduleOutlined,
|
||||||
SettingOutlined,
|
SettingOutlined,
|
||||||
|
StockOutlined,
|
||||||
UsergroupAddOutlined,
|
UsergroupAddOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
UserSwitchOutlined,
|
UserSwitchOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { Button } from '../../atoms';
|
import { Button } from '../../atoms';
|
||||||
import { FC, ReactElement } from 'react';
|
import { FC, ReactElement, useState } from 'react';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
import { cn, For, useSession } from '@imphnen-frontend-service/utils';
|
import { cn, For, useSession } from '@imphnen-frontend-service/utils';
|
||||||
|
|
||||||
const MENUS = [
|
type MenuItem = {
|
||||||
|
label: string;
|
||||||
|
href?: string;
|
||||||
|
icon?: ReactElement;
|
||||||
|
children?: Array<{ label: string; href: string; icon?: ReactElement }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MENUS: MenuItem[] = [
|
||||||
{
|
{
|
||||||
label: 'Dashboard & Set Gacha',
|
label: 'Hackathon',
|
||||||
href: '/dashboard',
|
icon: <StockOutlined className="text-p3" />,
|
||||||
icon: <AppstoreOutlined className="text-[20px]" />,
|
children: [
|
||||||
|
{
|
||||||
|
label: 'Dashboard',
|
||||||
|
href: '/hackathon-dashboard',
|
||||||
|
icon: <AppstoreOutlined className="text-p3" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Users',
|
||||||
|
href: '/hackathon-users',
|
||||||
|
icon: <UserOutlined className="text-p3" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Teams',
|
||||||
|
href: '/hackathon-teams',
|
||||||
|
icon: <UsergroupAddOutlined className="text-p3" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Submissions',
|
||||||
|
href: '/hackathon-submissions',
|
||||||
|
icon: <AuditOutlined className="text-p3" />,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Hackathon Dashboard',
|
label: 'Dimentorin',
|
||||||
href: '/hackathon-dashboard',
|
icon: <ReadOutlined className="text-p3" />,
|
||||||
icon: <AppstoreOutlined className="text-p3" />,
|
children: [
|
||||||
|
{
|
||||||
|
label: 'Dashboard - Dimentorin',
|
||||||
|
href: '/dashboard-dimentorin',
|
||||||
|
icon: <AppstoreOutlined className="text-[20px]" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'User - Dimentorin',
|
||||||
|
href: '/users-dimentorin',
|
||||||
|
icon: <UserSwitchOutlined className="text-[20px]" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Session - Dimentorin',
|
||||||
|
href: '/session-dimentorin',
|
||||||
|
icon: <ScheduleOutlined className="text-[20px]" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Content & Roadmap',
|
||||||
|
href: '/roadmap-dimentorin',
|
||||||
|
icon: <BookOutlined className="text-[20px]" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Feedback & Review',
|
||||||
|
href: '/feedback-review-dimentorin',
|
||||||
|
icon: <CommentOutlined className="text-[20px]" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Settings - Dimentorin',
|
||||||
|
href: '/settings-dimentorin',
|
||||||
|
icon: <SettingOutlined className="text-[20px]" />,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Hackathon Users',
|
label: 'Gacha',
|
||||||
href: '/hackathon-users',
|
icon: <ReloadOutlined className="text-[20px]" />,
|
||||||
icon: <UserOutlined className="text-p3" />,
|
children: [
|
||||||
|
{
|
||||||
|
label: 'Dashboard & Set Gacha',
|
||||||
|
href: '/dashboard',
|
||||||
|
icon: <AppstoreOutlined className="text-[20px]" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Gacha Roll',
|
||||||
|
href: '/gacha-roll',
|
||||||
|
icon: <ReloadOutlined className="text-[20px]" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Validasi Transaksi',
|
||||||
|
href: '/transactions',
|
||||||
|
icon: <AuditOutlined className="text-[20px]" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Data Pengiriman Hadiah',
|
||||||
|
href: '/prizes',
|
||||||
|
icon: <InboxOutlined className="text-[20px]" />,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Hackathon Teams',
|
label: 'Permissions',
|
||||||
href: '/hackathon-teams',
|
href: '/permissions',
|
||||||
icon: <UsergroupAddOutlined className="text-p3" />,
|
icon: <UserSwitchOutlined className="text-[20px]" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Hackathon Submissions',
|
label: 'Roles',
|
||||||
href: '/hackathon-submissions',
|
href: '/roles',
|
||||||
icon: <AuditOutlined className="text-p3" />,
|
icon: <UsergroupAddOutlined className="text-[20px]" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Data Akun',
|
||||||
|
href: '/accounts',
|
||||||
|
icon: <UserOutlined className="text-[20px]" />,
|
||||||
},
|
},
|
||||||
// { label: 'Dashboard - Dimentorin', href: '/dashboard-dimentorin', icon: <AppstoreOutlined className="text-[20px]" /> },
|
|
||||||
// { label: 'Gacha Roll', href: '/gacha-roll', icon: <ReloadOutlined className="text-[20px]" /> },
|
|
||||||
// { label: 'Permissions', href: '/permissions', icon: <UserSwitchOutlined className="text-[20px]" /> },
|
|
||||||
// { label: 'Roles', href: '/roles', icon: <UsergroupAddOutlined className="text-[20px]" /> },
|
|
||||||
// { label: 'Data Akun', href: '/accounts', icon: <UserOutlined className="text-[20px]" /> },
|
|
||||||
// { label: 'Validasi Transaksi', href: '/transactions', icon: <AuditOutlined className="text-[20px]" /> },
|
|
||||||
// { label: 'Data Pengiriman Hadiah', href: '/prizes', icon: <InboxOutlined className="text-[20px]" /> },
|
|
||||||
// { label: 'User - Dimentorin', href: '/users-dimentorin', icon: <UserSwitchOutlined className="text-[20px]" /> },
|
|
||||||
// { label: 'Session - Dimentorin', href: '/session-dimentorin', icon: <ScheduleOutlined className="text-[20px]" /> },
|
|
||||||
// { label: 'Content & Roadmap', href: '/roadmap-dimentorin', icon: <BookOutlined className="text-[20px]" /> },
|
|
||||||
// { label: 'Feedback & Review', href: '/feedback-review-dimentorin', icon: <CommentOutlined className="text-[20px]" /> },
|
|
||||||
// { label: 'Settings - Dimentorin', href: '/settings-dimentorin', icon: <SettingOutlined className="text-[20px]" /> },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export const BackofficeSidebar: FC = (): ReactElement => {
|
interface SidebarProps {
|
||||||
|
isOpen?: boolean;
|
||||||
|
onClose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BackofficeSidebar: FC<SidebarProps> = ({
|
||||||
|
isOpen = false,
|
||||||
|
onClose,
|
||||||
|
}): ReactElement => {
|
||||||
const { signOut } = useSession();
|
const { signOut } = useSession();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({});
|
||||||
const isActive = (path: string) => {
|
const isActive = (path: string) => {
|
||||||
if (path === '/dashboard' && location.pathname === '/dashboard-dimentorin')
|
if (path === '/dashboard' && location.pathname === '/dashboard-dimentorin')
|
||||||
return false;
|
return false;
|
||||||
return location.pathname.includes(path);
|
return location.pathname.includes(path);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
const toggleGroup = (groupLabel: string) => {
|
||||||
<aside className="sticky top-0 left-0 w-[280px] bg-white h-svh py-[60px] px-[28px] shadow-xl flex flex-col justify-between">
|
setOpenGroups((prev) => ({ ...prev, [groupLabel]: !prev[groupLabel] }));
|
||||||
<div className="flex flex-col gap-20 justify-between items-center">
|
};
|
||||||
<img src="/logos/simple.svg" alt="IMPHNEN Logo" className="w-[150px]" />
|
|
||||||
|
const sidebarContent = (
|
||||||
|
<div className="w-[280px] bg-white h-svh py-10 md:py-[60px] px-7 shadow-xl flex flex-col justify-between">
|
||||||
|
<div className="flex flex-col gap-10 md:gap-20 justify-between items-center">
|
||||||
|
<div className="flex justify-between md:justify-center items-center w-full">
|
||||||
|
<img
|
||||||
|
src="/logos/simple.svg"
|
||||||
|
alt="IMPHNEN Logo"
|
||||||
|
className="w-[150px]"
|
||||||
|
/>
|
||||||
|
{onClose && (
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="md:hidden p-2 rounded-lg hover:bg-gray-100 transition-colors cursor-pointer"
|
||||||
|
aria-label="Close sidebar"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5 text-gray-500"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<nav className="flex flex-col gap-4 w-full h-[calc(100svh-20rem)] overflow-y-auto">
|
<nav className="flex flex-col gap-4 w-full h-[calc(100svh-20rem)] overflow-y-auto">
|
||||||
<For data={MENUS}>
|
<For data={MENUS}>
|
||||||
{({ label, href, icon }) => (
|
{(menu) =>
|
||||||
<Link
|
menu.children && menu.children.length > 0 ? (
|
||||||
key={href}
|
<div key={menu.label} className="w-full">
|
||||||
to={href}
|
<button
|
||||||
className={cn(
|
type="button"
|
||||||
'flex items-center justify-items-start gap-3 px-[8px] py-[10px]',
|
onClick={() => toggleGroup(menu.label)}
|
||||||
isActive(href)
|
className={cn(
|
||||||
? 'bg-primary-500 text-white rounded-md'
|
'flex items-center justify-between w-full gap-3 px-2 py-2.5 rounded-md cursor-pointer',
|
||||||
: 'text-gray-700 hover:bg-gray-100'
|
openGroups[menu.label]
|
||||||
)}
|
? 'bg-primary-500 text-white'
|
||||||
>
|
: 'text-gray-700 hover:bg-gray-100'
|
||||||
{icon}
|
)}
|
||||||
<span className="text-p3 font-medium">{label}</span>
|
>
|
||||||
</Link>
|
<div className="flex items-center gap-3">
|
||||||
)}
|
{menu.icon}
|
||||||
|
<span className="text-p3 font-medium">{menu.label}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-label2">
|
||||||
|
{openGroups[menu.label] ? (
|
||||||
|
<DownOutlined className="text-label1" />
|
||||||
|
) : (
|
||||||
|
<RightOutlined className="text-label1" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{openGroups[menu.label] && (
|
||||||
|
<div className="mt-2 ml-6 flex flex-col gap-2">
|
||||||
|
{menu.children.map((child) => (
|
||||||
|
<Link
|
||||||
|
key={child.href}
|
||||||
|
to={child.href}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 px-2 py-2.5 rounded-md',
|
||||||
|
isActive(child.href)
|
||||||
|
? 'bg-primary-100 text-primary-700'
|
||||||
|
: 'text-gray-700 hover:bg-gray-100'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{child.icon}
|
||||||
|
<span className="text-label1 font-medium">
|
||||||
|
{child.label}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
key={menu.href ?? menu.label}
|
||||||
|
to={menu.href ?? '#'}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-items-start gap-3 px-2 py-2.5',
|
||||||
|
menu.href && isActive(menu.href)
|
||||||
|
? 'bg-primary-500 text-white rounded-md'
|
||||||
|
: 'text-gray-700 hover:bg-gray-100'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{menu.icon}
|
||||||
|
<span className="text-p3 font-medium">{menu.label}</span>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
</For>
|
</For>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
@@ -97,12 +263,36 @@ export const BackofficeSidebar: FC = (): ReactElement => {
|
|||||||
<Button
|
<Button
|
||||||
onClick={signOut}
|
onClick={signOut}
|
||||||
variant="text"
|
variant="text"
|
||||||
className="items-start justify-start gap-3 px-[8px] py-[10px] text-gray-700 hover:text-red-500 transition-colors w-full"
|
className="items-start justify-start gap-3 px-2 py-2.5 text-gray-700 hover:text-red-500 transition-colors w-full"
|
||||||
>
|
>
|
||||||
<LogoutOutlined className="text-[20px]" />
|
<LogoutOutlined className="text-p3" />
|
||||||
<span className="text-p3 font-medium">Log Out</span>
|
<span className="text-p3 font-medium">Log Out</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Desktop Sidebar - visible on lg+, sticky */}
|
||||||
|
<div className="hidden md:block sticky top-0 h-screen overflow-y-auto">
|
||||||
|
{sidebarContent}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Sidebar - overlay */}
|
||||||
|
{isOpen && (
|
||||||
|
<div className="md:hidden fixed inset-0 z-50">
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50 transition-opacity"
|
||||||
|
onClick={onClose}
|
||||||
|
/>
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="fixed inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out">
|
||||||
|
{sidebarContent}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user