167 lines
5.6 KiB
TypeScript
167 lines
5.6 KiB
TypeScript
import { useState } from 'react';
|
||
import { ADMIN_MENU } from './adminMenuConfig';
|
||
import type { AdminMenuItem } from './adminMenuConfig';
|
||
|
||
interface AdminSidebarProps {
|
||
activeMenu: string;
|
||
onSelect: (id: string) => void;
|
||
}
|
||
|
||
/** 관리자 좌측 사이드바 — 9-섹션 아코디언 */
|
||
const AdminSidebar = ({ activeMenu, onSelect }: AdminSidebarProps) => {
|
||
const [expanded, setExpanded] = useState<Set<string>>(() => {
|
||
// 초기: 첫 번째 섹션 열기
|
||
const init = new Set<string>();
|
||
if (ADMIN_MENU.length > 0) init.add(ADMIN_MENU[0].id);
|
||
return init;
|
||
});
|
||
|
||
const toggle = (id: string) => {
|
||
setExpanded((prev) => {
|
||
const next = new Set(prev);
|
||
if (next.has(id)) next.delete(id);
|
||
else next.add(id);
|
||
return next;
|
||
});
|
||
};
|
||
|
||
/** 재귀적으로 메뉴 아이템이 activeMenu를 포함하는지 확인 */
|
||
const containsActive = (item: AdminMenuItem): boolean => {
|
||
if (item.id === activeMenu) return true;
|
||
return item.children?.some((c) => containsActive(c)) ?? false;
|
||
};
|
||
|
||
const renderLeaf = (item: AdminMenuItem, depth: number) => {
|
||
const isActive = item.id === activeMenu;
|
||
return (
|
||
<button
|
||
key={item.id}
|
||
onClick={() => onSelect(item.id)}
|
||
className="w-full text-left px-3 py-1.5 text-label-2 font-korean transition-colors cursor-pointer rounded-[3px]"
|
||
style={{
|
||
paddingLeft: `${12 + depth * 14}px`,
|
||
background: isActive ? 'rgba(6,182,212,.12)' : 'transparent',
|
||
color: isActive ? 'var(--color-accent)' : 'var(--fg-sub)',
|
||
fontWeight: isActive ? 600 : 400,
|
||
}}
|
||
>
|
||
{item.label}
|
||
</button>
|
||
);
|
||
};
|
||
|
||
const renderGroup = (item: AdminMenuItem, depth: number) => {
|
||
const isOpen = expanded.has(item.id);
|
||
const hasActiveChild = containsActive(item);
|
||
|
||
return (
|
||
<div key={item.id}>
|
||
<button
|
||
onClick={() => {
|
||
toggle(item.id);
|
||
// 그룹 자체에 children의 첫 leaf가 있으면 자동 선택
|
||
if (!isOpen && item.children) {
|
||
const firstLeaf = findFirstLeaf(item.children);
|
||
if (firstLeaf) onSelect(firstLeaf.id);
|
||
}
|
||
}}
|
||
className="w-full flex items-center justify-between px-3 py-1.5 text-label-2 font-korean transition-colors cursor-pointer rounded-[3px]"
|
||
style={{
|
||
paddingLeft: `${12 + depth * 14}px`,
|
||
color: hasActiveChild ? 'var(--color-accent)' : 'var(--fg-sub)',
|
||
fontWeight: hasActiveChild ? 600 : 400,
|
||
}}
|
||
>
|
||
<span>{item.label}</span>
|
||
<span
|
||
className="text-caption text-fg-disabled transition-transform"
|
||
style={{ transform: isOpen ? 'rotate(90deg)' : 'rotate(0)' }}
|
||
>
|
||
▶
|
||
</span>
|
||
</button>
|
||
{isOpen && item.children && (
|
||
<div className="flex flex-col gap-px">
|
||
{item.children.map((child) => renderItem(child, depth + 1))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const renderItem = (item: AdminMenuItem, depth: number) => {
|
||
if (item.children && item.children.length > 0) {
|
||
return renderGroup(item, depth);
|
||
}
|
||
return renderLeaf(item, depth);
|
||
};
|
||
|
||
return (
|
||
<div
|
||
className="flex flex-col bg-bg-surface border-r border-stroke overflow-y-auto shrink-0"
|
||
style={{
|
||
width: 240,
|
||
scrollbarWidth: 'thin',
|
||
scrollbarColor: 'var(--stroke-light) transparent',
|
||
}}
|
||
>
|
||
{/* 헤더 */}
|
||
<div className="px-4 py-3 border-b border-stroke bg-bg-elevated shrink-0">
|
||
<div className="text-caption font-bold text-fg font-korean flex items-center gap-1.5">
|
||
<span>⚙️</span> 관리자 설정
|
||
</div>
|
||
</div>
|
||
|
||
{/* 메뉴 목록 */}
|
||
<div className="flex flex-col gap-0.5 p-2">
|
||
{ADMIN_MENU.map((section) => {
|
||
const isOpen = expanded.has(section.id);
|
||
const hasActiveChild = containsActive(section);
|
||
|
||
return (
|
||
<div key={section.id} className="mb-0.5">
|
||
{/* 섹션 헤더 */}
|
||
<button
|
||
onClick={() => toggle(section.id)}
|
||
className="w-full flex items-center gap-2 px-3 py-2 rounded-md text-label-2 font-bold font-korean transition-colors cursor-pointer"
|
||
style={{
|
||
background: hasActiveChild ? 'rgba(6,182,212,.08)' : 'transparent',
|
||
color: hasActiveChild ? 'var(--color-accent)' : 'var(--fg-default)',
|
||
}}
|
||
>
|
||
<span className="text-body-2">{section.icon}</span>
|
||
<span className="flex-1 text-left">{section.label}</span>
|
||
<span
|
||
className="text-caption text-fg-disabled transition-transform"
|
||
style={{ transform: isOpen ? 'rotate(90deg)' : 'rotate(0)' }}
|
||
>
|
||
▶
|
||
</span>
|
||
</button>
|
||
|
||
{/* 하위 메뉴 */}
|
||
{isOpen && section.children && (
|
||
<div className="flex flex-col gap-px mt-0.5 ml-1">
|
||
{section.children.map((child) => renderItem(child, 1))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
/** children 중 첫 번째 leaf 노드를 찾는다 */
|
||
function findFirstLeaf(items: AdminMenuItem[]): AdminMenuItem | null {
|
||
for (const item of items) {
|
||
if (!item.children || item.children.length === 0) return item;
|
||
const found = findFirstLeaf(item.children);
|
||
if (found) return found;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
export default AdminSidebar;
|