vignette/apps/web/src/components/shell/Sidebar.tsx

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>
);
}