@farmzone/fz-template-react 0.0.1 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/create.js +10 -4
- package/package.json +1 -1
- package/template/.env.example +5 -0
- package/template/eslint.config.js +4 -1
- package/template/index.css +15 -2
- package/template/index.html +1 -1
- package/template/package.json +55 -41
- package/template/public/favicon.ico +0 -0
- package/template/public/mockServiceWorker.js +349 -0
- package/template/src/app/App.tsx +2 -0
- package/template/src/app/api/api.ts +178 -0
- package/template/src/app/api/queries.ts +321 -0
- package/template/src/app/api/queryKey.ts +7 -0
- package/template/src/app/api/token.ts +7 -0
- package/template/src/app/layout/Layout.tsx +33 -16
- package/template/src/app/layout/ListContents.tsx +9 -0
- package/template/src/app/layout/ListHeader.tsx +41 -0
- package/template/src/app/layout/MultiTabNav.tsx +101 -0
- package/template/src/app/layout/Sidebar.tsx +33 -53
- package/template/src/app/layout/UserInfo.tsx +94 -0
- package/template/src/app/layout/menu.ts +46 -21
- package/template/src/app/layout/tabSwitchStore.ts +11 -0
- package/template/src/app/router/Router.tsx +54 -28
- package/template/src/app/store/index.ts +26 -0
- package/template/src/index.tsx +21 -12
- package/template/src/mocks/browser.ts +17 -0
- package/template/src/mocks/handlers.ts +43 -0
- package/template/src/mocks/scenarios.ts +57 -0
- package/template/src/pages/dashboard/index.tsx +541 -8
- package/template/src/pages/error/Error.tsx +29 -17
- package/template/src/pages/error/NotFound.tsx +27 -17
- package/template/src/pages/login/index.tsx +317 -0
- package/template/src/pages/post/PostFormModal.tsx +128 -0
- package/template/src/pages/post/detail/index.tsx +548 -0
- package/template/src/pages/post/index.tsx +267 -0
- package/template/src/pages/sample/SampleFormModal.tsx +77 -0
- package/template/src/pages/sample/detail/index.tsx +424 -0
- package/template/src/pages/sample/index.tsx +269 -0
- package/template/src/pages/system/log/index.tsx +173 -0
- package/template/src/pages/user/config/columns.tsx +109 -0
- package/template/src/pages/user/config/schema.ts +54 -0
- package/template/src/pages/user/index.tsx +641 -0
- package/template/src/shared/components/CommentInput.tsx +243 -0
- package/template/src/shared/components/FilePreviewCard.tsx +70 -0
- package/template/src/shared/config/text.ts +27 -0
- package/template/src/shared/config/type.ts +40 -0
- package/template/src/shared/utils/format.ts +11 -0
- package/template/src/types/auth.ts +10 -0
- package/template/src/types/comment.ts +33 -0
- package/template/src/types/common.ts +19 -0
- package/template/src/types/dashboard.ts +53 -0
- package/template/src/types/index.ts +16 -0
- package/template/src/types/log.ts +21 -0
- package/template/src/types/post.ts +32 -0
- package/template/src/types/sample.ts +28 -0
- package/template/src/types/user.ts +51 -0
- package/template/src/vite-env.d.ts +10 -0
- package/template/gitignore +0 -32
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import { useLocation, useNavigate } from "react-router";
|
|
3
|
+
import { MultiTab, useMultiTabStore } from "@farmzone/fz-react-ui";
|
|
4
|
+
|
|
5
|
+
import { findTabInfoForPath } from "./menu";
|
|
6
|
+
import { useTabSwitchStore } from "./tabSwitchStore";
|
|
7
|
+
|
|
8
|
+
const DASHBOARD_TAB = { basePath: "/", label: "대시보드" } as const;
|
|
9
|
+
|
|
10
|
+
export default function MultiTabNav() {
|
|
11
|
+
const { tabs, activeTabId, hasHydrated, openTab, addTab, switchToTab, closeTab, updateCurrentPath } =
|
|
12
|
+
useMultiTabStore();
|
|
13
|
+
const { commitTabSwitch } = useTabSwitchStore();
|
|
14
|
+
const navigate = useNavigate();
|
|
15
|
+
const location = useLocation();
|
|
16
|
+
const { pathname } = location;
|
|
17
|
+
const pendingSwitch = useRef(false);
|
|
18
|
+
|
|
19
|
+
// hydration 완료 후 최초 1회: 탭 없으면 대시보드 생성, 있으면 활성 탭 경로 복원
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
if (!hasHydrated) return;
|
|
22
|
+
if (tabs.length === 0) {
|
|
23
|
+
openTab(DASHBOARD_TAB.basePath, DASHBOARD_TAB.label);
|
|
24
|
+
navigate(DASHBOARD_TAB.basePath);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const activeTab = tabs.find((t) => t.id === activeTabId);
|
|
28
|
+
if (activeTab && pathname !== activeTab.currentPath) {
|
|
29
|
+
navigate(activeTab.currentPath, { replace: true });
|
|
30
|
+
}
|
|
31
|
+
// hasHydrated 변경 시 1회만 실행
|
|
32
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
33
|
+
}, [hasHydrated]);
|
|
34
|
+
|
|
35
|
+
// 탭 전환 후 URL이 실제로 바뀌면 commitTabSwitch 호출 → Outlet 강제 리마운트
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (!pendingSwitch.current) return;
|
|
38
|
+
pendingSwitch.current = false;
|
|
39
|
+
commitTabSwitch();
|
|
40
|
+
// location.key 변경 시 실행 (실제 URL 변경 감지)
|
|
41
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
42
|
+
}, [location.key]);
|
|
43
|
+
|
|
44
|
+
// pathname 변경 시: 탭 소유권 확인 → auto-switch / auto-create / currentPath 동기화
|
|
45
|
+
// getState() 사용으로 stale closure 방지
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (!hasHydrated) return;
|
|
48
|
+
const { tabs: currentTabs, activeTabId: currentActiveTabId } = useMultiTabStore.getState();
|
|
49
|
+
|
|
50
|
+
const ownerTab = currentTabs.find(
|
|
51
|
+
(t) => pathname === t.basePath || pathname.startsWith(`${t.basePath}/`),
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
if (ownerTab) {
|
|
55
|
+
if (ownerTab.id !== currentActiveTabId) {
|
|
56
|
+
// Sidebar 등에서 다른 탭 도메인으로 직접 navigate된 경우 — 활성 탭 전환
|
|
57
|
+
const fromTab = currentTabs.find((t) => t.id === currentActiveTabId);
|
|
58
|
+
useMultiTabStore.getState().switchToTab(ownerTab.id, fromTab?.currentPath ?? pathname);
|
|
59
|
+
}
|
|
60
|
+
if (ownerTab.currentPath !== pathname) {
|
|
61
|
+
updateCurrentPath(ownerTab.id, pathname);
|
|
62
|
+
}
|
|
63
|
+
} else {
|
|
64
|
+
// 어떤 탭에도 속하지 않는 경로 — 메뉴 정보로 탭 자동 생성
|
|
65
|
+
const info = findTabInfoForPath(pathname);
|
|
66
|
+
if (info) openTab(info.basePath, info.label, pathname);
|
|
67
|
+
}
|
|
68
|
+
}, [pathname, hasHydrated]);
|
|
69
|
+
|
|
70
|
+
if (!tabs.length) return null;
|
|
71
|
+
|
|
72
|
+
const activeTab = tabs.find((t) => t.id === activeTabId);
|
|
73
|
+
|
|
74
|
+
const handleTabClick = (tabId: string) => {
|
|
75
|
+
if (tabId === activeTabId) return;
|
|
76
|
+
pendingSwitch.current = true;
|
|
77
|
+
const targetPath = switchToTab(tabId, pathname);
|
|
78
|
+
navigate(targetPath);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const handleTabClose = (tabId: string) => {
|
|
82
|
+
const nextPath = closeTab(tabId);
|
|
83
|
+
if (nextPath != null) navigate(nextPath);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const handleTabAdd = () => {
|
|
87
|
+
if (!activeTab) return;
|
|
88
|
+
const newPath = addTab(activeTab.basePath, activeTab.label, activeTab.currentPath);
|
|
89
|
+
navigate(newPath);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<MultiTab
|
|
94
|
+
tabs={tabs}
|
|
95
|
+
activeTabId={activeTabId}
|
|
96
|
+
onTabClick={handleTabClick}
|
|
97
|
+
onTabClose={handleTabClose}
|
|
98
|
+
onTabAdd={handleTabAdd}
|
|
99
|
+
/>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
@@ -1,53 +1,33 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
<div className="
|
|
13
|
-
<
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
end={item.path === "/"}
|
|
35
|
-
className={({ isActive }) =>
|
|
36
|
-
cn(
|
|
37
|
-
"mb-0.5 flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors",
|
|
38
|
-
isActive
|
|
39
|
-
? "bg-blue-50 text-blue-600"
|
|
40
|
-
: "text-gray-600 hover:bg-gray-100 hover:text-gray-900",
|
|
41
|
-
)
|
|
42
|
-
}
|
|
43
|
-
>
|
|
44
|
-
<item.icon size={18} strokeWidth={1.8} />
|
|
45
|
-
{item.label}
|
|
46
|
-
</NavLink>
|
|
47
|
-
))}
|
|
48
|
-
</div>
|
|
49
|
-
))}
|
|
50
|
-
</nav>
|
|
51
|
-
</aside>
|
|
52
|
-
);
|
|
53
|
-
}
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { useNavigate } from "react-router";
|
|
3
|
+
import { Sidebar as FzSidebar } from "@farmzone/fz-react-ui";
|
|
4
|
+
|
|
5
|
+
import { MENU_SECTIONS } from "./menu";
|
|
6
|
+
import UserInfo from "./UserInfo";
|
|
7
|
+
|
|
8
|
+
function SidebarHeader() {
|
|
9
|
+
const navigate = useNavigate();
|
|
10
|
+
return (
|
|
11
|
+
<div className="flex flex-col gap-4 my-4">
|
|
12
|
+
<div className="text-center cursor-pointer" onClick={() => navigate("/")}>
|
|
13
|
+
<div className="inline-flex items-center justify-center">
|
|
14
|
+
<img src="/favicon.ico" alt="logo" className="w-44 pr-4 object-contain" />
|
|
15
|
+
</div>
|
|
16
|
+
</div>
|
|
17
|
+
<UserInfo />
|
|
18
|
+
</div>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export default function Sidebar() {
|
|
23
|
+
const [isOpen, setIsOpen] = useState(true);
|
|
24
|
+
return (
|
|
25
|
+
<FzSidebar
|
|
26
|
+
isOpen={isOpen}
|
|
27
|
+
onClose={() => setIsOpen(false)}
|
|
28
|
+
menuSections={MENU_SECTIONS}
|
|
29
|
+
header={<SidebarHeader />}
|
|
30
|
+
showCollapseButton
|
|
31
|
+
/>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { Edit, EllipsisVertical, LogOut } from "lucide-react";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { useNavigate } from "react-router";
|
|
4
|
+
import { confirmModal, Popover, PopoverContent, PopoverTrigger } from "@farmzone/fz-react-ui";
|
|
5
|
+
|
|
6
|
+
import { clearUserToken } from "@/app/api/token";
|
|
7
|
+
import { useUserStore } from "@/app/store";
|
|
8
|
+
|
|
9
|
+
export default function UserInfo() {
|
|
10
|
+
const navigate = useNavigate();
|
|
11
|
+
const { user, clearUser } = useUserStore();
|
|
12
|
+
const [open, setOpen] = useState(false);
|
|
13
|
+
|
|
14
|
+
const roleLabel = user?.role === "ADMIN" ? "관리자" : user?.role ? "사용자" : "";
|
|
15
|
+
|
|
16
|
+
const handleLogout = () => {
|
|
17
|
+
setOpen(false);
|
|
18
|
+
confirmModal({
|
|
19
|
+
content: "로그아웃 하시겠습니까?",
|
|
20
|
+
onOk: async () => {
|
|
21
|
+
clearUserToken();
|
|
22
|
+
clearUser();
|
|
23
|
+
navigate("/login");
|
|
24
|
+
},
|
|
25
|
+
onCancel: async () => {},
|
|
26
|
+
okText: "예",
|
|
27
|
+
cancelText: "아니오",
|
|
28
|
+
className: "max-w-100",
|
|
29
|
+
});
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<div className="group flex items-center gap-1 py-3 pl-3 pr-2 transition-all duration-200 border border-[var(--color-basic-gray)]/50 rounded-sm">
|
|
34
|
+
<div className="flex flex-row items-center min-w-0 flex-1">
|
|
35
|
+
<span className="w-[60px] text-xs text-gray-400 truncate mt-0.5">
|
|
36
|
+
{roleLabel && (
|
|
37
|
+
<span className="shrink-0 text-[10px] font-medium px-1.5 py-0.5 mr-1 rounded-full bg-[var(--color-main)]/10 text-[var(--color-main)] leading-none">
|
|
38
|
+
{roleLabel}
|
|
39
|
+
</span>
|
|
40
|
+
)}
|
|
41
|
+
</span>
|
|
42
|
+
<p className="w-full text-sm font-normal text-gray-900 leading-4 tracking-tight truncate">
|
|
43
|
+
{user?.name || ""}
|
|
44
|
+
</p>
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
48
|
+
<PopoverTrigger>
|
|
49
|
+
<EllipsisVertical className="shrink-0 w-4 h-4 text-gray-300 group-hover:text-gray-500 transition-colors duration-200 cursor-pointer" />
|
|
50
|
+
</PopoverTrigger>
|
|
51
|
+
<PopoverContent position="right" sideOffset={15} align="start" className="w-56 p-0">
|
|
52
|
+
<div className="flex flex-col">
|
|
53
|
+
{/* 사용자 ID */}
|
|
54
|
+
<div className="flex items-center px-4 py-3 border-b border-gray-100">
|
|
55
|
+
<span className="text-sm text-gray-500 font-normal">{user?.userId || ""}</span>
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
{/* 버튼 영역 */}
|
|
59
|
+
<div className="flex flex-col gap-2 p-3">
|
|
60
|
+
<div className="flex gap-2">
|
|
61
|
+
<button
|
|
62
|
+
type="button"
|
|
63
|
+
onClick={handleLogout}
|
|
64
|
+
className="w-full flex items-center justify-center gap-1 py-2 px-4 text-xs font-medium text-gray-700 hover:text-red-900 hover:bg-red-50 transition-all duration-150 bg-gray-50 border border-gray-200 rounded-sm cursor-pointer"
|
|
65
|
+
>
|
|
66
|
+
<LogOut size={12} />
|
|
67
|
+
<span>로그아웃</span>
|
|
68
|
+
</button>
|
|
69
|
+
<button
|
|
70
|
+
type="button"
|
|
71
|
+
onClick={() => {
|
|
72
|
+
setOpen(false);
|
|
73
|
+
navigate("/user");
|
|
74
|
+
}}
|
|
75
|
+
className="w-full flex items-center justify-center gap-1 py-2 px-4 text-xs font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100 transition-all duration-150 bg-gray-50 border border-gray-200 rounded-sm cursor-pointer"
|
|
76
|
+
>
|
|
77
|
+
<Edit size={12} />
|
|
78
|
+
<span>정보수정</span>
|
|
79
|
+
</button>
|
|
80
|
+
</div>
|
|
81
|
+
<button
|
|
82
|
+
type="button"
|
|
83
|
+
onClick={() => setOpen(false)}
|
|
84
|
+
className="w-full flex items-center justify-center gap-1 py-2 px-4 text-xs font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100 transition-all duration-150 bg-gray-50 border border-gray-200 rounded-sm cursor-pointer"
|
|
85
|
+
>
|
|
86
|
+
<span>닫기</span>
|
|
87
|
+
</button>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
</PopoverContent>
|
|
91
|
+
</Popover>
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
@@ -1,27 +1,52 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
1
|
+
import type { MenuSection } from "@farmzone/fz-react-ui";
|
|
2
|
+
import { FileText, FlaskConical, LayoutDashboard, ScrollText, Users } from "lucide-react";
|
|
3
3
|
|
|
4
|
-
export
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
export function findTabInfoForPath(pathname: string): { basePath: string; label: string } | null {
|
|
5
|
+
for (const section of MENU_SECTIONS) {
|
|
6
|
+
for (const item of section.items) {
|
|
7
|
+
if (item.children?.length) {
|
|
8
|
+
for (const child of item.children) {
|
|
9
|
+
if (child.children?.length) {
|
|
10
|
+
for (const grandchild of child.children) {
|
|
11
|
+
if (pathname === grandchild.path || pathname.startsWith(`${grandchild.path}/`)) {
|
|
12
|
+
return { basePath: grandchild.path, label: grandchild.label };
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
} else if (child.path && (pathname === child.path || pathname.startsWith(`${child.path}/`))) {
|
|
16
|
+
return { basePath: child.path, label: child.label };
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
} else if (item.path && (pathname === item.path || pathname.startsWith(`${item.path}/`))) {
|
|
20
|
+
return { basePath: item.path, label: item.label };
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
8
25
|
}
|
|
9
26
|
|
|
10
|
-
export
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
|
|
27
|
+
export const MENU_SECTIONS: Array<MenuSection> = [
|
|
28
|
+
{
|
|
29
|
+
items: [
|
|
30
|
+
{ icon: LayoutDashboard, label: "대시보드", path: "/" },
|
|
31
|
+
{
|
|
32
|
+
icon: FlaskConical,
|
|
33
|
+
label: "샘플 관리",
|
|
34
|
+
children: [{ label: "샘플 관리", path: "/sample" }],
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
icon: FileText,
|
|
38
|
+
label: "게시글 관리",
|
|
39
|
+
children: [{ label: "게시글 관리", path: "/post" }],
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
icon: Users,
|
|
43
|
+
label: "사용자 관리",
|
|
44
|
+
children: [{ label: "사용자 관리", path: "/user" }],
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
},
|
|
16
48
|
{
|
|
17
|
-
|
|
49
|
+
title: "시스템",
|
|
50
|
+
items: [{ icon: ScrollText, label: "로그 관리", path: "/system/log" }],
|
|
18
51
|
},
|
|
19
|
-
// 메뉴 섹션 추가 예시:
|
|
20
|
-
// {
|
|
21
|
-
// title: "관리",
|
|
22
|
-
// items: [
|
|
23
|
-
// { icon: Users, label: "사용자 관리", path: "/user" },
|
|
24
|
-
// { icon: ScrollText, label: "로그", path: "/log" },
|
|
25
|
-
// ],
|
|
26
|
-
// },
|
|
27
52
|
];
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { create } from "zustand";
|
|
2
|
+
|
|
3
|
+
interface TabSwitchState {
|
|
4
|
+
tabSwitchKey: number;
|
|
5
|
+
commitTabSwitch: () => void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const useTabSwitchStore = create<TabSwitchState>((set) => ({
|
|
9
|
+
tabSwitchKey: 0,
|
|
10
|
+
commitTabSwitch: () => set((s) => ({ tabSwitchKey: s.tabSwitchKey + 1 })),
|
|
11
|
+
}));
|
|
@@ -1,28 +1,54 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
import
|
|
6
|
-
import
|
|
7
|
-
import
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
1
|
+
import Cookies from "js-cookie";
|
|
2
|
+
import { useMemo } from "react";
|
|
3
|
+
import { createBrowserRouter, redirect, RouterProvider } from "react-router";
|
|
4
|
+
|
|
5
|
+
import Layout from "@/app/layout/Layout";
|
|
6
|
+
import DashboardPage from "@/pages/dashboard";
|
|
7
|
+
import ErrorPage from "@/pages/error/Error";
|
|
8
|
+
import NotFoundPage from "@/pages/error/NotFound";
|
|
9
|
+
import LoginPage from "@/pages/login";
|
|
10
|
+
import SamplePage from "@/pages/sample";
|
|
11
|
+
import SampleDetailPage from "@/pages/sample/detail";
|
|
12
|
+
import PostPage from "@/pages/post";
|
|
13
|
+
import PostDetailPage from "@/pages/post/detail";
|
|
14
|
+
import UserPage from "@/pages/user";
|
|
15
|
+
import LogPage from "@/pages/system/log";
|
|
16
|
+
|
|
17
|
+
const authLoader = () => {
|
|
18
|
+
const accessToken = Cookies.get("AccessToken");
|
|
19
|
+
if (!accessToken) {
|
|
20
|
+
return redirect("/login");
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export default function Router() {
|
|
26
|
+
const router = useMemo(
|
|
27
|
+
() =>
|
|
28
|
+
createBrowserRouter([
|
|
29
|
+
{
|
|
30
|
+
path: "/login",
|
|
31
|
+
element: <LoginPage />,
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
path: "/",
|
|
35
|
+
element: <Layout />,
|
|
36
|
+
errorElement: <ErrorPage />,
|
|
37
|
+
loader: authLoader,
|
|
38
|
+
children: [
|
|
39
|
+
{ index: true, element: <DashboardPage /> },
|
|
40
|
+
{ path: "sample", element: <SamplePage /> },
|
|
41
|
+
{ path: "sample/:id", element: <SampleDetailPage /> },
|
|
42
|
+
{ path: "post", element: <PostPage /> },
|
|
43
|
+
{ path: "post/:id", element: <PostDetailPage /> },
|
|
44
|
+
{ path: "user", element: <UserPage /> },
|
|
45
|
+
{ path: "system/log", element: <LogPage /> },
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
{ path: "*", element: <NotFoundPage /> },
|
|
49
|
+
]),
|
|
50
|
+
[],
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
return <RouterProvider router={router} />;
|
|
54
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { create } from "zustand";
|
|
2
|
+
import { persist } from "zustand/middleware";
|
|
3
|
+
|
|
4
|
+
export interface User {
|
|
5
|
+
id: string;
|
|
6
|
+
userId: string;
|
|
7
|
+
name: string;
|
|
8
|
+
role: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface UserStore {
|
|
12
|
+
user: User | null;
|
|
13
|
+
setUser: (user: User) => void;
|
|
14
|
+
clearUser: () => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const useUserStore = create<UserStore>()(
|
|
18
|
+
persist(
|
|
19
|
+
(set) => ({
|
|
20
|
+
user: null,
|
|
21
|
+
setUser: (user) => set({ user }),
|
|
22
|
+
clearUser: () => set({ user: null }),
|
|
23
|
+
}),
|
|
24
|
+
{ name: "user-store" },
|
|
25
|
+
),
|
|
26
|
+
);
|
package/template/src/index.tsx
CHANGED
|
@@ -1,12 +1,21 @@
|
|
|
1
|
-
import { StrictMode } from "react";
|
|
2
|
-
import { createRoot } from "react-dom/client";
|
|
3
|
-
|
|
4
|
-
import App from "@/app/App";
|
|
5
|
-
|
|
6
|
-
import "../index.css";
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
1
|
+
import { StrictMode } from "react";
|
|
2
|
+
import { createRoot } from "react-dom/client";
|
|
3
|
+
|
|
4
|
+
import App from "@/app/App";
|
|
5
|
+
|
|
6
|
+
import "../index.css";
|
|
7
|
+
|
|
8
|
+
const prepare = async () => {
|
|
9
|
+
if (import.meta.env.DEV) {
|
|
10
|
+
const { worker } = await import("./mocks/browser");
|
|
11
|
+
await worker.start({ onUnhandledRequest: "bypass" });
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
prepare().then(() => {
|
|
16
|
+
createRoot(document.getElementById("root")!).render(
|
|
17
|
+
<StrictMode>
|
|
18
|
+
<App />
|
|
19
|
+
</StrictMode>,
|
|
20
|
+
);
|
|
21
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { setupWorker } from "msw/browser";
|
|
2
|
+
|
|
3
|
+
import { handlers } from "./handlers";
|
|
4
|
+
import { setErrorScenario, type MswScenario, getScenarioGuide } from "./scenarios";
|
|
5
|
+
|
|
6
|
+
export const worker = setupWorker(...handlers);
|
|
7
|
+
|
|
8
|
+
// 콘솔에서 시나리오 가이드 출력: window.__getMswGuide()
|
|
9
|
+
declare global {
|
|
10
|
+
interface Window {
|
|
11
|
+
__setMswErrorScenario: (scenario: MswScenario, targetPath?: string) => void;
|
|
12
|
+
__getMswGuide: (scenario: MswScenario) => void;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
window.__setMswErrorScenario = setErrorScenario;
|
|
17
|
+
window.__getMswGuide = getScenarioGuide;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { HttpResponse, delay, http, passthrough } from "msw";
|
|
2
|
+
|
|
3
|
+
import { getScenario, getTargetPath } from "./scenarios";
|
|
4
|
+
|
|
5
|
+
const BASE_URL = `${import.meta.env.VITE_APP_API_HOST}/api/${import.meta.env.VITE_APP_API_VERSION}`;
|
|
6
|
+
|
|
7
|
+
const ERROR_RESPONSES: Record<string, () => Response> = {
|
|
8
|
+
"500": () => HttpResponse.json({ message: "Internal Server Error" }, { status: 500 }),
|
|
9
|
+
"503": () => HttpResponse.json({ message: "Service Unavailable" }, { status: 503 }),
|
|
10
|
+
"408": () => HttpResponse.json({ message: "Request Timeout" }, { status: 408 }),
|
|
11
|
+
"401": () => HttpResponse.json({ message: "Unauthorized" }, { status: 401 }),
|
|
12
|
+
"403": () => HttpResponse.json({ message: "Forbidden" }, { status: 403 }),
|
|
13
|
+
"404": () => HttpResponse.json({ message: "Not Found" }, { status: 404 }),
|
|
14
|
+
"400": () => HttpResponse.json({ message: "Bad Request" }, { status: 400 }),
|
|
15
|
+
network: () => HttpResponse.error(),
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const timeoutInterceptor = async () => {
|
|
19
|
+
await delay(3_000); // 3초 지연 후 408 반환 (타임아웃 시뮬레이션)
|
|
20
|
+
return HttpResponse.json({ message: "Request Timeout" }, { status: 408 });
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const errorInterceptor = async ({ request }: { request: Request }) => {
|
|
24
|
+
const scenario = getScenario();
|
|
25
|
+
|
|
26
|
+
if (scenario === "none") return passthrough();
|
|
27
|
+
|
|
28
|
+
const targetPath = getTargetPath();
|
|
29
|
+
if (targetPath && !request.url.includes(targetPath)) return passthrough();
|
|
30
|
+
|
|
31
|
+
if (scenario === "timeout") return timeoutInterceptor();
|
|
32
|
+
|
|
33
|
+
const handler = ERROR_RESPONSES[scenario];
|
|
34
|
+
return handler ? handler() : passthrough();
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const handlers = [
|
|
38
|
+
http.get(`${BASE_URL}/*`, errorInterceptor),
|
|
39
|
+
http.post(`${BASE_URL}/*`, errorInterceptor),
|
|
40
|
+
http.put(`${BASE_URL}/*`, errorInterceptor),
|
|
41
|
+
http.patch(`${BASE_URL}/*`, errorInterceptor),
|
|
42
|
+
http.delete(`${BASE_URL}/*`, errorInterceptor),
|
|
43
|
+
];
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// 브라우저 콘솔에서 에러 시나리오 변경:
|
|
2
|
+
// window.__setMswErrorScenario('500') → 모든 API 서버 내부 오류
|
|
3
|
+
// window.__setMswErrorScenario('500', 'users') → /users 포함 URL만 500, 나머지 정상
|
|
4
|
+
// window.__setMswErrorScenario('503') → 서비스 일시 불능 (1회 재시도)
|
|
5
|
+
// window.__setMswErrorScenario('408') → 타임아웃 (1회 재시도)
|
|
6
|
+
// window.__setMswErrorScenario('401') → 인증 오류
|
|
7
|
+
// window.__setMswErrorScenario('403') → 권한 오류
|
|
8
|
+
// window.__setMswErrorScenario('404') → 존재하지 않는 리소스
|
|
9
|
+
// window.__setMswErrorScenario('400') → 잘못된 요청
|
|
10
|
+
// window.__setMswErrorScenario('timeout') → 타임아웃 (3초 지연 후 408 반환 → 1회 재시도)
|
|
11
|
+
// window.__setMswErrorScenario('network') → 네트워크 장애 (1회 재시도)
|
|
12
|
+
// window.__setMswErrorScenario('none') → 모킹 해제
|
|
13
|
+
|
|
14
|
+
export type MswScenario =
|
|
15
|
+
| "500"
|
|
16
|
+
| "503"
|
|
17
|
+
| "408"
|
|
18
|
+
| "401"
|
|
19
|
+
| "403"
|
|
20
|
+
| "404"
|
|
21
|
+
| "400"
|
|
22
|
+
| "network"
|
|
23
|
+
| "timeout"
|
|
24
|
+
| "none";
|
|
25
|
+
|
|
26
|
+
const SCENARIO_KEY = "msw-scenario";
|
|
27
|
+
const TARGET_KEY = "msw-target-path";
|
|
28
|
+
|
|
29
|
+
export const getScenario = (): MswScenario => (localStorage.getItem(SCENARIO_KEY) as MswScenario) ?? "none";
|
|
30
|
+
|
|
31
|
+
export const getTargetPath = (): string | null => localStorage.getItem(TARGET_KEY);
|
|
32
|
+
|
|
33
|
+
export const setErrorScenario = (scenario: MswScenario, targetPath?: string) => {
|
|
34
|
+
localStorage.setItem(SCENARIO_KEY, scenario);
|
|
35
|
+
|
|
36
|
+
if (targetPath) {
|
|
37
|
+
localStorage.setItem(TARGET_KEY, targetPath);
|
|
38
|
+
} else {
|
|
39
|
+
localStorage.removeItem(TARGET_KEY);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const flags = [targetPath ? `대상: *${targetPath}*` : null].filter(Boolean).join(", ");
|
|
43
|
+
|
|
44
|
+
console.log(`[MSW] 시나리오 변경: ${scenario}${flags ? ` (${flags})` : ""} (새로고침 후 적용)`);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const getScenarioGuide = () => {
|
|
48
|
+
console.log(`
|
|
49
|
+
[브라우저 콘솔에서 에러 시나리오 변경 Guide]
|
|
50
|
+
|
|
51
|
+
(1) API 500 에러
|
|
52
|
+
window.__setMswErrorScenario('500')
|
|
53
|
+
|
|
54
|
+
(2) users API만 500 에러
|
|
55
|
+
window.__setMswErrorScenario('500', 'users')
|
|
56
|
+
`);
|
|
57
|
+
};
|