모바일 메뉴와 주요 화면 UX 개선 및 시각 검수
This commit is contained in:
parent
89b5093ec7
commit
e8b770e4d9
50 changed files with 3189 additions and 457 deletions
|
|
@ -1,4 +1,5 @@
|
|||
import { NavLink } from "react-router-dom";
|
||||
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";
|
||||
|
||||
|
|
@ -8,6 +9,8 @@ export interface NavItem {
|
|||
icon: IconName;
|
||||
/** NavLink end (정확 매칭) */
|
||||
end?: boolean;
|
||||
/** 데스크톱과 전체 메뉴에서 항목의 역할을 설명하는 그룹 */
|
||||
group?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -16,41 +19,48 @@ export interface NavItem {
|
|||
*/
|
||||
const NAV_BY_ROLE: Record<Role, NavItem[]> = {
|
||||
learner: [
|
||||
{ to: "/learn", label: "대시보드", icon: "home", end: true },
|
||||
{ to: "/learn/practice", label: "학습", icon: "session" },
|
||||
{ to: "/learn/history", label: "기록", icon: "review" },
|
||||
{ to: "/settings", label: "설정", icon: "settings" },
|
||||
{ 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 },
|
||||
{ to: "/teach/analysis", label: "학생 분석", icon: "review" },
|
||||
{ to: "/teach/supervision", label: "감독·연구", icon: "shield" },
|
||||
{ to: "/teach/personas", label: "페르소나", icon: "review" },
|
||||
{ to: "/settings", label: "설정", icon: "settings" },
|
||||
{ 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 },
|
||||
{ to: "/admin/ai", label: "AI 운영", icon: "session" },
|
||||
{ to: "/admin/continuous-improvement", label: "지속 개선", icon: "refresh" },
|
||||
{ to: "/admin/users", label: "사용자", icon: "users" },
|
||||
{ to: "/admin/access", label: "권한", icon: "settings" },
|
||||
{ to: "/admin/tickets", label: "티켓", icon: "review" },
|
||||
{ to: "/teach", label: "교수 콘솔", icon: "users", end: true },
|
||||
{ to: "/teach/analysis", label: "학생 분석", icon: "review" },
|
||||
{ to: "/teach/supervision", label: "감독·연구", icon: "shield" },
|
||||
{ to: "/teach/personas", label: "페르소나", icon: "review" },
|
||||
{ to: "/learn", label: "학습자 홈", icon: "home", end: true },
|
||||
{ to: "/learn/practice", label: "학습", icon: "session" },
|
||||
{ to: "/learn/history", label: "기록", icon: "review" },
|
||||
{ to: "/settings", label: "설정", icon: "settings" },
|
||||
{ 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: "운영 콘솔",
|
||||
label: "운영 홈",
|
||||
icon: "shield",
|
||||
end: true,
|
||||
group: "운영",
|
||||
};
|
||||
|
||||
export function navItemsFor(role: Role, showAdminEntry = false): NavItem[] {
|
||||
|
|
@ -63,32 +73,161 @@ export interface SidebarProps {
|
|||
role: Role;
|
||||
/** 기본 역할 화면에서도 관리자 권한 사용자가 운영 콘솔로 돌아갈 수 있게 한다. */
|
||||
showAdminEntry?: boolean;
|
||||
/** 상단 그룹 라벨 (기본 "메뉴") */
|
||||
/** 이전 호출부 호환용 기본 그룹 라벨 */
|
||||
groupLabel?: string;
|
||||
}
|
||||
|
||||
/** 좌측 네비 (240px). 활성 = accent-tint 알약. 강조바 금지. §6.3 */
|
||||
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="주 메뉴">
|
||||
<span className="vg-nav__label">{groupLabel}</span>
|
||||
{items.map((item) => (
|
||||
<NavLink
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
className={({ isActive }) =>
|
||||
"vg-nav__item" + (isActive ? " is-active" : "")
|
||||
}
|
||||
>
|
||||
<span className="vg-nav__ic">
|
||||
<Icon name={item.icon} size={18} strokeWidth={1.75} />
|
||||
</span>
|
||||
<span className="vg-nav__txt">{item.label}</span>
|
||||
</NavLink>
|
||||
{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">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue