feat: 앱 구조 개선 및 공통 컴포넌트
- publish 영역 lazy loading 적용 (빌드 시 tree-shaking) - Toast 공통 컴포넌트 추가 - assetPath 유틸 추가 (BASE_URL 기반 경로 해석) - csvDownload 유틸 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
부모
c123f234f2
커밋
a5131306c4
31
src/App.jsx
31
src/App.jsx
@ -1,14 +1,20 @@
|
|||||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
import { Routes, Route } from 'react-router-dom';
|
||||||
|
import { lazy, Suspense } from 'react';
|
||||||
|
|
||||||
// 구현 영역 - 레이아웃
|
// 구현 영역 - 레이아웃
|
||||||
import MainLayout from './components/layout/MainLayout';
|
import MainLayout from './components/layout/MainLayout';
|
||||||
|
import { ToastContainer } from './components/common/Toast';
|
||||||
|
|
||||||
// 퍼블리시 영역
|
// 퍼블리시 영역 (개발 환경에서만 동적 로드)
|
||||||
import PublishLayout from './publish/layouts/PublishLayout';
|
// 프로덕션 빌드 시 tree-shaking으로 제외됨
|
||||||
import PublishRoutes from './publish/PublishRoutes';
|
const PublishRouter = import.meta.env.DEV
|
||||||
|
? lazy(() => import('./publish'))
|
||||||
|
: null;
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<ToastContainer />
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* =====================
|
{/* =====================
|
||||||
구현 영역 (메인)
|
구현 영역 (메인)
|
||||||
@ -17,12 +23,21 @@ export default function App() {
|
|||||||
<Route path="/*" element={<MainLayout />} />
|
<Route path="/*" element={<MainLayout />} />
|
||||||
|
|
||||||
{/* =====================
|
{/* =====================
|
||||||
퍼블리시 영역
|
퍼블리시 영역 (개발 환경 전용)
|
||||||
/publish/* 로 접근하여 퍼블리시 결과물 미리보기
|
/publish/* 로 접근하여 퍼블리시 결과물 미리보기
|
||||||
|
프로덕션 빌드 시 이 라우트와 관련 모듈이 제외됨
|
||||||
===================== */}
|
===================== */}
|
||||||
<Route path="/publish/*" element={<PublishLayout />}>
|
{import.meta.env.DEV && PublishRouter && (
|
||||||
{PublishRoutes}
|
<Route
|
||||||
</Route>
|
path="/publish/*"
|
||||||
|
element={
|
||||||
|
<Suspense fallback={<div style={{ color: '#fff', padding: '2rem' }}>Loading publish...</div>}>
|
||||||
|
<PublishRouter />
|
||||||
|
</Suspense>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
91
src/components/common/Toast.jsx
Normal file
91
src/components/common/Toast.jsx
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* 토스트 알림 컴포넌트
|
||||||
|
* 간단한 메시지를 일정 시간 후 자동으로 사라지게 표시
|
||||||
|
*/
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import './Toast.scss';
|
||||||
|
|
||||||
|
// 토스트 메시지 표시 함수 (외부에서 호출용)
|
||||||
|
let showToastFn = null;
|
||||||
|
|
||||||
|
export function showToast(message, duration = 3000) {
|
||||||
|
if (showToastFn) {
|
||||||
|
showToastFn(message, duration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 토스트 컨테이너 컴포넌트
|
||||||
|
* App 최상위에 한 번만 마운트
|
||||||
|
*/
|
||||||
|
export function ToastContainer() {
|
||||||
|
const [toasts, setToasts] = useState([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
showToastFn = (message, duration) => {
|
||||||
|
const id = Date.now();
|
||||||
|
setToasts((prev) => [...prev, { id, message, duration }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
showToastFn = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeToast = (id) => {
|
||||||
|
setToasts((prev) => prev.filter((t) => t.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="toast-container">
|
||||||
|
{toasts.map((toast) => (
|
||||||
|
<ToastItem
|
||||||
|
key={toast.id}
|
||||||
|
message={toast.message}
|
||||||
|
duration={toast.duration}
|
||||||
|
onClose={() => removeToast(toast.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 개별 토스트 아이템
|
||||||
|
*/
|
||||||
|
function ToastItem({ message, duration, onClose }) {
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
const [isExiting, setIsExiting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 마운트 후 바로 표시
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
setIsVisible(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// duration 후 사라지기 시작
|
||||||
|
const hideTimer = setTimeout(() => {
|
||||||
|
setIsExiting(true);
|
||||||
|
}, duration);
|
||||||
|
|
||||||
|
// 애니메이션 후 제거
|
||||||
|
const removeTimer = setTimeout(() => {
|
||||||
|
onClose();
|
||||||
|
}, duration + 300);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(hideTimer);
|
||||||
|
clearTimeout(removeTimer);
|
||||||
|
};
|
||||||
|
}, [duration, onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`toast-item ${isVisible ? 'visible' : ''} ${isExiting ? 'exiting' : ''}`}>
|
||||||
|
{message}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ToastContainer;
|
||||||
36
src/components/common/Toast.scss
Normal file
36
src/components/common/Toast.scss
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
.toast-container {
|
||||||
|
position: fixed;
|
||||||
|
top: 80px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 10000;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-item {
|
||||||
|
background: rgba(0, 0, 0, 0.85);
|
||||||
|
color: #fff;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-20px);
|
||||||
|
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||||
|
pointer-events: auto;
|
||||||
|
|
||||||
|
&.visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.exiting {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/utils/assetPath.js
Normal file
39
src/utils/assetPath.js
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* Public 폴더 에셋 경로 유틸리티
|
||||||
|
*
|
||||||
|
* Vite의 base 설정을 적용하여 public 폴더의 에셋 경로를 반환합니다.
|
||||||
|
* 서브 경로 배포 시 (예: /kcgv/) 자동으로 경로를 조정합니다.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // 개발 환경 (base: '/')
|
||||||
|
* assetPath('/images/icon.svg') // → '/images/icon.svg'
|
||||||
|
*
|
||||||
|
* // 프로덕션 환경 (base: '/kcgv/')
|
||||||
|
* assetPath('/images/icon.svg') // → '/kcgv/images/icon.svg'
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* public 폴더 에셋의 전체 경로를 반환
|
||||||
|
* @param {string} path - '/'로 시작하는 에셋 경로 (예: '/images/icon.svg')
|
||||||
|
* @returns {string} base URL이 적용된 전체 경로
|
||||||
|
*/
|
||||||
|
export function assetPath(path) {
|
||||||
|
// import.meta.env.BASE_URL은 항상 '/'로 끝남 (예: '/', '/kcgv/')
|
||||||
|
const base = import.meta.env.BASE_URL;
|
||||||
|
|
||||||
|
// path가 '/'로 시작하면 제거하여 중복 방지
|
||||||
|
const cleanPath = path.startsWith('/') ? path.slice(1) : path;
|
||||||
|
|
||||||
|
return base + cleanPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이미지 경로를 위한 단축 함수
|
||||||
|
* @param {string} filename - 이미지 파일명 (예: 'icon.svg', 'photo.png')
|
||||||
|
* @returns {string} base URL이 적용된 전체 이미지 경로
|
||||||
|
*/
|
||||||
|
export function imagePath(filename) {
|
||||||
|
return assetPath(`/images/${filename}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default assetPath;
|
||||||
불러오는 중...
Reference in New Issue
Block a user