import { useEffect, useRef, useState } from "react"; import { NavLink, useLocation } from "react-router-dom"; import { Icon, type IconName } from "../ui/Icon"; import type { Role } from "../../lib/auth"; export interface NavItem { to: string; label: string; icon: IconName; /** NavLink end (정확 매칭) */ end?: boolean; /** 데스크톱과 전체 메뉴에서 항목의 역할을 설명하는 그룹 */ group?: string; } /** * 역할별 네비 항목. 권한 없는 항목은 애초에 목록에 없음(DOM 제거, §6.3). * disabled 회색 처리 안 함. */ const NAV_BY_ROLE: Record = { learner: [ { to: "/learn", label: "학습자 홈", icon: "home", end: true, group: "학습" }, { to: "/learn/practice", label: "상담 연습", icon: "session", group: "학습" }, { to: "/learn/history", label: "학습 기록", icon: "review", group: "학습" }, { to: "/settings", label: "설정", icon: "settings", group: "설정" }, ], teacher: [ { to: "/teach", label: "교수자 홈", icon: "users", end: true, group: "교수" }, { to: "/teach/analysis", label: "학생 분석", icon: "review", group: "교수" }, { to: "/teach/supervision", label: "감독·연구", icon: "shield", group: "교수" }, { to: "/teach/personas", label: "내담자 설정", icon: "review", group: "교수" }, { to: "/settings", label: "설정", icon: "settings", group: "설정" }, ], admin: [ { to: "/admin", label: "운영 홈", icon: "shield", end: true, group: "운영" }, { to: "/admin/ai", label: "AI 운영", icon: "session", group: "운영" }, { to: "/admin/continuous-improvement", label: "지속 개선", icon: "refresh", group: "운영" }, { to: "/admin/users", label: "사용자 관리", icon: "users", group: "운영" }, { to: "/admin/access", label: "접근 관리", icon: "settings", group: "운영" }, { to: "/admin/tickets", label: "지원 요청", icon: "review", group: "운영" }, { to: "/teach", label: "교수자 홈", icon: "users", end: true, group: "교수" }, { to: "/teach/analysis", label: "학생 분석", icon: "review", group: "교수" }, { to: "/teach/supervision", label: "감독·연구", icon: "shield", group: "교수" }, { to: "/teach/personas", label: "내담자 설정", icon: "review", group: "교수" }, { to: "/learn", label: "학습자 홈", icon: "home", end: true, group: "학습" }, { to: "/learn/practice", label: "상담 연습", icon: "session", group: "학습" }, { to: "/learn/history", label: "학습 기록", icon: "review", group: "학습" }, { to: "/settings", label: "설정", icon: "settings", group: "설정" }, ], }; const MOBILE_PRIMARY_PATHS: Record = { learner: ["/learn", "/learn/practice", "/learn/history"], teacher: ["/teach", "/teach/analysis", "/teach/supervision"], admin: ["/admin", "/admin/ai", "/admin/users"], }; const ADMIN_ENTRY: NavItem = { to: "/admin", label: "운영 홈", icon: "shield", end: true, group: "운영", }; export function navItemsFor(role: Role, showAdminEntry = false): NavItem[] { const items = NAV_BY_ROLE[role]; if (role === "admin" || !showAdminEntry) return items; return [ADMIN_ENTRY, ...items]; } export interface SidebarProps { role: Role; /** 기본 역할 화면에서도 관리자 권한 사용자가 운영 콘솔로 돌아갈 수 있게 한다. */ showAdminEntry?: boolean; /** 이전 호출부 호환용 기본 그룹 라벨 */ groupLabel?: string; } function isPathActive(pathname: string, item: NavItem): boolean { if (item.end) return pathname === item.to; return pathname === item.to || pathname.startsWith(`${item.to}/`); } function groupsFor(items: NavItem[], fallbackLabel: string): Array<[string, NavItem[]]> { const groups = new Map(); for (const item of items) { const group = item.group ?? fallbackLabel; groups.set(group, [...(groups.get(group) ?? []), item]); } return [...groups.entries()]; } /** 좌측 네비와 좁은 화면의 하단 내비. */ export function Sidebar({ role, showAdminEntry = false, groupLabel = "메뉴" }: SidebarProps) { const items = navItemsFor(role, showAdminEntry); const groups = groupsFor(items, groupLabel); const location = useLocation(); const dialogRef = useRef(null); const menuButtonRef = useRef(null); const closeButtonRef = useRef(null); const [menuOpen, setMenuOpen] = useState(false); const primaryPaths = MOBILE_PRIMARY_PATHS[role]; const isMoreActive = !items.some( (item) => primaryPaths.includes(item.to) && isPathActive(location.pathname, item), ); const closeMenu = () => { const dialog = dialogRef.current; if (dialog?.open) dialog.close(); }; const openMenu = () => { const dialog = dialogRef.current; if (!dialog || dialog.open) return; dialog.showModal(); setMenuOpen(true); window.requestAnimationFrame(() => closeButtonRef.current?.focus()); }; useEffect(() => { closeMenu(); }, [location.pathname]); useEffect(() => { const mobileViewport = window.matchMedia("(max-width: 720px)"); const closeOutsideMobile = () => { if (!mobileViewport.matches && dialogRef.current?.open) dialogRef.current.close(); }; mobileViewport.addEventListener("change", closeOutsideMobile); return () => mobileViewport.removeEventListener("change", closeOutsideMobile); }, []); return ( ); }