fromis_9/frontend/src/pages/pc/public/schedule/ScheduleDetail.jsx
caadiq 6feedae267 feat: PC 예능 일정 상세 페이지 추가
- VarietySection: 썸네일, 방송사 뱃지, 제목, 날짜/시간, 멤버, 다시보기 버튼
- ScheduleDetail에서 '예능' 카테고리 분기 연결

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 13:45:14 +09:00

211 lines
6.9 KiB
JavaScript

import { useParams, Link } from 'react-router-dom';
import { useQuery, keepPreviousData } from '@tanstack/react-query';
import { motion } from 'framer-motion';
import { Calendar, ChevronRight } from 'lucide-react';
import { getSchedule } from '@/api';
// 섹션 컴포넌트들
import { YoutubeSection, XSection, VarietySection, DefaultSection, decodeHtmlEntities } from './sections';
import Birthday from './Birthday';
/**
* 특수 일정 ID 파싱
* @param {string} id - 일정 ID
* @returns {object|null} { type, year, nameEn } 또는 null
*/
function parseSpecialId(id) {
// birthday-{year}-{nameEn} 형식
const birthdayMatch = id.match(/^birthday-(\d{4})-(.+)$/);
if (birthdayMatch) {
return { type: 'birthday', year: birthdayMatch[1], nameEn: birthdayMatch[2] };
}
// debut-{year} 형식
const debutMatch = id.match(/^debut-(\d{4})$/);
if (debutMatch) {
return { type: 'debut', year: debutMatch[1] };
}
// anniversary-{year} 형식
const anniversaryMatch = id.match(/^anniversary-(\d{4})$/);
if (anniversaryMatch) {
return { type: 'anniversary', year: anniversaryMatch[1] };
}
return null;
}
/**
* PC 일정 상세 페이지
*/
function PCScheduleDetail() {
const { id } = useParams();
// 특수 일정 ID 체크
const specialId = parseSpecialId(id);
if (specialId?.type === 'birthday') {
return <Birthday year={specialId.year} nameEn={specialId.nameEn} />;
}
const {
data: schedule,
isLoading,
error,
} = useQuery({
queryKey: ['schedule', id],
queryFn: () => getSchedule(id),
placeholderData: keepPreviousData,
retry: false,
});
if (isLoading) {
return <div className="min-h-[calc(100vh-64px)] bg-gray-50" />;
}
if (error || !schedule) {
return (
<div className="min-h-[calc(100vh-64px)] flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100">
<div className="text-center px-6">
{/* 아이콘 */}
<motion.div
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5, type: 'spring', stiffness: 100 }}
className="mb-8"
>
<div className="w-32 h-32 mx-auto bg-gradient-to-br from-primary/10 to-primary/5 rounded-3xl flex items-center justify-center">
<Calendar size={64} className="text-primary/40" />
</div>
</motion.div>
{/* 메시지 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2, duration: 0.5 }}
className="mb-8"
>
<h2 className="text-2xl font-bold text-gray-800 mb-3">일정을 찾을 없습니다</h2>
<p className="text-gray-500 leading-relaxed">
요청하신 일정이 존재하지 않거나 삭제되었을 있습니다.
<br />
다른 일정을 확인해 주세요.
</p>
</motion.div>
{/* 장식 요소 */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.4, duration: 0.5 }}
className="flex justify-center gap-2 mb-10"
>
{[...Array(5)].map((_, i) => (
<motion.div
key={i}
className="w-2 h-2 rounded-full bg-primary"
animate={{
y: [0, -8, 0],
opacity: [0.3, 1, 0.3],
}}
transition={{
duration: 1.2,
repeat: Infinity,
delay: i * 0.15,
ease: 'easeInOut',
}}
/>
))}
</motion.div>
{/* 버튼들 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5, duration: 0.5 }}
className="flex justify-center gap-4"
>
<button
onClick={() => window.history.back()}
className="flex items-center gap-2 px-6 py-3 border-2 border-primary text-primary rounded-full font-medium hover:bg-primary hover:text-white transition-colors duration-200"
>
<ChevronRight size={18} className="rotate-180" />
이전 페이지
</button>
<Link
to="/schedule"
className="flex items-center gap-2 px-6 py-3 bg-gradient-to-r from-primary to-primary-dark text-white rounded-full font-medium hover:shadow-lg hover:shadow-primary/30 transition-all duration-200"
>
<Calendar size={18} />
일정 목록
</Link>
</motion.div>
</div>
</div>
);
}
// 카테고리별 섹션 렌더링
const categoryName = schedule.category?.name;
const renderCategorySection = () => {
switch (categoryName) {
case '유튜브':
return <YoutubeSection schedule={schedule} />;
case 'X':
return <XSection schedule={schedule} />;
case '예능':
return <VarietySection schedule={schedule} />;
default:
return <DefaultSection schedule={schedule} />;
}
};
const isYoutube = categoryName === '유튜브';
const isX = categoryName === 'X';
const isVariety = categoryName === '예능';
const hasCustomLayout = isYoutube || isX || isVariety;
return (
<div className="min-h-[calc(100vh-64px)] bg-gray-50">
<div className={`${isYoutube ? 'max-w-5xl' : 'max-w-3xl'} mx-auto px-6 py-8`}>
{/* 브레드크럼 네비게이션 */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{
duration: 0.3,
ease: [0.25, 0.1, 0.25, 1],
delay: 0.1,
}}
className={`flex items-center gap-2 text-sm text-gray-500 mb-8 ${isX ? 'max-w-2xl mx-auto' : ''}`}
>
<Link to="/schedule" className="hover:text-primary transition-colors">
일정
</Link>
<ChevronRight size={14} />
<span className="hover:text-primary transition-colors" style={{ color: schedule.category?.color }}>
{schedule.category?.name}
</span>
<ChevronRight size={14} />
<span className="text-gray-700 font-medium truncate max-w-md">{decodeHtmlEntities(schedule.title)}</span>
</motion.div>
{/* 메인 컨텐츠 */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.4,
ease: [0.25, 0.1, 0.25, 1],
delay: 0.15,
}}
className={`${hasCustomLayout ? '' : 'bg-white rounded-3xl shadow-sm p-8'}`}
>
{renderCategorySection()}
</motion.div>
</div>
</div>
);
}
export default PCScheduleDetail;