feat(frontend): Phase 6 - 공통 컴포넌트 및 레이아웃 구현
- 공통 컴포넌트: Loading, ErrorBoundary, Toast, Tooltip, ScrollToTop, Lightbox - PC 레이아웃: Layout, Header, Footer - Mobile 레이아웃: Layout (Header + BottomNav 통합) - CSS: pc.css, mobile.css (디바이스별 스타일) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
2d42bf1603
commit
86bf2359f2
17 changed files with 852 additions and 0 deletions
45
frontend-temp/src/components/common/ErrorBoundary.jsx
Normal file
45
frontend-temp/src/components/common/ErrorBoundary.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-[200px] p-8">
|
||||||
|
<h2 className="text-xl font-bold text-gray-800 mb-2">
|
||||||
|
문제가 발생했습니다
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-500 text-sm mb-4">
|
||||||
|
페이지를 새로고침하거나 잠시 후 다시 시도해주세요.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors"
|
||||||
|
>
|
||||||
|
새로고침
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ErrorBoundary;
|
||||||
170
frontend-temp/src/components/common/Lightbox.jsx
Normal file
170
frontend-temp/src/components/common/Lightbox.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && images.length > 0 && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="fixed inset-0 bg-black/95 z-50 overflow-scroll"
|
||||||
|
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
{/* 내부 컨테이너 */}
|
||||||
|
<div className="min-w-[800px] min-h-[600px] w-full h-full relative flex items-center justify-center">
|
||||||
|
{/* 닫기 버튼 */}
|
||||||
|
<button
|
||||||
|
className="absolute top-6 right-6 text-white/70 hover:text-white transition-colors z-10"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<X size={32} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 이전 버튼 */}
|
||||||
|
{images.length > 1 && (
|
||||||
|
<button
|
||||||
|
className="absolute left-6 p-2 text-white/70 hover:text-white transition-colors z-10"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
goToPrev();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronLeft size={48} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 로딩 스피너 */}
|
||||||
|
{!imageLoaded && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-4 border-white border-t-transparent"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 이미지 */}
|
||||||
|
<div className="flex flex-col items-center mx-24">
|
||||||
|
<motion.img
|
||||||
|
key={currentIndex}
|
||||||
|
src={images[currentIndex]}
|
||||||
|
alt={`이미지 ${currentIndex + 1}`}
|
||||||
|
className={`max-w-[90vw] max-h-[85vh] object-contain transition-opacity duration-200 ${
|
||||||
|
imageLoaded ? 'opacity-100' : 'opacity-0'
|
||||||
|
}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onLoad={() => setImageLoaded(true)}
|
||||||
|
initial={{ x: slideDirection * 100 }}
|
||||||
|
animate={{ x: 0 }}
|
||||||
|
transition={{ duration: 0.25, ease: 'easeOut' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 다음 버튼 */}
|
||||||
|
{images.length > 1 && (
|
||||||
|
<button
|
||||||
|
className="absolute right-6 p-2 text-white/70 hover:text-white transition-colors z-10"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
goToNext();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronRight size={48} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 인디케이터 */}
|
||||||
|
{images.length > 1 && (
|
||||||
|
<LightboxIndicator
|
||||||
|
count={images.length}
|
||||||
|
currentIndex={currentIndex}
|
||||||
|
goToIndex={goToIndex}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Lightbox;
|
||||||
55
frontend-temp/src/components/common/LightboxIndicator.jsx
Normal file
55
frontend-temp/src/components/common/LightboxIndicator.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<div
|
||||||
|
className="absolute bottom-6 left-1/2 -translate-x-1/2 overflow-hidden"
|
||||||
|
style={{ width: `${width}px` }}
|
||||||
|
>
|
||||||
|
{/* 양옆 페이드 그라데이션 */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 pointer-events-none z-10"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
'linear-gradient(to right, rgba(0,0,0,1) 0%, transparent 20%, transparent 80%, rgba(0,0,0,1) 100%)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* 슬라이딩 컨테이너 - CSS transition으로 GPU 가속 */}
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 justify-center"
|
||||||
|
style={{
|
||||||
|
width: `${count * 18}px`,
|
||||||
|
transform: `translateX(${translateX}px)`,
|
||||||
|
transition: 'transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
className={`rounded-full flex-shrink-0 transition-all duration-300 ${
|
||||||
|
i === currentIndex
|
||||||
|
? 'w-3 h-3 bg-white'
|
||||||
|
: 'w-2.5 h-2.5 bg-white/40 hover:bg-white/60'
|
||||||
|
}`}
|
||||||
|
onClick={() => goToIndex(i)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default LightboxIndicator;
|
||||||
12
frontend-temp/src/components/common/Loading.jsx
Normal file
12
frontend-temp/src/components/common/Loading.jsx
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
/**
|
||||||
|
* 로딩 컴포넌트
|
||||||
|
*/
|
||||||
|
function Loading({ className = '' }) {
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center justify-center ${className}`}>
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-4 border-primary border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Loading;
|
||||||
24
frontend-temp/src/components/common/ScrollToTop.jsx
Normal file
24
frontend-temp/src/components/common/ScrollToTop.jsx
Normal file
|
|
@ -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;
|
||||||
32
frontend-temp/src/components/common/Toast.jsx
Normal file
32
frontend-temp/src/components/common/Toast.jsx
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toast 컴포넌트
|
||||||
|
* - 하단 중앙에 표시
|
||||||
|
* - type: 'success' | 'error' | 'warning'
|
||||||
|
*/
|
||||||
|
function Toast({ toast, onClose }) {
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
{toast && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 50 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: 50 }}
|
||||||
|
onClick={onClose}
|
||||||
|
className={`fixed bottom-8 inset-x-0 mx-auto w-fit z-[9999] backdrop-blur-sm text-white px-6 py-3 rounded-xl text-center font-medium shadow-lg cursor-pointer ${
|
||||||
|
toast.type === 'error'
|
||||||
|
? 'bg-red-500/90'
|
||||||
|
: toast.type === 'warning'
|
||||||
|
? 'bg-amber-500/90'
|
||||||
|
: 'bg-emerald-500/90'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{toast.message}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Toast;
|
||||||
72
frontend-temp/src/components/common/Tooltip.jsx
Normal file
72
frontend-temp/src/components/common/Tooltip.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
ref={triggerRef}
|
||||||
|
className={`inline-flex items-center ${className}`}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseLeave={() => setIsVisible(false)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
{isVisible &&
|
||||||
|
tooltipContent &&
|
||||||
|
ReactDOM.createPortal(
|
||||||
|
<AnimatePresence>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 5, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: 5, scale: 0.95 }}
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
style={{
|
||||||
|
bottom: position.bottom,
|
||||||
|
left: position.left,
|
||||||
|
}}
|
||||||
|
className="fixed z-[9999] -translate-x-1/2 px-3 py-2 bg-gray-800 text-white text-xs font-medium rounded-lg shadow-xl pointer-events-none"
|
||||||
|
>
|
||||||
|
{tooltipContent}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Tooltip;
|
||||||
7
frontend-temp/src/components/common/index.js
Normal file
7
frontend-temp/src/components/common/index.js
Normal file
|
|
@ -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';
|
||||||
8
frontend-temp/src/components/index.js
Normal file
8
frontend-temp/src/components/index.js
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
// 공통 컴포넌트 (디바이스 무관)
|
||||||
|
export * from './common';
|
||||||
|
|
||||||
|
// PC 컴포넌트
|
||||||
|
export * as PC from './pc';
|
||||||
|
|
||||||
|
// Mobile 컴포넌트
|
||||||
|
export * as Mobile from './mobile';
|
||||||
110
frontend-temp/src/components/mobile/Layout.jsx
Normal file
110
frontend-temp/src/components/mobile/Layout.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<header
|
||||||
|
className={`bg-white sticky top-0 z-50 ${noShadow ? '' : 'shadow-sm'}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center h-14 px-4">
|
||||||
|
{title ? (
|
||||||
|
<span className="text-xl font-bold text-primary">{title}</span>
|
||||||
|
) : (
|
||||||
|
<NavLink to="/" className="text-xl font-bold text-primary">
|
||||||
|
fromis_9
|
||||||
|
</NavLink>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모바일 하단 네비게이션
|
||||||
|
*/
|
||||||
|
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 (
|
||||||
|
<nav className="flex-shrink-0 bg-white border-t border-gray-200 z-50 safe-area-bottom">
|
||||||
|
<div className="flex items-center justify-around h-16">
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const isActive =
|
||||||
|
location.pathname === item.path ||
|
||||||
|
(item.path !== '/' && location.pathname.startsWith(item.path));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
key={item.path}
|
||||||
|
to={item.path}
|
||||||
|
onClick={() => window.scrollTo(0, 0)}
|
||||||
|
className={`flex flex-col items-center justify-center gap-1 w-full h-full transition-colors ${
|
||||||
|
isActive ? 'text-primary' : 'text-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon size={22} strokeWidth={isActive ? 2.5 : 2} />
|
||||||
|
<span className="text-xs font-medium">{item.label}</span>
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모바일 레이아웃 컴포넌트
|
||||||
|
* @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 (
|
||||||
|
<div className="mobile-layout-container bg-white">
|
||||||
|
{children}
|
||||||
|
<MobileBottomNav />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mobile-layout-container bg-white">
|
||||||
|
{!hideHeader && <MobileHeader title={pageTitle} noShadow={noShadow} />}
|
||||||
|
<main className="mobile-content">{children}</main>
|
||||||
|
<MobileBottomNav />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default MobileLayout;
|
||||||
1
frontend-temp/src/components/mobile/index.js
Normal file
1
frontend-temp/src/components/mobile/index.js
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
export { default as Layout } from './Layout';
|
||||||
22
frontend-temp/src/components/pc/Footer.jsx
Normal file
22
frontend-temp/src/components/pc/Footer.jsx
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
/**
|
||||||
|
* PC 푸터 컴포넌트
|
||||||
|
*/
|
||||||
|
function Footer() {
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className="bg-gray-900 text-white py-8">
|
||||||
|
<div className="max-w-7xl mx-auto px-6">
|
||||||
|
{/* 저작권 */}
|
||||||
|
<div className="text-center text-sm text-gray-500">
|
||||||
|
<p>
|
||||||
|
© {currentYear} fromis_9 Fan Site. This is an unofficial
|
||||||
|
fan-made website.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Footer;
|
||||||
84
frontend-temp/src/components/pc/Header.jsx
Normal file
84
frontend-temp/src/components/pc/Header.jsx
Normal file
|
|
@ -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 }) => (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PC 헤더 컴포넌트
|
||||||
|
*/
|
||||||
|
function Header() {
|
||||||
|
const navItems = [
|
||||||
|
{ path: '/', label: '홈' },
|
||||||
|
{ path: '/members', label: '멤버' },
|
||||||
|
{ path: '/album', label: '앨범' },
|
||||||
|
{ path: '/schedule', label: '일정' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="bg-white shadow-sm sticky top-0 z-50">
|
||||||
|
<div className="px-24">
|
||||||
|
<div className="flex items-center justify-between h-16">
|
||||||
|
{/* 로고 */}
|
||||||
|
<NavLink to="/" className="flex items-center gap-2">
|
||||||
|
<span className="text-2xl font-bold text-primary">fromis_9</span>
|
||||||
|
</NavLink>
|
||||||
|
|
||||||
|
{/* 네비게이션 */}
|
||||||
|
<nav className="flex items-center gap-8">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.path}
|
||||||
|
to={item.path}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`text-sm font-medium transition-colors hover:text-primary ${
|
||||||
|
isActive ? 'text-primary' : 'text-gray-600'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* SNS 링크 */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<a
|
||||||
|
href={SOCIAL_LINKS.youtube}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-gray-500 hover:text-red-600 transition-colors"
|
||||||
|
>
|
||||||
|
<Youtube size={20} />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={SOCIAL_LINKS.instagram}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-gray-500 hover:text-pink-600 transition-colors"
|
||||||
|
>
|
||||||
|
<Instagram size={20} />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href={SOCIAL_LINKS.twitter}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-gray-500 hover:text-black transition-colors"
|
||||||
|
>
|
||||||
|
<XIcon size={20} />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Header;
|
||||||
37
frontend-temp/src/components/pc/Layout.jsx
Normal file
37
frontend-temp/src/components/pc/Layout.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="h-screen overflow-hidden flex flex-col">
|
||||||
|
<Header />
|
||||||
|
<main
|
||||||
|
className={`flex-1 min-h-0 flex flex-col ${
|
||||||
|
isSchedulePage ? 'overflow-hidden' : 'overflow-y-auto'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex-1 flex flex-col">{children}</div>
|
||||||
|
{!hideFooter && <Footer />}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Layout;
|
||||||
3
frontend-temp/src/components/pc/index.js
Normal file
3
frontend-temp/src/components/pc/index.js
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { default as Layout } from './Layout';
|
||||||
|
export { default as Header } from './Header';
|
||||||
|
export { default as Footer } from './Footer';
|
||||||
156
frontend-temp/src/mobile.css
Normal file
156
frontend-temp/src/mobile.css
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
14
frontend-temp/src/pc.css
Normal file
14
frontend-temp/src/pc.css
Normal file
|
|
@ -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%;
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue