- 공통 컴포넌트 11개 생성 (PageHeader, StatusBadge, CategoryBadge, FilterDropdown, DateRangeInput, SearchInput, FilterResetButton, Pagination, EmptyState, CopyButton, PlatformBadge) - AppHeader: 다단계 breadcrumb, 알림 드롭다운 구현 - AppLayout: 푸터 개인정보처리방침/이용약관 모달 추가 - AppSidebar: 이메일 폰트 자동 축소 (clamp) - SignupPage: 모달 닫기 버튼 x 아이콘으로 통일 - Suspense fallback SVG 스피너로 변경 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
194 lines
6.6 KiB
TypeScript
194 lines
6.6 KiB
TypeScript
import { useState } from "react";
|
|
import { Link, NavLink, useLocation } from "react-router-dom";
|
|
import { useAuthStore } from "@/stores/authStore";
|
|
|
|
/** 단일 메뉴 항목 */
|
|
interface NavItem {
|
|
to: string;
|
|
icon: string;
|
|
label: string;
|
|
}
|
|
|
|
/** 드롭다운 메뉴 그룹 */
|
|
interface NavGroup {
|
|
icon: string;
|
|
label: string;
|
|
children: { to: string; label: string }[];
|
|
}
|
|
|
|
type NavEntry =
|
|
| { type: "item"; item: NavItem }
|
|
| { type: "group"; group: NavGroup }
|
|
| { type: "separator" };
|
|
|
|
/** 네비게이션 메뉴 정의 (가이드라인 기준) */
|
|
const navEntries: NavEntry[] = [
|
|
{ type: "item", item: { to: "/dashboard", icon: "dashboard", label: "대시보드" } },
|
|
{ type: "item", item: { to: "/services", icon: "manage_accounts", label: "서비스 관리" } },
|
|
{ type: "item", item: { to: "/messages", icon: "mail", label: "메시지 관리" } },
|
|
{ type: "item", item: { to: "/devices", icon: "devices", label: "기기 관리" } },
|
|
{
|
|
type: "group",
|
|
group: {
|
|
icon: "send",
|
|
label: "발송 관리",
|
|
children: [
|
|
{ to: "/statistics", label: "발송 통계" },
|
|
{ to: "/statistics/history", label: "발송 이력" },
|
|
],
|
|
},
|
|
},
|
|
{ type: "item", item: { to: "/tags", icon: "sell", label: "태그 관리" } },
|
|
{ type: "separator" },
|
|
{
|
|
type: "group",
|
|
group: {
|
|
icon: "settings",
|
|
label: "설정",
|
|
children: [
|
|
{ to: "/settings/notifications", label: "알림" },
|
|
{ to: "/settings", label: "마이 페이지" },
|
|
],
|
|
},
|
|
},
|
|
];
|
|
|
|
/** 단일 메뉴 아이템 */
|
|
function SidebarItem({ item }: { item: NavItem }) {
|
|
const location = useLocation();
|
|
const isActive = location.pathname === item.to || location.pathname.startsWith(item.to + "/");
|
|
|
|
return (
|
|
<NavLink
|
|
to={item.to}
|
|
className={`flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors group ${
|
|
isActive
|
|
? "bg-white/10 text-white"
|
|
: "text-gray-400 hover:bg-white/5 hover:text-white"
|
|
}`}
|
|
>
|
|
<span className="material-symbols-outlined text-xl transition-colors group-hover:text-white">
|
|
{item.icon}
|
|
</span>
|
|
<span className="flex-1">{item.label}</span>
|
|
</NavLink>
|
|
);
|
|
}
|
|
|
|
/** 드롭다운 메뉴 그룹 */
|
|
function SidebarGroup({ group }: { group: NavGroup }) {
|
|
const location = useLocation();
|
|
const isChildActive = group.children.some(
|
|
(child) => location.pathname === child.to || location.pathname.startsWith(child.to + "/"),
|
|
);
|
|
const [isOpen, setIsOpen] = useState(isChildActive);
|
|
|
|
return (
|
|
<div>
|
|
<button
|
|
onClick={() => setIsOpen(!isOpen)}
|
|
className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors group ${
|
|
isChildActive
|
|
? "bg-white/10 text-white"
|
|
: "text-gray-400 hover:bg-white/5 hover:text-white"
|
|
}`}
|
|
>
|
|
<span className="material-symbols-outlined text-xl transition-colors group-hover:text-white">
|
|
{group.icon}
|
|
</span>
|
|
<span className="flex-1 text-left">{group.label}</span>
|
|
<span
|
|
className="material-symbols-outlined text-base transition-transform"
|
|
style={{ transform: isOpen ? "rotate(180deg)" : "rotate(0deg)" }}
|
|
>
|
|
keyboard_arrow_down
|
|
</span>
|
|
</button>
|
|
|
|
{isOpen && (
|
|
<div className="mt-1 flex flex-col gap-1 pl-9">
|
|
{group.children.map((child) => {
|
|
const isActive = location.pathname === child.to;
|
|
return (
|
|
<NavLink
|
|
key={child.to}
|
|
to={child.to}
|
|
className={`flex items-center gap-2 rounded-lg px-3 py-1.5 text-sm transition-colors ${
|
|
isActive
|
|
? "bg-white/5 text-primary"
|
|
: "text-gray-500 hover:bg-white/5 hover:text-gray-300"
|
|
}`}
|
|
>
|
|
<span
|
|
className={`h-1.5 w-1.5 rounded-full ${
|
|
isActive ? "bg-primary" : "bg-gray-500"
|
|
}`}
|
|
/>
|
|
<span>{child.label}</span>
|
|
</NavLink>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function AppSidebar() {
|
|
const { user, logout } = useAuthStore();
|
|
|
|
return (
|
|
<aside className="fixed left-0 top-0 z-50 flex h-screen w-64 flex-shrink-0 flex-col justify-between border-r border-gray-800 bg-sidebar text-white">
|
|
<div className="flex flex-col gap-6 p-6">
|
|
{/* 로고 → 홈 이동 */}
|
|
<Link to="/" className="flex items-center gap-3">
|
|
<div className="flex size-10 items-center justify-center rounded-lg border border-primary/30 bg-primary/20">
|
|
<span className="material-symbols-outlined text-2xl text-white">grid_view</span>
|
|
</div>
|
|
<div className="flex flex-col">
|
|
<h1 className="text-base font-bold leading-none tracking-tight text-white">SPMS</h1>
|
|
<p className="mt-1 text-xs font-normal text-gray-400">Admin Console</p>
|
|
</div>
|
|
</Link>
|
|
|
|
{/* 네비게이션 */}
|
|
<nav className="flex flex-col gap-1">
|
|
{navEntries.map((entry, idx) => {
|
|
if (entry.type === "separator") {
|
|
return <div key={idx} className="mx-3 my-2 h-px bg-gray-800" />;
|
|
}
|
|
if (entry.type === "item") {
|
|
return <SidebarItem key={entry.item.to} item={entry.item} />;
|
|
}
|
|
return <SidebarGroup key={entry.group.label} group={entry.group} />;
|
|
})}
|
|
</nav>
|
|
</div>
|
|
|
|
{/* 사용자 프로필 */}
|
|
<div className="border-t border-gray-800 p-4">
|
|
<div className="flex items-center gap-3 px-2">
|
|
<div className="flex size-9 items-center justify-center rounded-full bg-blue-600 text-xs font-bold tracking-wide text-white shadow-sm">
|
|
{user?.name?.slice(0, 2)?.toUpperCase() ?? "AU"}
|
|
</div>
|
|
<div className="flex-1 overflow-hidden" style={{ containerType: "inline-size" }}>
|
|
<p className="truncate text-sm font-medium text-white">
|
|
{user?.name ?? "Admin User"}
|
|
</p>
|
|
<p className="text-gray-400" style={{ fontSize: "clamp(9px, 2.5cqi, 12px)" }}>
|
|
{user?.email ?? "admin@spms.com"}
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={logout}
|
|
className="rounded p-1 text-gray-400 transition-colors hover:bg-white/5 hover:text-white"
|
|
title="로그아웃"
|
|
>
|
|
<span className="material-symbols-outlined text-[20px]">logout</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
);
|
|
}
|