first commit
This commit is contained in:
42
src/app/App.css
Normal file
42
src/app/App.css
Normal file
@@ -0,0 +1,42 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
35
src/app/App.tsx
Normal file
35
src/app/App.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useState } from 'react'
|
||||
import reactLogo from '../assets/react.svg'
|
||||
import viteLogo from '/vite.svg'
|
||||
import './App.css'
|
||||
|
||||
function App() {
|
||||
const [count, setCount] = useState(0)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<a href="https://vite.dev" target="_blank">
|
||||
<img src={viteLogo} className="logo" alt="Vite logo" />
|
||||
</a>
|
||||
<a href="https://react.dev" target="_blank">
|
||||
<img src={reactLogo} className="logo react" alt="React logo" />
|
||||
</a>
|
||||
</div>
|
||||
<h1>Vite + React</h1>
|
||||
<div className="card">
|
||||
<button onClick={() => setCount((count) => count + 1)}>
|
||||
count is {count}
|
||||
</button>
|
||||
<p>
|
||||
Edit <code>src/app/App.tsx</code> and save to test HMR
|
||||
</p>
|
||||
</div>
|
||||
<p className="read-the-docs">
|
||||
Click on the Vite and React logos to learn more
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
1
src/assets/react.svg
Normal file
1
src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
28
src/common/api/endpoints.ts
Normal file
28
src/common/api/endpoints.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* API 경로 상수 정의
|
||||
*
|
||||
* 목표:
|
||||
* - 문자열 API 경로를 코드 전역에 흩뿌리지 않음
|
||||
* - route 변경/버전 변경 시 영향 범위 최소화
|
||||
*
|
||||
* 규칙:
|
||||
* - API 경로는 endpoints.ts로만 관리
|
||||
* - feature 코드에서 문자열 URL 직접 사용 금지
|
||||
*/
|
||||
export const API = {
|
||||
AUTH: {
|
||||
CSRF: "/auth/csrf",
|
||||
LOGIN: "/auth/login",
|
||||
LOGOUT: "/auth/logout",
|
||||
ME: "/auth/me",
|
||||
},
|
||||
USER: {
|
||||
LIST: "/users",
|
||||
DETAIL: (id: string | number) => `/users/${encodeURIComponent(id)}`,
|
||||
ME: "/users/me",
|
||||
},
|
||||
DOCUMENT: {
|
||||
LIST: "/documents",
|
||||
DETAIL: (id: string | number) => `/documents/${encodeURIComponent(id)}`,
|
||||
},
|
||||
} as const;
|
||||
22
src/common/api/http.ts
Normal file
22
src/common/api/http.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import axios from "axios";
|
||||
|
||||
/**
|
||||
* Axios 인스턴스 설정
|
||||
*
|
||||
* - 세션 기반 인증 (withCredentials: true)
|
||||
* - 환경 변수 기반 baseURL 설정
|
||||
* - 타임아웃 설정
|
||||
*/
|
||||
const baseURL = `${import.meta.env.VITE_API_BASE_URL ?? ""}${import.meta.env.VITE_API_PREFIX ?? "/api"}`
|
||||
.replace(/\/+$/g, "");
|
||||
const timeout = Number(import.meta.env.VITE_HTTP_TIMEOUT_MS ?? 20000);
|
||||
|
||||
export const http = axios.create({
|
||||
baseURL,
|
||||
timeout,
|
||||
withCredentials: true,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
},
|
||||
});
|
||||
107
src/common/api/interceptors.ts
Normal file
107
src/common/api/interceptors.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { http } from "./http";
|
||||
import type { AxiosError, InternalAxiosRequestConfig } from "axios";
|
||||
|
||||
/**
|
||||
* Axios Interceptor 설정
|
||||
*
|
||||
* 역할:
|
||||
* - 모든 요청은 전역 로딩과 연결
|
||||
* - 모든 응답은 로딩 종료 보장
|
||||
* - 401 응답은 전역 인증 만료로 처리
|
||||
* - 401 재시도 루프 방지
|
||||
* - 민감정보는 절대 로그에 남기지 않음
|
||||
*
|
||||
* 보안 규칙:
|
||||
* - localStorage/sessionStorage에 인증정보 저장 금지
|
||||
* - 401인데 아무 처리 없이 콘솔만 출력 금지
|
||||
*
|
||||
* 주의:
|
||||
* - loading.start/end는 동적으로 주입받아야 함 (순환 참조 방지)
|
||||
* - window 객체를 통해 접근
|
||||
*/
|
||||
|
||||
// 401 재시도 루프 방지 플래그
|
||||
let isHandling401 = false;
|
||||
|
||||
// 로딩 제어 함수 (동적 주입)
|
||||
let loadingStart: (() => void) | null = null;
|
||||
let loadingEnd: (() => void) | null = null;
|
||||
|
||||
/**
|
||||
* 로딩 제어 함수 등록 (LoadingProvider에서 호출)
|
||||
*/
|
||||
export function setLoadingControls(start: () => void, end: () => void) {
|
||||
loadingStart = start;
|
||||
loadingEnd = end;
|
||||
}
|
||||
|
||||
/**
|
||||
* 요청 인터셉터
|
||||
*/
|
||||
http.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
// 로딩 시작 (X-Skip-Loading 헤더가 없을 때만)
|
||||
if (!config.headers?.["X-Skip-Loading"] && loadingStart) {
|
||||
loadingStart();
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
// 요청 에러 시에도 로딩 종료
|
||||
if (loadingEnd) {
|
||||
loadingEnd();
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 응답 인터셉터
|
||||
*/
|
||||
http.interceptors.response.use(
|
||||
(response) => {
|
||||
// 로딩 종료 (X-Skip-Loading 헤더가 없을 때만)
|
||||
if (!response.config.headers?.["X-Skip-Loading"] && loadingEnd) {
|
||||
loadingEnd();
|
||||
}
|
||||
return response;
|
||||
},
|
||||
(error: AxiosError) => {
|
||||
// 로딩 종료 (에러 시에도)
|
||||
if (!error.config?.headers?.["X-Skip-Loading"] && loadingEnd) {
|
||||
loadingEnd();
|
||||
}
|
||||
|
||||
const status = error.response?.status;
|
||||
|
||||
// 401 처리 (인증 만료)
|
||||
if (status === 401 && !isHandling401) {
|
||||
isHandling401 = true;
|
||||
|
||||
// 전역 인증 만료 이벤트 발생
|
||||
window.dispatchEvent(new CustomEvent("app:unauthorized"));
|
||||
|
||||
// 플래그 리셋 (다음 요청을 위해)
|
||||
setTimeout(() => {
|
||||
isHandling401 = false;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// 403 처리 (권한 없음)
|
||||
if (status === 403) {
|
||||
// 필요 시 권한 없음 이벤트 발생
|
||||
// window.dispatchEvent(new CustomEvent("app:forbidden"));
|
||||
}
|
||||
|
||||
// 로깅 (민감정보 제외)
|
||||
// 운영 환경에서는 console.log 금지
|
||||
if (import.meta.env.DEV && import.meta.env.VITE_HTTP_LOGGING === "true") {
|
||||
const url = error.config?.url;
|
||||
const method = error.config?.method?.toUpperCase();
|
||||
console.warn(`[API Error] ${method} ${url} - ${status}`);
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
24
src/common/auth/AuthContext.tsx
Normal file
24
src/common/auth/AuthContext.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { createContext } from "react";
|
||||
import type { AuthContextValue } from "./types";
|
||||
|
||||
/**
|
||||
* 인증 Context 정의
|
||||
*
|
||||
* 규칙:
|
||||
* - 인증 상태 및 사용자 정보는 AuthContext에서만 관리
|
||||
* - 페이지별 /me 호출로 중복 상태 생성 금지
|
||||
* - 앱 초기 로딩에서 1회 /me 호출 + refresh 로직만 허용
|
||||
*/
|
||||
export const AuthContext = createContext<AuthContextValue>({
|
||||
loading: true,
|
||||
user: null,
|
||||
login: async () => {
|
||||
throw new Error("AuthProvider not initialized");
|
||||
},
|
||||
logout: async () => {
|
||||
throw new Error("AuthProvider not initialized");
|
||||
},
|
||||
refreshMe: async () => {
|
||||
throw new Error("AuthProvider not initialized");
|
||||
},
|
||||
});
|
||||
97
src/common/auth/AuthProvider.tsx
Normal file
97
src/common/auth/AuthProvider.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { AuthContext } from "./AuthContext";
|
||||
import { authService } from "./authService";
|
||||
import type { User, AuthContextValue } from "./types";
|
||||
|
||||
/**
|
||||
* 인증 Provider
|
||||
*
|
||||
* 책임:
|
||||
* - 앱 최초 로딩 시 사용자 상태 확정
|
||||
* - /api/auth/me 호출
|
||||
* - 인증 성공 시 user 상태 설정
|
||||
* - 인증 실패(401) 시 user = null 처리
|
||||
* - 전역 unauthorized 이벤트 대응
|
||||
*
|
||||
* 규칙:
|
||||
* - AuthProvider가 초기 /me 호출 → user set
|
||||
* - 페이지는 useAuth로 읽기만
|
||||
*/
|
||||
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
|
||||
/**
|
||||
* 사용자 정보 갱신
|
||||
*/
|
||||
const refreshMe = useCallback(async () => {
|
||||
try {
|
||||
const me = await authService.me();
|
||||
setUser(me);
|
||||
} catch {
|
||||
setUser(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 로그인
|
||||
*/
|
||||
const login = useCallback(async (username: string, password: string) => {
|
||||
await authService.login(username, password);
|
||||
// 로그인 성공 후 사용자 정보 갱신
|
||||
await refreshMe();
|
||||
}, [refreshMe]);
|
||||
|
||||
/**
|
||||
* 로그아웃
|
||||
*/
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
await authService.logout();
|
||||
} catch {
|
||||
// 로그아웃 실패해도 상태는 초기화
|
||||
} finally {
|
||||
setUser(null);
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 초기 사용자 정보 조회
|
||||
*/
|
||||
useEffect(() => {
|
||||
refreshMe();
|
||||
}, [refreshMe]);
|
||||
|
||||
/**
|
||||
* 전역 401 이벤트 리스너
|
||||
* interceptor에서 발생시킨 이벤트 처리
|
||||
*/
|
||||
useEffect(() => {
|
||||
const handleUnauthorized = () => {
|
||||
setUser(null);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
window.addEventListener("app:unauthorized", handleUnauthorized);
|
||||
return () => {
|
||||
window.removeEventListener("app:unauthorized", handleUnauthorized);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const value: AuthContextValue = {
|
||||
loading,
|
||||
user,
|
||||
login,
|
||||
logout,
|
||||
refreshMe,
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={value}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
29
src/common/auth/ProtectedRoute.tsx
Normal file
29
src/common/auth/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Navigate } from "react-router-dom";
|
||||
import { useAuth } from "./useAuth";
|
||||
|
||||
/**
|
||||
* 보호된 라우트
|
||||
*
|
||||
* 역할:
|
||||
* - 인증이 필요한 페이지 접근 제어
|
||||
* - 인증되지 않은 사용자는 로그인 페이지로 리다이렉트
|
||||
*
|
||||
* 규칙:
|
||||
* - ProtectedRoute는 라우팅 레벨에서 적용
|
||||
* - 버튼 숨김/메뉴 숨김은 보안이 아님 (라우트 차단 필수)
|
||||
*/
|
||||
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { loading, user } = useAuth();
|
||||
|
||||
// 로딩 중에는 아무것도 렌더링하지 않음
|
||||
if (loading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 인증되지 않은 경우 로그인 페이지로 리다이렉트
|
||||
if (!user) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
51
src/common/auth/authService.ts
Normal file
51
src/common/auth/authService.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { http } from "../api/http";
|
||||
import { API } from "../api/endpoints";
|
||||
import type { User } from "./types";
|
||||
|
||||
/**
|
||||
* 인증 서비스
|
||||
*
|
||||
* 역할:
|
||||
* - login / logout / me API 호출
|
||||
* - 세션 기반 인증 (withCredentials 자동 처리)
|
||||
*
|
||||
* 보안 규칙:
|
||||
* - localStorage/sessionStorage에 인증정보 저장 금지
|
||||
* - 토큰을 프론트엔드에서 직접 관리하지 않음
|
||||
*/
|
||||
export const authService = {
|
||||
/**
|
||||
* 로그인
|
||||
*/
|
||||
async login(username: string, password: string): Promise<void> {
|
||||
await http.post(API.AUTH.LOGIN, { username, password });
|
||||
},
|
||||
|
||||
/**
|
||||
* 로그아웃
|
||||
*/
|
||||
async logout(): Promise<void> {
|
||||
await http.post(API.AUTH.LOGOUT);
|
||||
},
|
||||
|
||||
/**
|
||||
* 현재 사용자 정보 조회
|
||||
*/
|
||||
async me(): Promise<User | null> {
|
||||
try {
|
||||
const response = await http.get(API.AUTH.ME);
|
||||
// ApiResponse 구조에 맞게 data.data 접근
|
||||
return response.data?.data ?? null;
|
||||
} catch (error) {
|
||||
// 401 등 에러 시 null 반환
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* CSRF 토큰 초기화
|
||||
*/
|
||||
async csrf(): Promise<void> {
|
||||
await http.get(API.AUTH.CSRF);
|
||||
},
|
||||
};
|
||||
32
src/common/auth/types.ts
Normal file
32
src/common/auth/types.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* 인증 관련 타입 정의
|
||||
*
|
||||
* 백엔드 MeResponse와 일치하도록 정의
|
||||
* - userId: 사용자 ID (number)
|
||||
* - email: 이메일 (string, 필수)
|
||||
* - displayName: 표시 이름 (string, 필수)
|
||||
* - role: 역할 (string, 필수)
|
||||
*
|
||||
* 보안 규칙:
|
||||
* - password, token 등 민감정보 포함 금지
|
||||
* - 최소 정보만 포함
|
||||
*/
|
||||
export interface User {
|
||||
userId: number;
|
||||
email: string;
|
||||
displayName: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export interface AuthState {
|
||||
loading: boolean;
|
||||
user: User | null;
|
||||
}
|
||||
|
||||
export interface AuthActions {
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
refreshMe: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface AuthContextValue extends AuthState, AuthActions {}
|
||||
14
src/common/auth/useAuth.ts
Normal file
14
src/common/auth/useAuth.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useContext } from "react";
|
||||
import { AuthContext } from "./AuthContext";
|
||||
|
||||
/**
|
||||
* AuthContext 접근 훅
|
||||
*
|
||||
* 사용법:
|
||||
* ```tsx
|
||||
* const { loading, user, login, logout } = useAuth();
|
||||
* ```
|
||||
*/
|
||||
export function useAuth() {
|
||||
return useContext(AuthContext);
|
||||
}
|
||||
68
src/common/components/action/Button.tsx
Normal file
68
src/common/components/action/Button.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import React from "react";
|
||||
|
||||
/**
|
||||
* Button 공통 컴포넌트
|
||||
*
|
||||
* 공통 원칙:
|
||||
* - id, name, className 전달 옵션 지원
|
||||
* - 스타일은 CSS로 처리
|
||||
* - 범용성 위주, 과도한 추상화 지양
|
||||
* - 동작은 props 옵션으로만 제어
|
||||
*
|
||||
* 지원 옵션:
|
||||
* - disabled: 비활성화
|
||||
* - loading: 로딩 상태 (중복 클릭 방지)
|
||||
* - size: 버튼 크기
|
||||
* - variant: 버튼 스타일 변형
|
||||
*/
|
||||
export type ButtonSize = "sm" | "md" | "lg";
|
||||
export type ButtonVariant = "primary" | "secondary" | "danger" | "outline" | "ghost";
|
||||
|
||||
export interface ButtonProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "onClick"> {
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
size?: ButtonSize;
|
||||
variant?: ButtonVariant;
|
||||
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Button({
|
||||
disabled = false,
|
||||
loading = false,
|
||||
size = "md",
|
||||
variant = "primary",
|
||||
onClick,
|
||||
className = "",
|
||||
id,
|
||||
name,
|
||||
children,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (disabled || loading) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
onClick?.(e);
|
||||
};
|
||||
|
||||
const sizeClass = `btn-${size}`;
|
||||
const variantClass = `btn-${variant}`;
|
||||
const loadingClass = loading ? "btn-loading" : "";
|
||||
const disabledClass = disabled || loading ? "btn-disabled" : "";
|
||||
|
||||
return (
|
||||
<button
|
||||
id={id}
|
||||
name={name}
|
||||
className={`btn ${sizeClass} ${variantClass} ${loadingClass} ${disabledClass} ${className}`.trim()}
|
||||
disabled={disabled || loading}
|
||||
onClick={handleClick}
|
||||
{...props}
|
||||
>
|
||||
{loading && <span className="btn-spinner" aria-hidden="true" />}
|
||||
<span className={loading ? "btn-content-loading" : ""}>{children}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
4
src/common/components/action/index.ts
Normal file
4
src/common/components/action/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* Action 컴포넌트 통합 export
|
||||
*/
|
||||
export * from "./Button";
|
||||
189
src/common/components/display/table/Table.tsx
Normal file
189
src/common/components/display/table/Table.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
import React from "react";
|
||||
import type { SortValue, TableColumn, RowSelectionState } from "./types";
|
||||
import { TableEmpty } from "./TableEmpty";
|
||||
import { TableSkeleton } from "./TableSkeleton";
|
||||
|
||||
/**
|
||||
* 공통 Table 컴포넌트
|
||||
*
|
||||
* 목표:
|
||||
* - 페이지마다 테이블이 달라도 "동작"은 동일하게 유지
|
||||
* - 로딩, Empty, RowClick, Selection, Server Sort UI를 표준화
|
||||
* - 데이터 fetch는 Table이 하지 않음
|
||||
* - 페이지 상태(page/size/sort/filter)는 URL state가 담당
|
||||
*
|
||||
* Table이 책임지는 것:
|
||||
* - 로딩 표시(영역 Skeleton)
|
||||
* - Empty 표시
|
||||
* - Row 클릭 핸들링(옵션)
|
||||
* - Selection(checkbox) 핸들링(옵션)
|
||||
* - 서버 정렬 UI 표시 및 정렬 변경 이벤트 발생
|
||||
*
|
||||
* Table이 책임지지 않는 것:
|
||||
* - 데이터 fetch
|
||||
* - page/size/filter 상태 관리
|
||||
* - 비즈니스 로직 처리(권한에 따른 컬럼 숨김, API 조건 조합 등)
|
||||
*/
|
||||
export interface TableProps<T> {
|
||||
columns: Array<TableColumn<T>>;
|
||||
rows: T[];
|
||||
getRowId: (row: T) => string;
|
||||
|
||||
loading?: boolean;
|
||||
emptyText?: string;
|
||||
|
||||
onRowClick?: (row: T) => void;
|
||||
|
||||
selection?: RowSelectionState;
|
||||
|
||||
sort?: SortValue;
|
||||
onSortChange?: (next: SortValue) => void;
|
||||
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Table<T>({
|
||||
columns,
|
||||
rows,
|
||||
getRowId,
|
||||
loading,
|
||||
emptyText,
|
||||
onRowClick,
|
||||
selection,
|
||||
sort,
|
||||
onSortChange,
|
||||
className,
|
||||
}: TableProps<T>) {
|
||||
const allIds = rows.map(getRowId);
|
||||
const allSelected =
|
||||
selection
|
||||
? allIds.length > 0 && allIds.every((id) => selection.selectedIds.has(id))
|
||||
: false;
|
||||
|
||||
function toggleSort(col: TableColumn<T>) {
|
||||
if (!col.sortable || !onSortChange) return;
|
||||
const key = col.sortKey ?? col.key;
|
||||
|
||||
if (!sort || sort.key !== key) {
|
||||
return onSortChange({ key, dir: "asc" });
|
||||
}
|
||||
if (sort.dir === "asc") {
|
||||
return onSortChange({ key, dir: "desc" });
|
||||
}
|
||||
return onSortChange(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{
|
||||
border: "1px solid rgba(255,255,255,0.12)",
|
||||
borderRadius: 10,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<table style={{ width: "100%", borderCollapse: "collapse" }}>
|
||||
<thead style={{ background: "rgba(255,255,255,0.03)" }}>
|
||||
<tr>
|
||||
{selection ? (
|
||||
<th style={{ padding: 10, width: 44 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
onChange={() => selection.toggleAll(allIds)}
|
||||
aria-label="select all"
|
||||
/>
|
||||
</th>
|
||||
) : null}
|
||||
|
||||
{columns.map((c) => {
|
||||
const isSorted = sort && (c.sortKey ?? c.key) === sort.key;
|
||||
const sortMark = isSorted
|
||||
? sort!.dir === "asc"
|
||||
? " ▲"
|
||||
: " ▼"
|
||||
: "";
|
||||
return (
|
||||
<th
|
||||
key={c.key}
|
||||
onClick={() => toggleSort(c)}
|
||||
style={{
|
||||
padding: 10,
|
||||
textAlign: c.align ?? "left",
|
||||
width: c.width,
|
||||
cursor: c.sortable ? "pointer" : "default",
|
||||
userSelect: "none",
|
||||
borderBottom: "1px solid rgba(255,255,255,0.08)",
|
||||
}}
|
||||
>
|
||||
{c.header}
|
||||
{c.sortable ? sortMark : null}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={(selection ? 1 : 0) + columns.length}
|
||||
style={{ padding: 12 }}
|
||||
>
|
||||
<TableSkeleton rows={8} />
|
||||
</td>
|
||||
</tr>
|
||||
) : rows.length === 0 ? (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={(selection ? 1 : 0) + columns.length}
|
||||
style={{ padding: 12 }}
|
||||
>
|
||||
<TableEmpty text={emptyText ?? "데이터가 없습니다."} />
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map((row) => {
|
||||
const id = getRowId(row);
|
||||
const clickable = Boolean(onRowClick);
|
||||
return (
|
||||
<tr
|
||||
key={id}
|
||||
onClick={() => onRowClick?.(row)}
|
||||
style={{
|
||||
cursor: clickable ? "pointer" : "default",
|
||||
borderBottom: "1px solid rgba(255,255,255,0.06)",
|
||||
}}
|
||||
>
|
||||
{selection ? (
|
||||
<td
|
||||
style={{ padding: 10, width: 44 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selection.selectedIds.has(id)}
|
||||
onChange={() => selection.toggle(id)}
|
||||
aria-label={`select ${id}`}
|
||||
/>
|
||||
</td>
|
||||
) : null}
|
||||
|
||||
{columns.map((c) => (
|
||||
<td
|
||||
key={c.key}
|
||||
style={{ padding: 10, textAlign: c.align ?? "left" }}
|
||||
>
|
||||
{c.cell(row)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
src/common/components/display/table/TableEmpty.tsx
Normal file
16
src/common/components/display/table/TableEmpty.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Table Empty 컴포넌트
|
||||
*
|
||||
* 데이터가 없을 때 표시되는 컴포넌트
|
||||
*/
|
||||
export interface TableEmptyProps {
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export function TableEmpty({ text }: TableEmptyProps) {
|
||||
return (
|
||||
<div style={{ padding: 12, textAlign: "center", color: "#888" }}>
|
||||
{text ?? "데이터가 없습니다."}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
src/common/components/display/table/TablePagination.tsx
Normal file
60
src/common/components/display/table/TablePagination.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Table Pagination 컴포넌트
|
||||
*
|
||||
* 주의:
|
||||
* - Pagination은 Table 내부 상태로 두지 않음
|
||||
* - Pagination은 URL state 업데이트만 호출
|
||||
*/
|
||||
export interface TablePaginationProps {
|
||||
page: number;
|
||||
totalPages: number;
|
||||
onChangePage: (page: number) => void;
|
||||
}
|
||||
|
||||
export function TablePagination({ page, totalPages, onChangePage }: TablePaginationProps) {
|
||||
const tp = Math.max(1, totalPages ?? 1);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 8,
|
||||
justifyContent: "flex-end",
|
||||
paddingTop: 10,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<button
|
||||
disabled={page <= 1}
|
||||
onClick={() => onChangePage(1)}
|
||||
style={{ padding: "4px 8px", cursor: page <= 1 ? "not-allowed" : "pointer" }}
|
||||
>
|
||||
{"<<"}
|
||||
</button>
|
||||
<button
|
||||
disabled={page <= 1}
|
||||
onClick={() => onChangePage(page - 1)}
|
||||
style={{ padding: "4px 8px", cursor: page <= 1 ? "not-allowed" : "pointer" }}
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<span style={{ padding: "0 8px" }}>
|
||||
Page {page} / {tp}
|
||||
</span>
|
||||
<button
|
||||
disabled={page >= tp}
|
||||
onClick={() => onChangePage(page + 1)}
|
||||
style={{ padding: "4px 8px", cursor: page >= tp ? "not-allowed" : "pointer" }}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
<button
|
||||
disabled={page >= tp}
|
||||
onClick={() => onChangePage(tp)}
|
||||
style={{ padding: "4px 8px", cursor: page >= tp ? "not-allowed" : "pointer" }}
|
||||
>
|
||||
{">>"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
src/common/components/display/table/TableSkeleton.tsx
Normal file
26
src/common/components/display/table/TableSkeleton.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Table Skeleton 컴포넌트
|
||||
*
|
||||
* 로딩 중 표시되는 스켈레톤 UI
|
||||
*/
|
||||
export interface TableSkeletonProps {
|
||||
rows?: number;
|
||||
}
|
||||
|
||||
export function TableSkeleton({ rows = 8 }: TableSkeletonProps) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{Array.from({ length: rows }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
height: 14,
|
||||
background: "rgba(255,255,255,0.06)",
|
||||
borderRadius: 6,
|
||||
animation: "pulse 1.5s ease-in-out infinite",
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
6
src/common/components/display/table/index.ts
Normal file
6
src/common/components/display/table/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { Table } from "./Table";
|
||||
export type { TableProps } from "./Table";
|
||||
export { TableEmpty } from "./TableEmpty";
|
||||
export { TableSkeleton } from "./TableSkeleton";
|
||||
export { TablePagination } from "./TablePagination";
|
||||
export type { TableColumn, SortValue, RowSelectionState, ColumnAlign } from "./types";
|
||||
33
src/common/components/display/table/types.ts
Normal file
33
src/common/components/display/table/types.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import React from "react";
|
||||
|
||||
/**
|
||||
* Table 컴포넌트 타입 정의
|
||||
*
|
||||
* 설계 원칙:
|
||||
* - Column 정의는 "표준 타입"으로만 작성
|
||||
* - Column 정의는 페이지마다 임의로 만들지 않음
|
||||
*/
|
||||
|
||||
export type ColumnAlign = "left" | "center" | "right";
|
||||
|
||||
export type TableColumn<T> = {
|
||||
key: string;
|
||||
header: React.ReactNode;
|
||||
width?: number | string;
|
||||
align?: ColumnAlign;
|
||||
cell: (row: T) => React.ReactNode;
|
||||
sortable?: boolean;
|
||||
sortKey?: string;
|
||||
};
|
||||
|
||||
export type SortValue = {
|
||||
key: string;
|
||||
dir: "asc" | "desc";
|
||||
} | null;
|
||||
|
||||
export type RowSelectionState = {
|
||||
selectedIds: Set<string>;
|
||||
toggle: (id: string) => void;
|
||||
toggleAll: (ids: string[]) => void;
|
||||
clear: () => void;
|
||||
};
|
||||
45
src/common/components/form/Checkbox.tsx
Normal file
45
src/common/components/form/Checkbox.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React from "react";
|
||||
|
||||
/**
|
||||
* Checkbox 공통 컴포넌트
|
||||
*
|
||||
* 공통 원칙:
|
||||
* - id, name, className 전달 옵션 지원
|
||||
* - 스타일은 CSS로 처리
|
||||
* - 기본 UI 통일 목적
|
||||
* - 추가 로직은 옵션 기반으로 확장 가능
|
||||
*/
|
||||
export interface CheckboxProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type" | "checked" | "onChange"> {
|
||||
checked?: boolean;
|
||||
onChange?: (checked: boolean) => void;
|
||||
label?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Checkbox({
|
||||
checked = false,
|
||||
onChange,
|
||||
label,
|
||||
id,
|
||||
name,
|
||||
className = "",
|
||||
...props
|
||||
}: CheckboxProps) {
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange?.(e.target.checked);
|
||||
};
|
||||
|
||||
return (
|
||||
<label className={`checkbox-wrapper ${className}`.trim()}>
|
||||
<input
|
||||
id={id}
|
||||
name={name}
|
||||
type="checkbox"
|
||||
className="checkbox"
|
||||
checked={checked}
|
||||
onChange={handleChange}
|
||||
{...props}
|
||||
/>
|
||||
{label && <span className="checkbox-label">{label}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
183
src/common/components/form/Input.tsx
Normal file
183
src/common/components/form/Input.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
import { useEffect, useRef, useState, type ChangeEvent, type KeyboardEvent } from "react";
|
||||
import type { InputHTMLAttributes } from "react";
|
||||
|
||||
/**
|
||||
* Input 공통 컴포넌트
|
||||
*
|
||||
* 공통 원칙:
|
||||
* - id, name, className 전달 옵션 지원
|
||||
* - 스타일은 CSS로 처리
|
||||
* - 범용성 위주, 과도한 추상화 지양
|
||||
* - 동작은 props 옵션으로만 제어
|
||||
*
|
||||
* 지원 타입:
|
||||
* - text: 기본 텍스트 입력
|
||||
* - password: 비밀번호 입력
|
||||
* - number: 숫자 입력 (숫자 이외 문자 자동 제거)
|
||||
*
|
||||
* text 타입 옵션:
|
||||
* - formatEmail: 이메일 형식 자동 포맷
|
||||
* - formatPhone: 전화번호 형식 자동 포맷
|
||||
* - validationRegex: 정규식 검증 (옵션)
|
||||
*
|
||||
* 검색 옵션:
|
||||
* - debounce, commitOnEnter, clearOnEscape 등
|
||||
*/
|
||||
export interface InputSearchOptions {
|
||||
enabled?: boolean;
|
||||
debounceMs?: number;
|
||||
autoCommit?: boolean;
|
||||
commitOnEnter?: boolean;
|
||||
clearOnEscape?: boolean;
|
||||
onCommit?: (value: string) => void;
|
||||
}
|
||||
|
||||
export interface InputFormatOptions {
|
||||
formatEmail?: boolean;
|
||||
formatPhone?: boolean;
|
||||
validationRegex?: RegExp;
|
||||
}
|
||||
|
||||
export interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "value" | "onChange"> {
|
||||
type?: "text" | "password" | "number";
|
||||
value: string;
|
||||
onChange?: (value: string) => void;
|
||||
search?: InputSearchOptions;
|
||||
format?: InputFormatOptions;
|
||||
}
|
||||
|
||||
export function Input({
|
||||
type = "text",
|
||||
value,
|
||||
onChange,
|
||||
search,
|
||||
format,
|
||||
id,
|
||||
name,
|
||||
className = "",
|
||||
...props
|
||||
}: InputProps) {
|
||||
const [localValue, setLocalValue] = useState(value);
|
||||
const timer = useRef<number | null>(null);
|
||||
|
||||
// value prop 변경 시 localValue 동기화
|
||||
useEffect(() => {
|
||||
setLocalValue(value);
|
||||
}, [value]);
|
||||
|
||||
// 포맷 적용 함수
|
||||
const applyFormat = (v: string): string => {
|
||||
if (type !== "text" || !format) return v;
|
||||
|
||||
// 이메일 포맷 (자동 포맷은 적용하지 않음, 검증만)
|
||||
if (format.formatEmail) {
|
||||
// 이메일 형식 검증은 onChange에서 처리하지 않고, 외부에서 처리
|
||||
return v;
|
||||
}
|
||||
|
||||
// 전화번호 포맷 (010-1234-5678)
|
||||
if (format.formatPhone) {
|
||||
const digits = v.replace(/\D/g, "");
|
||||
if (digits.length <= 3) return digits;
|
||||
if (digits.length <= 7) return `${digits.slice(0, 3)}-${digits.slice(3)}`;
|
||||
if (digits.length <= 11) return `${digits.slice(0, 3)}-${digits.slice(3, 7)}-${digits.slice(7)}`;
|
||||
return `${digits.slice(0, 3)}-${digits.slice(3, 7)}-${digits.slice(7, 11)}`;
|
||||
}
|
||||
|
||||
return v;
|
||||
};
|
||||
|
||||
// 정규식 검증
|
||||
const validateRegex = (v: string): boolean => {
|
||||
if (!format?.validationRegex) return true;
|
||||
return format.validationRegex.test(v);
|
||||
};
|
||||
|
||||
// 검색 커밋
|
||||
const commit = (v: string) => {
|
||||
search?.onCommit?.(v);
|
||||
};
|
||||
|
||||
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
let v = e.target.value;
|
||||
|
||||
// number 타입: 숫자 이외 문자 제거
|
||||
if (type === "number") {
|
||||
v = v.replace(/\D/g, "");
|
||||
}
|
||||
|
||||
// 포맷 적용 (text 타입만)
|
||||
if (type === "text" && format) {
|
||||
v = applyFormat(v);
|
||||
}
|
||||
|
||||
// 정규식 검증 (실패 시 이전 값 유지)
|
||||
if (format?.validationRegex && !validateRegex(v)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLocalValue(v);
|
||||
onChange?.(v);
|
||||
|
||||
if (!search?.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// autoCommit이면 즉시 커밋
|
||||
if (search.autoCommit) {
|
||||
commit(v);
|
||||
return;
|
||||
}
|
||||
|
||||
// debounce 처리
|
||||
if (search.debounceMs) {
|
||||
if (timer.current !== null) {
|
||||
window.clearTimeout(timer.current);
|
||||
}
|
||||
timer.current = window.setTimeout(() => {
|
||||
commit(v);
|
||||
}, search.debounceMs);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (!search?.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter 키 커밋
|
||||
if (e.key === "Enter" && search.commitOnEnter) {
|
||||
e.preventDefault();
|
||||
commit(localValue);
|
||||
}
|
||||
|
||||
// Escape 키 초기화
|
||||
if (e.key === "Escape" && search.clearOnEscape) {
|
||||
setLocalValue("");
|
||||
onChange?.("");
|
||||
commit("");
|
||||
}
|
||||
};
|
||||
|
||||
// cleanup
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timer.current !== null) {
|
||||
window.clearTimeout(timer.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<input
|
||||
id={id}
|
||||
name={name}
|
||||
type={type}
|
||||
className={`input ${className}`.trim()}
|
||||
value={localValue}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
45
src/common/components/form/Radio.tsx
Normal file
45
src/common/components/form/Radio.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React from "react";
|
||||
|
||||
/**
|
||||
* Radio 공통 컴포넌트
|
||||
*
|
||||
* 공통 원칙:
|
||||
* - id, name, className 전달 옵션 지원
|
||||
* - 스타일은 CSS로 처리
|
||||
* - 기본 UI 통일 목적
|
||||
* - 추가 로직은 옵션 기반으로 확장 가능
|
||||
*/
|
||||
export interface RadioProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "type" | "checked" | "onChange"> {
|
||||
checked?: boolean;
|
||||
onChange?: (checked: boolean) => void;
|
||||
label?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Radio({
|
||||
checked = false,
|
||||
onChange,
|
||||
label,
|
||||
id,
|
||||
name,
|
||||
className = "",
|
||||
...props
|
||||
}: RadioProps) {
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange?.(e.target.checked);
|
||||
};
|
||||
|
||||
return (
|
||||
<label className={`radio-wrapper ${className}`.trim()}>
|
||||
<input
|
||||
id={id}
|
||||
name={name}
|
||||
type="radio"
|
||||
className="radio"
|
||||
checked={checked}
|
||||
onChange={handleChange}
|
||||
{...props}
|
||||
/>
|
||||
{label && <span className="radio-label">{label}</span>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
64
src/common/components/form/Select.tsx
Normal file
64
src/common/components/form/Select.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import React from "react";
|
||||
|
||||
/**
|
||||
* Select 공통 컴포넌트
|
||||
*
|
||||
* 공통 원칙:
|
||||
* - id, name, className 전달 옵션 지원
|
||||
* - 스타일은 CSS로 처리
|
||||
* - 기본 select 기능을 공통 컴포넌트화
|
||||
* - 스타일 통일 목적 위주
|
||||
*/
|
||||
export interface SelectOption {
|
||||
value: string | number;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface SelectProps extends Omit<React.SelectHTMLAttributes<HTMLSelectElement>, "onChange"> {
|
||||
options: SelectOption[];
|
||||
value?: string | number;
|
||||
onChange?: (value: string | number) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function Select({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
id,
|
||||
name,
|
||||
className = "",
|
||||
...props
|
||||
}: SelectProps) {
|
||||
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const selectedValue = e.target.value;
|
||||
// 숫자 옵션이면 숫자로 변환 시도
|
||||
const numValue = Number(selectedValue);
|
||||
const finalValue = isNaN(numValue) ? selectedValue : numValue;
|
||||
onChange?.(finalValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<select
|
||||
id={id}
|
||||
name={name}
|
||||
className={`select ${className}`.trim()}
|
||||
value={value ?? ""}
|
||||
onChange={handleChange}
|
||||
{...props}
|
||||
>
|
||||
{placeholder && (
|
||||
<option value="" disabled>
|
||||
{placeholder}
|
||||
</option>
|
||||
)}
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value} disabled={option.disabled}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
46
src/common/components/form/Textarea.tsx
Normal file
46
src/common/components/form/Textarea.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React from "react";
|
||||
|
||||
/**
|
||||
* Textarea 공통 컴포넌트
|
||||
*
|
||||
* 공통 원칙:
|
||||
* - id, name, className 전달 옵션 지원
|
||||
* - 스타일은 CSS로 처리
|
||||
*
|
||||
* 기본 속성:
|
||||
* - resize: none
|
||||
* - width: 100%
|
||||
*/
|
||||
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
value: string;
|
||||
onChange?: (value: string) => void;
|
||||
}
|
||||
|
||||
export function Textarea({
|
||||
value,
|
||||
onChange,
|
||||
id,
|
||||
name,
|
||||
className = "",
|
||||
...props
|
||||
}: TextareaProps) {
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
onChange?.(e.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<textarea
|
||||
id={id}
|
||||
name={name}
|
||||
className={`textarea ${className}`.trim()}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
style={{
|
||||
resize: "none",
|
||||
width: "100%",
|
||||
...props.style,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
8
src/common/components/form/index.ts
Normal file
8
src/common/components/form/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Form 컴포넌트 통합 export
|
||||
*/
|
||||
export * from "./Input";
|
||||
export * from "./Select";
|
||||
export * from "./Textarea";
|
||||
export * from "./Checkbox";
|
||||
export * from "./Radio";
|
||||
23
src/common/components/index.ts
Normal file
23
src/common/components/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* 공통 컴포넌트 통합 export
|
||||
*
|
||||
* 모든 공통 컴포넌트는 여기서 export하여 사용 편의성 제공
|
||||
*
|
||||
* 디렉터리 구조:
|
||||
* - form/: 폼 입력 컴포넌트 (Input, Select, Textarea, Checkbox, Radio)
|
||||
* - action/: 액션 컴포넌트 (Button)
|
||||
* - display/: 표시 컴포넌트 (Table)
|
||||
* - system/: 시스템 컴포넌트 (LoadingOverlay, LoadingProvider, Toast, CommonModal)
|
||||
*/
|
||||
|
||||
// Form 컴포넌트
|
||||
export * from "./form";
|
||||
|
||||
// Action 컴포넌트
|
||||
export * from "./action";
|
||||
|
||||
// Display 컴포넌트
|
||||
export * from "./display/table";
|
||||
|
||||
// System 컴포넌트
|
||||
export * from "./system";
|
||||
237
src/common/components/system/CommonModal.tsx
Normal file
237
src/common/components/system/CommonModal.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* CommonModal 공통 컴포넌트
|
||||
*
|
||||
* 공통 원칙:
|
||||
* - id, name, className 전달 옵션 지원
|
||||
* - 스타일은 CSS로 처리
|
||||
*
|
||||
* 구조:
|
||||
* - 헤더: 타이틀, X 닫기 버튼
|
||||
* - 컨텐츠: children
|
||||
* - 푸터: 확인/닫기 버튼 (옵션)
|
||||
*
|
||||
* 사이즈:
|
||||
* - 기본: width 50vw, height 50vh
|
||||
* - 커스텀 사이즈 옵션 제공
|
||||
*/
|
||||
export interface CommonModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
children: React.ReactNode;
|
||||
|
||||
// 푸터 옵션
|
||||
showFooter?: boolean;
|
||||
onConfirm?: () => void;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
|
||||
// 사이즈 옵션
|
||||
width?: string | number;
|
||||
height?: string | number;
|
||||
|
||||
// 공통 속성
|
||||
id?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CommonModal({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
showFooter = true,
|
||||
onConfirm,
|
||||
confirmText = "확인",
|
||||
cancelText = "닫기",
|
||||
width = "50vw",
|
||||
height = "50vh",
|
||||
id,
|
||||
className = "",
|
||||
}: CommonModalProps) {
|
||||
// ESC 키로 닫기
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleEscape);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleEscape);
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
// 모달 열릴 때 body 스크롤 방지
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
document.body.style.overflow = "hidden";
|
||||
} else {
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (onConfirm) {
|
||||
onConfirm();
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackdropClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const modalStyle: React.CSSProperties = {
|
||||
width: typeof width === "number" ? `${width}px` : width,
|
||||
height: typeof height === "number" ? `${height}px` : height,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`modal-backdrop ${className}`.trim()}
|
||||
onClick={handleBackdropClick}
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: "rgba(0, 0, 0, 0.5)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 10000,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
id={id}
|
||||
className={`modal ${className}`.trim()}
|
||||
style={{
|
||||
background: "white",
|
||||
borderRadius: 8,
|
||||
boxShadow: "0 4px 20px rgba(0, 0, 0, 0.15)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
maxWidth: "90vw",
|
||||
maxHeight: "90vh",
|
||||
...modalStyle,
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div
|
||||
className="modal-header"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
padding: "16px 20px",
|
||||
borderBottom: "1px solid #e0e0e0",
|
||||
}}
|
||||
>
|
||||
{title && (
|
||||
<h3
|
||||
className="modal-title"
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: "18px",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</h3>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="modal-close"
|
||||
onClick={onClose}
|
||||
aria-label="닫기"
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
fontSize: "24px",
|
||||
cursor: "pointer",
|
||||
padding: 0,
|
||||
width: "24px",
|
||||
height: "24px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 컨텐츠 */}
|
||||
<div
|
||||
className="modal-content"
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "20px",
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* 푸터 */}
|
||||
{showFooter && (
|
||||
<div
|
||||
className="modal-footer"
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: onConfirm ? "flex-end" : "center",
|
||||
gap: "8px",
|
||||
padding: "16px 20px",
|
||||
borderTop: "1px solid #e0e0e0",
|
||||
}}
|
||||
>
|
||||
{onConfirm && (
|
||||
<button
|
||||
type="button"
|
||||
className="modal-btn modal-btn-cancel"
|
||||
onClick={onClose}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
border: "1px solid #ccc",
|
||||
background: "white",
|
||||
borderRadius: 4,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="modal-btn modal-btn-confirm"
|
||||
onClick={handleConfirm}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
border: "none",
|
||||
background: "#007bff",
|
||||
color: "white",
|
||||
borderRadius: 4,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
src/common/components/system/LoadingOverlay.tsx
Normal file
46
src/common/components/system/LoadingOverlay.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* 전역 로딩 Overlay 컴포넌트
|
||||
*
|
||||
* 설계 목적:
|
||||
* - "표현 전용 컴포넌트"
|
||||
* - 로딩 상태를 내부에서 판단하지 않음
|
||||
* - visible prop만 보고 렌더링 여부 결정
|
||||
* - 디자인 시스템에 맞게 교체 가능
|
||||
*
|
||||
* 규칙:
|
||||
* - 로딩 상태를 내부에서 판단하지 않음
|
||||
* - visible prop만 보고 렌더링 여부 결정
|
||||
*/
|
||||
export interface LoadingOverlayProps {
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
export function LoadingOverlay({ visible }: LoadingOverlayProps) {
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: "rgba(0,0,0,0.35)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
zIndex: 9999,
|
||||
pointerEvents: "auto",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: "white",
|
||||
padding: "20px 40px",
|
||||
borderRadius: 8,
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
|
||||
}}
|
||||
>
|
||||
Loading...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
src/common/components/system/LoadingProvider.tsx
Normal file
72
src/common/components/system/LoadingProvider.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { createContext, useContext, useState, useCallback, useMemo, useEffect } from "react";
|
||||
import { setLoadingControls } from "../api/interceptors";
|
||||
|
||||
/**
|
||||
* 전역 로딩 Provider
|
||||
*
|
||||
* 설계 목적:
|
||||
* - 네트워크 통신이 발생하면 사용자는 항상 "지금 처리 중"임을 인지
|
||||
* - 페이지별 로딩 처리로 인한 UX 불일치와 코드 중복 제거
|
||||
* - Vibe Coding / AI 코드 생성 시 "각 페이지에서 로딩 처리" 패턴 원천 차단
|
||||
*
|
||||
* 동작 원칙:
|
||||
* - 요청 시작 시 count +1
|
||||
* - 요청 종료 시 count -1
|
||||
* - count > 0 이면 로딩 표시
|
||||
* - 중첩 요청에서도 안정적으로 동작
|
||||
*
|
||||
* 책임:
|
||||
* - 로딩 상태 단일 관리
|
||||
* - Axios Interceptor와 직접 연결
|
||||
* - UI 표현은 담당하지 않음
|
||||
*/
|
||||
interface LoadingContextValue {
|
||||
start: () => void;
|
||||
end: () => void;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
const LoadingContext = createContext<LoadingContextValue>({
|
||||
start: () => {},
|
||||
end: () => {},
|
||||
visible: false,
|
||||
});
|
||||
|
||||
export function LoadingProvider({ children }: { children: React.ReactNode }) {
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
const start = useCallback(() => {
|
||||
setCount((c) => c + 1);
|
||||
}, []);
|
||||
|
||||
const end = useCallback(() => {
|
||||
setCount((c) => Math.max(0, c - 1));
|
||||
}, []);
|
||||
|
||||
// interceptor에 로딩 제어 함수 등록
|
||||
useEffect(() => {
|
||||
setLoadingControls(start, end);
|
||||
return () => {
|
||||
setLoadingControls(() => {}, () => {});
|
||||
};
|
||||
}, [start, end]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
start,
|
||||
end,
|
||||
visible: count > 0,
|
||||
}),
|
||||
[start, end, count]
|
||||
);
|
||||
|
||||
return (
|
||||
<LoadingContext.Provider value={value}>
|
||||
{children}
|
||||
</LoadingContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useLoading() {
|
||||
return useContext(LoadingContext);
|
||||
}
|
||||
103
src/common/components/system/Toast.tsx
Normal file
103
src/common/components/system/Toast.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
/**
|
||||
* Toast 메시지 시스템
|
||||
*
|
||||
* 목적:
|
||||
* - 사용자에게 "즉시 피드백" 제공
|
||||
* - 성공/실패/경고 메시지를 화면 어디서든 통일되게 출력
|
||||
* - 페이지마다 alert/console.log로 처리하는 것을 금지
|
||||
*
|
||||
* 규칙:
|
||||
* - 성공: 저장/삭제/등록 완료 정도만 짧게 표시
|
||||
* - 실패: 네트워크/권한/유효성 등 최소 정보로 표시
|
||||
* - 경고: 사용자 선택이 필요할 때만
|
||||
*/
|
||||
export interface ToastItem {
|
||||
id: string;
|
||||
message: string;
|
||||
type?: "success" | "error" | "warning" | "info";
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
push: (message: string, type?: ToastItem["type"]) => void;
|
||||
remove: (id: string) => void;
|
||||
}
|
||||
|
||||
export const ToastContext = createContext<ToastContextValue>({
|
||||
push: () => {},
|
||||
remove: () => {},
|
||||
});
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [items, setItems] = useState<ToastItem[]>([]);
|
||||
|
||||
const remove = useCallback((id: string) => {
|
||||
setItems((prev) => prev.filter((x) => x.id !== id));
|
||||
}, []);
|
||||
|
||||
const push = useCallback(
|
||||
(message: string, type: ToastItem["type"] = "info") => {
|
||||
const id = `${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||
setItems((prev) => [...prev, { id, message, type }]);
|
||||
// 3.5초 후 자동 제거
|
||||
window.setTimeout(() => remove(id), 3500);
|
||||
},
|
||||
[remove]
|
||||
);
|
||||
|
||||
const value = useMemo(() => ({ push, remove }), [push, remove]);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={value}>
|
||||
{children}
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
right: 14,
|
||||
bottom: 14,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 10,
|
||||
zIndex: 9998,
|
||||
pointerEvents: "none",
|
||||
}}
|
||||
>
|
||||
{items.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
style={{
|
||||
border: "1px solid rgba(255,255,255,0.16)",
|
||||
background:
|
||||
t.type === "error"
|
||||
? "rgba(220, 38, 38, 0.9)"
|
||||
: t.type === "success"
|
||||
? "rgba(34, 197, 94, 0.9)"
|
||||
: t.type === "warning"
|
||||
? "rgba(234, 179, 8, 0.9)"
|
||||
: "rgba(0,0,0,0.65)",
|
||||
color: "white",
|
||||
padding: "10px 12px",
|
||||
borderRadius: 10,
|
||||
maxWidth: 360,
|
||||
cursor: "pointer",
|
||||
pointerEvents: "auto",
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
|
||||
}}
|
||||
onClick={() => remove(t.id)}
|
||||
role="alert"
|
||||
>
|
||||
{t.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
8
src/common/components/system/index.ts
Normal file
8
src/common/components/system/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* System 컴포넌트 통합 export
|
||||
*/
|
||||
export * from "./CommonModal";
|
||||
export * from "./LoadingOverlay";
|
||||
export * from "./LoadingProvider";
|
||||
export * from "./Toast";
|
||||
export * from "./useToast";
|
||||
17
src/common/components/system/useToast.ts
Normal file
17
src/common/components/system/useToast.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useContext } from "react";
|
||||
import { ToastContext } from "./Toast";
|
||||
|
||||
/**
|
||||
* Toast 훅
|
||||
*
|
||||
* ToastProvider의 Context를 사용하여 Toast 기능을 제공합니다.
|
||||
*
|
||||
* 사용법:
|
||||
* ```tsx
|
||||
* const toast = useToast();
|
||||
* toast.push("메시지", "success");
|
||||
* ```
|
||||
*/
|
||||
export function useToast() {
|
||||
return useContext(ToastContext);
|
||||
}
|
||||
60
src/common/hooks/useAppError.ts
Normal file
60
src/common/hooks/useAppError.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useCallback } from "react";
|
||||
import { mapAxiosError } from "../utils/errorMapper";
|
||||
import { useToast } from "../components";
|
||||
import type { AppError } from "../types/error";
|
||||
|
||||
/**
|
||||
* 공통 에러 처리 훅
|
||||
*
|
||||
* 역할:
|
||||
* - AxiosError를 AppError로 변환
|
||||
* - Toast 메시지 자동 표시 (옵션)
|
||||
* - 에러 정보 반환
|
||||
*
|
||||
* 사용법:
|
||||
* ```tsx
|
||||
* const { handle } = useAppError();
|
||||
*
|
||||
* try {
|
||||
* await apiCall();
|
||||
* } catch (error) {
|
||||
* const appError = handle(error);
|
||||
* // 추가 처리
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export interface UseAppErrorOptions {
|
||||
toast?: boolean;
|
||||
fallbackMessage?: string;
|
||||
}
|
||||
|
||||
export function useAppError() {
|
||||
const toast = useToast();
|
||||
|
||||
const handle = useCallback(
|
||||
(error: unknown, opts?: UseAppErrorOptions): AppError => {
|
||||
const appErr = mapAxiosError(error);
|
||||
|
||||
// Toast 표시 (기본값: true)
|
||||
if (opts?.toast !== false) {
|
||||
const message = opts?.fallbackMessage ?? appErr.message;
|
||||
const type =
|
||||
appErr.kind === "unauthorized" ||
|
||||
appErr.kind === "forbidden" ||
|
||||
appErr.kind === "server" ||
|
||||
appErr.kind === "network" ||
|
||||
appErr.kind === "timeout"
|
||||
? "error"
|
||||
: appErr.kind === "validation"
|
||||
? "warning"
|
||||
: "error";
|
||||
toast.push(message, type);
|
||||
}
|
||||
|
||||
return appErr;
|
||||
},
|
||||
[toast]
|
||||
);
|
||||
|
||||
return { handle };
|
||||
}
|
||||
120
src/common/hooks/useFilterQuery.ts
Normal file
120
src/common/hooks/useFilterQuery.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
|
||||
/**
|
||||
* URL 기반 필터 쿼리 훅
|
||||
*
|
||||
* 역할:
|
||||
* - URL → 상태 파싱
|
||||
* - 상태 → URL 반영
|
||||
* - 타입 정규화
|
||||
* - page 리셋 규칙 강제 (q, f_*, size 변경 시 page=1)
|
||||
* - 조합형 검색 통합
|
||||
*
|
||||
* 핵심 원칙:
|
||||
* - 리스트 상태의 단일 진실은 URL SearchParams
|
||||
* - 컴포넌트 내부 state는 보조 표현일 뿐
|
||||
* - 뒤로가기/앞으로가기/새로고침 시 100% 동일 상태 복원
|
||||
*
|
||||
* page 리셋 규칙 (중요):
|
||||
* - q 변경 시 page=1
|
||||
* - f_* 변경 시 page=1
|
||||
* - size 변경 시 page=1
|
||||
*/
|
||||
export interface FilterQueryState {
|
||||
q: string;
|
||||
page: number;
|
||||
size: number;
|
||||
sort?: string;
|
||||
filters: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface FilterQueryUpdate {
|
||||
q?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
sort?: string;
|
||||
filters?: Record<string, string | null>;
|
||||
}
|
||||
|
||||
export interface UseFilterQueryOptions {
|
||||
defaults?: {
|
||||
size?: number;
|
||||
sort?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function useFilterQuery(options: UseFilterQueryOptions = {}) {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const defaults = options.defaults ?? {};
|
||||
|
||||
// URL → 상태 파싱
|
||||
const state: FilterQueryState = {
|
||||
q: params.get("q") ?? "",
|
||||
page: Number(params.get("page") ?? 1) || 1,
|
||||
size: Number(params.get("size") ?? defaults.size ?? 20) || 20,
|
||||
sort: params.get("sort") ?? defaults.sort,
|
||||
filters: Object.fromEntries(
|
||||
[...params.entries()].filter(([k]) => k.startsWith("f_"))
|
||||
),
|
||||
};
|
||||
|
||||
// 상태 → URL 반영
|
||||
const update = (next: FilterQueryUpdate) => {
|
||||
const p = new URLSearchParams(params);
|
||||
|
||||
// q 변경 시 page=1 리셋
|
||||
if (next.q !== undefined) {
|
||||
if (next.q === "") {
|
||||
p.delete("q");
|
||||
} else {
|
||||
p.set("q", next.q);
|
||||
}
|
||||
p.set("page", "1");
|
||||
}
|
||||
|
||||
// page 변경
|
||||
if (next.page !== undefined) {
|
||||
p.set("page", String(next.page));
|
||||
}
|
||||
|
||||
// size 변경 시 page=1 리셋
|
||||
if (next.size !== undefined) {
|
||||
p.set("size", String(next.size));
|
||||
p.set("page", "1");
|
||||
}
|
||||
|
||||
// sort 변경
|
||||
if (next.sort !== undefined) {
|
||||
if (next.sort === "" || next.sort == null) {
|
||||
p.delete("sort");
|
||||
} else {
|
||||
p.set("sort", next.sort);
|
||||
}
|
||||
}
|
||||
|
||||
// filters 변경 시 page=1 리셋
|
||||
if (next.filters !== undefined) {
|
||||
// 기존 f_* 파라미터 제거
|
||||
const keysToDelete = [...p.keys()].filter((k) => k.startsWith("f_"));
|
||||
keysToDelete.forEach((k) => p.delete(k));
|
||||
|
||||
// 새로운 filters 설정
|
||||
Object.entries(next.filters).forEach(([k, v]) => {
|
||||
if (v == null || v === "") {
|
||||
p.delete(k);
|
||||
} else {
|
||||
p.set(k, String(v));
|
||||
}
|
||||
});
|
||||
|
||||
// filters 변경 시 page=1 리셋
|
||||
if (Object.keys(next.filters).length > 0) {
|
||||
p.set("page", "1");
|
||||
}
|
||||
}
|
||||
|
||||
setParams(p);
|
||||
};
|
||||
|
||||
return { state, update };
|
||||
}
|
||||
23
src/common/query/QueryProvider.tsx
Normal file
23
src/common/query/QueryProvider.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { queryClient } from "./queryClient";
|
||||
|
||||
/**
|
||||
* TanStack Query Provider 래퍼
|
||||
*
|
||||
* 옵션으로 사용 가능하도록 래핑
|
||||
* 환경 변수 VITE_USE_REACT_QUERY로 제어
|
||||
*/
|
||||
export function QueryProvider({ children }: { children: React.ReactNode }) {
|
||||
const useReactQuery = import.meta.env.VITE_USE_REACT_QUERY === "true";
|
||||
|
||||
if (!useReactQuery) {
|
||||
// TanStack Query 비활성화 시 그대로 children 반환
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
48
src/common/query/hooks/useDocuments.ts
Normal file
48
src/common/query/hooks/useDocuments.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { qk } from "../keys";
|
||||
import { http } from "../../api/http";
|
||||
import { API } from "../../api/endpoints";
|
||||
|
||||
/**
|
||||
* 문서 목록 Query Hook
|
||||
*
|
||||
* 주의:
|
||||
* - TanStack Query가 비활성화된 경우 사용 불가
|
||||
* - 환경 변수 VITE_USE_REACT_QUERY=true 필요
|
||||
*/
|
||||
export interface DocumentRow {
|
||||
id: string | number;
|
||||
title: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface DocumentListResponse {
|
||||
items: DocumentRow[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export function useDocuments(params: {
|
||||
q: string;
|
||||
page: number;
|
||||
size: number;
|
||||
sort?: string;
|
||||
filters: Record<string, any>;
|
||||
}) {
|
||||
const useReactQuery = import.meta.env.VITE_USE_REACT_QUERY === "true";
|
||||
|
||||
if (!useReactQuery) {
|
||||
throw new Error(
|
||||
"TanStack Query is not enabled. Set VITE_USE_REACT_QUERY=true in .env"
|
||||
);
|
||||
}
|
||||
|
||||
return useQuery({
|
||||
queryKey: qk.documents(params),
|
||||
queryFn: async () => {
|
||||
const res = await http.get(API.DOCUMENT.LIST, { params });
|
||||
// ApiResponse 구조에 맞게 data.data 접근
|
||||
return (res.data?.data ?? { items: [], total: 0 }) as DocumentListResponse;
|
||||
},
|
||||
keepPreviousData: true,
|
||||
});
|
||||
}
|
||||
33
src/common/query/keys.ts
Normal file
33
src/common/query/keys.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* QueryKey 팩토리
|
||||
*
|
||||
* 원칙:
|
||||
* - queryKey의 shape는 항상 동일해야 함
|
||||
* - params는 URL state를 그대로 사용하되 "정규화된 값"만 포함
|
||||
* - filters는 useFilterQuery에서 만든 "빈값 제거/타입 통일"된 값을 사용
|
||||
*
|
||||
* 정리:
|
||||
* - 리스트: ["documents", {q,page,size,sort,filters}]
|
||||
* - 상세: ["document", id]
|
||||
*/
|
||||
export const qk = {
|
||||
documents: (params: {
|
||||
q: string;
|
||||
page: number;
|
||||
size: number;
|
||||
sort?: string;
|
||||
filters: Record<string, any>;
|
||||
}) => ["documents", params] as const,
|
||||
|
||||
document: (id: string | number) => ["document", id] as const,
|
||||
|
||||
users: (params: {
|
||||
q: string;
|
||||
page: number;
|
||||
size: number;
|
||||
sort?: string;
|
||||
filters: Record<string, any>;
|
||||
}) => ["users", params] as const,
|
||||
|
||||
user: (id: string | number) => ["user", id] as const,
|
||||
};
|
||||
28
src/common/query/queryClient.ts
Normal file
28
src/common/query/queryClient.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
/**
|
||||
* TanStack Query Client 설정
|
||||
*
|
||||
* 정책:
|
||||
* - staleTime 30초: 뒤로가기 UX에 유리 (짧은 시간은 캐시를 신선하게 취급)
|
||||
* - retry 1: 네트워크 튐 보완
|
||||
* - refetchOnWindowFocus false: 업무 시스템에서 포커스만으로 리패치 반복 방지
|
||||
*
|
||||
* 전제:
|
||||
* - 검색/필터/페이지/정렬 상태는 URL이 단일 진실
|
||||
* - TanStack Query는 URL state를 입력으로 받아 fetch
|
||||
* - 전역 로딩(Overlay)은 axios interceptor가 처리
|
||||
* - Query의 loading은 TableSkeleton 같은 "영역 UX"에만 사용
|
||||
*/
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
mutations: {
|
||||
retry: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
13
src/common/types/api.ts
Normal file
13
src/common/types/api.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* API 응답 타입 정의
|
||||
*
|
||||
* 백엔드 ApiResponse<T> 구조와 일치
|
||||
*/
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: {
|
||||
code: string;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
29
src/common/types/error.ts
Normal file
29
src/common/types/error.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* 공통 에러 타입
|
||||
*
|
||||
* 설계 목적:
|
||||
* - AxiosError를 공통 구조(AppError)로 매핑
|
||||
* - 사용자에게는 안전한 메시지만 표시
|
||||
* - 필요 시 토스트로 즉시 피드백
|
||||
* - 로깅은 민감정보를 완전히 제거한 형태로만
|
||||
*
|
||||
* 규정:
|
||||
* - 서버 메시지를 그대로 사용자에게 노출하지 않음
|
||||
* - 서버 message는 내부 정보(SQL, stack trace, 경로 등)를 포함할 수 있음
|
||||
* - 프론트는 상태코드/코드 기반으로 사용자 메시지를 통제
|
||||
*/
|
||||
export type AppError = {
|
||||
kind:
|
||||
| "network"
|
||||
| "timeout"
|
||||
| "unauthorized"
|
||||
| "forbidden"
|
||||
| "notfound"
|
||||
| "validation"
|
||||
| "server"
|
||||
| "unknown";
|
||||
status?: number;
|
||||
code?: string;
|
||||
message: string;
|
||||
details?: unknown;
|
||||
};
|
||||
115
src/common/utils/errorMapper.ts
Normal file
115
src/common/utils/errorMapper.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import type { AxiosError } from "axios";
|
||||
import type { AppError } from "../types/error";
|
||||
|
||||
/**
|
||||
* AxiosError → AppError 매핑
|
||||
*
|
||||
* 규정:
|
||||
* - 서버 메시지를 그대로 사용자에게 노출하지 않음
|
||||
* - 상태코드/코드 기반으로 사용자 메시지 통제
|
||||
* - 에러 종류를 최소 공통 분류로 통일
|
||||
*/
|
||||
function isTimeout(err: AxiosError): boolean {
|
||||
return (
|
||||
err.code === "ECONNABORTED" ||
|
||||
(typeof err.message === "string" &&
|
||||
err.message.toLowerCase().includes("timeout"))
|
||||
);
|
||||
}
|
||||
|
||||
export function mapAxiosError(error: unknown): AppError {
|
||||
const ax = error as AxiosError<any>;
|
||||
|
||||
// 응답이 없는 경우 (네트워크 오류, 타임아웃)
|
||||
if (!ax.response) {
|
||||
if (isTimeout(ax)) {
|
||||
return {
|
||||
kind: "timeout",
|
||||
message: "요청 시간이 초과되었습니다. 잠시 후 다시 시도해주세요.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "network",
|
||||
message: "네트워크 오류가 발생했습니다. 연결 상태를 확인해주세요.",
|
||||
};
|
||||
}
|
||||
|
||||
const status = ax.response.status;
|
||||
const data = ax.response.data;
|
||||
|
||||
// 서버 메시지 추출 (안전하게)
|
||||
const serverMessage: string | undefined =
|
||||
(typeof data?.error?.message === "string" && data.error.message) ||
|
||||
(typeof data?.message === "string" && data.message) ||
|
||||
(typeof data?.error === "string" && data.error) ||
|
||||
undefined;
|
||||
|
||||
// 상태코드별 에러 분류
|
||||
if (status === 401) {
|
||||
return {
|
||||
kind: "unauthorized",
|
||||
status,
|
||||
message: serverMessage ?? "로그인이 필요합니다.",
|
||||
};
|
||||
}
|
||||
|
||||
if (status === 403) {
|
||||
return {
|
||||
kind: "forbidden",
|
||||
status,
|
||||
message: serverMessage ?? "권한이 없습니다.",
|
||||
};
|
||||
}
|
||||
|
||||
if (status === 404) {
|
||||
return {
|
||||
kind: "notfound",
|
||||
status,
|
||||
message: serverMessage ?? "요청한 리소스를 찾을 수 없습니다.",
|
||||
};
|
||||
}
|
||||
|
||||
if (status === 409) {
|
||||
return {
|
||||
kind: "validation",
|
||||
status,
|
||||
message: serverMessage ?? "충돌이 발생했습니다. 다시 시도해주세요.",
|
||||
details: data,
|
||||
};
|
||||
}
|
||||
|
||||
if (status === 429) {
|
||||
return {
|
||||
kind: "server",
|
||||
status,
|
||||
message: "요청이 너무 많습니다. 잠시 후 다시 시도해주세요.",
|
||||
};
|
||||
}
|
||||
|
||||
// 4xx 클라이언트 오류
|
||||
if (status >= 400 && status < 500) {
|
||||
return {
|
||||
kind: "validation",
|
||||
status,
|
||||
message: serverMessage ?? "요청 값이 올바르지 않습니다.",
|
||||
details: data,
|
||||
};
|
||||
}
|
||||
|
||||
// 5xx 서버 오류
|
||||
if (status >= 500) {
|
||||
return {
|
||||
kind: "server",
|
||||
status,
|
||||
message: serverMessage ?? "서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.",
|
||||
};
|
||||
}
|
||||
|
||||
// 알 수 없는 오류
|
||||
return {
|
||||
kind: "unknown",
|
||||
status,
|
||||
message: serverMessage ?? "알 수 없는 오류가 발생했습니다.",
|
||||
details: data,
|
||||
};
|
||||
}
|
||||
68
src/index.css
Normal file
68
src/index.css
Normal file
@@ -0,0 +1,68 @@
|
||||
:root {
|
||||
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
color-scheme: light dark;
|
||||
color: rgba(255, 255, 255, 0.87);
|
||||
background-color: #242424;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
a {
|
||||
font-weight: 500;
|
||||
color: #646cff;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a:hover {
|
||||
color: #535bf2;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
place-items: center;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 3.2em;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
padding: 0.6em 1.2em;
|
||||
font-size: 1em;
|
||||
font-weight: 500;
|
||||
font-family: inherit;
|
||||
background-color: #1a1a1a;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.25s;
|
||||
}
|
||||
button:hover {
|
||||
border-color: #646cff;
|
||||
}
|
||||
button:focus,
|
||||
button:focus-visible {
|
||||
outline: 4px auto -webkit-focus-ring-color;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
color: #213547;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
a:hover {
|
||||
color: #747bff;
|
||||
}
|
||||
button {
|
||||
background-color: #f9f9f9;
|
||||
}
|
||||
}
|
||||
51
src/main.tsx
Normal file
51
src/main.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import "./index.css";
|
||||
import App from "./app/App.tsx";
|
||||
import { LoadingProvider, useLoading, LoadingOverlay, ToastProvider } from "./common/components";
|
||||
import { AuthProvider } from "./common/auth/AuthProvider";
|
||||
import { QueryProvider } from "./common/query/QueryProvider";
|
||||
|
||||
/**
|
||||
* Root 컴포넌트
|
||||
* - LoadingOverlay 표시
|
||||
*/
|
||||
export function Root() {
|
||||
const { visible } = useLoading();
|
||||
|
||||
return (
|
||||
<>
|
||||
<LoadingOverlay visible={visible} />
|
||||
<App />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider 조합 순서 (구조적으로 고정):
|
||||
* 1. BrowserRouter (라우팅)
|
||||
* 2. LoadingProvider (최상단에서 모든 요청 감시)
|
||||
* 3. QueryProvider (TanStack Query - 옵션, 환경 변수로 제어)
|
||||
* 4. AuthProvider (서버 상태 기반)
|
||||
* 5. ToastProvider (토스트 메시지)
|
||||
* 6. Root (App + LoadingOverlay)
|
||||
*
|
||||
* TanStack Query 활성화:
|
||||
* - .env.development 또는 .env.production에 VITE_USE_REACT_QUERY=true 추가
|
||||
*/
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<LoadingProvider>
|
||||
<QueryProvider>
|
||||
<AuthProvider>
|
||||
<ToastProvider>
|
||||
<Root />
|
||||
</ToastProvider>
|
||||
</AuthProvider>
|
||||
</QueryProvider>
|
||||
</LoadingProvider>
|
||||
</BrowserRouter>
|
||||
</StrictMode>
|
||||
);
|
||||
Reference in New Issue
Block a user