240 lines
8.9 KiB
TypeScript
240 lines
8.9 KiB
TypeScript
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<Role, NavItem[]> = {
|
|
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<Role, string[]> = {
|
|
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<string, NavItem[]>();
|
|
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<HTMLDialogElement | null>(null);
|
|
const menuButtonRef = useRef<HTMLButtonElement | null>(null);
|
|
const closeButtonRef = useRef<HTMLButtonElement | null>(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 (
|
|
<nav className="vg-nav" aria-label="주 메뉴">
|
|
{groups.map(([group, groupItems]) => (
|
|
<section className="vg-nav__group" key={group} aria-label={`${group} 메뉴`}>
|
|
<span className="vg-nav__label">{group}</span>
|
|
{groupItems.map((item) => {
|
|
const isPrimary = primaryPaths.includes(item.to);
|
|
return (
|
|
<NavLink
|
|
key={item.to}
|
|
to={item.to}
|
|
end={item.end}
|
|
onClick={closeMenu}
|
|
className={({ isActive }) =>
|
|
"vg-nav__item" +
|
|
(isActive ? " is-active" : "") +
|
|
(isPrimary ? " vg-nav__item--mobile-primary" : "")
|
|
}
|
|
>
|
|
<span className="vg-nav__ic">
|
|
<Icon name={item.icon} size={18} strokeWidth={1.75} />
|
|
</span>
|
|
<span className="vg-nav__txt">{item.label}</span>
|
|
</NavLink>
|
|
);
|
|
})}
|
|
</section>
|
|
))}
|
|
|
|
<button
|
|
ref={menuButtonRef}
|
|
type="button"
|
|
className={"vg-nav__more" + (isMoreActive ? " is-active" : "")}
|
|
aria-label="전체 메뉴"
|
|
aria-expanded={menuOpen}
|
|
aria-controls="vg-all-menu-dialog"
|
|
onClick={openMenu}
|
|
>
|
|
<span className="vg-nav__ic">
|
|
<Icon name="menu" size={20} strokeWidth={1.75} />
|
|
</span>
|
|
<span className="vg-nav__txt">전체 메뉴</span>
|
|
</button>
|
|
|
|
<dialog
|
|
id="vg-all-menu-dialog"
|
|
ref={dialogRef}
|
|
className="vg-nav__dialog"
|
|
aria-labelledby="vg-all-menu-title"
|
|
onCancel={(event) => {
|
|
event.preventDefault();
|
|
closeMenu();
|
|
}}
|
|
onClose={() => {
|
|
setMenuOpen(false);
|
|
const trigger = menuButtonRef.current;
|
|
if (trigger && window.getComputedStyle(trigger).display !== "none") trigger.focus();
|
|
}}
|
|
>
|
|
<div className="vg-nav__dialog-head">
|
|
<div>
|
|
<p className="vg-nav__dialog-kicker">현재 역할에서 이동</p>
|
|
<h2 id="vg-all-menu-title">전체 메뉴</h2>
|
|
</div>
|
|
<button
|
|
ref={closeButtonRef}
|
|
type="button"
|
|
className="vg-nav__dialog-close"
|
|
aria-label="전체 메뉴 닫기"
|
|
onClick={closeMenu}
|
|
>
|
|
<Icon name="x" size={20} />
|
|
</button>
|
|
</div>
|
|
<div className="vg-nav__dialog-body">
|
|
{groups.map(([group, groupItems]) => (
|
|
<section className="vg-nav__dialog-group" key={group} aria-label={`${group} 메뉴`}>
|
|
<h3>{group}</h3>
|
|
{groupItems.map((item) => (
|
|
<NavLink
|
|
key={item.to}
|
|
to={item.to}
|
|
end={item.end}
|
|
onClick={closeMenu}
|
|
className={({ isActive }) =>
|
|
"vg-nav__dialog-item" + (isActive ? " is-active" : "")
|
|
}
|
|
>
|
|
<Icon name={item.icon} size={18} strokeWidth={1.75} />
|
|
<span>{item.label}</span>
|
|
</NavLink>
|
|
))}
|
|
</section>
|
|
))}
|
|
</div>
|
|
</dialog>
|
|
|
|
<span className="vg-nav__spacer" />
|
|
|
|
<div className="vg-nav__foot">
|
|
<p className="vg-nav__ethic">
|
|
교육용 시뮬레이션 도구입니다. 실제 치료·진단을 대체하지 않습니다.
|
|
</p>
|
|
</div>
|
|
</nav>
|
|
);
|
|
}
|