diff --git a/src/App.jsx b/src/App.jsx index a722a4b3..11431e97 100644 --- a/src/App.jsx +++ b/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 ( + <> + {/* ===================== 구현 영역 (메인) @@ -17,12 +23,21 @@ export default function App() { } /> {/* ===================== - 퍼블리시 영역 + 퍼블리시 영역 (개발 환경 전용) /publish/* 로 접근하여 퍼블리시 결과물 미리보기 + 프로덕션 빌드 시 이 라우트와 관련 모듈이 제외됨 ===================== */} - }> - {PublishRoutes} - + {import.meta.env.DEV && PublishRouter && ( + Loading publish...}> + + + } + /> + )} + ); } diff --git a/src/components/common/Toast.jsx b/src/components/common/Toast.jsx new file mode 100644 index 00000000..61bb66c4 --- /dev/null +++ b/src/components/common/Toast.jsx @@ -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( +
+ {toasts.map((toast) => ( + removeToast(toast.id)} + /> + ))} +
, + 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 ( +
+ {message} +
+ ); +} + +export default ToastContainer; diff --git a/src/components/common/Toast.scss b/src/components/common/Toast.scss new file mode 100644 index 00000000..ad6d2b50 --- /dev/null +++ b/src/components/common/Toast.scss @@ -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); + } +} diff --git a/src/utils/assetPath.js b/src/utils/assetPath.js new file mode 100644 index 00000000..7b286ff7 --- /dev/null +++ b/src/utils/assetPath.js @@ -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;