revert(frontend): Phase 5로 롤백 - 구조 재설계
Phase 6-9에서 추가된 파일 제거
- react-device-detect 미사용 문제로 인한 구조 재설계
- 올바른 폴더 구조로 재시작 예정:
- pages/{feature}/pc/, pages/{feature}/mobile/ 구조
- react-device-detect BrowserView/MobileView 사용
- components/pc/, components/mobile/ 레이아웃 분리
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
e0ab3ce0f8
commit
2d42bf1603
28 changed files with 130 additions and 2145 deletions
|
|
@ -1,75 +1,144 @@
|
|||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||
import { ErrorBoundary, ToastContainer, Layout } from "@/components";
|
||||
import { Schedule, Album, Home, Members, NotFound } from "@/pages";
|
||||
import { cn, getTodayKST, formatFullDate } from "@/utils";
|
||||
import { useAuthStore, useUIStore } from "@/stores";
|
||||
import { useIsMobile, useCategories, useCalendar } from "@/hooks";
|
||||
import { memberApi } from "@/api";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
/**
|
||||
* 프로미스나인 팬사이트 메인 앱
|
||||
*
|
||||
* Phase 9: 기타 공개 페이지 마이그레이션
|
||||
* - Home 페이지 (PC/Mobile 통합)
|
||||
* - Members 페이지 (PC/Mobile 통합)
|
||||
* - NotFound 페이지
|
||||
* Phase 5: 커스텀 훅 완료
|
||||
* - useMediaQuery, useIsMobile, useIsDesktop
|
||||
* - useScheduleData, useScheduleDetail, useCategories
|
||||
* - useScheduleSearch (무한 스크롤)
|
||||
* - useScheduleFiltering, useCategoryCounts
|
||||
* - useCalendar
|
||||
* - useAdminAuth
|
||||
*/
|
||||
function App() {
|
||||
const today = getTodayKST();
|
||||
const isMobile = useIsMobile();
|
||||
const { isAuthenticated } = useAuthStore();
|
||||
const { showSuccess, toasts } = useUIStore();
|
||||
|
||||
// 커스텀 훅 사용
|
||||
const { data: categories, isLoading: categoriesLoading } = useCategories();
|
||||
const calendar = useCalendar();
|
||||
|
||||
// 멤버 데이터 (기존 방식 유지)
|
||||
const { data: members, isLoading: membersLoading } = useQuery({
|
||||
queryKey: ["members"],
|
||||
queryFn: memberApi.getMembers,
|
||||
});
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<ErrorBoundary>
|
||||
<Routes>
|
||||
{/* 홈 */}
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<Layout>
|
||||
<Home />
|
||||
<ToastContainer />
|
||||
</Layout>
|
||||
}
|
||||
/>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 p-4">
|
||||
<div className="text-center space-y-4 max-w-md w-full">
|
||||
<h1 className="text-2xl font-bold text-primary mb-2">
|
||||
fromis_9 Frontend Refactoring
|
||||
</h1>
|
||||
<p className="text-gray-600">Phase 5 완료 - 커스텀 훅</p>
|
||||
<p className={cn("text-sm", isMobile ? "text-blue-500" : "text-green-500")}>
|
||||
디바이스: {isMobile ? "모바일" : "PC"} (useIsMobile 훅)
|
||||
</p>
|
||||
|
||||
{/* 멤버 */}
|
||||
<Route
|
||||
path="/members"
|
||||
element={
|
||||
<Layout pageTitle="멤버">
|
||||
<Members />
|
||||
<ToastContainer />
|
||||
</Layout>
|
||||
}
|
||||
/>
|
||||
<div className="mt-6 p-4 bg-white rounded-lg shadow text-left text-sm space-y-3">
|
||||
<p><strong>오늘:</strong> {formatFullDate(today)}</p>
|
||||
<p><strong>인증:</strong> {isAuthenticated ? "✅" : "❌"}</p>
|
||||
|
||||
{/* 앨범 */}
|
||||
<Route
|
||||
path="/album"
|
||||
element={
|
||||
<Layout pageTitle="앨범">
|
||||
<Album />
|
||||
<ToastContainer />
|
||||
</Layout>
|
||||
}
|
||||
/>
|
||||
<div className="border-t pt-3">
|
||||
<p className="font-semibold mb-2">useCategories 훅</p>
|
||||
<p>
|
||||
{categoriesLoading ? "로딩 중..." : `${categories?.length || 0}개 카테고리`}
|
||||
</p>
|
||||
{categories && (
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{categories.map((c) => (
|
||||
<span
|
||||
key={c.id}
|
||||
className="px-2 py-0.5 rounded text-xs text-white"
|
||||
style={{ backgroundColor: c.color }}
|
||||
>
|
||||
{c.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 스케줄 */}
|
||||
<Route
|
||||
path="/schedule"
|
||||
element={
|
||||
<Layout useCustomLayout>
|
||||
<Schedule />
|
||||
<ToastContainer />
|
||||
</Layout>
|
||||
}
|
||||
/>
|
||||
<div className="border-t pt-3">
|
||||
<p className="font-semibold mb-2">useCalendar 훅</p>
|
||||
<p><strong>현재:</strong> {calendar.year}년 {calendar.monthName}</p>
|
||||
<p><strong>선택:</strong> {calendar.selectedDate}</p>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button
|
||||
onClick={calendar.goToPrevMonth}
|
||||
disabled={!calendar.canGoPrevMonth}
|
||||
className={cn(
|
||||
"px-3 py-1 rounded text-xs",
|
||||
calendar.canGoPrevMonth ? "bg-primary text-white" : "bg-gray-200 text-gray-400"
|
||||
)}
|
||||
>
|
||||
이전
|
||||
</button>
|
||||
<button
|
||||
onClick={calendar.goToToday}
|
||||
className="px-3 py-1 bg-gray-500 text-white rounded text-xs"
|
||||
>
|
||||
오늘
|
||||
</button>
|
||||
<button
|
||||
onClick={calendar.goToNextMonth}
|
||||
className="px-3 py-1 bg-primary text-white rounded text-xs"
|
||||
>
|
||||
다음
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 404 */}
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
<Layout>
|
||||
<NotFound />
|
||||
</Layout>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</ErrorBoundary>
|
||||
<div className="border-t pt-3">
|
||||
<p className="font-semibold mb-2">멤버 데이터</p>
|
||||
<p>{membersLoading ? "로딩 중..." : `${members?.length || 0}명`}</p>
|
||||
{members && (
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{members.map((m) => (
|
||||
<span key={m.id} className="px-2 py-0.5 bg-gray-100 rounded text-xs">
|
||||
{m.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 토스트 표시 */}
|
||||
<div className="fixed bottom-4 right-4 space-y-2">
|
||||
{toasts.map((toast) => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={cn(
|
||||
"px-4 py-2 rounded shadow-lg text-white text-sm",
|
||||
toast.type === "success" && "bg-green-500",
|
||||
toast.type === "error" && "bg-red-500",
|
||||
toast.type === "warning" && "bg-yellow-500",
|
||||
toast.type === "info" && "bg-blue-500"
|
||||
)}
|
||||
>
|
||||
{toast.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,62 +0,0 @@
|
|||
import { Component } from 'react';
|
||||
import { AlertTriangle, RefreshCw } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* 에러 경계 컴포넌트
|
||||
* React 컴포넌트 트리에서 발생하는 에러를 캐치하여 폴백 UI를 표시
|
||||
*/
|
||||
class ErrorBoundary extends Component {
|
||||
state = { hasError: false, error: null };
|
||||
|
||||
static getDerivedStateFromError(error) {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error, errorInfo) {
|
||||
console.error('ErrorBoundary:', error, errorInfo);
|
||||
}
|
||||
|
||||
handleReset = () => {
|
||||
this.setState({ hasError: false, error: null });
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
// 커스텀 폴백이 있으면 사용
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-8 text-center min-h-[200px]">
|
||||
<AlertTriangle size={48} className="text-red-500 mb-4" />
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-2">
|
||||
문제가 발생했습니다
|
||||
</h2>
|
||||
<p className="text-gray-500 mb-4">
|
||||
{this.state.error?.message || '알 수 없는 오류'}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={this.handleReset}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
|
||||
>
|
||||
다시 시도
|
||||
</button>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
새로고침
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
|
|
@ -1,225 +0,0 @@
|
|||
import { useState, useEffect, useCallback, memo } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import { useUIStore } from '@/stores';
|
||||
|
||||
/**
|
||||
* 라이트박스 인디케이터
|
||||
*/
|
||||
const LightboxIndicator = memo(function LightboxIndicator({
|
||||
count,
|
||||
currentIndex,
|
||||
goToIndex,
|
||||
}) {
|
||||
const translateX = -(currentIndex * 18) + 100 - 6;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute bottom-6 left-1/2 -translate-x-1/2 overflow-hidden"
|
||||
style={{ width: '200px' }}
|
||||
>
|
||||
{/* 양옆 페이드 그라데이션 */}
|
||||
<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%)',
|
||||
}}
|
||||
/>
|
||||
{/* 슬라이딩 컨테이너 */}
|
||||
<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>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* 라이트박스 컴포넌트
|
||||
* useUIStore와 연동하거나 props로 직접 제어 가능
|
||||
*/
|
||||
function Lightbox({ images: propImages, currentIndex: propIndex, isOpen: propIsOpen, onClose: propOnClose, onIndexChange: propOnIndexChange }) {
|
||||
// useUIStore에서 상태 가져오기 (props가 없으면 스토어 사용)
|
||||
const store = useUIStore();
|
||||
|
||||
const images = propImages ?? store.lightboxImages;
|
||||
const currentIndex = propIndex ?? store.lightboxIndex;
|
||||
const isOpen = propIsOpen ?? store.lightboxOpen;
|
||||
const onClose = propOnClose ?? store.closeLightbox;
|
||||
const onIndexChange = propOnIndexChange ?? store.setLightboxIndex;
|
||||
|
||||
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 lightbox-no-scrollbar"
|
||||
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 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;
|
||||
|
|
@ -1,53 +0,0 @@
|
|||
import { cn } from '@/utils';
|
||||
|
||||
/**
|
||||
* 로딩 스피너 컴포넌트
|
||||
* @param {object} props
|
||||
* @param {'sm'|'md'|'lg'} props.size - 크기
|
||||
* @param {string} props.className - 추가 클래스
|
||||
* @param {string} props.text - 로딩 텍스트
|
||||
*/
|
||||
export default function Loading({ size = 'md', className = '', text = '' }) {
|
||||
const sizeClasses = {
|
||||
sm: 'h-6 w-6 border-2',
|
||||
md: 'h-10 w-10 border-3',
|
||||
lg: 'h-12 w-12 border-4',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col justify-center items-center gap-3', className)}>
|
||||
<div
|
||||
className={cn(
|
||||
'animate-spin rounded-full border-primary border-t-transparent',
|
||||
sizeClasses[size]
|
||||
)}
|
||||
/>
|
||||
{text && <p className="text-sm text-gray-500">{text}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 화면 로딩
|
||||
*/
|
||||
export function FullPageLoading({ text = '로딩 중...' }) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<Loading size="lg" text={text} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 인라인 로딩 (버튼 등에 사용)
|
||||
*/
|
||||
export function InlineLoading({ className = '' }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent',
|
||||
className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,103 +0,0 @@
|
|||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, CheckCircle, AlertCircle, AlertTriangle, Info } from 'lucide-react';
|
||||
import { cn } from '@/utils';
|
||||
import { useUIStore } from '@/stores';
|
||||
|
||||
/**
|
||||
* 토스트 아이콘 매핑
|
||||
*/
|
||||
const icons = {
|
||||
success: CheckCircle,
|
||||
error: AlertCircle,
|
||||
warning: AlertTriangle,
|
||||
info: Info,
|
||||
};
|
||||
|
||||
/**
|
||||
* 토스트 스타일 매핑
|
||||
*/
|
||||
const styles = {
|
||||
success: 'bg-emerald-500/90',
|
||||
error: 'bg-red-500/90',
|
||||
warning: 'bg-amber-500/90',
|
||||
info: 'bg-blue-500/90',
|
||||
};
|
||||
|
||||
/**
|
||||
* 단일 토스트 아이템
|
||||
*/
|
||||
function ToastItem({ toast, onClose }) {
|
||||
const Icon = icons[toast.type] || icons.info;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
layout
|
||||
initial={{ opacity: 0, y: 50, scale: 0.9 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 20, scale: 0.9 }}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-4 py-3 rounded-xl shadow-lg backdrop-blur-sm text-white cursor-pointer min-w-[200px] max-w-[400px]',
|
||||
styles[toast.type]
|
||||
)}
|
||||
onClick={onClose}
|
||||
>
|
||||
<Icon size={20} className="flex-shrink-0" />
|
||||
<span className="flex-1 text-sm font-medium">{toast.message}</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
}}
|
||||
className="flex-shrink-0 hover:bg-white/20 rounded p-0.5 transition-colors"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 토스트 컨테이너
|
||||
* useUIStore와 연동하여 전역 토스트 관리
|
||||
*/
|
||||
export default function ToastContainer() {
|
||||
const { toasts, removeToast } = useUIStore();
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-[9999] flex flex-col gap-2">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{toasts.map((toast) => (
|
||||
<ToastItem
|
||||
key={toast.id}
|
||||
toast={toast}
|
||||
onClose={() => removeToast(toast.id)}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 토스트 (레거시 호환)
|
||||
*/
|
||||
export function Toast({ toast, onClose }) {
|
||||
if (!toast) return null;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 50 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 50 }}
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
'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',
|
||||
styles[toast.type]
|
||||
)}
|
||||
>
|
||||
{toast.message}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
/**
|
||||
* 공통 컴포넌트 export
|
||||
*/
|
||||
export { default as ErrorBoundary } from './ErrorBoundary';
|
||||
export { default as Loading, FullPageLoading, InlineLoading } from './Loading';
|
||||
export { default as ToastContainer, Toast } from './Toast';
|
||||
export { default as Lightbox } from './Lightbox';
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
/**
|
||||
* 컴포넌트 통합 export
|
||||
*/
|
||||
|
||||
// 공통 컴포넌트
|
||||
export * from './common';
|
||||
|
||||
// 레이아웃 컴포넌트
|
||||
export * from './layout';
|
||||
|
||||
// 스케줄 컴포넌트
|
||||
export * from './schedule';
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
/**
|
||||
* 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;
|
||||
|
|
@ -1,77 +0,0 @@
|
|||
import { NavLink } from 'react-router-dom';
|
||||
import { Instagram, Youtube } from 'lucide-react';
|
||||
import { SOCIAL_LINKS, NAV_ITEMS } 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() {
|
||||
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">
|
||||
{NAV_ITEMS.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;
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
import { useEffect } from 'react';
|
||||
import { useLocation, NavLink } from 'react-router-dom';
|
||||
import { useIsMobile } from '@/hooks';
|
||||
import Header from './Header';
|
||||
import Footer from './Footer';
|
||||
import MobileNav from './MobileNav';
|
||||
|
||||
/**
|
||||
* 모바일 헤더 컴포넌트
|
||||
*/
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 통합 레이아웃 컴포넌트
|
||||
* PC/Mobile 자동 분기
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {React.ReactNode} props.children - 페이지 컨텐츠
|
||||
* @param {string} props.pageTitle - 모바일 헤더 타이틀 (없으면 fromis_9)
|
||||
* @param {boolean} props.hideHeader - 헤더 숨김 여부 (자체 헤더가 있는 페이지)
|
||||
* @param {boolean} props.hideFooter - 푸터 숨김 여부
|
||||
* @param {boolean} props.useCustomLayout - 자체 레이아웃 사용 (모바일)
|
||||
* @param {boolean} props.noShadow - 모바일 헤더 그림자 숨김
|
||||
*/
|
||||
function Layout({
|
||||
children,
|
||||
pageTitle,
|
||||
hideHeader = false,
|
||||
hideFooter = false,
|
||||
useCustomLayout = false,
|
||||
noShadow = false,
|
||||
}) {
|
||||
const isMobile = useIsMobile();
|
||||
const location = useLocation();
|
||||
|
||||
// 모바일 레이아웃 활성화 (body 스크롤 방지)
|
||||
useEffect(() => {
|
||||
if (isMobile) {
|
||||
document.documentElement.classList.add('mobile-layout');
|
||||
return () => {
|
||||
document.documentElement.classList.remove('mobile-layout');
|
||||
};
|
||||
}
|
||||
}, [isMobile]);
|
||||
|
||||
// PC 레이아웃
|
||||
if (!isMobile) {
|
||||
// Footer 숨김 페이지 (화면 고정 레이아웃)
|
||||
const hideFooterPages = ['/schedule', '/members', '/album'];
|
||||
const shouldHideFooter = 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">
|
||||
{!hideHeader && <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>
|
||||
{!shouldHideFooter && <Footer />}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 모바일 레이아웃 - 자체 레이아웃 사용 시 (Schedule 페이지 등)
|
||||
if (useCustomLayout) {
|
||||
return (
|
||||
<div className="mobile-layout-container bg-white">
|
||||
{children}
|
||||
<MobileNav />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 모바일 레이아웃 - 기본
|
||||
return (
|
||||
<div className="mobile-layout-container bg-white">
|
||||
{!hideHeader && <MobileHeader title={pageTitle} noShadow={noShadow} />}
|
||||
<main className="mobile-content">{children}</main>
|
||||
<MobileNav />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Layout;
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import { Home, Users, Disc3, Calendar } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* 모바일 하단 네비게이션 컴포넌트
|
||||
*/
|
||||
function MobileNav() {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
export default MobileNav;
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
/**
|
||||
* 레이아웃 컴포넌트 export
|
||||
*/
|
||||
export { default as Layout } from './Layout';
|
||||
export { default as Header } from './Header';
|
||||
export { default as Footer } from './Footer';
|
||||
export { default as MobileNav } from './MobileNav';
|
||||
|
|
@ -1,233 +0,0 @@
|
|||
import { memo } from 'react';
|
||||
import { Clock, Tag, Link2, ExternalLink, Edit2, Trash2 } from 'lucide-react';
|
||||
import { cn, decodeHtmlEntities } from '@/utils';
|
||||
import {
|
||||
getCategoryInfo,
|
||||
getScheduleDate,
|
||||
getScheduleTime,
|
||||
getMemberList,
|
||||
} from '@/utils/schedule';
|
||||
import { WEEKDAYS } from '@/constants';
|
||||
|
||||
/**
|
||||
* 멤버 뱃지 컴포넌트
|
||||
*/
|
||||
const MemberBadges = memo(function MemberBadges({ members }) {
|
||||
if (!members || members.length === 0) return null;
|
||||
|
||||
// 5명 이상이면 "프로미스나인"으로 표시
|
||||
if (members.length >= 5) {
|
||||
return (
|
||||
<span className="px-2 py-0.5 bg-primary/10 text-primary text-xs font-medium rounded-full">
|
||||
프로미스나인
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return members.map((member, i) => (
|
||||
<span
|
||||
key={member.id || i}
|
||||
className="px-2 py-0.5 bg-primary/10 text-primary text-xs font-medium rounded-full"
|
||||
>
|
||||
{typeof member === 'string' ? member : member.name}
|
||||
</span>
|
||||
));
|
||||
});
|
||||
|
||||
/**
|
||||
* 스케줄 카드 컴포넌트
|
||||
* PC/Mobile 공용, variant로 스타일 변경
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {object} props.schedule - 스케줄 데이터
|
||||
* @param {'public'|'admin'} props.variant - 카드 스타일 (public: 공개 페이지용 카드, admin: 관리자 페이지용 리스트)
|
||||
* @param {boolean} props.showDate - 날짜 표시 여부
|
||||
* @param {boolean} props.showYear - 연도 표시 여부
|
||||
* @param {function} props.onClick - 클릭 핸들러
|
||||
* @param {function} props.onEdit - 수정 버튼 클릭 핸들러 (admin variant)
|
||||
* @param {function} props.onDelete - 삭제 버튼 클릭 핸들러 (admin variant)
|
||||
* @param {string} props.className - 추가 클래스
|
||||
*/
|
||||
const ScheduleCard = memo(function ScheduleCard({
|
||||
schedule,
|
||||
variant = 'public',
|
||||
showDate = true,
|
||||
showYear = false,
|
||||
onClick,
|
||||
onEdit,
|
||||
onDelete,
|
||||
className,
|
||||
}) {
|
||||
const dateStr = getScheduleDate(schedule);
|
||||
const date = dateStr ? new Date(dateStr) : null;
|
||||
const categoryInfo = getCategoryInfo(schedule);
|
||||
const timeStr = getScheduleTime(schedule);
|
||||
const memberList = getMemberList(schedule);
|
||||
const title = decodeHtmlEntities(schedule.title || '');
|
||||
|
||||
// 관리자 페이지용 리스트 스타일
|
||||
if (variant === 'admin') {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'p-6 hover:bg-gray-50 transition-colors group',
|
||||
onClick && 'cursor-pointer',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
{/* 날짜 - 세로 중앙 정렬 */}
|
||||
{showDate && date && (
|
||||
<div className="w-20 text-center flex-shrink-0 self-center">
|
||||
{showYear && (
|
||||
<div className="text-xs text-gray-400 mb-0.5">
|
||||
{date.getFullYear()}.{date.getMonth() + 1}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-2xl font-bold text-gray-900">
|
||||
{date.getDate()}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{WEEKDAYS[date.getDay()]}요일
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 카테고리 바 */}
|
||||
<div
|
||||
className="w-1.5 rounded-full flex-shrink-0 self-stretch"
|
||||
style={{ backgroundColor: categoryInfo.color }}
|
||||
/>
|
||||
|
||||
{/* 내용 */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 line-clamp-2">{title}</h3>
|
||||
<div className="flex items-center flex-wrap gap-3 mt-1 text-sm text-gray-500">
|
||||
{timeStr && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock size={14} />
|
||||
{timeStr}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<Tag size={14} />
|
||||
{categoryInfo.name}
|
||||
</span>
|
||||
{schedule.source?.name && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Link2 size={14} />
|
||||
{schedule.source.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{memberList.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
<MemberBadges members={memberList} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 액션 버튼 - 생일 일정이 아닐 때만 표시 */}
|
||||
{!schedule.is_birthday && !String(schedule.id).startsWith('birthday-') && (
|
||||
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{schedule.source?.url && (
|
||||
<a
|
||||
href={schedule.source.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="p-2 hover:bg-blue-100 rounded-lg transition-colors text-blue-500"
|
||||
>
|
||||
<ExternalLink size={18} />
|
||||
</a>
|
||||
)}
|
||||
{onEdit && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit(schedule);
|
||||
}}
|
||||
className="p-2 hover:bg-gray-200 rounded-lg transition-colors text-gray-500"
|
||||
>
|
||||
<Edit2 size={18} />
|
||||
</button>
|
||||
)}
|
||||
{onDelete && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(schedule);
|
||||
}}
|
||||
className="p-2 hover:bg-red-100 rounded-lg transition-colors text-red-500"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 공개 페이지용 카드 스타일 (기본)
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'flex items-stretch bg-white rounded-2xl shadow-sm hover:shadow-md transition-shadow overflow-hidden',
|
||||
onClick && 'cursor-pointer',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* 좌측 날짜/카테고리 영역 */}
|
||||
<div
|
||||
className="w-24 flex flex-col items-center justify-center text-white py-6"
|
||||
style={{ backgroundColor: categoryInfo.color }}
|
||||
>
|
||||
{date && (
|
||||
<>
|
||||
{showYear && (
|
||||
<span className="text-xs font-medium opacity-60">
|
||||
{date.getFullYear()}.{date.getMonth() + 1}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-3xl font-bold">{date.getDate()}</span>
|
||||
<span className="text-sm font-medium opacity-80">{WEEKDAYS[date.getDay()]}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 우측 내용 영역 */}
|
||||
<div className="flex-1 p-6 flex flex-col justify-center">
|
||||
<h3 className="font-bold text-lg mb-2 line-clamp-2">{title}</h3>
|
||||
<div className="flex flex-wrap gap-3 text-base text-gray-500">
|
||||
{timeStr && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock size={16} className="opacity-60" />
|
||||
{timeStr}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<Tag size={16} className="opacity-60" />
|
||||
{categoryInfo.name}
|
||||
</span>
|
||||
{schedule.source?.name && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Link2 size={16} className="opacity-60" />
|
||||
{schedule.source.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{memberList.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
<MemberBadges members={memberList} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default ScheduleCard;
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
/**
|
||||
* 스케줄 컴포넌트 export
|
||||
*/
|
||||
export { default as ScheduleCard } from './ScheduleCard';
|
||||
|
|
@ -57,11 +57,3 @@ export const MONTH_NAMES = [
|
|||
'1월', '2월', '3월', '4월', '5월', '6월',
|
||||
'7월', '8월', '9월', '10월', '11월', '12월',
|
||||
];
|
||||
|
||||
/** 네비게이션 메뉴 항목 */
|
||||
export const NAV_ITEMS = [
|
||||
{ path: '/', label: '홈' },
|
||||
{ path: '/members', label: '멤버' },
|
||||
{ path: '/album', label: '앨범' },
|
||||
{ path: '/schedule', label: '일정' },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -24,9 +24,3 @@ export { useCalendar } from './useCalendar';
|
|||
|
||||
// 인증
|
||||
export { useAdminAuth, useRedirectIfAuthenticated } from './useAdminAuth';
|
||||
|
||||
// 앨범 데이터
|
||||
export { useAlbums, useAlbumDetail, useAlbumGallery } from './useAlbumData';
|
||||
|
||||
// 멤버 데이터
|
||||
export { useMembers, useMemberDetail } from './useMemberData';
|
||||
|
|
|
|||
|
|
@ -5,17 +5,10 @@ import { getTodayKST } from '@/utils';
|
|||
/**
|
||||
* 캘린더 훅
|
||||
* 날짜 선택, 월 이동 등 캘린더 로직 제공
|
||||
* @param {Date|string} initialDate - 초기 날짜
|
||||
* @param {Date} initialDate - 초기 날짜
|
||||
*/
|
||||
export function useCalendar(initialDate = new Date()) {
|
||||
// initialDate가 Date 객체가 아니면 변환
|
||||
const ensureDate = (date) => {
|
||||
if (date instanceof Date) return date;
|
||||
if (typeof date === 'string') return new Date(date);
|
||||
return new Date();
|
||||
};
|
||||
|
||||
const [currentDate, setCurrentDate] = useState(() => ensureDate(initialDate));
|
||||
const [currentDate, setCurrentDate] = useState(initialDate);
|
||||
const [selectedDate, setSelectedDate] = useState(getTodayKST());
|
||||
|
||||
const year = currentDate.getFullYear();
|
||||
|
|
@ -27,37 +20,6 @@ export function useCalendar(initialDate = new Date()) {
|
|||
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||
const prevMonthDays = new Date(year, month, 0).getDate();
|
||||
|
||||
// 캘린더에 표시할 날짜 배열 생성
|
||||
const days = [];
|
||||
|
||||
// 이전 달 날짜
|
||||
for (let i = firstDay - 1; i >= 0; i--) {
|
||||
days.push({
|
||||
day: prevMonthDays - i,
|
||||
isCurrentMonth: false,
|
||||
date: new Date(year, month - 1, prevMonthDays - i),
|
||||
});
|
||||
}
|
||||
|
||||
// 현재 달 날짜
|
||||
for (let i = 1; i <= daysInMonth; i++) {
|
||||
days.push({
|
||||
day: i,
|
||||
isCurrentMonth: true,
|
||||
date: new Date(year, month, i),
|
||||
});
|
||||
}
|
||||
|
||||
// 다음 달 날짜 (6주 채우기)
|
||||
const remaining = 42 - days.length; // 6주 * 7일 = 42
|
||||
for (let i = 1; i <= remaining; i++) {
|
||||
days.push({
|
||||
day: i,
|
||||
isCurrentMonth: false,
|
||||
date: new Date(year, month + 1, i),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
year,
|
||||
month,
|
||||
|
|
@ -66,7 +28,6 @@ export function useCalendar(initialDate = new Date()) {
|
|||
daysInMonth,
|
||||
prevMonthDays,
|
||||
weekdays: WEEKDAYS,
|
||||
days,
|
||||
};
|
||||
}, [year, month]);
|
||||
|
||||
|
|
@ -132,7 +93,6 @@ export function useCalendar(initialDate = new Date()) {
|
|||
...calendarData,
|
||||
currentDate,
|
||||
selectedDate,
|
||||
canGoPrev: canGoPrevMonth,
|
||||
canGoPrevMonth,
|
||||
goToPrevMonth,
|
||||
goToNextMonth,
|
||||
|
|
|
|||
|
|
@ -1,188 +0,0 @@
|
|||
import { useNavigate } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Calendar, Music } from 'lucide-react';
|
||||
import { useIsMobile } from '@/hooks';
|
||||
import { useAlbums } from '@/hooks';
|
||||
import { Loading } from '@/components';
|
||||
import { formatDate } from '@/utils';
|
||||
|
||||
/**
|
||||
* 앨범 목록 페이지 (PC/Mobile 통합)
|
||||
*/
|
||||
function Album() {
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// useQuery로 앨범 데이터 로드
|
||||
const { data: albums = [], isLoading } = useAlbums();
|
||||
|
||||
// 타이틀곡 찾기
|
||||
const getTitleTrack = (tracks) => {
|
||||
if (!tracks || tracks.length === 0) return '';
|
||||
const titleTrack = tracks.find((t) => t.is_title_track);
|
||||
return titleTrack ? titleTrack.title : tracks[0].title;
|
||||
};
|
||||
|
||||
// 앨범 타입 (short 우선)
|
||||
const getAlbumType = (album) => album.album_type_short || album.album_type;
|
||||
|
||||
// 앨범 통계
|
||||
const albumStats = {
|
||||
정규: albums.filter((a) => getAlbumType(a) === '정규').length,
|
||||
미니: albums.filter((a) => getAlbumType(a) === '미니').length,
|
||||
싱글: albums.filter((a) => getAlbumType(a) === '싱글').length,
|
||||
총: albums.length,
|
||||
};
|
||||
|
||||
// 앨범 클릭 핸들러
|
||||
const handleAlbumClick = (album) => {
|
||||
const path = isMobile ? album.folder_name : encodeURIComponent(album.title);
|
||||
navigate(`/album/${path}`);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-[60vh]">
|
||||
<Loading text="앨범 로딩 중..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 모바일 레이아웃
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div className="px-4 py-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{albums.map((album, index) => (
|
||||
<motion.div
|
||||
key={album.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
onClick={() => handleAlbumClick(album)}
|
||||
className="bg-white rounded-2xl overflow-hidden shadow-md cursor-pointer"
|
||||
>
|
||||
<div className="aspect-square bg-gray-200">
|
||||
{album.cover_thumb_url && (
|
||||
<img
|
||||
src={album.cover_thumb_url}
|
||||
alt={album.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<p className="font-semibold text-sm truncate">{album.title}</p>
|
||||
<p className="text-xs text-gray-400 mt-0.5">
|
||||
{getAlbumType(album)} · {album.release_date?.slice(0, 4)}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// PC 레이아웃
|
||||
return (
|
||||
<div className="py-16">
|
||||
<div className="max-w-7xl mx-auto px-6">
|
||||
{/* 헤더 */}
|
||||
<div className="text-center mb-8">
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-4xl font-bold mb-4"
|
||||
>
|
||||
앨범
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="text-gray-500"
|
||||
>
|
||||
프로미스나인의 음악을 만나보세요
|
||||
</motion.p>
|
||||
</div>
|
||||
|
||||
{/* 통계 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="mb-12 grid grid-cols-4 gap-4"
|
||||
>
|
||||
<div className="bg-gray-50 rounded-xl p-4 text-center">
|
||||
<p className="text-2xl font-bold text-primary mb-1">{albumStats.정규}</p>
|
||||
<p className="text-gray-500 text-sm">정규 앨범</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-xl p-4 text-center">
|
||||
<p className="text-2xl font-bold text-primary mb-1">{albumStats.미니}</p>
|
||||
<p className="text-gray-500 text-sm">미니 앨범</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-xl p-4 text-center">
|
||||
<p className="text-2xl font-bold text-primary mb-1">{albumStats.싱글}</p>
|
||||
<p className="text-gray-500 text-sm">싱글 앨범</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 rounded-xl p-4 text-center">
|
||||
<p className="text-2xl font-bold text-primary mb-1">{albumStats.총}</p>
|
||||
<p className="text-gray-500 text-sm">총 앨범</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 앨범 그리드 */}
|
||||
<div className="grid grid-cols-4 gap-8">
|
||||
{albums.map((album, index) => (
|
||||
<motion.div
|
||||
key={album.id}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.1, duration: 0.3 }}
|
||||
className="group bg-white rounded-2xl overflow-hidden shadow-lg hover:shadow-2xl transition-shadow duration-300 cursor-pointer"
|
||||
onClick={() => handleAlbumClick(album)}
|
||||
>
|
||||
{/* 앨범 커버 */}
|
||||
<div className="relative aspect-square bg-gray-100 overflow-hidden">
|
||||
<img
|
||||
src={album.cover_medium_url || album.cover_original_url}
|
||||
alt={album.title}
|
||||
loading="lazy"
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500 will-change-transform"
|
||||
/>
|
||||
|
||||
{/* 호버 오버레이 */}
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center">
|
||||
<div className="text-center text-white">
|
||||
<Music size={40} className="mx-auto mb-2" />
|
||||
<p className="text-sm">{album.tracks?.length || 0}곡 수록</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 앨범 정보 */}
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<h3 className="font-bold text-lg truncate flex-1">{album.title}</h3>
|
||||
<span className="px-2 py-0.5 bg-primary/10 text-primary text-xs font-medium rounded-full flex-shrink-0">
|
||||
{getAlbumType(album)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-primary text-sm font-medium mb-3">
|
||||
{getTitleTrack(album.tracks)}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<Calendar size={14} />
|
||||
<span>{formatDate(album.release_date, 'YYYY.MM.DD')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Album;
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
/**
|
||||
* 앨범 페이지 export
|
||||
*/
|
||||
export { default as Album } from './Album';
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
import { Link } from 'react-router-dom';
|
||||
import { Home } from 'lucide-react';
|
||||
import { useIsMobile } from '@/hooks';
|
||||
|
||||
/**
|
||||
* 404 페이지 (PC/Mobile 통합)
|
||||
*/
|
||||
function NotFound() {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh] p-4 text-center">
|
||||
<h1 className={`font-bold text-primary mb-4 ${isMobile ? 'text-6xl' : 'text-9xl'}`}>
|
||||
404
|
||||
</h1>
|
||||
<p className={`text-gray-600 mb-8 ${isMobile ? 'text-lg' : 'text-2xl'}`}>
|
||||
페이지를 찾을 수 없습니다
|
||||
</p>
|
||||
<Link
|
||||
to="/"
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-primary text-white rounded-full hover:bg-primary-dark transition-colors"
|
||||
>
|
||||
<Home size={20} />
|
||||
<span>홈으로 돌아가기</span>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NotFound;
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { default as NotFound } from './NotFound';
|
||||
|
|
@ -1,333 +0,0 @@
|
|||
import { Link } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Calendar, ArrowRight, Clock, Tag, Music } from 'lucide-react';
|
||||
import { useIsMobile, useMembers, useAlbums, useUpcomingSchedules } from '@/hooks';
|
||||
import { Loading } from '@/components';
|
||||
|
||||
/**
|
||||
* 홈 페이지 (PC/Mobile 통합)
|
||||
*/
|
||||
function Home() {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// 멤버 데이터
|
||||
const { data: members = [] } = useMembers();
|
||||
|
||||
// 앨범 데이터 (최신 4개)
|
||||
const { data: allAlbums = [] } = useAlbums();
|
||||
const albums = allAlbums.slice(0, 4);
|
||||
|
||||
// 다가오는 일정 (3개)
|
||||
const { data: upcomingSchedules = [] } = useUpcomingSchedules(3);
|
||||
|
||||
// D+Day 계산
|
||||
const debutDate = new Date('2018-01-24');
|
||||
const today = new Date();
|
||||
const dDay = Math.floor((today - debutDate) / (1000 * 60 * 60 * 24)) + 1;
|
||||
|
||||
// 모바일 레이아웃
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div className="pb-4">
|
||||
{/* 히어로 */}
|
||||
<div className="bg-gradient-to-br from-primary to-primary-dark text-white p-6">
|
||||
<h1 className="text-3xl font-bold mb-1">fromis_9</h1>
|
||||
<p className="text-lg opacity-80">프로미스나인</p>
|
||||
</div>
|
||||
|
||||
{/* 통계 */}
|
||||
<div className="grid grid-cols-2 gap-3 p-4">
|
||||
{[
|
||||
{ value: '2018.01.24', label: '데뷔일' },
|
||||
{ value: `D+${dDay.toLocaleString()}`, label: 'D+Day' },
|
||||
].map((stat, i) => (
|
||||
<div key={i} className="bg-primary/10 rounded-xl p-4 text-center">
|
||||
<p className="text-lg font-bold text-primary">{stat.value}</p>
|
||||
<p className="text-xs text-gray-500">{stat.label}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 멤버 미리보기 */}
|
||||
<div className="px-4 mb-6">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h2 className="text-lg font-bold">멤버</h2>
|
||||
<Link to="/members" className="text-primary text-sm flex items-center gap-1">
|
||||
전체보기 <ArrowRight size={14} />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex gap-3 overflow-x-auto pb-2">
|
||||
{members.filter((m) => !m.is_former).map((member) => (
|
||||
<div key={member.id} className="flex-shrink-0 w-20">
|
||||
<div className="aspect-square rounded-full overflow-hidden mb-1">
|
||||
<img src={member.image_url} alt={member.name} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
<p className="text-xs text-center font-medium truncate">{member.name}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 앨범 미리보기 */}
|
||||
<div className="px-4 mb-6">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h2 className="text-lg font-bold">앨범</h2>
|
||||
<Link to="/album" className="text-primary text-sm flex items-center gap-1">
|
||||
전체보기 <ArrowRight size={14} />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{albums.slice(0, 2).map((album) => (
|
||||
<Link key={album.id} to={`/album/${album.folder_name}`} className="block">
|
||||
<div className="aspect-square rounded-xl overflow-hidden mb-2">
|
||||
<img src={album.cover_thumb_url} alt={album.title} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
<p className="text-sm font-medium truncate">{album.title}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 일정 미리보기 */}
|
||||
<div className="px-4">
|
||||
<div className="flex justify-between items-center mb-3">
|
||||
<h2 className="text-lg font-bold">다가오는 일정</h2>
|
||||
<Link to="/schedule" className="text-primary text-sm flex items-center gap-1">
|
||||
전체보기 <ArrowRight size={14} />
|
||||
</Link>
|
||||
</div>
|
||||
{upcomingSchedules.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-400">
|
||||
<Calendar size={32} className="mx-auto mb-2 opacity-30" />
|
||||
<p className="text-sm">예정된 일정이 없습니다</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{upcomingSchedules.map((schedule) => (
|
||||
<div key={schedule.id} className="bg-white rounded-xl p-4 shadow-sm">
|
||||
<p className="font-medium mb-1">{schedule.title}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{new Date(schedule.date).getMonth() + 1}월 {new Date(schedule.date).getDate()}일
|
||||
{schedule.time && ` · ${schedule.time.slice(0, 5)}`}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// PC 레이아웃
|
||||
return (
|
||||
<div>
|
||||
{/* 히어로 섹션 */}
|
||||
<section className="relative h-[600px] bg-gradient-to-br from-primary to-primary-dark overflow-hidden">
|
||||
<div className="absolute inset-0 bg-black/20" />
|
||||
<div className="relative max-w-7xl mx-auto px-6 h-full flex items-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-white"
|
||||
>
|
||||
<h1 className="text-6xl font-bold mb-4">fromis_9</h1>
|
||||
<p className="text-2xl font-light mb-2">프로미스나인</p>
|
||||
<p className="text-lg opacity-80 leading-relaxed">
|
||||
인사드리겠습니다. 둘, 셋!
|
||||
<br />
|
||||
이제는 약속해 소중히 간직해,
|
||||
<br />
|
||||
당신의 아이돌로 성장하겠습니다!
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
<div className="absolute right-0 bottom-0 w-1/2 h-full opacity-10">
|
||||
<div className="absolute right-10 top-20 w-64 h-64 rounded-full bg-white/30" />
|
||||
<div className="absolute right-40 bottom-20 w-48 h-48 rounded-full bg-white/20" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 그룹 통계 */}
|
||||
<section className="py-16 bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto px-6">
|
||||
<div className="grid grid-cols-4 gap-6">
|
||||
{[
|
||||
{ value: '2018.01.24', label: '데뷔일' },
|
||||
{ value: `D+${dDay.toLocaleString()}`, label: 'D+Day' },
|
||||
{ value: '5', label: '멤버 수' },
|
||||
{ value: 'flover', label: '팬덤명' },
|
||||
].map((stat, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
className="bg-gradient-to-br from-primary to-primary-dark rounded-2xl p-6 text-white text-center"
|
||||
>
|
||||
<p className="text-3xl font-bold mb-1">{stat.value}</p>
|
||||
<p className="text-white/70 text-sm">{stat.label}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 멤버 미리보기 */}
|
||||
<section className="py-16 bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto px-6">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<h2 className="text-3xl font-bold">멤버</h2>
|
||||
<Link to="/members" className="text-primary hover:underline flex items-center gap-1">
|
||||
전체보기 <ArrowRight size={16} />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid grid-cols-5 gap-6">
|
||||
{members.filter((m) => !m.is_former).map((member, index) => (
|
||||
<motion.div
|
||||
key={member.id}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 + index * 0.1 }}
|
||||
className="group relative rounded-2xl overflow-hidden shadow-sm hover:shadow-xl transition-all duration-300"
|
||||
>
|
||||
<div className="aspect-[3/4] overflow-hidden">
|
||||
<img
|
||||
src={member.image_url}
|
||||
alt={member.name}
|
||||
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent opacity-60 group-hover:opacity-90 transition-opacity duration-300" />
|
||||
<div className="absolute bottom-0 left-0 right-0 p-5 text-white">
|
||||
<h3 className="font-bold text-xl drop-shadow-lg">{member.name}</h3>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 앨범 미리보기 */}
|
||||
<section className="py-16 bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto px-6">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<h2 className="text-3xl font-bold">앨범</h2>
|
||||
<Link to="/album" className="text-primary hover:underline flex items-center gap-1">
|
||||
전체보기 <ArrowRight size={16} />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-6">
|
||||
{albums.map((album, index) => (
|
||||
<motion.div
|
||||
key={album.id}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 + index * 0.1 }}
|
||||
className="group cursor-pointer"
|
||||
onClick={() => (window.location.href = `/album/${encodeURIComponent(album.title)}`)}
|
||||
>
|
||||
<div className="relative aspect-square rounded-2xl overflow-hidden shadow-md hover:shadow-xl transition-all duration-300">
|
||||
<img
|
||||
src={album.cover_medium_url || album.cover_original_url}
|
||||
alt={album.title}
|
||||
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center">
|
||||
<div className="text-center text-white">
|
||||
<Music size={32} className="mx-auto mb-2" />
|
||||
<p className="text-sm">{album.tracks?.length || 0}곡 수록</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<h3 className="font-bold text-lg truncate">{album.title}</h3>
|
||||
<p className="text-gray-500 text-sm">{album.release_date?.slice(0, 4)}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 일정 미리보기 */}
|
||||
<section className="py-16 bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto px-6">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<h2 className="text-3xl font-bold">다가오는 일정</h2>
|
||||
<Link to="/schedule" className="text-primary hover:underline flex items-center gap-1">
|
||||
전체보기 <ArrowRight size={16} />
|
||||
</Link>
|
||||
</div>
|
||||
{upcomingSchedules.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<Calendar size={48} className="mx-auto mb-4 opacity-30" />
|
||||
<p>예정된 일정이 없습니다</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{upcomingSchedules.map((schedule) => {
|
||||
const scheduleDate = new Date(schedule.date);
|
||||
const categoryColor = schedule.category_color || '#6366f1';
|
||||
const memberList = schedule.member_names
|
||||
? schedule.member_names.split(',')
|
||||
: schedule.members?.map((m) => m.name) || [];
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={schedule.id}
|
||||
initial={{ opacity: 0, x: -30 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
viewport={{ once: true }}
|
||||
className="flex items-stretch bg-white rounded-2xl shadow-sm hover:shadow-md transition-shadow overflow-hidden cursor-pointer"
|
||||
>
|
||||
<div
|
||||
className="w-24 flex flex-col items-center justify-center text-white py-6"
|
||||
style={{ backgroundColor: categoryColor }}
|
||||
>
|
||||
<span className="text-3xl font-bold">{scheduleDate.getDate()}</span>
|
||||
<span className="text-sm font-medium opacity-80">
|
||||
{['일', '월', '화', '수', '목', '금', '토'][scheduleDate.getDay()]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 p-6 flex flex-col justify-center">
|
||||
<h3 className="font-bold text-lg mb-2">{schedule.title}</h3>
|
||||
<div className="flex flex-wrap gap-3 text-base text-gray-500">
|
||||
{schedule.time && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock size={16} className="opacity-60" />
|
||||
{schedule.time.slice(0, 5)}
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
<Tag size={16} className="opacity-60" />
|
||||
{schedule.category_name}
|
||||
</span>
|
||||
</div>
|
||||
{memberList.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
{memberList.map((name, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="px-2 py-0.5 bg-primary/10 text-primary text-sm font-medium rounded-full"
|
||||
>
|
||||
{name.trim()}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Home;
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { default as Home } from './Home';
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
/**
|
||||
* 페이지 export
|
||||
*/
|
||||
export * from './schedule';
|
||||
export * from './album';
|
||||
export * from './home';
|
||||
export * from './members';
|
||||
export * from './common';
|
||||
|
|
@ -1,181 +0,0 @@
|
|||
import { motion } from 'framer-motion';
|
||||
import { Instagram, Calendar } from 'lucide-react';
|
||||
import { useIsMobile, useMembers } from '@/hooks';
|
||||
import { Loading } from '@/components';
|
||||
import { formatDate } from '@/utils';
|
||||
|
||||
/**
|
||||
* 멤버 카드 컴포넌트 (PC)
|
||||
*/
|
||||
function MemberCard({ member, isFormer = false, delay = 0 }) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay }}
|
||||
className="group h-full"
|
||||
>
|
||||
<div className="relative bg-white rounded-3xl overflow-hidden shadow-lg hover:shadow-2xl transition-all duration-300 h-full flex flex-col">
|
||||
{/* 이미지 */}
|
||||
<div className="aspect-[3/4] bg-gray-100 overflow-hidden flex-shrink-0">
|
||||
<img
|
||||
src={member.image_url}
|
||||
alt={member.name}
|
||||
className={`w-full h-full object-cover group-hover:scale-105 transition-transform duration-500 ${
|
||||
isFormer ? 'grayscale group-hover:grayscale-0' : ''
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 정보 */}
|
||||
<div className="p-6 flex-1 flex flex-col">
|
||||
<h3 className={`text-xl font-bold mb-3 ${isFormer ? 'text-gray-500' : ''}`}>
|
||||
{member.name}
|
||||
</h3>
|
||||
|
||||
<div className={`flex items-center gap-2 text-sm ${isFormer ? 'text-gray-400' : 'text-gray-500'} mb-2`}>
|
||||
<Calendar size={14} />
|
||||
<span>{formatDate(member.birth_date, 'YYYY.MM.DD')}</span>
|
||||
</div>
|
||||
|
||||
{/* 인스타그램 링크 */}
|
||||
{member.instagram && !isFormer && (
|
||||
<a
|
||||
href={member.instagram}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 text-sm text-gray-500 hover:text-pink-500 transition-colors mt-auto"
|
||||
>
|
||||
<Instagram size={16} />
|
||||
<span>Instagram</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 호버 효과 - 컬러 바 */}
|
||||
<div
|
||||
className={`absolute bottom-0 left-0 right-0 h-1 ${
|
||||
isFormer ? 'bg-gray-400' : 'bg-primary'
|
||||
} transform scale-x-0 group-hover:scale-x-100 transition-transform duration-300`}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 멤버 페이지 (PC/Mobile 통합)
|
||||
*/
|
||||
function Members() {
|
||||
const isMobile = useIsMobile();
|
||||
const { data: members = [], isLoading } = useMembers();
|
||||
|
||||
const currentMembers = members.filter((m) => !m.is_former);
|
||||
const formerMembers = members.filter((m) => m.is_former);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-[60vh]">
|
||||
<Loading text="멤버 로딩 중..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 모바일 레이아웃
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div className="px-4 py-4">
|
||||
{/* 현재 멤버 */}
|
||||
<div className="grid grid-cols-2 gap-4 mb-8">
|
||||
{currentMembers.map((member) => (
|
||||
<div key={member.id} className="bg-white rounded-2xl overflow-hidden shadow-md">
|
||||
<div className="aspect-[3/4] bg-gray-200">
|
||||
<img src={member.image_url} alt={member.name} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<p className="font-semibold">{member.name}</p>
|
||||
<p className="text-xs text-gray-400">{formatDate(member.birth_date, 'YYYY.MM.DD')}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 전 멤버 */}
|
||||
{formerMembers.length > 0 && (
|
||||
<>
|
||||
<h2 className="text-lg font-bold text-gray-400 mb-4">전 멤버</h2>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{formerMembers.map((member) => (
|
||||
<div key={member.id} className="bg-white rounded-2xl overflow-hidden shadow-md">
|
||||
<div className="aspect-[3/4] bg-gray-200">
|
||||
<img
|
||||
src={member.image_url}
|
||||
alt={member.name}
|
||||
className="w-full h-full object-cover grayscale"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<p className="font-semibold text-gray-500">{member.name}</p>
|
||||
<p className="text-xs text-gray-400">{formatDate(member.birth_date, 'YYYY.MM.DD')}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// PC 레이아웃
|
||||
return (
|
||||
<div className="py-16">
|
||||
<div className="max-w-7xl mx-auto px-6">
|
||||
{/* 헤더 */}
|
||||
<div className="text-center mb-12">
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-4xl font-bold mb-4"
|
||||
>
|
||||
멤버
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="text-gray-500"
|
||||
>
|
||||
프로미스나인의 멤버를 소개합니다
|
||||
</motion.p>
|
||||
</div>
|
||||
|
||||
{/* 현재 멤버 그리드 */}
|
||||
<div className="grid grid-cols-5 gap-8">
|
||||
{currentMembers.map((member, index) => (
|
||||
<MemberCard key={member.id} member={member} delay={index * 0.1} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 전 멤버 섹션 */}
|
||||
{formerMembers.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
className="mt-16"
|
||||
>
|
||||
<h2 className="text-2xl font-bold mb-8 text-gray-400">전 멤버</h2>
|
||||
<div className="grid grid-cols-5 gap-8">
|
||||
{formerMembers.map((member, index) => (
|
||||
<MemberCard key={member.id} member={member} isFormer delay={0.6 + index * 0.1} />
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Members;
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { default as Members } from './Members';
|
||||
|
|
@ -1,330 +0,0 @@
|
|||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ChevronLeft, ChevronRight, Search } from 'lucide-react';
|
||||
import { useIsMobile, useScheduleData, useCategories, useCalendar } from '@/hooks';
|
||||
import { useScheduleStore } from '@/stores';
|
||||
import { Loading, ScheduleCard } from '@/components';
|
||||
import { cn, getTodayKST, decodeHtmlEntities } from '@/utils';
|
||||
import { WEEKDAYS, MIN_YEAR } from '@/constants';
|
||||
|
||||
/**
|
||||
* PC 캘린더 컴포넌트
|
||||
*/
|
||||
function PCCalendar({ selectedDate, schedules, categories, onSelectDate, onMonthChange }) {
|
||||
const { days, year, month, canGoPrev } = useCalendar(selectedDate);
|
||||
|
||||
// 날짜별 일정 맵
|
||||
const scheduleDates = useMemo(() => {
|
||||
const dateMap = {};
|
||||
schedules?.forEach((schedule) => {
|
||||
const date = schedule.date?.split('T')[0];
|
||||
if (!dateMap[date]) dateMap[date] = [];
|
||||
const cat = categories?.find((c) => c.id === (schedule.category_id || schedule.category?.id));
|
||||
dateMap[date].push(cat?.color || '#6b7280');
|
||||
});
|
||||
return dateMap;
|
||||
}, [schedules, categories]);
|
||||
|
||||
const isToday = (date) => {
|
||||
const today = new Date();
|
||||
return (
|
||||
date.getDate() === today.getDate() &&
|
||||
date.getMonth() === today.getMonth() &&
|
||||
date.getFullYear() === today.getFullYear()
|
||||
);
|
||||
};
|
||||
|
||||
const isSelected = (date) => {
|
||||
const sel = new Date(selectedDate);
|
||||
return (
|
||||
date.getDate() === sel.getDate() &&
|
||||
date.getMonth() === sel.getMonth() &&
|
||||
date.getFullYear() === sel.getFullYear()
|
||||
);
|
||||
};
|
||||
|
||||
const formatDateStr = (date) => {
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-2xl shadow-sm p-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<button
|
||||
onClick={() => canGoPrev && onMonthChange(-1)}
|
||||
disabled={!canGoPrev}
|
||||
className={cn('p-2 rounded-lg hover:bg-gray-100', !canGoPrev && 'opacity-30 cursor-not-allowed')}
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
<h2 className="text-lg font-bold">
|
||||
{year}년 {month + 1}월
|
||||
</h2>
|
||||
<button onClick={() => onMonthChange(1)} className="p-2 rounded-lg hover:bg-gray-100">
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 요일 헤더 */}
|
||||
<div className="grid grid-cols-7 gap-1 mb-2">
|
||||
{WEEKDAYS.map((day, i) => (
|
||||
<div
|
||||
key={day}
|
||||
className={cn(
|
||||
'text-center text-xs font-medium py-2',
|
||||
i === 0 ? 'text-red-400' : i === 6 ? 'text-blue-400' : 'text-gray-500'
|
||||
)}
|
||||
>
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 날짜 그리드 */}
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{days.map((item, index) => {
|
||||
const dayOfWeek = index % 7;
|
||||
const dateStr = formatDateStr(item.date);
|
||||
const colors = scheduleDates[dateStr] || [];
|
||||
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => onSelectDate(item.date)}
|
||||
className="flex flex-col items-center py-2"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'w-9 h-9 flex items-center justify-center text-sm font-medium rounded-full transition-all',
|
||||
!item.isCurrentMonth
|
||||
? 'text-gray-300'
|
||||
: isSelected(item.date)
|
||||
? 'bg-primary text-white font-bold'
|
||||
: isToday(item.date)
|
||||
? 'text-primary font-bold'
|
||||
: dayOfWeek === 0
|
||||
? 'text-red-500 hover:bg-red-50'
|
||||
: dayOfWeek === 6
|
||||
? 'text-blue-500 hover:bg-blue-50'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
)}
|
||||
>
|
||||
{item.day}
|
||||
</span>
|
||||
{/* 일정 점 */}
|
||||
{!isSelected(item.date) && colors.length > 0 && (
|
||||
<div className="flex gap-0.5 mt-1 h-1.5">
|
||||
{colors.slice(0, 3).map((color, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="w-1 h-1 rounded-full"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 스케줄 페이지 (PC/Mobile 통합)
|
||||
*/
|
||||
function Schedule() {
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
// Zustand store
|
||||
const { currentDate: storedCurrentDate, setCurrentDate, selectedDate, setSelectedDate } = useScheduleStore();
|
||||
|
||||
// 초기값 설정 - currentDate가 Date 객체가 아닐 수 있으므로 안전하게 변환
|
||||
const today = getTodayKST();
|
||||
const actualSelectedDate = selectedDate || today;
|
||||
const currentDate = storedCurrentDate instanceof Date ? storedCurrentDate : new Date(storedCurrentDate || today);
|
||||
|
||||
// 데이터 로드
|
||||
const year = currentDate.getFullYear();
|
||||
const month = currentDate.getMonth() + 1;
|
||||
const { data: schedules, isLoading: schedulesLoading } = useScheduleData(year, month);
|
||||
const { data: categories } = useCategories();
|
||||
|
||||
// 선택된 날짜의 일정
|
||||
const selectedDateSchedules = useMemo(() => {
|
||||
if (!schedules) return [];
|
||||
const sel = new Date(actualSelectedDate);
|
||||
const dateStr = `${sel.getFullYear()}-${String(sel.getMonth() + 1).padStart(2, '0')}-${String(sel.getDate()).padStart(2, '0')}`;
|
||||
return schedules
|
||||
.filter((s) => s.date?.split('T')[0] === dateStr)
|
||||
.sort((a, b) => {
|
||||
const aIsBirthday = a.is_birthday || String(a.id).startsWith('birthday-');
|
||||
const bIsBirthday = b.is_birthday || String(b.id).startsWith('birthday-');
|
||||
if (aIsBirthday && !bIsBirthday) return -1;
|
||||
if (!aIsBirthday && bIsBirthday) return 1;
|
||||
return 0;
|
||||
});
|
||||
}, [schedules, actualSelectedDate]);
|
||||
|
||||
// 월 변경
|
||||
const changeMonth = (delta) => {
|
||||
const newDate = new Date(currentDate);
|
||||
newDate.setMonth(newDate.getMonth() + delta);
|
||||
|
||||
// 2017년 1월 이전으로 이동 불가
|
||||
if (newDate.getFullYear() < MIN_YEAR || (newDate.getFullYear() === MIN_YEAR && newDate.getMonth() < 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentDate(newDate);
|
||||
|
||||
// 이번 달이면 오늘 날짜, 다른 달이면 1일 선택
|
||||
const now = new Date();
|
||||
if (newDate.getFullYear() === now.getFullYear() && newDate.getMonth() === now.getMonth()) {
|
||||
setSelectedDate(getTodayKST());
|
||||
} else {
|
||||
const firstDay = `${newDate.getFullYear()}-${String(newDate.getMonth() + 1).padStart(2, '0')}-01`;
|
||||
setSelectedDate(firstDay);
|
||||
}
|
||||
};
|
||||
|
||||
// 날짜 선택
|
||||
const handleSelectDate = (date) => {
|
||||
const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||
setSelectedDate(dateStr);
|
||||
|
||||
// 월이 다르면 currentDate도 변경
|
||||
if (date.getMonth() !== currentDate.getMonth() || date.getFullYear() !== currentDate.getFullYear()) {
|
||||
setCurrentDate(date);
|
||||
}
|
||||
};
|
||||
|
||||
// PC 레이아웃
|
||||
if (!isMobile) {
|
||||
return (
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* 좌측: 캘린더 */}
|
||||
<div className="w-80 flex-shrink-0 p-6 border-r overflow-y-auto">
|
||||
<PCCalendar
|
||||
selectedDate={actualSelectedDate}
|
||||
schedules={schedules}
|
||||
categories={categories}
|
||||
onSelectDate={handleSelectDate}
|
||||
onMonthChange={changeMonth}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 우측: 일정 목록 */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-bold">
|
||||
{new Date(actualSelectedDate).getMonth() + 1}월{' '}
|
||||
{new Date(actualSelectedDate).getDate()}일{' '}
|
||||
{WEEKDAYS[new Date(actualSelectedDate).getDay()]}요일
|
||||
</h2>
|
||||
<span className="text-sm text-gray-500">
|
||||
{selectedDateSchedules.length}개의 일정
|
||||
</span>
|
||||
</div>
|
||||
<button className="p-2 hover:bg-gray-100 rounded-lg">
|
||||
<Search size={20} className="text-gray-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 일정 목록 */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{schedulesLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<Loading text="일정 로딩 중..." />
|
||||
</div>
|
||||
) : selectedDateSchedules.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
{new Date(actualSelectedDate).getMonth() + 1}월{' '}
|
||||
{new Date(actualSelectedDate).getDate()}일 일정이 없습니다
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{selectedDateSchedules.map((schedule) => (
|
||||
<ScheduleCard
|
||||
key={schedule.id}
|
||||
schedule={schedule}
|
||||
variant="public"
|
||||
showDate={false}
|
||||
onClick={() => navigate(`/schedule/${schedule.id}`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 모바일 레이아웃 (간소화된 버전)
|
||||
return (
|
||||
<>
|
||||
{/* 모바일 툴바 */}
|
||||
<div className="mobile-toolbar-schedule shadow-sm z-50">
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<button
|
||||
onClick={() => changeMonth(-1)}
|
||||
className="p-2"
|
||||
disabled={currentDate.getFullYear() === MIN_YEAR && currentDate.getMonth() === 0}
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
<span className="font-bold">
|
||||
{currentDate.getFullYear()}년 {currentDate.getMonth() + 1}월
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => changeMonth(1)} className="p-2">
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
<button className="p-2">
|
||||
<Search size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 모바일 컨텐츠 */}
|
||||
<div className="mobile-content">
|
||||
<div className="px-4 py-4">
|
||||
{schedulesLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<Loading size="sm" />
|
||||
</div>
|
||||
) : selectedDateSchedules.length === 0 ? (
|
||||
<div className="text-center py-8 text-gray-400">
|
||||
{new Date(actualSelectedDate).getMonth() + 1}월{' '}
|
||||
{new Date(actualSelectedDate).getDate()}일 일정이 없습니다
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{selectedDateSchedules.map((schedule) => (
|
||||
<ScheduleCard
|
||||
key={schedule.id}
|
||||
schedule={schedule}
|
||||
variant="public"
|
||||
showDate={false}
|
||||
onClick={() => navigate(`/schedule/${schedule.id}`)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Schedule;
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
/**
|
||||
* 스케줄 페이지 export
|
||||
*/
|
||||
export { default as Schedule } from './Schedule';
|
||||
Loading…
Add table
Reference in a new issue