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