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 { ToastContainer } from './components/common/Toast';
|
||||
|
||||
// 퍼블리시 영역
|
||||
import PublishLayout from './publish/layouts/PublishLayout';
|
||||
import PublishRoutes from './publish/PublishRoutes';
|
||||
// 퍼블리시 영역 (개발 환경에서만 동적 로드)
|
||||
// 프로덕션 빌드 시 tree-shaking으로 제외됨
|
||||
const PublishRouter = import.meta.env.DEV
|
||||
? lazy(() => import('./publish'))
|
||||
: null;
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<>
|
||||
<ToastContainer />
|
||||
<Routes>
|
||||
{/* =====================
|
||||
구현 영역 (메인)
|
||||
@ -17,12 +23,21 @@ export default function App() {
|
||||
<Route path="/*" element={<MainLayout />} />
|
||||
|
||||
{/* =====================
|
||||
퍼블리시 영역
|
||||
퍼블리시 영역 (개발 환경 전용)
|
||||
/publish/* 로 접근하여 퍼블리시 결과물 미리보기
|
||||
프로덕션 빌드 시 이 라우트와 관련 모듈이 제외됨
|
||||
===================== */}
|
||||
<Route path="/publish/*" element={<PublishLayout />}>
|
||||
{PublishRoutes}
|
||||
</Route>
|
||||
{import.meta.env.DEV && PublishRouter && (
|
||||
<Route
|
||||
path="/publish/*"
|
||||
element={
|
||||
<Suspense fallback={<div style={{ color: '#fff', padding: '2rem' }}>Loading publish...</div>}>
|
||||
<PublishRouter />
|
||||
</Suspense>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</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