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:
caadiq 2026-01-21 20:11:21 +09:00
parent e0ab3ce0f8
commit 2d42bf1603
28 changed files with 130 additions and 2145 deletions

View file

@ -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>
}
/>
<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>
<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>
</ErrorBoundary>
</BrowserRouter>
);
}

View file

@ -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;

View file

@ -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;

View file

@ -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
)}
/>
);
}

View file

@ -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>
);
}

View file

@ -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';

View file

@ -1,12 +0,0 @@
/**
* 컴포넌트 통합 export
*/
// 공통 컴포넌트
export * from './common';
// 레이아웃 컴포넌트
export * from './layout';
// 스케줄 컴포넌트
export * from './schedule';

View file

@ -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>&copy; {currentYear} fromis_9 Fan Site. This is an unofficial fan-made website.</p>
</div>
</div>
</footer>
);
}
export default Footer;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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';

View file

@ -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;

View file

@ -1,4 +0,0 @@
/**
* 스케줄 컴포넌트 export
*/
export { default as ScheduleCard } from './ScheduleCard';

View file

@ -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: '일정' },
];

View file

@ -24,9 +24,3 @@ export { useCalendar } from './useCalendar';
// 인증
export { useAdminAuth, useRedirectIfAuthenticated } from './useAdminAuth';
// 앨범 데이터
export { useAlbums, useAlbumDetail, useAlbumGallery } from './useAlbumData';
// 멤버 데이터
export { useMembers, useMemberDetail } from './useMemberData';

View file

@ -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,

View file

@ -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;

View file

@ -1,4 +0,0 @@
/**
* 앨범 페이지 export
*/
export { default as Album } from './Album';

View file

@ -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;

View file

@ -1 +0,0 @@
export { default as NotFound } from './NotFound';

View file

@ -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;

View file

@ -1 +0,0 @@
export { default as Home } from './Home';

View file

@ -1,8 +0,0 @@
/**
* 페이지 export
*/
export * from './schedule';
export * from './album';
export * from './home';
export * from './members';
export * from './common';

View file

@ -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;

View file

@ -1 +0,0 @@
export { default as Members } from './Members';

View file

@ -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;

View file

@ -1,4 +0,0 @@
/**
* 스케줄 페이지 export
*/
export { default as Schedule } from './Schedule';