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:
HeungTak Lee 2026-02-05 06:35:50 +09:00
부모 c123f234f2
커밋 a5131306c4
4개의 변경된 파일189개의 추가작업 그리고 8개의 파일을 삭제

파일 보기

@ -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>
</>
); );
} }

파일 보기

@ -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;

파일 보기

@ -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
파일 보기

@ -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;