diff --git a/frontend-temp/src/components/common/ErrorBoundary.jsx b/frontend-temp/src/components/common/ErrorBoundary.jsx new file mode 100644 index 0000000..92ce861 --- /dev/null +++ b/frontend-temp/src/components/common/ErrorBoundary.jsx @@ -0,0 +1,45 @@ +import { Component } from 'react'; + +/** + * 에러 바운더리 컴포넌트 + * React 컴포넌트 트리에서 발생하는 에러를 캐치 + */ +class ErrorBoundary extends Component { + constructor(props) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error) { + return { hasError: true, error }; + } + + componentDidCatch(error, errorInfo) { + console.error('ErrorBoundary caught an error:', error, errorInfo); + } + + render() { + if (this.state.hasError) { + return ( +
+

+ 문제가 발생했습니다 +

+

+ 페이지를 새로고침하거나 잠시 후 다시 시도해주세요. +

+ +
+ ); + } + + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/frontend-temp/src/components/common/Lightbox.jsx b/frontend-temp/src/components/common/Lightbox.jsx new file mode 100644 index 0000000..11b2dfc --- /dev/null +++ b/frontend-temp/src/components/common/Lightbox.jsx @@ -0,0 +1,170 @@ +import { useState, useEffect, useCallback, memo } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { X, ChevronLeft, ChevronRight } from 'lucide-react'; +import LightboxIndicator from './LightboxIndicator'; + +/** + * 라이트박스 공통 컴포넌트 + * 이미지 갤러리를 전체 화면으로 표시 + */ +function Lightbox({ images, currentIndex, isOpen, onClose, onIndexChange }) { + const [imageLoaded, setImageLoaded] = useState(false); + const [slideDirection, setSlideDirection] = useState(0); + + // 이전/다음 네비게이션 + const goToPrev = useCallback(() => { + if (images.length <= 1) return; + setImageLoaded(false); + setSlideDirection(-1); + onIndexChange((currentIndex - 1 + images.length) % images.length); + }, [images.length, currentIndex, onIndexChange]); + + const goToNext = useCallback(() => { + if (images.length <= 1) return; + setImageLoaded(false); + setSlideDirection(1); + onIndexChange((currentIndex + 1) % images.length); + }, [images.length, currentIndex, onIndexChange]); + + const goToIndex = useCallback( + (index) => { + if (index === currentIndex) return; + setImageLoaded(false); + setSlideDirection(index > currentIndex ? 1 : -1); + onIndexChange(index); + }, + [currentIndex, onIndexChange] + ); + + // 라이트박스 열릴 때 body 스크롤 숨기기 + useEffect(() => { + if (isOpen) { + document.documentElement.style.overflow = 'hidden'; + document.body.style.overflow = 'hidden'; + } else { + document.documentElement.style.overflow = ''; + document.body.style.overflow = ''; + } + return () => { + document.documentElement.style.overflow = ''; + document.body.style.overflow = ''; + }; + }, [isOpen]); + + // 키보드 이벤트 핸들러 + useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (e) => { + switch (e.key) { + case 'ArrowLeft': + goToPrev(); + break; + case 'ArrowRight': + goToNext(); + break; + case 'Escape': + onClose(); + break; + default: + break; + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isOpen, goToPrev, goToNext, onClose]); + + // 이미지가 바뀔 때 로딩 상태 리셋 + useEffect(() => { + setImageLoaded(false); + }, [currentIndex]); + + return ( + + {isOpen && images.length > 0 && ( + + {/* 내부 컨테이너 */} +
+ {/* 닫기 버튼 */} + + + {/* 이전 버튼 */} + {images.length > 1 && ( + + )} + + {/* 로딩 스피너 */} + {!imageLoaded && ( +
+
+
+ )} + + {/* 이미지 */} +
+ e.stopPropagation()} + onLoad={() => setImageLoaded(true)} + initial={{ x: slideDirection * 100 }} + animate={{ x: 0 }} + transition={{ duration: 0.25, ease: 'easeOut' }} + /> +
+ + {/* 다음 버튼 */} + {images.length > 1 && ( + + )} + + {/* 인디케이터 */} + {images.length > 1 && ( + + )} +
+
+ )} +
+ ); +} + +export default Lightbox; diff --git a/frontend-temp/src/components/common/LightboxIndicator.jsx b/frontend-temp/src/components/common/LightboxIndicator.jsx new file mode 100644 index 0000000..5766ba5 --- /dev/null +++ b/frontend-temp/src/components/common/LightboxIndicator.jsx @@ -0,0 +1,55 @@ +import { memo } from 'react'; + +/** + * 라이트박스 인디케이터 컴포넌트 + * 이미지 갤러리에서 현재 위치를 표시하는 슬라이딩 점 인디케이터 + * CSS transition 사용으로 GPU 가속 + */ +const LightboxIndicator = memo(function LightboxIndicator({ + count, + currentIndex, + goToIndex, + width = 200, +}) { + const halfWidth = width / 2; + const translateX = -(currentIndex * 18) + halfWidth - 6; + + return ( +
+ {/* 양옆 페이드 그라데이션 */} +
+ {/* 슬라이딩 컨테이너 - CSS transition으로 GPU 가속 */} +
+ {Array.from({ length: count }).map((_, i) => ( +
+
+ ); +}); + +export default LightboxIndicator; diff --git a/frontend-temp/src/components/common/Loading.jsx b/frontend-temp/src/components/common/Loading.jsx new file mode 100644 index 0000000..2ca5896 --- /dev/null +++ b/frontend-temp/src/components/common/Loading.jsx @@ -0,0 +1,12 @@ +/** + * 로딩 컴포넌트 + */ +function Loading({ className = '' }) { + return ( +
+
+
+ ); +} + +export default Loading; diff --git a/frontend-temp/src/components/common/ScrollToTop.jsx b/frontend-temp/src/components/common/ScrollToTop.jsx new file mode 100644 index 0000000..99bcedc --- /dev/null +++ b/frontend-temp/src/components/common/ScrollToTop.jsx @@ -0,0 +1,24 @@ +import { useEffect } from 'react'; +import { useLocation } from 'react-router-dom'; + +/** + * 페이지 이동 시 스크롤을 맨 위로 이동시키는 컴포넌트 + */ +function ScrollToTop() { + const { pathname } = useLocation(); + + useEffect(() => { + // window 스크롤 초기화 + window.scrollTo(0, 0); + + // 모바일 레이아웃 스크롤 컨테이너 초기화 + const mobileContent = document.querySelector('.mobile-content'); + if (mobileContent) { + mobileContent.scrollTop = 0; + } + }, [pathname]); + + return null; +} + +export default ScrollToTop; diff --git a/frontend-temp/src/components/common/Toast.jsx b/frontend-temp/src/components/common/Toast.jsx new file mode 100644 index 0000000..2cfc125 --- /dev/null +++ b/frontend-temp/src/components/common/Toast.jsx @@ -0,0 +1,32 @@ +import { motion, AnimatePresence } from 'framer-motion'; + +/** + * Toast 컴포넌트 + * - 하단 중앙에 표시 + * - type: 'success' | 'error' | 'warning' + */ +function Toast({ toast, onClose }) { + return ( + + {toast && ( + + {toast.message} + + )} + + ); +} + +export default Toast; diff --git a/frontend-temp/src/components/common/Tooltip.jsx b/frontend-temp/src/components/common/Tooltip.jsx new file mode 100644 index 0000000..41ac0b1 --- /dev/null +++ b/frontend-temp/src/components/common/Tooltip.jsx @@ -0,0 +1,72 @@ +import { useState, useRef } from 'react'; +import ReactDOM from 'react-dom'; +import { motion, AnimatePresence } from 'framer-motion'; + +/** + * 커스텀 툴팁 컴포넌트 + * 마우스 커서를 따라다니는 방식 + * @param {React.ReactNode} children - 툴팁을 표시할 요소 + * @param {string|React.ReactNode} text - 툴팁에 표시할 내용 (content prop과 호환) + * @param {string|React.ReactNode} content - 툴팁에 표시할 내용 (text prop과 호환) + */ +function Tooltip({ children, text, content, className = '' }) { + const [isVisible, setIsVisible] = useState(false); + const [position, setPosition] = useState({ bottom: 0, left: 0 }); + const triggerRef = useRef(null); + + // text 또는 content prop 사용 (문자열 또는 React 노드) + const tooltipContent = text || content; + + const handleMouseEnter = (e) => { + // 마우스 커서 위치를 기준으로 툴팁 위치 설정 (커서 위로) + setPosition({ + bottom: window.innerHeight - e.clientY + 10, + left: e.clientX, + }); + setIsVisible(true); + }; + + const handleMouseMove = (e) => { + // 마우스 이동 시 툴팁 위치 업데이트 + setPosition({ + bottom: window.innerHeight - e.clientY + 10, + left: e.clientX, + }); + }; + + return ( + <> +
setIsVisible(false)} + > + {children} +
+ {isVisible && + tooltipContent && + ReactDOM.createPortal( + + + {tooltipContent} + + , + document.body + )} + + ); +} + +export default Tooltip; diff --git a/frontend-temp/src/components/common/index.js b/frontend-temp/src/components/common/index.js new file mode 100644 index 0000000..137ad55 --- /dev/null +++ b/frontend-temp/src/components/common/index.js @@ -0,0 +1,7 @@ +export { default as Loading } from './Loading'; +export { default as ErrorBoundary } from './ErrorBoundary'; +export { default as Toast } from './Toast'; +export { default as Tooltip } from './Tooltip'; +export { default as ScrollToTop } from './ScrollToTop'; +export { default as Lightbox } from './Lightbox'; +export { default as LightboxIndicator } from './LightboxIndicator'; diff --git a/frontend-temp/src/components/index.js b/frontend-temp/src/components/index.js new file mode 100644 index 0000000..fb45a36 --- /dev/null +++ b/frontend-temp/src/components/index.js @@ -0,0 +1,8 @@ +// 공통 컴포넌트 (디바이스 무관) +export * from './common'; + +// PC 컴포넌트 +export * as PC from './pc'; + +// Mobile 컴포넌트 +export * as Mobile from './mobile'; diff --git a/frontend-temp/src/components/mobile/Layout.jsx b/frontend-temp/src/components/mobile/Layout.jsx new file mode 100644 index 0000000..9d44951 --- /dev/null +++ b/frontend-temp/src/components/mobile/Layout.jsx @@ -0,0 +1,110 @@ +import { NavLink, useLocation } from 'react-router-dom'; +import { Home, Users, Disc3, Calendar } from 'lucide-react'; +import { useEffect } from 'react'; +import '@/mobile.css'; + +/** + * 모바일 헤더 컴포넌트 + */ +function MobileHeader({ title, noShadow = false }) { + return ( +
+
+ {title ? ( + {title} + ) : ( + + fromis_9 + + )} +
+
+ ); +} + +/** + * 모바일 하단 네비게이션 + */ +function MobileBottomNav() { + const location = useLocation(); + + const navItems = [ + { path: '/', label: '홈', icon: Home }, + { path: '/members', label: '멤버', icon: Users }, + { path: '/album', label: '앨범', icon: Disc3 }, + { path: '/schedule', label: '일정', icon: Calendar }, + ]; + + return ( + + ); +} + +/** + * 모바일 레이아웃 컴포넌트 + * @param {React.ReactNode} children - 페이지 컨텐츠 + * @param {string} pageTitle - 헤더에 표시할 제목 (없으면 fromis_9) + * @param {boolean} hideHeader - true면 헤더 숨김 (일정 페이지처럼 자체 헤더가 있는 경우) + * @param {boolean} useCustomLayout - true면 자체 레이아웃 사용 + * @param {boolean} noShadow - 헤더 그림자 숨김 + */ +function MobileLayout({ + children, + pageTitle, + hideHeader = false, + useCustomLayout = false, + noShadow = false, +}) { + // 모바일 레이아웃 활성화 (body 스크롤 방지) + useEffect(() => { + document.documentElement.classList.add('mobile-layout'); + return () => { + document.documentElement.classList.remove('mobile-layout'); + }; + }, []); + + // 자체 레이아웃 사용 시 (Schedule 페이지 등) + if (useCustomLayout) { + return ( +
+ {children} + +
+ ); + } + + return ( +
+ {!hideHeader && } +
{children}
+ +
+ ); +} + +export default MobileLayout; diff --git a/frontend-temp/src/components/mobile/index.js b/frontend-temp/src/components/mobile/index.js new file mode 100644 index 0000000..6c48fae --- /dev/null +++ b/frontend-temp/src/components/mobile/index.js @@ -0,0 +1 @@ +export { default as Layout } from './Layout'; diff --git a/frontend-temp/src/components/pc/Footer.jsx b/frontend-temp/src/components/pc/Footer.jsx new file mode 100644 index 0000000..13e12ec --- /dev/null +++ b/frontend-temp/src/components/pc/Footer.jsx @@ -0,0 +1,22 @@ +/** + * PC 푸터 컴포넌트 + */ +function Footer() { + const currentYear = new Date().getFullYear(); + + return ( +
+
+ {/* 저작권 */} +
+

+ © {currentYear} fromis_9 Fan Site. This is an unofficial + fan-made website. +

+
+
+
+ ); +} + +export default Footer; diff --git a/frontend-temp/src/components/pc/Header.jsx b/frontend-temp/src/components/pc/Header.jsx new file mode 100644 index 0000000..0090ae4 --- /dev/null +++ b/frontend-temp/src/components/pc/Header.jsx @@ -0,0 +1,84 @@ +import { NavLink } from 'react-router-dom'; +import { Instagram, Youtube } from 'lucide-react'; +import { SOCIAL_LINKS } from '@/constants'; + +/** + * X (Twitter) 아이콘 컴포넌트 + */ +const XIcon = ({ size = 20 }) => ( + + + +); + +/** + * PC 헤더 컴포넌트 + */ +function Header() { + const navItems = [ + { path: '/', label: '홈' }, + { path: '/members', label: '멤버' }, + { path: '/album', label: '앨범' }, + { path: '/schedule', label: '일정' }, + ]; + + return ( +
+
+
+ {/* 로고 */} + + fromis_9 + + + {/* 네비게이션 */} + + + {/* SNS 링크 */} + +
+
+
+ ); +} + +export default Header; diff --git a/frontend-temp/src/components/pc/Layout.jsx b/frontend-temp/src/components/pc/Layout.jsx new file mode 100644 index 0000000..d962908 --- /dev/null +++ b/frontend-temp/src/components/pc/Layout.jsx @@ -0,0 +1,37 @@ +import { useLocation } from 'react-router-dom'; +import Header from './Header'; +import Footer from './Footer'; +import '@/pc.css'; + +/** + * PC 레이아웃 컴포넌트 + */ +function Layout({ children }) { + const location = useLocation(); + + // Footer 숨김 페이지 (화면 고정 레이아웃) + const hideFooterPages = ['/schedule', '/members', '/album']; + const hideFooter = hideFooterPages.some( + (path) => + location.pathname === path || location.pathname.startsWith(path + '/') + ); + + // 일정 페이지에서는 스크롤바도 숨김 (내부에서 자체 스크롤 처리) + const isSchedulePage = location.pathname === '/schedule'; + + return ( +
+
+
+
{children}
+ {!hideFooter &&
+
+ ); +} + +export default Layout; diff --git a/frontend-temp/src/components/pc/index.js b/frontend-temp/src/components/pc/index.js new file mode 100644 index 0000000..85bb816 --- /dev/null +++ b/frontend-temp/src/components/pc/index.js @@ -0,0 +1,3 @@ +export { default as Layout } from './Layout'; +export { default as Header } from './Header'; +export { default as Footer } from './Footer'; diff --git a/frontend-temp/src/mobile.css b/frontend-temp/src/mobile.css new file mode 100644 index 0000000..01ee413 --- /dev/null +++ b/frontend-temp/src/mobile.css @@ -0,0 +1,156 @@ +/* 모바일 전용 스타일 */ + +/* 모바일 html,body 스크롤 방지 */ +html.mobile-layout, +html.mobile-layout body { + margin: 0; + padding: 0; + height: 100%; + overflow: hidden; +} + +/* 모바일 레이아웃 컨테이너 */ +.mobile-layout-container { + display: flex; + flex-direction: column; + height: 100dvh; + overflow: hidden; +} + +/* 모바일 툴바 (기본 56px) */ +.mobile-toolbar { + flex-shrink: 0; + background-color: #ffffff; +} + +/* 일정 페이지 툴바 (헤더 + 날짜 선택기) */ +.mobile-toolbar-schedule { + flex-shrink: 0; + background-color: #ffffff; +} + +/* 하단 네비게이션 */ +.mobile-bottom-nav { + flex-shrink: 0; +} + +/* 컨텐츠 영역 - 스크롤 가능, 스크롤바 숨김 */ +.mobile-content { + flex: 1; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + overscroll-behavior: contain; + scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; /* IE/Edge */ +} + +.mobile-content::-webkit-scrollbar { + display: none; /* Chrome, Safari, Opera */ +} + +/* 모바일 safe-area 지원 (노치, 홈 인디케이터) */ +.safe-area-bottom { + padding-bottom: env(safe-area-inset-bottom, 0); +} + +.safe-area-top { + padding-top: env(safe-area-inset-top, 0); +} + +/* 모바일 달력 스타일 */ +.mobile-calendar-wrapper .react-calendar__navigation button:hover { + background-color: #f3f4f6; + border-radius: 0.5rem; +} + +.mobile-calendar-wrapper .react-calendar__navigation__label { + font-weight: 600; + font-size: 0.875rem; + color: #374151; +} + +.mobile-calendar-wrapper .react-calendar__month-view__weekdays { + text-align: center; + font-size: 0.75rem; + font-weight: 500; + color: #6b7280; +} + +.mobile-calendar-wrapper .react-calendar__month-view__weekdays__weekday { + padding: 0.5rem 0; +} + +.mobile-calendar-wrapper .react-calendar__month-view__weekdays__weekday abbr { + text-decoration: none; +} + +/* 일요일 (빨간색) */ +.mobile-calendar-wrapper + .react-calendar__month-view__weekdays__weekday:first-child { + color: #f87171; +} + +/* 토요일 (파란색) */ +.mobile-calendar-wrapper + .react-calendar__month-view__weekdays__weekday:last-child { + color: #60a5fa; +} + +.mobile-calendar-wrapper .react-calendar__tile { + display: flex; + flex-direction: column; + align-items: center; + padding: 0.25rem; + background: none; + border: none; + font-size: 0.75rem; + color: #374151; +} + +.mobile-calendar-wrapper .react-calendar__tile:hover { + background-color: #f3f4f6; + border-radius: 9999px; +} + +.mobile-calendar-wrapper .react-calendar__tile abbr { + display: flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + border-radius: 9999px; +} + +/* 이웃 달 날짜 (흐리게) */ +.mobile-calendar-wrapper + .react-calendar__month-view__days__day--neighboringMonth { + color: #d1d5db; +} + +/* 일요일 */ +.mobile-calendar-wrapper .react-calendar__tile.sunday abbr { + color: #ef4444; +} + +/* 토요일 */ +.mobile-calendar-wrapper .react-calendar__tile.saturday abbr { + color: #3b82f6; +} + +/* 오늘 */ +.mobile-calendar-wrapper .react-calendar__tile--now abbr { + background-color: #548360; + color: white; + font-weight: 700; +} + +/* 선택된 날짜 */ +.mobile-calendar-wrapper .react-calendar__tile--active abbr { + background-color: #548360; + color: white; +} + +.mobile-calendar-wrapper .react-calendar__tile--active:enabled:hover abbr, +.mobile-calendar-wrapper .react-calendar__tile--active:enabled:focus abbr { + background-color: #456e50; +} diff --git a/frontend-temp/src/pc.css b/frontend-temp/src/pc.css new file mode 100644 index 0000000..3d217c9 --- /dev/null +++ b/frontend-temp/src/pc.css @@ -0,0 +1,14 @@ +/* PC 전용 스타일 - body.is-pc 클래스가 있을 때만 적용 */ + +/* PC에서는 body 스크롤 숨기고 내부 영역에서만 스크롤 */ +html.is-pc, +body.is-pc { + height: 100%; + overflow: hidden; +} + +/* PC 최소 너비 설정 */ +body.is-pc #root { + min-width: 1440px; + height: 100%; +}