refactor: 코드 구조 개선 및 중복 제거

- 페이지 폴더 구조를 문서대로 정리 (pc/, mobile/ 하위 폴더)
- Mobile Schedule 리팩토링 (1,495줄 → 780줄, 48% 감소)
- MobileCalendar를 별도 공통 컴포넌트로 분리
- MobileBirthdayCard에 motion/delay 지원 추가
- 중복 상수 통합: CATEGORY_ID, MIN_YEAR, SEARCH_LIMIT, MEMBER_ENGLISH_NAMES
- sections/utils.js 중복 함수 제거 (@/utils에서 re-export)
- formatXDateTime 함수 개선 (datetime 문자열 직접 처리)
- 모바일 유튜브 숏츠 표시 개선 (가로 비율, 전체화면시 세로)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-01-22 11:32:43 +09:00
parent dd0e508117
commit 97850b12c1
30 changed files with 4736 additions and 591 deletions

View file

@ -14,8 +14,24 @@ import { Layout as MobileLayout } from '@/components/mobile';
// //
import { PCHome, MobileHome } from '@/pages/home'; import { PCHome, MobileHome } from '@/pages/home';
import { PCMembers, MobileMembers } from '@/pages/members'; import { PCMembers, MobileMembers } from '@/pages/members';
import { PCSchedule, MobileSchedule } from '@/pages/schedule'; import {
import { PCAlbum, MobileAlbum } from '@/pages/album'; PCSchedule,
MobileSchedule,
PCScheduleDetail,
MobileScheduleDetail,
PCBirthday,
MobileBirthday,
} from '@/pages/schedule';
import {
PCAlbum,
MobileAlbum,
PCAlbumDetail,
MobileAlbumDetail,
PCTrackDetail,
MobileTrackDetail,
PCAlbumGallery,
MobileAlbumGallery,
} from '@/pages/album';
/** /**
* PC 환경에서 body에 클래스 추가하는 래퍼 * PC 환경에서 body에 클래스 추가하는 래퍼
@ -53,9 +69,13 @@ function App() {
<Route path="/" element={<PCHome />} /> <Route path="/" element={<PCHome />} />
<Route path="/members" element={<PCMembers />} /> <Route path="/members" element={<PCMembers />} />
<Route path="/schedule" element={<PCSchedule />} /> <Route path="/schedule" element={<PCSchedule />} />
<Route path="/schedule/:id" element={<PCScheduleDetail />} />
<Route path="/birthday/:memberName/:year" element={<PCBirthday />} />
<Route path="/album" element={<PCAlbum />} /> <Route path="/album" element={<PCAlbum />} />
{/* 추가 페이지는 Phase 11에서 구현 */} <Route path="/album/:name" element={<PCAlbumDetail />} />
{/* <Route path="/album/:name" element={<PCAlbumDetail />} /> */} <Route path="/album/:name/track/:trackTitle" element={<PCTrackDetail />} />
<Route path="/album/:name/gallery" element={<PCAlbumGallery />} />
{/* 추가 페이지 */}
{/* <Route path="*" element={<PCNotFound />} /> */} {/* <Route path="*" element={<PCNotFound />} /> */}
</Routes> </Routes>
</PCLayout> </PCLayout>
@ -92,6 +112,8 @@ function App() {
</MobileLayout> </MobileLayout>
} }
/> />
<Route path="/schedule/:id" element={<MobileScheduleDetail />} />
<Route path="/birthday/:memberName/:year" element={<MobileBirthday />} />
<Route <Route
path="/album" path="/album"
element={ element={
@ -100,8 +122,31 @@ function App() {
</MobileLayout> </MobileLayout>
} }
/> />
{/* 추가 페이지는 Phase 11에서 구현 */} <Route
{/* <Route path="/album/:name" element={<MobileLayout><MobileAlbumDetail /></MobileLayout>} /> */} path="/album/:name"
element={
<MobileLayout hideHeader>
<MobileAlbumDetail />
</MobileLayout>
}
/>
<Route
path="/album/:name/track/:trackTitle"
element={
<MobileLayout pageTitle="곡 상세">
<MobileTrackDetail />
</MobileLayout>
}
/>
<Route
path="/album/:name/gallery"
element={
<MobileLayout hideHeader>
<MobileAlbumGallery />
</MobileLayout>
}
/>
{/* 추가 페이지 */}
</Routes> </Routes>
</MobileView> </MobileView>
</BrowserRouter> </BrowserRouter>

View file

@ -10,7 +10,7 @@ function Layout({ children }) {
const location = useLocation(); const location = useLocation();
// Footer ( ) // Footer ( )
const hideFooterPages = ['/schedule', '/members', '/album']; const hideFooterPages = ['/schedule', '/members', '/album', '/birthday'];
const hideFooter = hideFooterPages.some( const hideFooter = hideFooterPages.some(
(path) => (path) =>
location.pathname === path || location.pathname.startsWith(path + '/') location.pathname === path || location.pathname.startsWith(path + '/')

View file

@ -1,5 +1,6 @@
import { motion } from 'framer-motion';
import confetti from 'canvas-confetti'; import confetti from 'canvas-confetti';
import { dayjs } from '@/utils'; import { dayjs, decodeHtmlEntities } from '@/utils';
/** /**
* 생일 폭죽 애니메이션 * 생일 폭죽 애니메이션
@ -118,8 +119,12 @@ function BirthdayCard({ schedule, showYear = false, onClick }) {
/** /**
* Mobile용 생일 카드 컴포넌트 * Mobile용 생일 카드 컴포넌트
* @param {Object} schedule - 일정 데이터
* @param {boolean} showYear - 년도 표시 여부
* @param {number} delay - 애니메이션 딜레이 ()
* @param {function} onClick - 클릭 핸들러
*/ */
export function MobileBirthdayCard({ schedule, showYear = false, onClick }) { export function MobileBirthdayCard({ schedule, showYear = false, delay = 0, onClick }) {
const scheduleDate = dayjs(schedule.date); const scheduleDate = dayjs(schedule.date);
const formatted = { const formatted = {
year: scheduleDate.year(), year: scheduleDate.year(),
@ -127,23 +132,20 @@ export function MobileBirthdayCard({ schedule, showYear = false, onClick }) {
day: scheduleDate.date(), day: scheduleDate.date(),
}; };
return ( const CardContent = (
<div <div className="relative overflow-hidden bg-gradient-to-r from-pink-400 via-purple-400 to-indigo-400 rounded-xl shadow-md hover:shadow-lg transition-shadow cursor-pointer">
onClick={onClick}
className="relative overflow-hidden bg-gradient-to-r from-pink-400 via-purple-400 to-indigo-400 rounded-xl shadow-md hover:shadow-lg transition-shadow cursor-pointer"
>
{/* 배경 장식 */} {/* 배경 장식 */}
<div className="absolute inset-0 overflow-hidden"> <div className="absolute inset-0 overflow-hidden">
<div className="absolute -top-2 -right-2 w-16 h-16 bg-white/10 rounded-full" /> <div className="absolute -top-3 -right-3 w-16 h-16 bg-white/10 rounded-full" />
<div className="absolute -bottom-4 -left-4 w-20 h-20 bg-white/10 rounded-full" /> <div className="absolute -bottom-4 -left-4 w-20 h-20 bg-white/10 rounded-full" />
<div className="absolute bottom-2 left-8 text-base animate-pulse">🎉</div> <div className="absolute bottom-3 left-8 text-sm">🎉</div>
</div> </div>
<div className="relative flex items-center p-3 gap-3"> <div className="relative flex items-center p-4 gap-3">
{/* 멤버 사진 */} {/* 멤버 사진 */}
{schedule.member_image && ( {schedule.member_image && (
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<div className="w-14 h-14 rounded-full border-2 border-white/50 shadow-lg overflow-hidden bg-white"> <div className="w-14 h-14 rounded-full border-2 border-white/50 shadow-md overflow-hidden bg-white">
<img <img
src={schedule.member_image} src={schedule.member_image}
alt={schedule.member_names} alt={schedule.member_names}
@ -155,21 +157,44 @@ export function MobileBirthdayCard({ schedule, showYear = false, onClick }) {
{/* 내용 */} {/* 내용 */}
<div className="flex-1 text-white flex items-center gap-2 min-w-0"> <div className="flex-1 text-white flex items-center gap-2 min-w-0">
<span className="text-2xl">🎂</span> <span className="text-2xl flex-shrink-0">🎂</span>
<h3 className="font-bold text-lg truncate">{schedule.title}</h3> <h3 className="font-bold text-base tracking-wide truncate">
{decodeHtmlEntities(schedule.title)}
</h3>
</div> </div>
{/* 날짜 뱃지 */} {/* 날짜 뱃지 (showYear가 true일 때만 표시) */}
<div className="flex-shrink-0 bg-white/20 backdrop-blur-sm rounded-lg px-3 py-1.5 text-center"> {showYear && (
{showYear && ( <div className="flex-shrink-0 bg-white/20 backdrop-blur-sm rounded-lg px-3 py-1.5 text-center">
<div className="text-white/70 text-[10px] font-medium">{formatted.year}</div> <div className="text-white/70 text-[10px] font-medium">{formatted.year}</div>
)} <div className="text-white/70 text-[10px] font-medium">{formatted.month}</div>
<div className="text-white/70 text-[10px] font-medium">{formatted.month}</div> <div className="text-white text-xl font-bold">{formatted.day}</div>
<div className="text-white text-xl font-bold">{formatted.day}</div> </div>
</div> )}
</div> </div>
</div> </div>
); );
// delay motion
if (delay > 0) {
return (
<motion.div
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay, type: 'spring', stiffness: 300, damping: 30 }}
onClick={onClick}
className="cursor-pointer"
>
{CardContent}
</motion.div>
);
}
return (
<div onClick={onClick} className="cursor-pointer">
{CardContent}
</div>
);
} }
export default BirthdayCard; export default BirthdayCard;

View file

@ -2,10 +2,9 @@ import { useState, useRef, useEffect, useMemo } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { ChevronLeft, ChevronRight, ChevronDown } from 'lucide-react'; import { ChevronLeft, ChevronRight, ChevronDown } from 'lucide-react';
import { getTodayKST, dayjs } from '@/utils'; import { getTodayKST, dayjs } from '@/utils';
import { MIN_YEAR, WEEKDAYS, MONTH_NAMES } from '@/constants';
const WEEKDAYS = ['일', '월', '화', '수', '목', '금', '토']; const MONTHS = MONTH_NAMES;
const MONTHS = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월'];
const MIN_YEAR = 2017;
/** /**
* 달력 컴포넌트 * 달력 컴포넌트

View file

@ -0,0 +1,383 @@
import { useState, useRef, useEffect, useMemo, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { ChevronLeft, ChevronRight, ChevronDown } from 'lucide-react';
import { getCategoryInfo } from '@/utils';
import { MIN_YEAR, WEEKDAYS } from '@/constants';
/**
* 모바일 달력 컴포넌트 (팝업형)
* @param {Date} selectedDate - 선택된 날짜
* @param {Array} schedules - 일정 목록 ( 표시용)
* @param {function} onSelectDate - 날짜 선택 핸들러
* @param {boolean} hideHeader - 헤더 숨김 여부
* @param {Date} externalViewDate - 외부에서 제어하는 viewDate
* @param {function} onViewDateChange - viewDate 변경 콜백
* @param {boolean} externalShowYearMonth - 외부에서 제어하는 년월 선택 모드
* @param {function} onShowYearMonthChange - 년월 선택 모드 변경 콜백
*/
function MobileCalendar({
selectedDate,
schedules = [],
onSelectDate,
hideHeader = false,
externalViewDate,
onViewDateChange,
externalShowYearMonth,
onShowYearMonthChange,
}) {
const [internalViewDate, setInternalViewDate] = useState(new Date(selectedDate));
// viewDate ,
const viewDate = externalViewDate || internalViewDate;
const setViewDate = (date) => {
if (onViewDateChange) {
onViewDateChange(date);
} else {
setInternalViewDate(date);
}
};
//
const touchStartX = useRef(0);
const touchEndX = useRef(0);
// ( , 3)
const getDaySchedules = (date) => {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
const dateStr = `${y}-${m}-${d}`;
return schedules.filter((s) => s.date?.split('T')[0] === dateStr).slice(0, 3);
};
const year = viewDate.getFullYear();
const month = viewDate.getMonth();
// 2017 1
const canGoPrevMonth = !(year === MIN_YEAR && month === 0);
//
const getCalendarDays = useCallback((y, m) => {
const firstDay = new Date(y, m, 1);
const lastDay = new Date(y, m + 1, 0);
const startDay = firstDay.getDay();
const daysInMonth = lastDay.getDate();
const days = [];
//
const prevMonth = new Date(y, m, 0);
for (let i = startDay - 1; i >= 0; i--) {
days.push({
day: prevMonth.getDate() - i,
isCurrentMonth: false,
date: new Date(y, m - 1, prevMonth.getDate() - i),
});
}
//
for (let i = 1; i <= daysInMonth; i++) {
days.push({
day: i,
isCurrentMonth: true,
date: new Date(y, m, i),
});
}
// ( )
const remaining = (7 - (days.length % 7)) % 7;
for (let i = 1; i <= remaining; i++) {
days.push({
day: i,
isCurrentMonth: false,
date: new Date(y, m + 1, i),
});
}
return days;
}, []);
const changeMonth = useCallback(
(delta) => {
if (delta < 0 && !canGoPrevMonth) return;
const newDate = new Date(viewDate);
newDate.setMonth(newDate.getMonth() + delta);
setViewDate(newDate);
},
[viewDate, canGoPrevMonth]
);
const isToday = (date) => {
const today = new Date();
return (
date.getDate() === today.getDate() &&
date.getMonth() === today.getMonth() &&
date.getFullYear() === today.getFullYear()
);
};
//
const isSelected = (date) => {
return (
date.getDate() === selectedDate.getDate() &&
date.getMonth() === selectedDate.getMonth() &&
date.getFullYear() === selectedDate.getFullYear()
);
};
// -
const [internalShowYearMonth, setInternalShowYearMonth] = useState(false);
const showYearMonth =
externalShowYearMonth !== undefined ? externalShowYearMonth : internalShowYearMonth;
const setShowYearMonth = (value) => {
if (onShowYearMonthChange) {
onShowYearMonthChange(value);
} else {
setInternalShowYearMonth(value);
}
};
const [yearRangeStart, setYearRangeStart] = useState(MIN_YEAR);
const yearRange = Array.from({ length: 12 }, (_, i) => yearRangeStart + i);
const canGoPrevYearRange = yearRangeStart > MIN_YEAR;
//
useEffect(() => {
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = '';
};
}, []);
//
const currentMonthDays = useMemo(() => {
return getCalendarDays(year, month);
}, [year, month, getCalendarDays]);
//
const handleTouchStart = (e) => {
touchStartX.current = e.touches[0].clientX;
};
const handleTouchMove = (e) => {
touchEndX.current = e.touches[0].clientX;
};
const handleTouchEnd = () => {
const diff = touchStartX.current - touchEndX.current;
const threshold = 50;
if (Math.abs(diff) > threshold) {
if (diff > 0) {
changeMonth(1);
} else {
changeMonth(-1);
}
}
touchStartX.current = 0;
touchEndX.current = 0;
};
//
const renderMonth = (days) => (
<div onTouchStart={handleTouchStart} onTouchMove={handleTouchMove} onTouchEnd={handleTouchEnd}>
{/* 요일 헤더 */}
<div className="grid grid-cols-7 gap-1 mb-2">
{WEEKDAYS.map((day, i) => (
<div
key={day}
className={`text-center text-xs font-medium py-1 ${
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 isSunday = dayOfWeek === 0;
const isSaturday = dayOfWeek === 6;
const daySchedules = item.isCurrentMonth ? getDaySchedules(item.date) : [];
return (
<button key={index} onClick={() => onSelectDate(item.date)} className="flex flex-col items-center py-2">
<span
className={`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 shadow-lg'
: isToday(item.date)
? 'text-primary font-bold'
: isSunday
? 'text-red-500 hover:bg-red-50'
: isSaturday
? 'text-blue-500 hover:bg-blue-50'
: 'text-gray-700 hover:bg-gray-100'
}`}
>
{item.day}
</span>
{/* 일정 점 - 선택된 날짜에는 표시하지 않음, 최대 3개 */}
{!isSelected(item.date) && daySchedules.length > 0 && (
<div className="flex gap-0.5 mt-0.5 h-1.5">
{daySchedules.map((schedule, i) => {
const categoryInfo = getCategoryInfo(schedule);
return (
<div
key={i}
className="w-1 h-1 rounded-full"
style={{ backgroundColor: categoryInfo.color }}
/>
);
})}
</div>
)}
</button>
);
})}
</div>
</div>
);
return (
<div className="p-4">
<AnimatePresence mode="wait">
{showYearMonth ? (
// UI
<motion.div
key="yearMonth"
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.15 }}
>
{/* 년도 범위 헤더 */}
<div className="flex items-center justify-between mb-3">
<button
onClick={() =>
canGoPrevYearRange && setYearRangeStart(Math.max(MIN_YEAR, yearRangeStart - 12))
}
disabled={!canGoPrevYearRange}
className={`p-1 ${canGoPrevYearRange ? '' : 'opacity-30'}`}
>
<ChevronLeft size={18} />
</button>
<span className="font-semibold text-sm">
{yearRangeStart} - {yearRangeStart + 11}
</span>
<button onClick={() => setYearRangeStart(yearRangeStart + 12)} className="p-1">
<ChevronRight size={18} />
</button>
</div>
{/* 년도 선택 */}
<div className="text-center text-xs text-gray-400 mb-2">년도</div>
<div className="grid grid-cols-4 gap-2 mb-4">
{yearRange.map((y) => {
const isCurrentYear = y === new Date().getFullYear();
return (
<button
key={y}
onClick={() => {
const newDate = new Date(viewDate);
newDate.setFullYear(y);
setViewDate(newDate);
}}
className={`py-2 text-sm rounded-lg transition-colors ${
y === year
? 'bg-primary text-white'
: isCurrentYear
? 'text-primary font-semibold hover:bg-gray-100'
: 'text-gray-600 hover:bg-gray-100'
}`}
>
{y}
</button>
);
})}
</div>
{/* 월 선택 */}
<div className="text-center text-xs text-gray-400 mb-2"></div>
<div className="grid grid-cols-4 gap-2">
{Array.from({ length: 12 }, (_, i) => i + 1).map((m) => {
const today = new Date();
const isCurrentMonth = year === today.getFullYear() && m === today.getMonth() + 1;
return (
<button
key={m}
onClick={() => {
const newDate = new Date(year, m - 1, 1);
setViewDate(newDate);
setShowYearMonth(false);
}}
className={`py-2 text-sm rounded-lg transition-colors ${
m === month + 1
? 'bg-primary text-white'
: isCurrentMonth
? 'text-primary font-semibold hover:bg-gray-100'
: 'text-gray-600 hover:bg-gray-100'
}`}
>
{m}
</button>
);
})}
</div>
</motion.div>
) : (
<motion.div
key={`calendar-${year}-${month}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
{/* 달력 헤더 - hideHeader일 때 숨김 */}
{!hideHeader && (
<div className="flex items-center justify-between mb-4">
<button
onClick={() => changeMonth(-1)}
disabled={!canGoPrevMonth}
className={`p-1 ${!canGoPrevMonth ? 'opacity-30' : ''}`}
>
<ChevronLeft size={18} />
</button>
<button
onClick={() => setShowYearMonth(true)}
className="flex items-center gap-1 font-semibold text-sm hover:text-primary transition-colors"
>
{year} {month + 1}
<ChevronDown size={16} />
</button>
<button onClick={() => changeMonth(1)} className="p-1">
<ChevronRight size={18} />
</button>
</div>
)}
{/* 달력 (터치 스와이프 지원) */}
{renderMonth(currentMonthDays)}
{/* 오늘 버튼 */}
<div className="mt-3 flex justify-center">
<button
onClick={() => onSelectDate(new Date())}
className="text-xs text-primary font-medium px-4 py-1.5 bg-primary/10 rounded-full"
>
오늘
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
export default MobileCalendar;

View file

@ -1,13 +1,14 @@
// PC 컴포넌트 // PC 컴포넌트
export { default as ScheduleCard } from './ScheduleCard'; export { default as ScheduleCard } from './ScheduleCard';
export { default as AdminScheduleCard } from './AdminScheduleCard'; export { default as AdminScheduleCard } from './AdminScheduleCard';
export { default as Calendar } from './Calendar';
// Mobile 컴포넌트 // Mobile 컴포넌트
export { default as MobileScheduleCard } from './MobileScheduleCard'; export { default as MobileScheduleCard } from './MobileScheduleCard';
export { default as MobileScheduleListCard } from './MobileScheduleListCard'; export { default as MobileScheduleListCard } from './MobileScheduleListCard';
export { default as MobileScheduleSearchCard } from './MobileScheduleSearchCard'; export { default as MobileScheduleSearchCard } from './MobileScheduleSearchCard';
export { default as MobileCalendar } from './MobileCalendar';
// 공통 컴포넌트 // 공통 컴포넌트
export { default as Calendar } from './Calendar';
export { default as CategoryFilter } from './CategoryFilter'; export { default as CategoryFilter } from './CategoryFilter';
export { default as BirthdayCard, MobileBirthdayCard, fireBirthdayConfetti } from './BirthdayCard'; export { default as BirthdayCard, MobileBirthdayCard, fireBirthdayConfetti } from './BirthdayCard';

View file

@ -57,3 +57,16 @@ export const MONTH_NAMES = [
'1월', '2월', '3월', '4월', '5월', '6월', '1월', '2월', '3월', '4월', '5월', '6월',
'7월', '8월', '9월', '10월', '11월', '12월', '7월', '8월', '9월', '10월', '11월', '12월',
]; ];
/** 멤버 한글 이름 → 영어 이름 매핑 */
export const MEMBER_ENGLISH_NAMES = {
송하영: 'HAYOUNG',
박지원: 'JIWON',
이채영: 'CHAEYOUNG',
이나경: 'NAKYUNG',
백지헌: 'JIHEON',
장규리: 'GYURI',
이새롬: 'SAEROM',
노지선: 'JISUN',
이서연: 'SEOYEON',
};

View file

@ -1,2 +1,8 @@
export { default as PCAlbum } from './PCAlbum'; export { default as PCAlbum } from './pc/Album';
export { default as MobileAlbum } from './MobileAlbum'; export { default as MobileAlbum } from './mobile/Album';
export { default as PCAlbumDetail } from './pc/AlbumDetail';
export { default as MobileAlbumDetail } from './mobile/AlbumDetail';
export { default as PCTrackDetail } from './pc/TrackDetail';
export { default as MobileTrackDetail } from './mobile/TrackDetail';
export { default as PCAlbumGallery } from './pc/AlbumGallery';
export { default as MobileAlbumGallery } from './mobile/AlbumGallery';

View file

@ -0,0 +1,455 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { motion, AnimatePresence } from 'framer-motion';
import {
Play,
Calendar,
Music2,
Clock,
X,
Download,
ChevronDown,
ChevronUp,
FileText,
ChevronRight,
} from 'lucide-react';
import { Swiper, SwiperSlide } from 'swiper/react';
import { Virtual } from 'swiper/modules';
import 'swiper/css';
import { getAlbumByName } from '@/api/albums';
import { formatDate } from '@/utils';
import { LightboxIndicator } from '@/components/common';
/**
* Mobile 앨범 상세 페이지
*/
function MobileAlbumDetail() {
const { name } = useParams();
const navigate = useNavigate();
const [lightbox, setLightbox] = useState({ open: false, images: [], index: 0, showNav: true, teasers: null });
const [showAllTracks, setShowAllTracks] = useState(false);
const [showDescriptionModal, setShowDescriptionModal] = useState(false);
const swiperRef = useRef(null);
//
const { data: album, isLoading: loading } = useQuery({
queryKey: ['album', name],
queryFn: () => getAlbumByName(name),
enabled: !!name,
});
//
const openLightbox = useCallback((images, index, options = {}) => {
setLightbox({
open: true,
images,
index,
showNav: options.showNav !== false,
teasers: options.teasers,
});
window.history.pushState({ lightbox: true }, '');
}, []);
//
const openDescriptionModal = useCallback(() => {
setShowDescriptionModal(true);
window.history.pushState({ description: true }, '');
}, []);
//
useEffect(() => {
const handlePopState = () => {
if (showDescriptionModal) {
setShowDescriptionModal(false);
} else if (lightbox.open) {
setLightbox((prev) => ({ ...prev, open: false }));
}
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, [showDescriptionModal, lightbox.open]);
//
const downloadImage = useCallback(async () => {
const imageUrl = lightbox.images[lightbox.index];
if (!imageUrl) return;
try {
const response = await fetch(imageUrl);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `fromis9_photo_${String(lightbox.index + 1).padStart(2, '0')}.webp`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (error) {
console.error('다운로드 오류:', error);
}
}, [lightbox.images, lightbox.index]);
// / body
useEffect(() => {
if (lightbox.open || showDescriptionModal) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [lightbox.open, showDescriptionModal]);
//
const getTotalDuration = () => {
if (!album?.tracks) return '';
let totalSeconds = 0;
album.tracks.forEach((track) => {
if (track.duration) {
const parts = track.duration.split(':');
totalSeconds += parseInt(parts[0]) * 60 + parseInt(parts[1]);
}
});
const mins = Math.floor(totalSeconds / 60);
const secs = totalSeconds % 60;
return `${mins}:${String(secs).padStart(2, '0')}`;
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div>
);
}
if (!album) {
return (
<div className="text-center py-12">
<p className="text-gray-500">앨범을 찾을 없습니다</p>
</div>
);
}
const allPhotos = album.conceptPhotos ? Object.values(album.conceptPhotos).flat() : [];
const displayTracks = showAllTracks ? album.tracks : album.tracks?.slice(0, 5);
return (
<>
<div>
{/* 앨범 히어로 섹션 */}
<div className="relative">
{/* 배경 블러 이미지 */}
<div className="absolute inset-0 overflow-hidden">
<img src={album.cover_medium_url} alt="" className="w-full h-full object-cover blur-2xl scale-110 opacity-30" />
<div className="absolute inset-0 bg-gradient-to-b from-white/60 via-white/80 to-white" />
</div>
{/* 콘텐츠 */}
<div className="relative px-5 pt-4 pb-5">
<div className="flex flex-col items-center">
{/* 앨범 커버 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="w-44 h-44 rounded-2xl overflow-hidden shadow-xl mb-4"
onClick={() => openLightbox([album.cover_original_url || album.cover_medium_url], 0, { showNav: false })}
>
<img src={album.cover_medium_url} alt={album.title} className="w-full h-full object-cover" />
</motion.div>
{/* 앨범 정보 */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="text-center"
>
<span className="inline-block px-3 py-1 bg-primary/10 text-primary text-xs font-medium rounded-full mb-2">
{album.album_type}
</span>
<h1 className="text-2xl font-bold mb-2">{album.title}</h1>
{/* 메타 정보 */}
<div className="flex items-center justify-center gap-4 text-sm text-gray-500">
<div className="flex items-center gap-1">
<Calendar size={14} />
<span>{formatDate(album.release_date, 'YYYY.MM.DD')}</span>
</div>
<div className="flex items-center gap-1">
<Music2 size={14} />
<span>{album.tracks?.length || 0}</span>
</div>
<div className="flex items-center gap-1">
<Clock size={14} />
<span>{getTotalDuration()}</span>
</div>
</div>
{/* 앨범 소개 버튼 */}
{album.description && (
<button
onClick={openDescriptionModal}
className="mt-3 flex items-center gap-1.5 mx-auto px-3 py-1.5 text-xs text-gray-500 bg-white/80 rounded-full shadow-sm"
>
<FileText size={12} />
앨범 소개
</button>
)}
</motion.div>
</div>
</div>
</div>
{/* 티저 이미지 */}
{album.teasers && album.teasers.length > 0 && (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="px-4 py-4 border-b border-gray-100">
<p className="text-sm font-semibold mb-3">티저 이미지</p>
<div className="flex gap-3 overflow-x-auto pb-1 -mx-4 px-4 scrollbar-hide">
{album.teasers.map((teaser, index) => (
<div
key={index}
onClick={() =>
openLightbox(
album.teasers.map((t) => (t.media_type === 'video' ? t.video_url || t.original_url : t.original_url)),
index,
{ teasers: album.teasers, showNav: true }
)
}
className="w-24 h-24 flex-shrink-0 bg-gray-100 rounded-2xl overflow-hidden relative shadow-sm"
>
<img
src={teaser.thumb_url || teaser.original_url}
alt={`Teaser ${index + 1}`}
className="w-full h-full object-cover"
/>
{teaser.media_type === 'video' && (
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
<div className="w-8 h-8 bg-white/90 rounded-full flex items-center justify-center">
<Play size={14} fill="currentColor" className="ml-0.5 text-gray-800" />
</div>
</div>
)}
</div>
))}
</div>
</motion.div>
)}
{/* 수록곡 */}
{album.tracks && album.tracks.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="px-4 py-4 border-b border-gray-100"
>
<p className="text-sm font-semibold mb-3">수록곡</p>
<div className="space-y-1">
{displayTracks?.map((track) => (
<div
key={track.id}
onClick={() =>
navigate(`/album/${encodeURIComponent(album.title)}/track/${encodeURIComponent(track.title)}`)
}
className="flex items-center gap-3 py-2.5 px-3 rounded-xl hover:bg-gray-50 active:bg-gray-100 transition-colors cursor-pointer"
>
<span className="w-6 text-center text-sm text-gray-400 tabular-nums">
{String(track.track_number).padStart(2, '0')}
</span>
<div className="flex-1 min-w-0 flex items-center gap-2">
<p className={`text-sm font-medium truncate ${track.is_title_track ? 'text-primary' : 'text-gray-800'}`}>
{track.title}
</p>
{track.is_title_track === 1 && (
<span className="px-1.5 py-0.5 bg-primary text-white text-[10px] font-bold rounded flex-shrink-0">
TITLE
</span>
)}
</div>
<span className="text-xs text-gray-400 tabular-nums">{track.duration || '-'}</span>
</div>
))}
</div>
{/* 더보기/접기 버튼 */}
{album.tracks.length > 5 && (
<button
onClick={() => setShowAllTracks(!showAllTracks)}
className="w-full mt-2 py-2 text-sm text-gray-500 flex items-center justify-center gap-1"
>
{showAllTracks ? '접기' : `${album.tracks.length - 5}곡 더보기`}
{showAllTracks ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</button>
)}
</motion.div>
)}
{/* 컨셉 포토 */}
{allPhotos.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="px-4 py-4"
>
<p className="text-sm font-semibold mb-3">컨셉 포토</p>
<div className="grid grid-cols-3 gap-2">
{allPhotos.slice(0, 6).map((photo, idx) => (
<div
key={photo.id}
onClick={() => openLightbox([photo.original_url], 0, { showNav: false })}
className="aspect-square bg-gray-100 rounded-xl overflow-hidden shadow-sm"
>
<img
src={photo.thumb_url || photo.medium_url}
alt={`컨셉 포토 ${idx + 1}`}
loading="lazy"
className="w-full h-full object-cover"
/>
</div>
))}
</div>
{/* 전체보기 버튼 */}
<button
onClick={() => navigate(`/album/${name}/gallery`)}
className="w-full mt-3 py-3 text-sm text-primary font-medium bg-primary/5 rounded-xl flex items-center justify-center gap-1"
>
전체 {allPhotos.length} 보기
<ChevronRight size={16} />
</button>
</motion.div>
)}
</div>
{/* 앨범 소개 다이얼로그 */}
<AnimatePresence>
{showDescriptionModal && album?.description && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/60 z-[60] flex items-end justify-center"
onClick={() => window.history.back()}
>
<motion.div
initial={{ y: '100%' }}
animate={{ y: 0 }}
exit={{ y: '100%' }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
drag="y"
dragConstraints={{ top: 0, bottom: 0 }}
dragElastic={{ top: 0, bottom: 0.5 }}
onDragEnd={(_, info) => {
if (info.offset.y > 100 || info.velocity.y > 300) {
window.history.back();
}
}}
className="bg-white rounded-t-3xl w-full max-h-[80vh] overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* 드래그 핸들 */}
<div className="flex justify-center pt-3 pb-2 cursor-grab active:cursor-grabbing">
<div className="w-10 h-1 bg-gray-300 rounded-full" />
</div>
{/* 헤더 */}
<div className="flex items-center justify-between px-5 pb-3 border-b border-gray-100">
<h3 className="text-lg font-bold">앨범 소개</h3>
<button onClick={() => window.history.back()} className="p-1.5">
<X size={20} className="text-gray-500" />
</button>
</div>
{/* 내용 */}
<div className="px-5 py-4 overflow-y-auto max-h-[60vh]">
<p className="text-sm text-gray-600 leading-relaxed whitespace-pre-line text-justify">{album.description}</p>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* 라이트박스 - Swiper ViewPager 스타일 */}
<AnimatePresence>
{lightbox.open && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black z-[60] flex flex-col"
>
{/* 상단 헤더 */}
<div className="absolute top-0 left-0 right-0 flex items-center px-4 py-3 z-20">
<div className="flex-1 flex justify-start">
<button onClick={() => window.history.back()} className="text-white/80 p-1">
<X size={24} />
</button>
</div>
{lightbox.showNav && lightbox.images.length > 1 && (
<span className="text-white/70 text-sm tabular-nums">
{lightbox.index + 1} / {lightbox.images.length}
</span>
)}
<div className="flex-1 flex justify-end">
<button onClick={downloadImage} className="text-white/80 p-1">
<Download size={22} />
</button>
</div>
</div>
{/* Swiper */}
<Swiper
modules={[Virtual]}
virtual
initialSlide={lightbox.index}
onSwiper={(swiper) => {
swiperRef.current = swiper;
}}
onSlideChange={(swiper) => setLightbox((prev) => ({ ...prev, index: swiper.activeIndex }))}
className="w-full h-full"
spaceBetween={0}
slidesPerView={1}
resistance={true}
resistanceRatio={0.5}
>
{lightbox.images.map((url, index) => (
<SwiperSlide key={index} virtualIndex={index}>
<div className="w-full h-full flex items-center justify-center">
{lightbox.teasers?.[index]?.media_type === 'video' ? (
<video
src={url}
className="max-w-full max-h-full object-contain"
controls
autoPlay={index === lightbox.index}
/>
) : (
<img
src={url}
alt=""
className="max-w-full max-h-full object-contain"
loading={Math.abs(index - lightbox.index) <= 2 ? 'eager' : 'lazy'}
/>
)}
</div>
</SwiperSlide>
))}
</Swiper>
{/* 모바일용 인디케이터 */}
{lightbox.showNav && lightbox.images.length > 1 && (
<LightboxIndicator
count={lightbox.images.length}
currentIndex={lightbox.index}
goToIndex={(i) => swiperRef.current?.slideTo(i)}
width={120}
/>
)}
</motion.div>
)}
</AnimatePresence>
</>
);
}
export default MobileAlbumDetail;

View file

@ -0,0 +1,346 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { X, Download, ChevronRight, Info, Users, Tag } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { Swiper, SwiperSlide } from 'swiper/react';
import { Virtual } from 'swiper/modules';
import 'swiper/css';
import { getAlbumByName } from '@/api/albums';
import { LightboxIndicator } from '@/components/common';
/**
* Mobile 앨범 갤러리 페이지
*/
function MobileAlbumGallery() {
const { name } = useParams();
const navigate = useNavigate();
const [selectedIndex, setSelectedIndex] = useState(null);
const [showInfo, setShowInfo] = useState(false);
const swiperRef = useRef(null);
//
const { data: album, isLoading: loading } = useQuery({
queryKey: ['album', name],
queryFn: () => getAlbumByName(name),
enabled: !!name,
});
//
const photos = useMemo(() => {
if (!album?.conceptPhotos) return [];
const allPhotos = [];
Object.entries(album.conceptPhotos).forEach(([concept, conceptPhotos]) => {
conceptPhotos.forEach((p) =>
allPhotos.push({
...p,
concept: concept !== 'Default' ? concept : null,
})
);
});
return allPhotos;
}, [album]);
//
const openLightbox = useCallback((index) => {
setSelectedIndex(index);
window.history.pushState({ lightbox: true }, '');
}, []);
//
const openInfo = useCallback(() => {
setShowInfo(true);
window.history.pushState({ infoSheet: true }, '');
}, []);
//
useEffect(() => {
const handlePopState = () => {
if (showInfo) {
setShowInfo(false);
} else if (selectedIndex !== null) {
setSelectedIndex(null);
}
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, [showInfo, selectedIndex]);
//
const downloadImage = useCallback(async () => {
const photo = photos[selectedIndex];
if (!photo) return;
try {
const response = await fetch(photo.original_url);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `fromis9_${album?.title || 'photo'}_${String(selectedIndex + 1).padStart(2, '0')}.webp`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (error) {
console.error('다운로드 오류:', error);
}
}, [photos, selectedIndex, album?.title]);
//
useEffect(() => {
if (selectedIndex !== null) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [selectedIndex]);
// 2 ( )
const distributePhotos = () => {
const leftColumn = [];
const rightColumn = [];
let leftHeight = 0;
let rightHeight = 0;
photos.forEach((photo, index) => {
const aspectRatio = photo.height && photo.width ? photo.height / photo.width : 1;
if (leftHeight <= rightHeight) {
leftColumn.push({ ...photo, originalIndex: index });
leftHeight += aspectRatio;
} else {
rightColumn.push({ ...photo, originalIndex: index });
rightHeight += aspectRatio;
}
});
return { leftColumn, rightColumn };
};
const { leftColumn, rightColumn } = distributePhotos();
//
const currentPhoto = selectedIndex !== null ? photos[selectedIndex] : null;
const hasInfo = currentPhoto?.concept || currentPhoto?.members;
//
const handleInfoDragEnd = (_, info) => {
if (info.offset.y > 100 || info.velocity.y > 300) {
window.history.back();
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div>
);
}
return (
<>
<div className="pb-4">
{/* 앨범 헤더 카드 */}
<div
className="mx-4 mt-4 mb-4 p-4 bg-gradient-to-r from-primary/5 to-primary/10 rounded-2xl flex items-center gap-4"
onClick={() => navigate(-1)}
>
{album?.cover_thumb_url && (
<img
src={album.cover_thumb_url}
alt={album.title}
className="w-14 h-14 rounded-xl object-cover shadow-sm"
/>
)}
<div className="flex-1 min-w-0">
<p className="text-xs text-primary font-medium mb-0.5">컨셉 포토</p>
<p className="font-bold truncate">{album?.title}</p>
<p className="text-xs text-gray-500">{photos.length}장의 사진</p>
</div>
<ChevronRight size={20} className="text-gray-400 rotate-180" />
</div>
{/* 2열 그리드 */}
<div className="px-3 flex gap-2">
<div className="flex-1 flex flex-col gap-2">
{leftColumn.map((photo) => (
<motion.div
key={photo.id || photo.originalIndex}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: Math.min(photo.originalIndex * 0.02, 0.4) }}
onClick={() => openLightbox(photo.originalIndex)}
className="cursor-pointer overflow-hidden rounded-xl bg-gray-100"
>
<img
src={photo.thumb_url || photo.medium_url}
alt=""
className="w-full h-auto object-cover"
loading="lazy"
/>
</motion.div>
))}
</div>
<div className="flex-1 flex flex-col gap-2">
{rightColumn.map((photo) => (
<motion.div
key={photo.id || photo.originalIndex}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: Math.min(photo.originalIndex * 0.02, 0.4) }}
onClick={() => openLightbox(photo.originalIndex)}
className="cursor-pointer overflow-hidden rounded-xl bg-gray-100"
>
<img
src={photo.thumb_url || photo.medium_url}
alt=""
className="w-full h-auto object-cover"
loading="lazy"
/>
</motion.div>
))}
</div>
</div>
</div>
{/* 풀스크린 라이트박스 */}
<AnimatePresence>
{selectedIndex !== null && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black z-[60] flex flex-col"
>
{/* 상단 헤더 */}
<div className="absolute top-0 left-0 right-0 flex items-center px-4 py-3 z-20">
<div className="flex-1 flex justify-start">
<button onClick={() => window.history.back()} className="text-white/80 p-1">
<X size={24} />
</button>
</div>
<span className="text-white/70 text-sm tabular-nums">
{selectedIndex + 1} / {photos.length}
</span>
<div className="flex-1 flex justify-end items-center gap-2">
{hasInfo && (
<button onClick={openInfo} className="text-white/80 p-1">
<Info size={22} />
</button>
)}
<button onClick={downloadImage} className="text-white/80 p-1">
<Download size={22} />
</button>
</div>
</div>
{/* Swiper */}
<Swiper
modules={[Virtual]}
virtual
initialSlide={selectedIndex}
onSwiper={(swiper) => {
swiperRef.current = swiper;
}}
onSlideChange={(swiper) => setSelectedIndex(swiper.activeIndex)}
className="w-full h-full"
spaceBetween={0}
slidesPerView={1}
resistance={true}
resistanceRatio={0.5}
>
{photos.map((photo, index) => (
<SwiperSlide key={photo.id || index} virtualIndex={index}>
<div className="w-full h-full flex items-center justify-center">
<img
src={photo.medium_url || photo.original_url}
alt=""
className="max-w-full max-h-full object-contain"
loading={Math.abs(index - selectedIndex) <= 2 ? 'eager' : 'lazy'}
/>
</div>
</SwiperSlide>
))}
</Swiper>
{/* 모바일용 인디케이터 */}
<LightboxIndicator
count={photos.length}
currentIndex={selectedIndex}
goToIndex={(i) => swiperRef.current?.slideTo(i)}
width={120}
/>
{/* 정보 바텀시트 */}
<AnimatePresence>
{showInfo && hasInfo && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 bg-black/60 z-30"
onClick={() => window.history.back()}
>
<motion.div
initial={{ y: '100%' }}
animate={{ y: 0 }}
exit={{ y: '100%' }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
drag="y"
dragConstraints={{ top: 0, bottom: 0 }}
dragElastic={{ top: 0, bottom: 0.5 }}
onDragEnd={handleInfoDragEnd}
className="absolute bottom-0 left-0 right-0 bg-zinc-900 rounded-t-3xl"
onClick={(e) => e.stopPropagation()}
>
{/* 드래그 핸들 */}
<div className="flex justify-center pt-3 pb-2 cursor-grab active:cursor-grabbing">
<div className="w-10 h-1 bg-zinc-600 rounded-full" />
</div>
{/* 정보 내용 */}
<div className="px-5 pb-8 space-y-4">
<h3 className="text-white font-semibold text-lg">사진 정보</h3>
{currentPhoto?.members && (
<div className="flex items-start gap-3">
<div className="w-8 h-8 bg-primary/20 rounded-full flex items-center justify-center flex-shrink-0">
<Users size={16} className="text-primary" />
</div>
<div>
<p className="text-zinc-400 text-xs mb-1">멤버</p>
<p className="text-white">{currentPhoto.members}</p>
</div>
</div>
)}
{currentPhoto?.concept && (
<div className="flex items-start gap-3">
<div className="w-8 h-8 bg-white/10 rounded-full flex items-center justify-center flex-shrink-0">
<Tag size={16} className="text-zinc-400" />
</div>
<div>
<p className="text-zinc-400 text-xs mb-1">컨셉</p>
<p className="text-white">{currentPhoto.concept}</p>
</div>
</div>
)}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
)}
</AnimatePresence>
</>
);
}
export default MobileAlbumGallery;

View file

@ -0,0 +1,269 @@
import { useState, useMemo, useEffect } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { motion } from 'framer-motion';
import { Clock, User, Music, Mic2, ChevronDown, ChevronUp } from 'lucide-react';
import { getTrack } from '@/api/albums';
/**
* 유튜브 URL에서 비디오 ID 추출
*/
function getYoutubeVideoId(url) {
if (!url) return null;
const patterns = [/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/];
for (const pattern of patterns) {
const match = url.match(pattern);
if (match) return match[1];
}
return null;
}
/**
* 쉼표 기준 줄바꿈 처리
*/
function formatCredit(text) {
if (!text) return null;
return text.split(',').map((item, index) => (
<span key={index} className="block">
{item.trim()}
</span>
));
}
/**
* Mobile 상세 페이지
*/
function MobileTrackDetail() {
const { name: albumName, trackTitle } = useParams();
const navigate = useNavigate();
//
const {
data: track,
isLoading: loading,
error,
} = useQuery({
queryKey: ['track', albumName, trackTitle],
queryFn: () => getTrack(albumName, trackTitle),
enabled: !!albumName && !!trackTitle,
});
const youtubeVideoId = useMemo(() => getYoutubeVideoId(track?.music_video_url), [track?.music_video_url]);
//
const [showFullLyrics, setShowFullLyrics] = useState(false);
//
useEffect(() => {
const handleFullscreenChange = async () => {
const isFullscreen = !!document.fullscreenElement;
if (isFullscreen) {
try {
if (screen.orientation && screen.orientation.lock) {
await screen.orientation.lock('landscape');
}
} catch (e) {
//
}
} else {
try {
if (screen.orientation && screen.orientation.unlock) {
screen.orientation.unlock();
}
} catch (e) {
//
}
}
};
document.addEventListener('fullscreenchange', handleFullscreenChange);
document.addEventListener('webkitfullscreenchange', handleFullscreenChange);
return () => {
document.removeEventListener('fullscreenchange', handleFullscreenChange);
document.removeEventListener('webkitfullscreenchange', handleFullscreenChange);
};
}, []);
if (loading) {
return (
<div className="flex justify-center items-center min-h-[60vh]">
<div className="animate-spin rounded-full h-10 w-10 border-4 border-primary border-t-transparent" />
</div>
);
}
if (error || !track) {
return (
<div className="py-12 text-center">
<p className="text-gray-500">트랙을 찾을 없습니다.</p>
</div>
);
}
return (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.3 }} className="pb-4">
{/* 트랙 정보 헤더 */}
<div className="px-4 py-5">
<div className="flex gap-4 items-start">
{/* 앨범 커버 */}
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.3 }}
className="w-24 h-24 rounded-xl overflow-hidden shadow-md flex-shrink-0"
>
<img src={track.album?.cover_medium_url} alt={track.album?.title} className="w-full h-full object-cover" />
</motion.div>
{/* 트랙 정보 */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1.5">
{track.is_title_track === 1 && (
<span className="px-2 py-0.5 bg-primary text-white text-xs font-bold rounded-full">TITLE</span>
)}
<span className="text-gray-400 text-xs">Track {String(track.track_number).padStart(2, '0')}</span>
</div>
<h1 className="text-xl font-bold mb-1 text-gray-900 truncate">{track.title}</h1>
<p className="text-sm text-gray-500 mb-2">
{track.album?.album_type} · {track.album?.title}
</p>
{track.duration && (
<div className="flex items-center gap-1.5 text-gray-400 text-sm">
<Clock size={14} />
<span>{track.duration}</span>
</div>
)}
</div>
</div>
</div>
{/* 뮤직비디오 섹션 */}
{youtubeVideoId && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1, duration: 0.3 }}
className="px-4 mb-5"
>
<h2 className="text-base font-bold mb-3 flex items-center gap-2">
<div className="w-1 h-4 bg-red-500 rounded-full" />
뮤직비디오
</h2>
<div className="relative w-full aspect-video rounded-xl overflow-hidden shadow-md bg-black">
<iframe
src={`https://www.youtube.com/embed/${youtubeVideoId}`}
title={`${track.title} 뮤직비디오`}
className="absolute inset-0 w-full h-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen; web-share"
allowFullScreen
/>
</div>
</motion.div>
)}
{/* 크레딧 */}
{(track.lyricist || track.composer || track.arranger) && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.15, duration: 0.3 }}
className="px-4 mb-5"
>
<h2 className="text-base font-bold mb-3 flex items-center gap-2">
<div className="w-1 h-4 bg-primary rounded-full" />
크레딧
</h2>
<div className="bg-gray-50 rounded-xl p-4 space-y-4">
{track.lyricist && (
<div className="flex items-start gap-3">
<div className="w-8 h-8 bg-white rounded-lg flex items-center justify-center flex-shrink-0 shadow-sm">
<Mic2 size={14} className="text-gray-500" />
</div>
<div className="flex-1">
<p className="text-xs text-gray-400 mb-0.5">작사</p>
<p className="text-sm text-gray-700 leading-relaxed">{formatCredit(track.lyricist)}</p>
</div>
</div>
)}
{track.composer && (
<div className="flex items-start gap-3">
<div className="w-8 h-8 bg-white rounded-lg flex items-center justify-center flex-shrink-0 shadow-sm">
<Music size={14} className="text-gray-500" />
</div>
<div className="flex-1">
<p className="text-xs text-gray-400 mb-0.5">작곡</p>
<p className="text-sm text-gray-700 leading-relaxed">{formatCredit(track.composer)}</p>
</div>
</div>
)}
{track.arranger && (
<div className="flex items-start gap-3">
<div className="w-8 h-8 bg-white rounded-lg flex items-center justify-center flex-shrink-0 shadow-sm">
<User size={14} className="text-gray-500" />
</div>
<div className="flex-1">
<p className="text-xs text-gray-400 mb-0.5">편곡</p>
<p className="text-sm text-gray-700 leading-relaxed">{formatCredit(track.arranger)}</p>
</div>
</div>
)}
</div>
</motion.div>
)}
{/* 가사 */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2, duration: 0.3 }}
className="px-4"
>
<h2 className="text-base font-bold mb-3 flex items-center gap-2">
<div className="w-1 h-4 bg-primary rounded-full" />
가사
</h2>
<div className="bg-gray-50 rounded-xl p-4">
{track.lyrics ? (
<>
<div
className={`text-gray-700 leading-[1.8] whitespace-pre-line text-sm overflow-hidden ${
!showFullLyrics ? 'max-h-32' : ''
}`}
>
{track.lyrics}
</div>
<button
onClick={() => setShowFullLyrics(!showFullLyrics)}
className="w-full mt-3 pt-3 border-t border-gray-200 flex items-center justify-center gap-1 text-sm text-gray-500"
>
{showFullLyrics ? (
<>
접기
<ChevronUp size={16} />
</>
) : (
<>
더보기
<ChevronDown size={16} />
</>
)}
</button>
</>
) : (
<div className="text-center py-8 text-gray-400">
<Mic2 size={36} className="mx-auto mb-2 opacity-20" />
<p className="text-sm">가사 정보가 없습니다</p>
</div>
)}
</div>
</motion.div>
</motion.div>
);
}
export default MobileTrackDetail;

View file

@ -0,0 +1,588 @@
import { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { motion, AnimatePresence } from 'framer-motion';
import {
Calendar,
Music2,
Clock,
X,
ChevronLeft,
ChevronRight,
Download,
MoreVertical,
FileText,
} from 'lucide-react';
import { getAlbumByName } from '@/api/albums';
import { formatDate } from '@/utils';
import { LightboxIndicator } from '@/components/common';
/**
* PC 앨범 상세 페이지
*/
function PCAlbumDetail() {
const { name } = useParams();
const navigate = useNavigate();
const [lightbox, setLightbox] = useState({ open: false, images: [], index: 0, teasers: null });
const [slideDirection, setSlideDirection] = useState(0);
const [imageLoaded, setImageLoaded] = useState(false);
const [preloadedImages] = useState(() => new Set());
const [showDescriptionModal, setShowDescriptionModal] = useState(false);
const [showMenu, setShowMenu] = useState(false);
//
const { data: album, isLoading: loading } = useQuery({
queryKey: ['album', name],
queryFn: () => getAlbumByName(name),
enabled: !!name,
});
//
const goToPrev = useCallback(() => {
if (lightbox.images.length <= 1) return;
setImageLoaded(false);
setSlideDirection(-1);
setLightbox((prev) => ({
...prev,
index: (prev.index - 1 + prev.images.length) % prev.images.length,
}));
}, [lightbox.images.length]);
const goToNext = useCallback(() => {
if (lightbox.images.length <= 1) return;
setImageLoaded(false);
setSlideDirection(1);
setLightbox((prev) => ({
...prev,
index: (prev.index + 1) % prev.images.length,
}));
}, [lightbox.images.length]);
//
const openLightbox = useCallback((images, index, options = {}) => {
setLightbox({ open: true, images, index, teasers: options.teasers });
window.history.pushState({ lightbox: true }, '');
}, []);
const closeLightbox = useCallback(() => {
setLightbox((prev) => ({ ...prev, open: false }));
}, []);
//
useEffect(() => {
const handlePopState = () => {
if (showDescriptionModal) {
setShowDescriptionModal(false);
} else if (lightbox.open) {
setLightbox((prev) => ({ ...prev, open: false }));
}
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, [showDescriptionModal, lightbox.open]);
// body
useEffect(() => {
if (lightbox.open) {
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 = '';
};
}, [lightbox.open]);
//
const downloadImage = useCallback(async () => {
const imageUrl = lightbox.images[lightbox.index];
if (!imageUrl) return;
try {
const response = await fetch(imageUrl);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `fromis9_photo_${String(lightbox.index + 1).padStart(2, '0')}.webp`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (error) {
console.error('다운로드 오류:', error);
}
}, [lightbox.images, lightbox.index]);
//
useEffect(() => {
if (!lightbox.open) return;
const handleKeyDown = (e) => {
switch (e.key) {
case 'ArrowLeft':
goToPrev();
break;
case 'ArrowRight':
goToNext();
break;
case 'Escape':
window.history.back();
break;
default:
break;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [lightbox.open, goToPrev, goToNext]);
//
useEffect(() => {
if (!lightbox.open || lightbox.images.length <= 1) return;
const indicesToPreload = [];
for (let offset = -2; offset <= 2; offset++) {
if (offset === 0) continue;
const idx = (lightbox.index + offset + lightbox.images.length) % lightbox.images.length;
indicesToPreload.push(idx);
}
indicesToPreload.forEach((idx) => {
const url = lightbox.images[idx];
if (preloadedImages.has(url)) return;
const img = new Image();
img.onload = () => preloadedImages.add(url);
img.src = url;
});
}, [lightbox.open, lightbox.index, lightbox.images, preloadedImages]);
//
const getTotalDuration = () => {
if (!album?.tracks) return '';
let totalSeconds = 0;
album.tracks.forEach((track) => {
if (track.duration) {
const parts = track.duration.split(':');
totalSeconds += parseInt(parts[0]) * 60 + parseInt(parts[1]);
}
});
const mins = Math.floor(totalSeconds / 60);
const secs = totalSeconds % 60;
return `${mins}:${String(secs).padStart(2, '0')}`;
};
//
const getTitleTrack = () => {
if (!album?.tracks) return null;
return album.tracks.find((t) => t.is_title_track);
};
if (loading) {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="py-16 flex justify-center items-center min-h-[60vh]"
>
<div className="animate-spin rounded-full h-12 w-12 border-4 border-primary border-t-transparent" />
</motion.div>
);
}
if (!album) {
return (
<div className="py-16 text-center">
<p className="text-gray-500">앨범을 찾을 없습니다.</p>
</div>
);
}
return (
<>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
className="py-16"
>
<div className="max-w-7xl mx-auto px-6">
{/* 브레드크럼 네비게이션 */}
<div className="flex items-center gap-2 text-sm text-gray-500 mb-6">
<button onClick={() => navigate('/album')} className="hover:text-primary transition-colors">
앨범
</button>
<span>/</span>
<span className="text-gray-700">{album?.title}</span>
</div>
{/* 앨범 정보 헤더 */}
<div className="flex gap-8 mb-10">
{/* 앨범 커버 */}
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4 }}
className="w-80 h-80 flex-shrink-0 rounded-2xl overflow-hidden shadow-lg cursor-pointer group"
onClick={() => openLightbox([album.cover_original_url || album.cover_medium_url], 0)}
>
<img
src={album.cover_medium_url || album.cover_original_url}
alt={album.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
/>
</motion.div>
{/* 앨범 정보 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1, duration: 0.4 }}
className="flex-1 flex flex-col"
>
<div>
<div className="flex items-center justify-between mb-3">
<span className="inline-block w-fit px-3 py-1 bg-primary/10 text-primary text-sm font-medium rounded-full">
{album.album_type}
</span>
{/* 메뉴 버튼 */}
{album.description && (
<div className="relative">
<button
onClick={() => setShowMenu(!showMenu)}
className="p-2 hover:bg-gray-100 rounded-full transition-colors relative z-20"
>
<MoreVertical size={20} className="text-gray-500" />
</button>
<AnimatePresence>
{showMenu && (
<>
<div className="fixed inset-0 z-10" onClick={() => setShowMenu(false)} />
<motion.div
initial={{ opacity: 0, scale: 0.95, y: -5 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: -5 }}
transition={{ duration: 0.15 }}
className="absolute right-0 top-full mt-1 bg-white rounded-xl shadow-lg border border-gray-100 py-1 z-20 min-w-[140px]"
>
<button
onClick={() => {
setShowDescriptionModal(true);
window.history.pushState({ description: true }, '');
setShowMenu(false);
}}
className="w-full flex items-center gap-2 px-4 py-2.5 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
>
<FileText size={16} />
앨범 소개
</button>
</motion.div>
</>
)}
</AnimatePresence>
</div>
)}
</div>
<h1 className="text-4xl font-bold mb-3">{album.title}</h1>
<div className="flex items-center gap-6 text-gray-500 mb-3">
<div className="flex items-center gap-2">
<Calendar size={18} />
<span>{formatDate(album.release_date, 'YYYY.MM.DD')}</span>
</div>
<div className="flex items-center gap-2">
<Music2 size={18} />
<span>{album.tracks?.length || 0}</span>
</div>
<div className="flex items-center gap-2">
<Clock size={18} />
<span>{getTotalDuration()}</span>
</div>
</div>
</div>
{/* 앨범 티저 이미지/영상 */}
{album.teasers && album.teasers.length > 0 && (
<div className="mt-auto">
<p className="text-xs text-gray-400 mb-2">티저 이미지</p>
<div className="flex gap-2">
{album.teasers.map((teaser, index) => (
<div
key={index}
onClick={() =>
openLightbox(
album.teasers.map((t) =>
t.media_type === 'video' ? t.video_url || t.original_url : t.original_url
),
index,
{ teasers: album.teasers }
)
}
className="w-24 h-24 bg-gray-200 rounded-lg overflow-hidden cursor-pointer transition-all duration-300 ease-out hover:scale-105 hover:shadow-xl hover:z-10 relative"
>
<img
src={teaser.thumb_url}
alt={`Teaser ${index + 1}`}
className="w-full h-full object-cover"
/>
{teaser.media_type === 'video' && (
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
<div className="w-8 h-8 bg-white/90 rounded-full flex items-center justify-center">
<div className="w-0 h-0 border-l-[10px] border-l-gray-800 border-y-[6px] border-y-transparent ml-1" />
</div>
</div>
)}
</div>
))}
</div>
</div>
)}
</motion.div>
</div>
{/* 수록곡 리스트 */}
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2, duration: 0.4 }}
>
<h2 className="text-xl font-bold mb-4">수록곡</h2>
<div className="bg-white rounded-2xl shadow-md overflow-hidden">
{album.tracks?.map((track, index) => (
<div
key={track.id}
onClick={() =>
navigate(`/album/${encodeURIComponent(album.title)}/track/${encodeURIComponent(track.title)}`)
}
className={`group flex items-center gap-4 p-4 hover:bg-primary/5 transition-all duration-200 cursor-pointer ${
index !== album.tracks.length - 1 ? 'border-b border-gray-100' : ''
}`}
>
{/* 트랙 번호 */}
<div className="w-10 h-10 flex items-center justify-center rounded-full bg-gray-100 group-hover:bg-primary/10 transition-colors">
<span className="text-gray-500 group-hover:text-primary transition-colors">
{String(track.track_number).padStart(2, '0')}
</span>
</div>
{/* 트랙 정보 */}
<div className="flex-1">
<div className="flex items-center gap-2">
<h3 className="font-semibold group-hover:text-primary transition-colors">{track.title}</h3>
{track.is_title_track === 1 && (
<span className="px-2 py-0.5 bg-primary text-white text-xs font-medium rounded-full">
TITLE
</span>
)}
</div>
</div>
{/* 재생 시간 */}
<div className="text-gray-400 tabular-nums text-sm">{track.duration || '-'}</div>
</div>
))}
</div>
</motion.div>
{/* 컨셉 포토 섹션 */}
{album.conceptPhotos && Object.keys(album.conceptPhotos).length > 0 && (
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4, duration: 0.4 }}
className="mt-10"
>
{(() => {
const allPhotos = Object.values(album.conceptPhotos).flat();
const previewPhotos = allPhotos.slice(0, 4);
const totalCount = allPhotos.length;
return (
<>
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-bold">컨셉 포토</h2>
<button
className="text-sm text-primary hover:underline"
onClick={() => navigate(`/album/${name}/gallery`)}
>
전체보기 ({totalCount})
</button>
</div>
<div className="grid grid-cols-4 gap-4">
{previewPhotos.map((photo, idx) => (
<div
key={photo.id}
onClick={() => openLightbox([photo.original_url], 0)}
className="aspect-square bg-gray-200 rounded-xl overflow-hidden cursor-pointer transition-all duration-300 ease-out hover:scale-[1.03] hover:shadow-xl hover:z-10"
>
<img
src={photo.medium_url}
alt={`컨셉 포토 ${idx + 1}`}
loading="lazy"
className="w-full h-full object-cover"
/>
</div>
))}
</div>
</>
);
})()}
</motion.div>
)}
</div>
</motion.div>
{/* 라이트박스 모달 */}
<AnimatePresence>
{lightbox.open && (
<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"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
<div className="min-w-[1400px] min-h-[1200px] w-full h-full relative flex items-center justify-center">
{/* 상단 버튼들 */}
<div className="absolute top-6 right-6 flex gap-3 z-10">
<button
className="text-white/70 hover:text-white transition-colors"
onClick={(e) => {
e.stopPropagation();
downloadImage();
}}
>
<Download size={28} />
</button>
<button className="text-white/70 hover:text-white transition-colors" onClick={() => window.history.back()}>
<X size={32} />
</button>
</div>
{/* 이전 버튼 */}
{lightbox.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">
{lightbox.teasers?.[lightbox.index]?.media_type === 'video' ? (
<motion.video
key={lightbox.index}
src={lightbox.images[lightbox.index]}
className={`max-w-[1100px] max-h-[900px] object-contain transition-opacity duration-200 ${
imageLoaded ? 'opacity-100' : 'opacity-0'
}`}
onClick={(e) => e.stopPropagation()}
onCanPlay={() => setImageLoaded(true)}
initial={{ x: slideDirection * 100 }}
animate={{ x: 0 }}
transition={{ duration: 0.25, ease: 'easeOut' }}
controls
autoPlay
/>
) : (
<motion.img
key={lightbox.index}
src={lightbox.images[lightbox.index]}
alt="확대 이미지"
className={`max-w-[1100px] max-h-[900px] 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>
{/* 다음 버튼 */}
{lightbox.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>
)}
{/* 인디케이터 */}
{lightbox.images.length > 1 && (
<LightboxIndicator
count={lightbox.images.length}
currentIndex={lightbox.index}
goToIndex={(i) => setLightbox((prev) => ({ ...prev, index: i }))}
/>
)}
</div>
</motion.div>
)}
</AnimatePresence>
{/* 앨범 소개 다이얼로그 */}
<AnimatePresence>
{showDescriptionModal && album?.description && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4"
onClick={() => window.history.back()}
>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
className="bg-white rounded-2xl shadow-2xl max-w-xl w-full max-h-[80vh] overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* 헤더 */}
<div className="flex items-center justify-between p-5 border-b border-gray-100">
<h3 className="text-lg font-bold">앨범 소개</h3>
<button
onClick={() => window.history.back()}
className="p-1.5 hover:bg-gray-100 rounded-full transition-colors"
>
<X size={20} className="text-gray-500" />
</button>
</div>
{/* 내용 */}
<div className="p-6 overflow-y-auto max-h-[60vh]">
<p className="text-gray-600 leading-relaxed whitespace-pre-line text-justify">{album.description}</p>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</>
);
}
export default PCAlbumDetail;

View file

@ -0,0 +1,372 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { motion, AnimatePresence } from 'framer-motion';
import { X, ChevronLeft, ChevronRight, Download } from 'lucide-react';
import { RowsPhotoAlbum } from 'react-photo-album';
import 'react-photo-album/rows.css';
import { getAlbumByName } from '@/api/albums';
import { LightboxIndicator } from '@/components/common';
// CSS
const galleryStyles = `
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.react-photo-album {
overflow: visible !important;
}
.react-photo-album--row {
overflow: visible !important;
}
.react-photo-album--photo {
transition: transform 0.3s ease, filter 0.3s ease !important;
cursor: pointer;
overflow: visible !important;
}
.react-photo-album--photo:hover {
transform: scale(1.05);
filter: brightness(0.9);
z-index: 10;
}
`;
/**
* PC 앨범 갤러리 페이지
*/
function PCAlbumGallery() {
const { name } = useParams();
const navigate = useNavigate();
const [lightbox, setLightbox] = useState({ open: false, index: 0 });
const [imageLoaded, setImageLoaded] = useState(false);
const [slideDirection, setSlideDirection] = useState(0);
const [preloadedImages] = useState(() => new Set());
//
const { data: album, isLoading: loading } = useQuery({
queryKey: ['album', name],
queryFn: () => getAlbumByName(name),
enabled: !!name,
});
//
const photos = useMemo(() => {
if (!album?.conceptPhotos) return [];
const allPhotos = [];
Object.entries(album.conceptPhotos).forEach(([concept, conceptPhotos]) => {
conceptPhotos.forEach((p) =>
allPhotos.push({
mediumUrl: p.medium_url,
originalUrl: p.original_url,
width: p.width || 800,
height: p.height || 1200,
title: concept,
members: p.members ? p.members.split(', ') : [],
})
);
});
return allPhotos;
}, [album]);
//
const openLightbox = useCallback((index) => {
setImageLoaded(false);
setLightbox({ open: true, index });
window.history.pushState({ lightbox: true }, '');
}, []);
//
useEffect(() => {
const handlePopState = () => {
if (lightbox.open) {
setLightbox((prev) => ({ ...prev, open: false }));
}
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, [lightbox.open]);
// body
useEffect(() => {
if (lightbox.open) {
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 = '';
};
}, [lightbox.open]);
// /
const goToPrev = useCallback(() => {
if (photos.length <= 1) return;
setImageLoaded(false);
setSlideDirection(-1);
setLightbox((prev) => ({
...prev,
index: (prev.index - 1 + photos.length) % photos.length,
}));
}, [photos.length]);
const goToNext = useCallback(() => {
if (photos.length <= 1) return;
setImageLoaded(false);
setSlideDirection(1);
setLightbox((prev) => ({
...prev,
index: (prev.index + 1) % photos.length,
}));
}, [photos.length]);
//
const downloadImage = useCallback(async () => {
const photo = photos[lightbox.index];
if (!photo) return;
try {
const response = await fetch(photo.originalUrl);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `fromis9_${album?.title || 'photo'}_${String(lightbox.index + 1).padStart(2, '0')}.webp`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
} catch (error) {
console.error('다운로드 오류:', error);
}
}, [photos, lightbox.index, album?.title]);
//
useEffect(() => {
if (!lightbox.open) return;
const handleKeyDown = (e) => {
switch (e.key) {
case 'ArrowLeft':
goToPrev();
break;
case 'ArrowRight':
goToNext();
break;
case 'Escape':
window.history.back();
break;
default:
break;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [lightbox.open, goToPrev, goToNext]);
//
useEffect(() => {
if (!lightbox.open || photos.length <= 1) return;
const indicesToPreload = [];
for (let offset = -2; offset <= 2; offset++) {
if (offset === 0) continue;
const idx = (lightbox.index + offset + photos.length) % photos.length;
indicesToPreload.push(idx);
}
indicesToPreload.forEach((idx) => {
const url = photos[idx].originalUrl;
if (preloadedImages.has(url)) return;
const img = new Image();
img.onload = () => preloadedImages.add(url);
img.src = url;
});
}, [lightbox.open, lightbox.index, photos, preloadedImages]);
if (loading) {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="py-16 flex justify-center items-center min-h-[60vh]"
>
<div className="animate-spin rounded-full h-12 w-12 border-4 border-primary border-t-transparent" />
</motion.div>
);
}
return (
<>
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.3 }} className="py-16">
<div className="px-24">
{/* 브레드크럼 스타일 헤더 */}
<div className="mb-8">
<div className="flex items-center gap-2 text-sm text-gray-500 mb-2">
<button onClick={() => navigate('/album')} className="hover:text-primary transition-colors">
앨범
</button>
<span>/</span>
<button onClick={() => navigate(`/album/${name}`)} className="hover:text-primary transition-colors">
{album?.title}
</button>
<span>/</span>
<span className="text-gray-700">컨셉 포토</span>
</div>
<h1 className="text-3xl font-bold">컨셉 포토</h1>
<p className="text-gray-500 mt-1">{photos.length}장의 사진</p>
</div>
{/* CSS 스타일 주입 */}
<style>{galleryStyles}</style>
{/* Justified 갤러리 */}
<RowsPhotoAlbum
photos={photos.map((photo, idx) => ({
src: photo.mediumUrl,
width: photo.width || 800,
height: photo.height || 1200,
key: idx.toString(),
}))}
targetRowHeight={280}
spacing={16}
rowConstraints={{ singleRowMaxHeight: 400, minPhotos: 1 }}
onClick={({ index }) => openLightbox(index)}
componentsProps={{
image: {
loading: 'lazy',
style: { borderRadius: '12px' },
},
}}
/>
</div>
</motion.div>
{/* 라이트박스 */}
<AnimatePresence>
{lightbox.open && (
<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"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
<div className="min-w-[1400px] min-h-[1200px] w-full h-full relative flex items-center justify-center">
{/* 상단 버튼들 */}
<div className="absolute top-6 right-6 flex gap-3 z-10">
<button className="text-white/70 hover:text-white transition-colors" onClick={downloadImage}>
<Download size={28} />
</button>
<button className="text-white/70 hover:text-white transition-colors" onClick={() => window.history.back()}>
<X size={32} />
</button>
</div>
{/* 카운터 */}
<div className="absolute top-6 left-6 text-white/70 text-sm z-10">
{lightbox.index + 1} / {photos.length}
</div>
{/* 이전 버튼 */}
{photos.length > 1 && (
<button
className="absolute left-6 p-2 text-white/70 hover:text-white transition-colors z-10"
onClick={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={lightbox.index}
src={photos[lightbox.index]?.originalUrl}
alt="확대 이미지"
className={`max-w-[1100px] max-h-[900px] object-contain transition-opacity duration-200 ${
imageLoaded ? 'opacity-100' : 'opacity-0'
}`}
onLoad={() => setImageLoaded(true)}
initial={{ x: slideDirection * 100 }}
animate={{ x: 0 }}
transition={{ duration: 0.25, ease: 'easeOut' }}
/>
{/* 컨셉 정보 + 멤버 */}
{imageLoaded &&
(() => {
const title = photos[lightbox.index]?.title;
const hasValidTitle = title && title.trim() && title !== 'Default';
const members = photos[lightbox.index]?.members;
const hasMembers = members && String(members).trim();
if (!hasValidTitle && !hasMembers) return null;
return (
<div className="mt-6 flex flex-col items-center gap-2">
{hasValidTitle && (
<span className="px-4 py-2 bg-white/10 backdrop-blur-sm rounded-full text-white font-medium text-base">
{title}
</span>
)}
{hasMembers && (
<div className="flex items-center gap-2">
{String(members)
.split(',')
.map((member, idx) => (
<span key={idx} className="px-3 py-1.5 bg-primary/80 rounded-full text-white text-sm">
{member.trim()}
</span>
))}
</div>
)}
</div>
);
})()}
</div>
{/* 다음 버튼 */}
{photos.length > 1 && (
<button
className="absolute right-6 p-2 text-white/70 hover:text-white transition-colors z-10"
onClick={goToNext}
>
<ChevronRight size={48} />
</button>
)}
{/* 하단 점 인디케이터 */}
<LightboxIndicator
count={photos.length}
currentIndex={lightbox.index}
goToIndex={(i) => setLightbox((prev) => ({ ...prev, index: i }))}
/>
</div>
</motion.div>
)}
</AnimatePresence>
</>
);
}
export default PCAlbumGallery;

View file

@ -0,0 +1,308 @@
import { useMemo } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { motion } from 'framer-motion';
import { Clock, User, Music, Mic2, ChevronRight } from 'lucide-react';
import { getTrack } from '@/api/albums';
/**
* 유튜브 URL에서 비디오 ID 추출
*/
function getYoutubeVideoId(url) {
if (!url) return null;
const patterns = [/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/];
for (const pattern of patterns) {
const match = url.match(pattern);
if (match) return match[1];
}
return null;
}
/**
* 쉼표 기준 줄바꿈 처리
*/
function formatCredit(text) {
if (!text) return null;
return text.split(',').map((item, index) => (
<span key={index} className="block">
{item.trim()}
</span>
));
}
/**
* PC 상세 페이지
*/
function PCTrackDetail() {
const { name: albumName, trackTitle } = useParams();
const navigate = useNavigate();
//
const {
data: track,
isLoading: loading,
error,
} = useQuery({
queryKey: ['track', albumName, trackTitle],
queryFn: () => getTrack(albumName, trackTitle),
enabled: !!albumName && !!trackTitle,
});
const youtubeVideoId = useMemo(() => getYoutubeVideoId(track?.music_video_url), [track?.music_video_url]);
if (loading) {
return (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="py-16 flex justify-center items-center min-h-[60vh]"
>
<div className="animate-spin rounded-full h-12 w-12 border-4 border-primary border-t-transparent" />
</motion.div>
);
}
if (error || !track) {
return (
<div className="py-16 text-center">
<p className="text-gray-500">트랙을 찾을 없습니다.</p>
</div>
);
}
return (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.3 }} className="py-12">
<div className="max-w-7xl mx-auto px-6">
{/* 브레드크럼 네비게이션 */}
<div className="flex items-center gap-2 text-sm text-gray-500 mb-8">
<Link to="/album" className="hover:text-primary transition-colors">
앨범
</Link>
<ChevronRight size={14} />
<Link
to={`/album/${encodeURIComponent(track.album?.title || albumName)}`}
className="hover:text-primary transition-colors"
>
{track.album?.title}
</Link>
<ChevronRight size={14} />
<span className="text-gray-700 font-medium">{track.title}</span>
</div>
{/* 트랙 정보 헤더 */}
<div className="flex gap-8 items-start mb-10">
{/* 앨범 커버 */}
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4 }}
className="w-48 h-48 rounded-2xl overflow-hidden shadow-lg flex-shrink-0"
>
<img src={track.album?.cover_medium_url} alt={track.album?.title} className="w-full h-full object-cover" />
</motion.div>
{/* 트랙 정보 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1, duration: 0.4 }}
className="flex-1"
>
<div className="flex items-center gap-3 mb-3">
{track.is_title_track === 1 && (
<span className="px-3 py-1 bg-primary text-white text-sm font-bold rounded-full">TITLE</span>
)}
<span className="text-gray-500 text-sm">Track {String(track.track_number).padStart(2, '0')}</span>
</div>
<h1 className="text-4xl font-bold mb-3 text-gray-900">{track.title}</h1>
{/* 앨범 정보 */}
<Link
to={`/album/${encodeURIComponent(track.album?.title || albumName)}`}
className="text-gray-500 hover:text-primary transition-colors mb-3 block"
>
{track.album?.album_type} · {track.album?.title}
</Link>
{/* 재생 시간 */}
{track.duration && (
<div className="flex items-center gap-2 text-gray-500">
<Clock size={16} />
<span>{track.duration}</span>
</div>
)}
</motion.div>
</div>
{/* 뮤직비디오 섹션 */}
{youtubeVideoId && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.15, duration: 0.4 }}
className="mb-10"
>
<h2 className="text-lg font-bold mb-4 flex items-center gap-2">
<div className="w-1 h-5 bg-red-500 rounded-full" />
뮤직비디오
</h2>
<div className="relative w-full aspect-video rounded-2xl overflow-hidden shadow-lg bg-black">
<iframe
src={`https://www.youtube.com/embed/${youtubeVideoId}`}
title={`${track.title} 뮤직비디오`}
className="absolute inset-0 w-full h-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
</motion.div>
)}
{/* 메인 콘텐츠 */}
<div className="grid grid-cols-3 gap-8">
{/* 왼쪽: 크레딧 + 수록곡 */}
<div className="col-span-1 space-y-6">
{/* 크레딧 */}
{(track.lyricist || track.composer || track.arranger) && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2, duration: 0.4 }}
className="bg-white rounded-2xl p-6 border border-gray-100 shadow-sm"
>
<h2 className="text-lg font-bold mb-5 flex items-center gap-2">
<div className="w-1 h-5 bg-primary rounded-full" />
크레딧
</h2>
<div className="space-y-5">
{track.lyricist && (
<div className="flex items-start gap-4">
<div className="w-9 h-9 bg-gray-100 rounded-lg flex items-center justify-center flex-shrink-0">
<Mic2 size={16} className="text-gray-500" />
</div>
<div className="flex-1">
<p className="text-xs text-gray-400 font-medium mb-1">작사</p>
<p className="text-sm text-gray-700 leading-relaxed">{formatCredit(track.lyricist)}</p>
</div>
</div>
)}
{track.composer && (
<div className="flex items-start gap-4">
<div className="w-9 h-9 bg-gray-100 rounded-lg flex items-center justify-center flex-shrink-0">
<Music size={16} className="text-gray-500" />
</div>
<div className="flex-1">
<p className="text-xs text-gray-400 font-medium mb-1">작곡</p>
<p className="text-sm text-gray-700 leading-relaxed">{formatCredit(track.composer)}</p>
</div>
</div>
)}
{track.arranger && (
<div className="flex items-start gap-4">
<div className="w-9 h-9 bg-gray-100 rounded-lg flex items-center justify-center flex-shrink-0">
<User size={16} className="text-gray-500" />
</div>
<div className="flex-1">
<p className="text-xs text-gray-400 font-medium mb-1">편곡</p>
<p className="text-sm text-gray-700 leading-relaxed">{formatCredit(track.arranger)}</p>
</div>
</div>
)}
</div>
</motion.div>
)}
{/* 수록곡 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3, duration: 0.4 }}
className="bg-white rounded-2xl p-6 border border-gray-100 shadow-sm"
>
<h2 className="text-lg font-bold mb-4 flex items-center gap-2">
<div className="w-1 h-5 bg-primary rounded-full" />
수록곡
</h2>
<div className="space-y-0.5">
{track.otherTracks?.map((t) => {
const isCurrent = t.title === track.title;
return (
<Link
key={t.id}
to={`/album/${encodeURIComponent(track.album?.title || albumName)}/track/${encodeURIComponent(t.title)}`}
className={`group flex items-center gap-3 px-3 py-2.5 rounded-xl transition-all ${
isCurrent ? 'bg-primary text-white' : 'hover:bg-gray-50'
}`}
>
{/* 트랙 번호 / 재생 아이콘 */}
<div
className={`w-6 text-center text-xs tabular-nums ${isCurrent ? 'text-white/80' : 'text-gray-400'}`}
>
{isCurrent ? (
<Music size={14} className="mx-auto text-white" />
) : (
String(t.track_number).padStart(2, '0')
)}
</div>
{/* 곡 제목 + 타이틀 배지 */}
<div className="flex-1 flex items-center gap-2 min-w-0">
<span className={`text-sm truncate ${isCurrent ? 'font-semibold' : 'group-hover:text-gray-900'}`}>
{t.title}
</span>
{/* 타이틀 배지 */}
{t.is_title_track === 1 && (
<span
className={`px-2 py-0.5 text-[10px] font-bold rounded-full flex-shrink-0 ${
isCurrent ? 'bg-white/20 text-white' : 'bg-primary/10 text-primary'
}`}
>
TITLE
</span>
)}
</div>
{/* 재생 시간 */}
<span className={`text-xs tabular-nums ${isCurrent ? 'text-white/70' : 'text-gray-400'}`}>
{t.duration || ''}
</span>
</Link>
);
})}
</div>
</motion.div>
</div>
{/* 오른쪽: 가사 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.25, duration: 0.4 }}
className="col-span-2"
>
<div className="bg-white rounded-2xl p-8 border border-gray-100 shadow-sm">
<h2 className="text-lg font-bold mb-6 flex items-center gap-2">
<div className="w-1 h-5 bg-primary rounded-full" />
가사
</h2>
{track.lyrics ? (
<div className="text-gray-700 leading-[2] whitespace-pre-line text-[15px] max-h-[600px] overflow-y-auto pr-4">
{track.lyrics}
</div>
) : (
<div className="text-center py-16 text-gray-400">
<Mic2 size={48} className="mx-auto mb-4 opacity-20" />
<p>가사 정보가 없습니다</p>
</div>
)}
</div>
</motion.div>
</div>
</div>
</motion.div>
);
}
export default PCTrackDetail;

View file

@ -1,516 +0,0 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { ChevronLeft, ChevronRight, ChevronDown, Search, X, Calendar as CalendarIcon, List } from 'lucide-react';
import { useQuery, useInfiniteQuery } from '@tanstack/react-query';
import { useInView } from 'react-intersection-observer';
import {
MobileScheduleListCard,
MobileScheduleSearchCard,
MobileBirthdayCard,
fireBirthdayConfetti,
} from '@/components/schedule';
import { getSchedules, searchSchedules } from '@/api/schedules';
import { useScheduleStore } from '@/stores';
import { getTodayKST, dayjs, getCategoryInfo } from '@/utils';
const WEEKDAYS = ['일', '월', '화', '수', '목', '금', '토'];
const MONTHS = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월'];
const SEARCH_LIMIT = 20;
const MIN_YEAR = 2017;
/**
* Mobile 스케줄 페이지
*/
function MobileSchedule() {
const navigate = useNavigate();
const scrollContainerRef = useRef(null);
// (zustand store)
const {
currentDate,
setCurrentDate,
selectedDate: storedSelectedDate,
setSelectedDate: setStoredSelectedDate,
selectedCategories,
setSelectedCategories,
isSearchMode,
setIsSearchMode,
searchInput,
setSearchInput,
searchTerm,
setSearchTerm,
} = useScheduleStore();
const selectedDate = storedSelectedDate === undefined ? getTodayKST() : storedSelectedDate;
const setSelectedDate = setStoredSelectedDate;
//
const [viewMode, setViewMode] = useState('calendar'); // 'calendar' | 'list'
const [showMonthPicker, setShowMonthPicker] = useState(false);
const year = currentDate.getFullYear();
const month = currentDate.getMonth();
//
const { data: schedules = [], isLoading: loading } = useQuery({
queryKey: ['schedules', year, month + 1],
queryFn: () => getSchedules(year, month + 1),
});
//
const { ref: loadMoreRef, inView } = useInView({ threshold: 0, rootMargin: '100px' });
const {
data: searchData,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['scheduleSearch', searchTerm],
queryFn: async ({ pageParam = 0 }) => {
return searchSchedules(searchTerm, { offset: pageParam, limit: SEARCH_LIMIT });
},
getNextPageParam: (lastPage) => {
if (lastPage.hasMore) {
return lastPage.offset + lastPage.schedules.length;
}
return undefined;
},
enabled: !!searchTerm && isSearchMode,
});
const searchResults = useMemo(() => {
if (!searchData?.pages) return [];
return searchData.pages.flatMap((page) => page.schedules);
}, [searchData]);
//
const prevInViewRef = useRef(false);
useEffect(() => {
if (inView && !prevInViewRef.current && hasNextPage && !isFetchingNextPage && isSearchMode && searchTerm) {
fetchNextPage();
}
prevInViewRef.current = inView;
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage, isSearchMode, searchTerm]);
//
useEffect(() => {
if (loading || schedules.length === 0) return;
const today = getTodayKST();
const confettiKey = `birthday-confetti-${today}`;
if (localStorage.getItem(confettiKey)) return;
const hasBirthdayToday = schedules.some((s) => s.is_birthday && s.date === today);
if (hasBirthdayToday) {
const timer = setTimeout(() => {
fireBirthdayConfetti();
localStorage.setItem(confettiKey, 'true');
}, 500);
return () => clearTimeout(timer);
}
}, [schedules, loading]);
//
const getDaysInMonth = (y, m) => new Date(y, m + 1, 0).getDate();
const getFirstDayOfMonth = (y, m) => new Date(y, m, 1).getDay();
const daysInMonth = getDaysInMonth(year, month);
const firstDay = getFirstDayOfMonth(year, month);
//
const scheduleDateMap = useMemo(() => {
const map = new Map();
schedules.forEach((s) => {
const dateStr = s.date;
if (!map.has(dateStr)) {
map.set(dateStr, []);
}
map.get(dateStr).push(s);
});
return map;
}, [schedules]);
//
const categories = useMemo(() => {
const categoryMap = new Map();
schedules.forEach((s) => {
if (s.category_id && !categoryMap.has(s.category_id)) {
categoryMap.set(s.category_id, {
id: s.category_id,
name: s.category_name,
color: s.category_color,
});
}
});
return Array.from(categoryMap.values());
}, [schedules]);
//
const filteredSchedules = useMemo(() => {
if (isSearchMode && searchTerm) {
if (selectedCategories.length === 0) return searchResults;
return searchResults.filter((s) => selectedCategories.includes(s.category_id));
}
return schedules
.filter((s) => {
const matchesDate = selectedDate ? s.date === selectedDate : true;
const matchesCategory = selectedCategories.length === 0 || selectedCategories.includes(s.category_id);
return matchesDate && matchesCategory;
})
.sort((a, b) => {
//
if (a.is_birthday && !b.is_birthday) return -1;
if (!a.is_birthday && b.is_birthday) return 1;
//
return (a.time || '00:00:00').localeCompare(b.time || '00:00:00');
});
}, [schedules, selectedDate, selectedCategories, isSearchMode, searchTerm, searchResults]);
// ( )
const groupedSchedules = useMemo(() => {
if (isSearchMode && searchTerm) {
const groups = new Map();
searchResults.forEach((s) => {
if (!groups.has(s.date)) {
groups.set(s.date, []);
}
groups.get(s.date).push(s);
});
return Array.from(groups.entries()).sort((a, b) => a[0].localeCompare(b[0]));
}
const groups = new Map();
schedules.forEach((s) => {
if (selectedCategories.length > 0 && !selectedCategories.includes(s.category_id)) return;
if (!groups.has(s.date)) {
groups.set(s.date, []);
}
groups.get(s.date).push(s);
});
return Array.from(groups.entries()).sort((a, b) => a[0].localeCompare(b[0]));
}, [schedules, selectedCategories, isSearchMode, searchTerm, searchResults]);
//
const canGoPrevMonth = !(year === MIN_YEAR && month === 0);
const prevMonth = () => {
if (!canGoPrevMonth) return;
const newDate = new Date(year, month - 1, 1);
setCurrentDate(newDate);
};
const nextMonth = () => {
const newDate = new Date(year, month + 1, 1);
setCurrentDate(newDate);
};
//
const selectDate = (day) => {
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
setSelectedDate(dateStr);
};
//
const handleScheduleClick = (schedule) => {
if (schedule.is_birthday) {
const scheduleYear = new Date(schedule.date).getFullYear();
navigate(`/birthday/${encodeURIComponent(schedule.member_names)}/${scheduleYear}`);
return;
}
if ([2, 3, 6].includes(schedule.category_id)) {
navigate(`/schedule/${schedule.id}`);
return;
}
if (!schedule.description && schedule.source?.url) {
window.open(schedule.source.url, '_blank');
} else {
navigate(`/schedule/${schedule.id}`);
}
};
//
const exitSearchMode = () => {
setIsSearchMode(false);
setSearchInput('');
setSearchTerm('');
};
return (
<div className="flex flex-col h-full bg-gray-50">
{/* 헤더 */}
<div className="bg-white sticky top-0 z-20">
{isSearchMode ? (
//
<div className="flex items-center gap-2 px-4 py-3">
<button onClick={exitSearchMode} className="p-1">
<ChevronLeft size={24} className="text-gray-600" />
</button>
<div className="flex-1 relative">
<input
type="text"
placeholder="일정 검색..."
value={searchInput}
autoFocus
onChange={(e) => setSearchInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && searchInput.trim()) {
setSearchTerm(searchInput);
}
}}
className="w-full pl-10 pr-10 py-2 bg-gray-100 rounded-full text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
<Search size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
{searchInput && (
<button
onClick={() => {
setSearchInput('');
setSearchTerm('');
}}
className="absolute right-3 top-1/2 -translate-y-1/2"
>
<X size={18} className="text-gray-400" />
</button>
)}
</div>
</div>
) : (
//
<>
<div className="flex items-center justify-between px-4 py-3">
<button
onClick={() => setShowMonthPicker(!showMonthPicker)}
className="flex items-center gap-1 text-lg font-bold"
>
{year} {month + 1}
<ChevronDown size={20} className={`transition-transform ${showMonthPicker ? 'rotate-180' : ''}`} />
</button>
<div className="flex items-center gap-2">
<button
onClick={() => setIsSearchMode(true)}
className="p-2 hover:bg-gray-100 rounded-full"
>
<Search size={20} className="text-gray-600" />
</button>
<button
onClick={() => setViewMode(viewMode === 'calendar' ? 'list' : 'calendar')}
className="p-2 hover:bg-gray-100 rounded-full"
>
{viewMode === 'calendar' ? (
<List size={20} className="text-gray-600" />
) : (
<CalendarIcon size={20} className="text-gray-600" />
)}
</button>
</div>
</div>
{/* 월 선택 드롭다운 */}
<AnimatePresence>
{showMonthPicker && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden border-t border-gray-100"
>
<div className="p-4">
<div className="flex items-center justify-between mb-4">
<button
onClick={() => setCurrentDate(new Date(year - 1, month, 1))}
disabled={year <= MIN_YEAR}
className={`p-1 ${year <= MIN_YEAR ? 'opacity-30' : ''}`}
>
<ChevronLeft size={20} />
</button>
<span className="font-medium">{year}</span>
<button onClick={() => setCurrentDate(new Date(year + 1, month, 1))} className="p-1">
<ChevronRight size={20} />
</button>
</div>
<div className="grid grid-cols-4 gap-2">
{MONTHS.map((m, i) => (
<button
key={m}
onClick={() => {
setCurrentDate(new Date(year, i, 1));
setShowMonthPicker(false);
}}
className={`py-2 rounded-lg text-sm ${
month === i ? 'bg-primary text-white' : 'hover:bg-gray-100'
}`}
>
{m}
</button>
))}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
{/* 달력 모드 - 달력 그리드 */}
{viewMode === 'calendar' && (
<div className="px-4 pb-4">
{/* 월 네비게이션 */}
<div className="flex items-center justify-between mb-4">
<button onClick={prevMonth} disabled={!canGoPrevMonth} className={`p-1 ${!canGoPrevMonth ? 'opacity-30' : ''}`}>
<ChevronLeft size={20} />
</button>
<span className="font-medium">{month + 1}</span>
<button onClick={nextMonth} className="p-1">
<ChevronRight size={20} />
</button>
</div>
{/* 요일 헤더 */}
<div className="grid grid-cols-7 mb-2">
{WEEKDAYS.map((day, i) => (
<div
key={day}
className={`text-center text-xs font-medium py-1 ${
i === 0 ? 'text-red-500' : i === 6 ? 'text-blue-500' : 'text-gray-500'
}`}
>
{day}
</div>
))}
</div>
{/* 날짜 그리드 */}
<div className="grid grid-cols-7 gap-1">
{/* 전달 빈 칸 */}
{Array.from({ length: firstDay }).map((_, i) => (
<div key={`empty-${i}`} className="aspect-square" />
))}
{/* 현재 달 날짜 */}
{Array.from({ length: daysInMonth }).map((_, i) => {
const day = i + 1;
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
const isSelected = selectedDate === dateStr;
const isToday = dateStr === getTodayKST();
const daySchedules = scheduleDateMap.get(dateStr) || [];
const dayOfWeek = (firstDay + i) % 7;
return (
<button
key={day}
onClick={() => selectDate(day)}
className={`aspect-square flex flex-col items-center justify-center rounded-lg text-sm relative
${isSelected ? 'bg-primary text-white' : ''}
${isToday && !isSelected ? 'text-primary font-bold' : ''}
${dayOfWeek === 0 && !isSelected ? 'text-red-500' : ''}
${dayOfWeek === 6 && !isSelected ? 'text-blue-500' : ''}
`}
>
<span>{day}</span>
{!isSelected && daySchedules.length > 0 && (
<div className="absolute bottom-1 flex gap-0.5">
{daySchedules.slice(0, 3).map((s, idx) => (
<span
key={idx}
className="w-1 h-1 rounded-full"
style={{ backgroundColor: s.category_color || '#4A7C59' }}
/>
))}
</div>
)}
</button>
);
})}
</div>
</div>
)}
</>
)}
</div>
{/* 일정 목록 */}
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center py-20">
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div>
) : isSearchMode && searchTerm ? (
//
<div className="p-4 space-y-3">
{searchResults.length > 0 ? (
<>
{searchResults.map((schedule) => (
<div key={schedule.id}>
{schedule.is_birthday ? (
<MobileBirthdayCard schedule={schedule} showYear onClick={() => handleScheduleClick(schedule)} />
) : (
<MobileScheduleSearchCard schedule={schedule} onClick={() => handleScheduleClick(schedule)} />
)}
</div>
))}
<div ref={loadMoreRef} className="py-4">
{isFetchingNextPage && (
<div className="flex justify-center">
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div>
)}
</div>
</>
) : (
<div className="text-center py-20 text-gray-400">검색 결과가 없습니다</div>
)}
</div>
) : viewMode === 'calendar' ? (
// -
<div className="p-4 space-y-3">
{filteredSchedules.length > 0 ? (
filteredSchedules.map((schedule) => (
<div key={schedule.id}>
{schedule.is_birthday ? (
<MobileBirthdayCard schedule={schedule} onClick={() => handleScheduleClick(schedule)} />
) : (
<MobileScheduleListCard schedule={schedule} onClick={() => handleScheduleClick(schedule)} />
)}
</div>
))
) : (
<div className="text-center py-20 text-gray-400">
{selectedDate ? '이 날짜에 일정이 없습니다' : '이번 달에 일정이 없습니다'}
</div>
)}
</div>
) : (
// -
<div className="divide-y divide-gray-100">
{groupedSchedules.length > 0 ? (
groupedSchedules.map(([date, daySchedules]) => {
const d = dayjs(date);
return (
<div key={date} className="bg-white">
<div className="sticky top-0 bg-gray-50 px-4 py-2 text-sm font-medium text-gray-600">
{d.format('M월 D일')} ({WEEKDAYS[d.day()]})
</div>
<div className="p-4 space-y-3">
{daySchedules.map((schedule) => (
<div key={schedule.id}>
{schedule.is_birthday ? (
<MobileBirthdayCard schedule={schedule} onClick={() => handleScheduleClick(schedule)} />
) : (
<MobileScheduleListCard schedule={schedule} onClick={() => handleScheduleClick(schedule)} />
)}
</div>
))}
</div>
</div>
);
})
) : (
<div className="text-center py-20 text-gray-400">이번 달에 일정이 없습니다</div>
)}
</div>
)}
</div>
</div>
);
}
export default MobileSchedule;

View file

@ -1,2 +1,6 @@
export { default as PCSchedule } from './PCSchedule'; export { default as PCSchedule } from './pc/Schedule';
export { default as MobileSchedule } from './MobileSchedule'; export { default as MobileSchedule } from './mobile/Schedule';
export { default as PCScheduleDetail } from './pc/ScheduleDetail';
export { default as MobileScheduleDetail } from './mobile/ScheduleDetail';
export { default as PCBirthday } from './pc/Birthday';
export { default as MobileBirthday } from './mobile/Birthday';

View file

@ -3,19 +3,7 @@ import { useQuery } from '@tanstack/react-query';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { ChevronLeft } from 'lucide-react'; import { ChevronLeft } from 'lucide-react';
import { fetchApi } from '@/api/client'; import { fetchApi } from '@/api/client';
import { MEMBER_ENGLISH_NAMES } from '@/constants';
//
const memberEnglishName = {
송하영: 'HAYOUNG',
박지원: 'JIWON',
이채영: 'CHAEYOUNG',
이나경: 'NAKYUNG',
백지헌: 'JIHEON',
장규리: 'GYURI',
이새롬: 'SAEROM',
노지선: 'JISUN',
이서연: 'SEOYEON',
};
/** /**
* Mobile 생일 페이지 * Mobile 생일 페이지
@ -25,7 +13,7 @@ function MobileBirthday() {
// URL // URL
const decodedMemberName = decodeURIComponent(memberName || ''); const decodedMemberName = decodeURIComponent(memberName || '');
const englishName = memberEnglishName[decodedMemberName]; const englishName = MEMBER_ENGLISH_NAMES[decodedMemberName];
// //
const { const {

View file

@ -0,0 +1,779 @@
import { useState, useEffect, useMemo, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { ChevronLeft, ChevronRight, ChevronDown, Search, X, Calendar } from 'lucide-react';
import { useQuery, useInfiniteQuery } from '@tanstack/react-query';
import { useInView } from 'react-intersection-observer';
import { useVirtualizer } from '@tanstack/react-virtual';
import { getTodayKST, getCategoryInfo } from '@/utils';
import { getSchedules, searchSchedules } from '@/api/schedules';
import { useScheduleStore } from '@/stores';
import { MIN_YEAR, SEARCH_LIMIT } from '@/constants';
import {
MobileCalendar,
MobileScheduleListCard,
MobileScheduleSearchCard,
MobileBirthdayCard,
fireBirthdayConfetti,
} from '@/components/schedule';
/**
* 모바일 일정 페이지
*/
function MobileSchedule() {
const navigate = useNavigate();
// zustand store
const {
selectedDate: storedSelectedDate,
setSelectedDate: setStoredSelectedDate,
} = useScheduleStore();
// (store )
const selectedDate = storedSelectedDate || new Date();
const setSelectedDate = (date) => setStoredSelectedDate(date);
const [isSearchMode, setIsSearchMode] = useState(false);
const [searchInput, setSearchInput] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const [showCalendar, setShowCalendar] = useState(false);
const [calendarViewDate, setCalendarViewDate] = useState(() => new Date(selectedDate));
const [calendarShowYearMonth, setCalendarShowYearMonth] = useState(false);
const contentRef = useRef(null);
const searchContainerRef = useRef(null);
const searchInputRef = useRef(null);
//
const [showSuggestions, setShowSuggestions] = useState(false);
const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1);
const [originalSearchQuery, setOriginalSearchQuery] = useState('');
const [suggestions, setSuggestions] = useState([]);
const [lastSearchTerm, setLastSearchTerm] = useState('');
const [showSuggestionsScreen, setShowSuggestionsScreen] = useState(false);
// /
const enterSearchMode = () => {
setIsSearchMode(true);
window.history.pushState({ searchMode: true }, '');
};
const exitSearchMode = () => {
setIsSearchMode(false);
setSearchInput('');
setOriginalSearchQuery('');
setSearchTerm('');
setLastSearchTerm('');
setShowSuggestions(false);
setShowSuggestionsScreen(false);
setSelectedSuggestionIndex(-1);
};
const hideSuggestionsScreen = () => {
setShowSuggestionsScreen(false);
setSearchInput(lastSearchTerm);
setOriginalSearchQuery(lastSearchTerm);
};
//
useEffect(() => {
const handlePopState = () => {
if (isSearchMode) {
if (showSuggestionsScreen && searchTerm) {
hideSuggestionsScreen();
window.history.pushState({ searchMode: true }, '');
} else {
exitSearchMode();
}
}
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, [isSearchMode, showSuggestionsScreen, searchTerm, lastSearchTerm]);
//
const changeCalendarMonth = (delta) => {
const newDate = new Date(calendarViewDate);
newDate.setMonth(newDate.getMonth() + delta);
setCalendarViewDate(newDate);
};
const scrollContainerRef = useRef(null);
const { ref: loadMoreRef, inView } = useInView({ threshold: 0, rootMargin: '100px' });
//
const {
data: searchData,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading: searchLoading,
} = useInfiniteQuery({
queryKey: ['mobileScheduleSearch', searchTerm],
queryFn: async ({ pageParam = 0 }) => {
return searchSchedules(searchTerm, { offset: pageParam, limit: SEARCH_LIMIT });
},
getNextPageParam: (lastPage) => {
if (lastPage.hasMore) {
return lastPage.offset + lastPage.schedules.length;
}
return undefined;
},
enabled: !!searchTerm && isSearchMode,
});
const searchResults = useMemo(() => {
if (!searchData?.pages) return [];
return searchData.pages.flatMap((page) => page.schedules);
}, [searchData]);
//
const virtualizer = useVirtualizer({
count: isSearchMode && searchTerm ? searchResults.length : 0,
getScrollElement: () => scrollContainerRef.current,
estimateSize: () => 100,
overscan: 5,
});
//
useEffect(() => {
if (searchTerm && !showSuggestionsScreen) {
requestAnimationFrame(() => {
virtualizer.scrollToOffset(0);
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTop = 0;
}
});
}
}, [searchTerm, showSuggestionsScreen]);
useEffect(() => {
if (inView && hasNextPage && !isFetchingNextPage && isSearchMode && searchTerm) {
fetchNextPage();
}
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage, isSearchMode, searchTerm]);
//
const viewYear = selectedDate.getFullYear();
const viewMonth = selectedDate.getMonth() + 1;
const { data: schedules = [], isLoading: loading } = useQuery({
queryKey: ['schedules', viewYear, viewMonth],
queryFn: () => getSchedules(viewYear, viewMonth),
});
//
const calendarYear = calendarViewDate.getFullYear();
const calendarMonth = calendarViewDate.getMonth() + 1;
const isSameMonth = viewYear === calendarYear && viewMonth === calendarMonth;
const { data: calendarSchedules = [] } = useQuery({
queryKey: ['schedules', calendarYear, calendarMonth],
queryFn: () => getSchedules(calendarYear, calendarMonth),
enabled: !isSameMonth,
});
//
useEffect(() => {
if (loading || schedules.length === 0) return;
const today = getTodayKST();
const confettiKey = `birthday-confetti-${today}`;
if (localStorage.getItem(confettiKey)) return;
const hasBirthdayToday = schedules.some((s) => {
if (!s.is_birthday) return false;
const scheduleDate = s.date ? s.date.split('T')[0] : '';
return scheduleDate === today;
});
if (hasBirthdayToday) {
const timer = setTimeout(() => {
fireBirthdayConfetti();
localStorage.setItem(confettiKey, 'true');
}, 500);
return () => clearTimeout(timer);
}
}, [schedules, loading]);
// 2017 1
const canGoPrevMonth = !(selectedDate.getFullYear() === MIN_YEAR && selectedDate.getMonth() === 0);
//
const changeMonth = (delta) => {
if (delta < 0 && !canGoPrevMonth) return;
const newDate = new Date(selectedDate);
newDate.setMonth(newDate.getMonth() + delta);
const today = new Date();
if (newDate.getFullYear() === today.getFullYear() && newDate.getMonth() === today.getMonth()) {
newDate.setDate(today.getDate());
} else {
newDate.setDate(1);
}
setSelectedDate(newDate);
setCalendarViewDate(newDate);
};
//
useEffect(() => {
if (contentRef.current) {
contentRef.current.scrollTop = 0;
}
}, [selectedDate]);
//
useEffect(() => {
const preventScroll = (e) => e.preventDefault();
if (showCalendar) {
document.addEventListener('touchmove', preventScroll, { passive: false });
} else {
document.removeEventListener('touchmove', preventScroll);
}
return () => {
document.removeEventListener('touchmove', preventScroll);
};
}, [showCalendar]);
//
useEffect(() => {
const handleClickOutside = (event) => {
if (searchContainerRef.current && !searchContainerRef.current.contains(event.target)) {
setShowSuggestions(false);
setSelectedSuggestionIndex(-1);
}
};
if (showSuggestions) {
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('touchstart', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('touchstart', handleClickOutside);
};
}, [showSuggestions]);
// API
useEffect(() => {
if (!originalSearchQuery || originalSearchQuery.trim().length === 0) {
setSuggestions([]);
return;
}
const timeoutId = setTimeout(async () => {
try {
const response = await fetch(
`/api/schedules/suggestions?q=${encodeURIComponent(originalSearchQuery)}&limit=10`
);
if (response.ok) {
const data = await response.json();
setSuggestions(data.suggestions || []);
}
} catch (error) {
console.error('추천 검색어 API 오류:', error);
setSuggestions([]);
}
}, 200);
return () => clearTimeout(timeoutId);
}, [originalSearchQuery]);
//
const daysInMonth = useMemo(() => {
const year = selectedDate.getFullYear();
const month = selectedDate.getMonth();
const lastDay = new Date(year, month + 1, 0).getDate();
const days = [];
for (let d = 1; d <= lastDay; d++) {
days.push(new Date(year, month, d));
}
return days;
}, [selectedDate]);
// ( )
const selectedDateSchedules = useMemo(() => {
const year = selectedDate.getFullYear();
const month = String(selectedDate.getMonth() + 1).padStart(2, '0');
const day = String(selectedDate.getDate()).padStart(2, '0');
const dateStr = `${year}-${month}-${day}`;
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, selectedDate]);
//
const getDayName = (date) => ['일', '월', '화', '수', '목', '금', '토'][date.getDay()];
//
const isToday = (date) => {
const today = new Date();
return (
date.getDate() === today.getDate() &&
date.getMonth() === today.getMonth() &&
date.getFullYear() === today.getFullYear()
);
};
//
const isSelected = (date) => {
return (
date.getDate() === selectedDate.getDate() &&
date.getMonth() === selectedDate.getMonth() &&
date.getFullYear() === selectedDate.getFullYear()
);
};
// ref
const dateScrollRef = useRef(null);
//
useEffect(() => {
if (!isSearchMode && dateScrollRef.current) {
const selectedDay = selectedDate.getDate();
const buttons = dateScrollRef.current.querySelectorAll('button');
if (buttons[selectedDay - 1]) {
setTimeout(() => {
buttons[selectedDay - 1].scrollIntoView({
behavior: 'smooth',
inline: 'center',
block: 'nearest',
});
}, 50);
}
}
}, [selectedDate, isSearchMode]);
//
const handleSearch = (term) => {
if (term) {
setSearchInput(term);
setSearchTerm(term);
setLastSearchTerm(term);
setShowSuggestionsScreen(false);
}
setShowSuggestions(false);
setSelectedSuggestionIndex(-1);
searchInputRef.current?.blur();
};
return (
<>
{/* 툴바 (헤더 + 날짜 선택기) */}
<div className="mobile-toolbar-schedule shadow-sm z-50">
{isSearchMode ? (
<div className="flex items-center gap-3 px-4 py-3 relative" ref={searchContainerRef}>
<div className="flex-1 flex items-center gap-2 bg-gray-100 rounded-full px-4 py-2 min-w-0">
<Search size={18} className="text-gray-400 flex-shrink-0" />
<input
ref={searchInputRef}
type="text"
inputMode="search"
enterKeyHint="search"
placeholder="일정 검색..."
value={searchInput}
onChange={(e) => {
setSearchInput(e.target.value);
setOriginalSearchQuery(e.target.value);
setShowSuggestions(true);
setShowSuggestionsScreen(true);
setSelectedSuggestionIndex(-1);
}}
onFocus={() => {
setShowSuggestions(true);
setShowSuggestionsScreen(true);
}}
onKeyDown={(e) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
const newIndex =
selectedSuggestionIndex < suggestions.length - 1
? selectedSuggestionIndex + 1
: 0;
setSelectedSuggestionIndex(newIndex);
if (suggestions[newIndex]) {
setSearchInput(suggestions[newIndex]);
}
} else if (e.key === 'ArrowUp') {
e.preventDefault();
const newIndex =
selectedSuggestionIndex > 0
? selectedSuggestionIndex - 1
: suggestions.length - 1;
setSelectedSuggestionIndex(newIndex);
if (suggestions[newIndex]) {
setSearchInput(suggestions[newIndex]);
}
} else if (e.key === 'Enter') {
e.preventDefault();
const term =
selectedSuggestionIndex >= 0 && suggestions[selectedSuggestionIndex]
? suggestions[selectedSuggestionIndex]
: searchInput.trim();
handleSearch(term);
}
}}
className="flex-1 bg-transparent outline-none text-sm min-w-0 [&::-webkit-search-cancel-button]:hidden"
autoFocus={!searchTerm}
/>
{searchInput && (
<button
onClick={() => {
setSearchInput('');
setOriginalSearchQuery('');
setShowSuggestions(true);
setShowSuggestionsScreen(true);
setSelectedSuggestionIndex(-1);
}}
className="flex-shrink-0"
>
<X size={18} className="text-gray-400" />
</button>
)}
</div>
<button onClick={exitSearchMode} className="text-sm text-gray-500 flex-shrink-0">
취소
</button>
</div>
) : (
<div className="relative flex items-center justify-between px-4 py-3">
{showCalendar ? (
<>
<div className="flex items-center gap-1">
<button
onClick={() => {
setShowCalendar(false);
setCalendarShowYearMonth(false);
}}
className="p-2 rounded-lg hover:bg-gray-100"
>
<Calendar size={20} className="text-primary" />
</button>
<button onClick={() => changeCalendarMonth(-1)} className="p-2">
<ChevronLeft size={20} />
</button>
</div>
<button
onClick={() => setCalendarShowYearMonth(!calendarShowYearMonth)}
className={`absolute left-1/2 -translate-x-1/2 font-bold transition-colors ${
calendarShowYearMonth ? 'text-primary' : ''
}`}
>
{calendarViewDate.getFullYear()} {calendarViewDate.getMonth() + 1}
</button>
<button
onClick={() => setCalendarShowYearMonth(!calendarShowYearMonth)}
className={`absolute transition-colors ${
calendarShowYearMonth ? 'text-primary' : ''
}`}
style={{ left: 'calc(50% + 52px)' }}
>
<ChevronDown
size={16}
className={`transition-transform duration-200 ${
calendarShowYearMonth ? 'rotate-180' : ''
}`}
/>
</button>
<div className="flex items-center gap-1">
<button onClick={() => changeCalendarMonth(1)} className="p-2">
<ChevronRight size={20} />
</button>
<button onClick={enterSearchMode} className="p-2">
<Search size={20} />
</button>
</div>
</>
) : (
<>
<div className="flex items-center gap-1">
<button
onClick={() => {
setCalendarViewDate(selectedDate);
setShowCalendar(true);
}}
className="p-2 rounded-lg hover:bg-gray-100"
>
<Calendar size={20} className="text-gray-600" />
</button>
<button
onClick={() => changeMonth(-1)}
disabled={!canGoPrevMonth}
className={`p-2 ${!canGoPrevMonth ? 'opacity-30' : ''}`}
>
<ChevronLeft size={20} />
</button>
</div>
<span className="font-bold">
{selectedDate.getFullYear()} {selectedDate.getMonth() + 1}
</span>
<div className="flex items-center gap-1">
<button onClick={() => changeMonth(1)} className="p-2">
<ChevronRight size={20} />
</button>
<button onClick={enterSearchMode} className="p-2">
<Search size={20} />
</button>
</div>
</>
)}
</div>
)}
{/* 가로 스크롤 날짜 선택기 */}
{!isSearchMode && (
<div
ref={dateScrollRef}
className="flex overflow-x-auto scrollbar-hide px-2 py-2 gap-1"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{daysInMonth.map((date) => {
const dayOfWeek = date.getDay();
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const dateStr = `${year}-${month}-${day}`;
const daySchedules = schedules
.filter((s) => s.date?.split('T')[0] === dateStr)
.slice(0, 3);
return (
<button
key={date.getDate()}
onClick={() => {
setSelectedDate(date);
setCalendarViewDate(date);
}}
className={`flex flex-col items-center min-w-[44px] h-[64px] py-2 px-1 rounded-xl transition-all ${
isSelected(date) ? 'bg-primary text-white' : 'hover:bg-gray-100'
}`}
>
<span
className={`text-[10px] font-medium ${
isSelected(date)
? 'text-white/80'
: dayOfWeek === 0
? 'text-red-400'
: dayOfWeek === 6
? 'text-blue-400'
: 'text-gray-400'
}`}
>
{getDayName(date)}
</span>
<span
className={`text-sm font-semibold mt-0.5 ${
isSelected(date)
? 'text-white'
: isToday(date)
? 'text-primary'
: 'text-gray-700'
}`}
>
{date.getDate()}
</span>
<div className="flex gap-0.5 mt-1 h-1.5">
{!isSelected(date) &&
daySchedules.map((schedule, i) => {
const categoryInfo = getCategoryInfo(schedule);
return (
<div
key={i}
className="w-1 h-1 rounded-full"
style={{ backgroundColor: categoryInfo.color }}
/>
);
})}
</div>
</button>
);
})}
</div>
)}
</div>
{/* 달력 팝업 */}
<AnimatePresence>
{showCalendar && !isSearchMode && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="fixed left-0 right-0 bg-white shadow-lg z-50 border-b overflow-hidden"
style={{ top: '56px' }}
>
<MobileCalendar
selectedDate={selectedDate}
schedules={isSameMonth ? schedules : calendarSchedules}
hideHeader={true}
externalViewDate={calendarViewDate}
onViewDateChange={setCalendarViewDate}
externalShowYearMonth={calendarShowYearMonth}
onShowYearMonthChange={setCalendarShowYearMonth}
onSelectDate={(date) => {
setSelectedDate(date);
setCalendarViewDate(date);
setCalendarShowYearMonth(false);
setShowCalendar(false);
}}
/>
</motion.div>
)}
</AnimatePresence>
{/* 캘린더 배경 오버레이 */}
<AnimatePresence>
{showCalendar && !isSearchMode && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setShowCalendar(false)}
className="fixed inset-0 bg-black/40 z-40"
style={{ top: 0 }}
/>
)}
</AnimatePresence>
{/* 컨텐츠 영역 */}
<div
className="mobile-content"
ref={isSearchMode && searchTerm && !showSuggestionsScreen ? scrollContainerRef : contentRef}
>
<div className={`px-4 pb-4 ${isSearchMode && showSuggestionsScreen ? 'pt-0' : 'pt-4'}`}>
{isSearchMode ? (
showSuggestionsScreen ? (
//
<div className="space-y-0">
{suggestions.length === 0 ? (
<div className="text-center py-8 text-gray-400">검색어를 입력하세요</div>
) : (
suggestions.map((suggestion, index) => (
<button
key={suggestion}
onClick={() => handleSearch(suggestion)}
className={`w-full px-0 py-3.5 text-left flex items-center gap-4 border-b border-gray-100 active:bg-gray-50 ${
index === selectedSuggestionIndex ? 'bg-primary/5' : ''
}`}
>
<Search size={18} className="text-gray-400 shrink-0" />
<span className="text-[15px] text-gray-700 flex-1">{suggestion}</span>
</button>
))
)}
</div>
) : !searchTerm ? (
<div className="text-center py-8 text-gray-400">검색어를 입력하세요</div>
) : searchLoading ? (
<div className="flex justify-center py-8">
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div>
) : searchResults.length === 0 ? (
<div className="text-center py-8 text-gray-400">검색 결과가 없습니다</div>
) : (
<>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualItem) => {
const schedule = searchResults[virtualItem.index];
if (!schedule) return null;
return (
<div
key={virtualItem.key}
ref={virtualizer.measureElement}
data-index={virtualItem.index}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualItem.start}px)`,
}}
>
<div className={virtualItem.index < searchResults.length - 1 ? 'pb-3' : ''}>
<MobileScheduleSearchCard
schedule={schedule}
onClick={() => navigate(`/schedule/${schedule.id}`)}
/>
</div>
</div>
);
})}
</div>
<div ref={loadMoreRef} className="py-2">
{isFetchingNextPage && (
<div className="flex justify-center">
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div>
)}
</div>
</>
)
) : loading ? (
<div className="flex justify-center py-8">
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div>
) : selectedDateSchedules.length === 0 ? (
<div className="text-center py-8 text-gray-400">
{selectedDate.getMonth() + 1} {selectedDate.getDate()} 일정이 없습니다
</div>
) : (
<div className="space-y-3">
{selectedDateSchedules.map((schedule, index) => {
const isBirthday = schedule.is_birthday || String(schedule.id).startsWith('birthday-');
if (isBirthday) {
return (
<MobileBirthdayCard
key={schedule.id}
schedule={schedule}
delay={index * 0.05}
onClick={() => {
const scheduleYear = new Date(schedule.date).getFullYear();
const memberName = schedule.member_names;
navigate(`/birthday/${encodeURIComponent(memberName)}/${scheduleYear}`);
}}
/>
);
}
return (
<MobileScheduleListCard
key={schedule.id}
schedule={schedule}
delay={index * 0.05}
onClick={() => navigate(`/schedule/${schedule.id}`)}
/>
);
})}
</div>
)}
</div>
</div>
</>
);
}
export default MobileSchedule;

View file

@ -0,0 +1,542 @@
import { useParams, Link } from 'react-router-dom';
import { useQuery, keepPreviousData } from '@tanstack/react-query';
import { useEffect, useState, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Calendar, Clock, ChevronLeft, Link2, X, ChevronRight } from 'lucide-react';
import Linkify from 'react-linkify';
import { getSchedule } from '@/api/schedules';
import { CATEGORY_ID, decodeHtmlEntities, formatFullDate, formatTime, formatXDateTime } from '../sections/utils';
/**
* 전체화면 자동 가로 회전 (숏츠가 아닐 때만)
*/
function useFullscreenOrientation(isShorts) {
useEffect(() => {
if (isShorts) return;
const handleFullscreenChange = async () => {
const isFullscreen = !!document.fullscreenElement;
if (isFullscreen) {
try {
if (screen.orientation && screen.orientation.lock) {
await screen.orientation.lock('landscape');
}
} catch (e) {
//
}
} else {
try {
if (screen.orientation && screen.orientation.unlock) {
screen.orientation.unlock();
}
} catch (e) {
//
}
}
};
document.addEventListener('fullscreenchange', handleFullscreenChange);
document.addEventListener('webkitfullscreenchange', handleFullscreenChange);
return () => {
document.removeEventListener('fullscreenchange', handleFullscreenChange);
document.removeEventListener('webkitfullscreenchange', handleFullscreenChange);
};
}, [isShorts]);
}
/**
* Mobile 유튜브 섹션
*/
function MobileYoutubeSection({ schedule }) {
const videoId = schedule.videoId;
const isShorts = schedule.videoType === 'shorts';
// ( )
useFullscreenOrientation(isShorts);
const members = schedule.members || [];
const isFullGroup = members.length === 5;
if (!videoId) return null;
return (
<div className="space-y-4">
{/* 영상 임베드 - 숏츠도 가로 비율로 표시 (전체화면에서는 유튜브가 세로로 처리) */}
<motion.div initial={{ opacity: 0, scale: 0.98 }} animate={{ opacity: 1, scale: 1 }} transition={{ delay: 0.1 }}>
<div className="relative bg-gray-900 rounded-xl overflow-hidden shadow-lg aspect-video">
<iframe
src={`https://www.youtube.com/embed/${videoId}?rel=0`}
title={schedule.title}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen; web-share"
allowFullScreen
className="absolute inset-0 w-full h-full"
/>
</div>
</motion.div>
{/* 영상 정보 */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="bg-gradient-to-br from-gray-100 to-gray-200/80 rounded-xl p-4"
>
<h1 className="font-bold text-gray-900 text-base leading-relaxed mb-3">{decodeHtmlEntities(schedule.title)}</h1>
{/* 메타 정보 */}
<div className="flex flex-wrap items-center gap-3 text-xs text-gray-500 mb-3">
<div className="flex items-center gap-1">
<Calendar size={12} />
<span>{formatXDateTime(schedule.datetime)}</span>
</div>
{schedule.channelName && (
<div className="flex items-center gap-1">
<Link2 size={12} />
<span>{schedule.channelName}</span>
</div>
)}
</div>
{/* 멤버 목록 */}
{members.length > 0 && (
<div className="flex flex-wrap gap-1.5 mb-4">
{isFullGroup ? (
<span className="px-2.5 py-1 bg-primary/10 text-primary text-xs font-medium rounded-full">
프로미스나인
</span>
) : (
members.map((member) => (
<span key={member.id} className="px-2.5 py-1 bg-primary/10 text-primary text-xs font-medium rounded-full">
{member.name}
</span>
))
)}
</div>
)}
{/* 유튜브에서 보기 버튼 */}
<div className="pt-4 border-t border-gray-300/50">
<a
href={schedule.videoUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2 w-full py-3 bg-red-500 active:bg-red-600 text-white rounded-xl font-medium transition-colors"
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
</svg>
YouTube에서 보기
</a>
</div>
</motion.div>
</div>
);
}
/**
* Mobile X(트위터) 섹션
*/
function MobileXSection({ schedule }) {
const profile = schedule.profile;
const username = profile?.username || 'realfromis_9';
const displayName = profile?.displayName || username;
const avatarUrl = profile?.avatarUrl;
//
const [lightboxOpen, setLightboxOpen] = useState(false);
const [lightboxIndex, setLightboxIndex] = useState(0);
const historyPushedRef = useRef(false);
const openLightbox = (index) => {
setLightboxIndex(index);
setLightboxOpen(true);
window.history.pushState({ lightbox: true }, '');
historyPushedRef.current = true;
};
const closeLightbox = () => {
setLightboxOpen(false);
if (historyPushedRef.current) {
historyPushedRef.current = false;
window.history.back();
}
};
const goToPrev = () => {
if (schedule.imageUrls?.length > 1) {
setLightboxIndex((lightboxIndex - 1 + schedule.imageUrls.length) % schedule.imageUrls.length);
}
};
const goToNext = () => {
if (schedule.imageUrls?.length > 1) {
setLightboxIndex((lightboxIndex + 1) % schedule.imageUrls.length);
}
};
// body
useEffect(() => {
if (lightboxOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [lightboxOpen]);
// ( )
useEffect(() => {
const handlePopState = () => {
if (lightboxOpen) {
historyPushedRef.current = false;
setLightboxOpen(false);
}
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, [lightboxOpen]);
//
const linkDecorator = (href, text, key) => (
<a key={key} href={href} target="_blank" rel="noopener noreferrer" className="text-blue-500">
{text}
</a>
);
return (
<>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="bg-white rounded-xl border border-gray-200 overflow-hidden"
>
{/* 헤더 */}
<div className="p-4 pb-0">
<div className="flex items-center gap-3">
{avatarUrl ? (
<img src={avatarUrl} alt={displayName} className="w-10 h-10 rounded-full object-cover" />
) : (
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-gray-700 to-gray-900 flex items-center justify-center">
<span className="text-white font-bold">{displayName.charAt(0).toUpperCase()}</span>
</div>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="font-bold text-gray-900 text-sm truncate">{displayName}</span>
<svg className="w-4 h-4 text-blue-500 flex-shrink-0" viewBox="0 0 24 24" fill="currentColor">
<path d="M22.25 12c0-1.43-.88-2.67-2.19-3.34.46-1.39.2-2.9-.81-3.91s-2.52-1.27-3.91-.81c-.66-1.31-1.91-2.19-3.34-2.19s-2.67.88-3.34 2.19c-1.39-.46-2.9-.2-3.91.81s-1.27 2.52-.81 3.91C2.63 9.33 1.75 10.57 1.75 12s.88 2.67 2.19 3.34c-.46 1.39-.2 2.9.81 3.91s2.52 1.27 3.91.81c.66 1.31 1.91 2.19 3.34 2.19s2.67-.88 3.34-2.19c1.39.46 2.9.2 3.91-.81s1.27-2.52.81-3.91c1.31-.67 2.19-1.91 2.19-3.34zm-11.07 4.57l-3.84-3.84 1.27-1.27 2.57 2.57 5.39-5.39 1.27 1.27-6.66 6.66z" />
</svg>
</div>
<span className="text-xs text-gray-500">@{username}</span>
</div>
</div>
</div>
{/* 본문 */}
<div className="p-4">
<p className="text-gray-900 text-[15px] leading-relaxed whitespace-pre-wrap">
<Linkify componentDecorator={linkDecorator}>{decodeHtmlEntities(schedule.content || schedule.title)}</Linkify>
</p>
</div>
{/* 이미지 */}
{schedule.imageUrls?.length > 0 && (
<div className="px-4 pb-3">
{schedule.imageUrls.length === 1 ? (
<img
src={schedule.imageUrls[0]}
alt=""
className="w-full rounded-xl border border-gray-100 cursor-pointer active:opacity-80 transition-opacity"
onClick={() => openLightbox(0)}
/>
) : (
<div className="grid gap-1 rounded-xl overflow-hidden border border-gray-100 grid-cols-2">
{schedule.imageUrls.slice(0, 4).map((url, i) => (
<img
key={i}
src={url}
alt=""
className={`w-full object-cover cursor-pointer active:opacity-80 transition-opacity ${
schedule.imageUrls.length === 3 && i === 0 ? 'row-span-2 h-full' : 'aspect-square'
}`}
onClick={() => openLightbox(i)}
/>
))}
</div>
)}
</div>
)}
{/* 날짜/시간 */}
<div className="px-4 py-3 border-t border-gray-100">
<span className="text-gray-500 text-sm">{formatXDateTime(schedule.datetime)}</span>
</div>
{/* X에서 보기 버튼 */}
<div className="px-4 py-3 border-t border-gray-100 bg-gray-50/50">
<a
href={schedule.postUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-2 w-full py-2.5 bg-gray-900 active:bg-black text-white rounded-full font-medium transition-colors"
>
<svg className="w-4 h-4" 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>
X에서 보기
</a>
</div>
</motion.div>
{/* 모바일 라이트박스 */}
<AnimatePresence>
{lightboxOpen && schedule.imageUrls?.length > 0 && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black z-50 flex items-center justify-center"
onClick={closeLightbox}
>
<button className="absolute top-4 right-4 p-2 text-white/70 z-10" onClick={closeLightbox}>
<X size={28} />
</button>
<motion.img
key={lightboxIndex}
src={schedule.imageUrls[lightboxIndex]}
alt=""
className="max-w-full max-h-full object-contain"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
onClick={(e) => e.stopPropagation()}
/>
{schedule.imageUrls.length > 1 && (
<>
<button
className="absolute left-2 top-1/2 -translate-y-1/2 p-2 text-white/70"
onClick={(e) => {
e.stopPropagation();
goToPrev();
}}
>
<ChevronLeft size={32} />
</button>
<button
className="absolute right-2 top-1/2 -translate-y-1/2 p-2 text-white/70"
onClick={(e) => {
e.stopPropagation();
goToNext();
}}
>
<ChevronRight size={32} />
</button>
</>
)}
{schedule.imageUrls.length > 1 && (
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 flex gap-2">
{schedule.imageUrls.map((_, i) => (
<button
key={i}
className={`w-2 h-2 rounded-full transition-colors ${i === lightboxIndex ? 'bg-white' : 'bg-white/40'}`}
onClick={(e) => {
e.stopPropagation();
setLightboxIndex(i);
}}
/>
))}
</div>
)}
</motion.div>
)}
</AnimatePresence>
</>
);
}
/**
* Mobile 기본 섹션
*/
function MobileDefaultSection({ schedule }) {
return (
<div className="bg-white rounded-xl p-4 shadow-sm">
<h1 className="font-bold text-gray-900 text-base mb-3">{decodeHtmlEntities(schedule.title)}</h1>
<div className="flex items-center gap-3 text-xs text-gray-500">
<div className="flex items-center gap-1">
<Calendar size={12} />
<span>{formatFullDate(schedule.datetime)}</span>
</div>
{schedule.datetime?.includes('T') && schedule.datetime.split('T')[1] !== '00:00:00' && (
<div className="flex items-center gap-1">
<Clock size={12} />
<span>{formatTime(schedule.datetime?.split('T')[1])}</span>
</div>
)}
</div>
{schedule.description && (
<p className="mt-3 text-sm text-gray-600 whitespace-pre-wrap">{decodeHtmlEntities(schedule.description)}</p>
)}
</div>
);
}
/**
* Mobile 일정 상세 페이지
*/
function MobileScheduleDetail() {
const { id } = useParams();
//
useEffect(() => {
document.documentElement.classList.add('mobile-layout');
return () => {
document.documentElement.classList.remove('mobile-layout');
};
}, []);
const {
data: schedule,
isLoading,
error,
} = useQuery({
queryKey: ['schedule', id],
queryFn: () => getSchedule(id),
placeholderData: keepPreviousData,
retry: false,
});
if (isLoading && !schedule) {
return (
<div className="mobile-layout-container bg-gray-50">
<div className="mobile-content flex items-center justify-center">
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div>
</div>
);
}
if (error || !schedule) {
return (
<div className="mobile-layout-container bg-gradient-to-br from-gray-50 to-gray-100">
<div className="mobile-content flex items-center justify-center px-6">
<div className="text-center">
<motion.div
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5, type: 'spring', stiffness: 100 }}
className="mb-6"
>
<div className="w-24 h-24 mx-auto bg-gradient-to-br from-primary/10 to-primary/5 rounded-2xl flex items-center justify-center">
<Calendar size={48} 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-6"
>
<h2 className="text-xl font-bold text-gray-800 mb-2">일정을 찾을 없습니다</h2>
<p className="text-gray-500 text-sm 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-1.5 mb-8"
>
{[...Array(5)].map((_, i) => (
<motion.div
key={i}
className="w-1.5 h-1.5 rounded-full bg-primary"
animate={{
y: [0, -6, 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 flex-col gap-3"
>
<button
onClick={() => window.history.back()}
className="flex items-center justify-center gap-2 w-full px-6 py-3 border-2 border-primary text-primary rounded-full font-medium active:bg-primary active:text-white transition-colors"
>
<ChevronLeft size={18} />
이전 페이지
</button>
<Link
to="/schedule"
className="flex items-center justify-center gap-2 w-full px-6 py-3 bg-gradient-to-r from-primary to-primary-dark text-white rounded-full font-medium active:opacity-90 transition-opacity"
>
<Calendar size={18} />
일정 목록
</Link>
</motion.div>
</div>
</div>
</div>
);
}
//
const categoryId = schedule.category?.id;
const renderCategorySection = () => {
switch (categoryId) {
case CATEGORY_ID.YOUTUBE:
return <MobileYoutubeSection schedule={schedule} />;
case CATEGORY_ID.X:
return <MobileXSection schedule={schedule} />;
default:
return <MobileDefaultSection schedule={schedule} />;
}
};
return (
<div className="mobile-layout-container bg-gray-50">
{/* 헤더 */}
<div className="flex-shrink-0 bg-white border-b border-gray-100">
<div className="flex items-center h-14 px-4">
<button onClick={() => window.history.back()} className="p-2 -ml-2 rounded-lg active:bg-gray-100">
<ChevronLeft size={24} />
</button>
<div className="flex-1 text-center">
<span className="text-sm font-medium" style={{ color: schedule.category?.color }}>
{schedule.category?.name}
</span>
</div>
<div className="w-10" />
</div>
</div>
{/* 메인 컨텐츠 */}
<div className="mobile-content p-4">{renderCategorySection()}</div>
</div>
);
}
export default MobileScheduleDetail;

View file

@ -3,19 +3,7 @@ import { useQuery } from '@tanstack/react-query';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { ChevronRight } from 'lucide-react'; import { ChevronRight } from 'lucide-react';
import { fetchApi } from '@/api/client'; import { fetchApi } from '@/api/client';
import { MEMBER_ENGLISH_NAMES } from '@/constants';
//
const memberEnglishName = {
송하영: 'HAYOUNG',
박지원: 'JIWON',
이채영: 'CHAEYOUNG',
이나경: 'NAKYUNG',
백지헌: 'JIHEON',
장규리: 'GYURI',
이새롬: 'SAEROM',
노지선: 'JISUN',
이서연: 'SEOYEON',
};
/** /**
* PC 생일 페이지 * PC 생일 페이지
@ -25,7 +13,7 @@ function PCBirthday() {
// URL // URL
const decodedMemberName = decodeURIComponent(memberName || ''); const decodedMemberName = decodeURIComponent(memberName || '');
const englishName = memberEnglishName[decodedMemberName]; const englishName = MEMBER_ENGLISH_NAMES[decodedMemberName];
// //
const { const {

View file

@ -16,8 +16,7 @@ import {
import { getSchedules, searchSchedules } from '@/api/schedules'; import { getSchedules, searchSchedules } from '@/api/schedules';
import { useScheduleStore } from '@/stores'; import { useScheduleStore } from '@/stores';
import { getTodayKST } from '@/utils'; import { getTodayKST } from '@/utils';
import { SEARCH_LIMIT } from '@/constants';
const SEARCH_LIMIT = 20;
/** /**
* PC 스케줄 페이지 * PC 스케줄 페이지

View file

@ -0,0 +1,169 @@
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/schedules';
//
import { YoutubeSection, XSection, DefaultSection, CATEGORY_ID, decodeHtmlEntities } from '../sections';
/**
* PC 일정 상세 페이지
*/
function PCScheduleDetail() {
const { id } = useParams();
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)] flex items-center justify-center bg-gray-50">
<div className="w-10 h-10 border-3 border-primary border-t-transparent rounded-full animate-spin" />
</div>
);
}
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 categoryId = schedule.category?.id;
const renderCategorySection = () => {
switch (categoryId) {
case CATEGORY_ID.YOUTUBE:
return <YoutubeSection schedule={schedule} />;
case CATEGORY_ID.X:
return <XSection schedule={schedule} />;
default:
return <DefaultSection schedule={schedule} />;
}
};
const isYoutube = categoryId === CATEGORY_ID.YOUTUBE;
const isX = categoryId === CATEGORY_ID.X;
const hasCustomLayout = isYoutube || isX;
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, y: -10 }}
animate={{ opacity: 1, y: 0 }}
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: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.05 }}
className={`${hasCustomLayout ? '' : 'bg-white rounded-3xl shadow-sm p-8'}`}
>
{renderCategorySection()}
</motion.div>
</div>
</div>
);
}
export default PCScheduleDetail;

View file

@ -0,0 +1,55 @@
import { Calendar, Clock } from 'lucide-react';
import { decodeHtmlEntities, formatFullDate, formatTime } from './utils';
/**
* 기본 일정 섹션 컴포넌트 (일반 카테고리용)
*/
function DefaultSection({ schedule }) {
return (
<div>
{/* 제목 */}
<h1 className="text-2xl font-bold text-gray-900 mb-4">{decodeHtmlEntities(schedule.title)}</h1>
{/* 메타 정보 */}
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-500 mb-6">
<div className="flex items-center gap-2">
<Calendar size={16} />
<span>{formatFullDate(schedule.datetime)}</span>
</div>
{schedule.datetime?.includes('T') && schedule.datetime.split('T')[1] !== '00:00:00' && (
<div className="flex items-center gap-2">
<Clock size={16} />
<span>{formatTime(schedule.datetime?.split('T')[1])}</span>
</div>
)}
</div>
{/* 멤버 */}
{schedule.members && schedule.members.length > 0 && (
<div className="mb-6">
<p className="text-sm text-gray-500 mb-2">참여 멤버</p>
<div className="flex flex-wrap gap-2">
{schedule.members.length === 5 ? (
<span className="px-3 py-1 bg-primary/10 text-primary text-sm font-medium rounded-full">프로미스나인</span>
) : (
schedule.members.map((member) => (
<span key={member.id} className="px-3 py-1 bg-primary/10 text-primary text-sm font-medium rounded-full">
{member.name}
</span>
))
)}
</div>
</div>
)}
{/* 설명 */}
{schedule.description && (
<div className="pt-6 border-t border-gray-100">
<p className="text-gray-600 leading-relaxed whitespace-pre-wrap">{decodeHtmlEntities(schedule.description)}</p>
</div>
)}
</div>
);
}
export default DefaultSection;

View file

@ -0,0 +1,164 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { motion } from 'framer-motion';
import Linkify from 'react-linkify';
import { decodeHtmlEntities, formatXDateTime } from './utils';
import { Lightbox } from '@/components/common';
/**
* PC X(트위터) 섹션 컴포넌트
*/
function XSection({ schedule }) {
const profile = schedule.profile;
const username = profile?.username || 'realfromis_9';
const displayName = profile?.displayName || username;
const avatarUrl = profile?.avatarUrl;
//
const [lightboxOpen, setLightboxOpen] = useState(false);
const [lightboxIndex, setLightboxIndex] = useState(0);
const historyPushedRef = useRef(false);
const openLightbox = useCallback((index) => {
setLightboxIndex(index);
setLightboxOpen(true);
window.history.pushState({ lightbox: true }, '');
historyPushedRef.current = true;
}, []);
const closeLightbox = useCallback(() => {
setLightboxOpen(false);
if (historyPushedRef.current) {
historyPushedRef.current = false;
window.history.back();
}
}, []);
// ( )
useEffect(() => {
const handlePopState = () => {
if (lightboxOpen) {
historyPushedRef.current = false;
setLightboxOpen(false);
}
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, [lightboxOpen]);
// ( )
const linkDecorator = (href, text, key) => (
<a key={key} href={href} target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:underline">
{text}
</a>
);
return (
<div className="max-w-2xl mx-auto">
{/* X 스타일 카드 */}
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="bg-white rounded-2xl border border-gray-200 overflow-hidden"
>
{/* 헤더 */}
<div className="p-5 pb-0">
<div className="flex items-center gap-3">
{/* 프로필 이미지 */}
{avatarUrl ? (
<img src={avatarUrl} alt={displayName} className="w-12 h-12 rounded-full object-cover" />
) : (
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-gray-700 to-gray-900 flex items-center justify-center">
<span className="text-white font-bold text-lg">{displayName.charAt(0).toUpperCase()}</span>
</div>
)}
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-bold text-gray-900">{displayName}</span>
<svg className="w-5 h-5 text-blue-500" viewBox="0 0 24 24" fill="currentColor">
<path d="M22.25 12c0-1.43-.88-2.67-2.19-3.34.46-1.39.2-2.9-.81-3.91s-2.52-1.27-3.91-.81c-.66-1.31-1.91-2.19-3.34-2.19s-2.67.88-3.34 2.19c-1.39-.46-2.9-.2-3.91.81s-1.27 2.52-.81 3.91C2.63 9.33 1.75 10.57 1.75 12s.88 2.67 2.19 3.34c-.46 1.39-.2 2.9.81 3.91s2.52 1.27 3.91.81c.66 1.31 1.91 2.19 3.34 2.19s2.67-.88 3.34-2.19c1.39.46 2.9.2 3.91-.81s1.27-2.52.81-3.91c1.31-.67 2.19-1.91 2.19-3.34zm-11.07 4.57l-3.84-3.84 1.27-1.27 2.57 2.57 5.39-5.39 1.27 1.27-6.66 6.66z" />
</svg>
</div>
<span className="text-sm text-gray-500">@{username}</span>
</div>
</div>
</div>
{/* 본문 */}
<div className="p-5">
<p className="text-gray-900 text-[17px] leading-relaxed whitespace-pre-wrap">
<Linkify componentDecorator={linkDecorator}>{decodeHtmlEntities(schedule.content || schedule.title)}</Linkify>
</p>
</div>
{/* 이미지 */}
{schedule.imageUrls?.length > 0 && (
<div className="px-5 pb-3">
{schedule.imageUrls.length === 1 ? (
<img
src={schedule.imageUrls[0]}
alt=""
className="w-full rounded-2xl border border-gray-100 cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => openLightbox(0)}
/>
) : (
<div
className={`grid gap-1 rounded-2xl overflow-hidden border border-gray-100 ${
schedule.imageUrls.length === 2
? 'grid-cols-2'
: schedule.imageUrls.length === 3
? 'grid-cols-2'
: 'grid-cols-2'
}`}
>
{schedule.imageUrls.slice(0, 4).map((url, i) => (
<img
key={i}
src={url}
alt=""
className={`w-full object-cover cursor-pointer hover:opacity-90 transition-opacity ${
schedule.imageUrls.length === 3 && i === 0 ? 'row-span-2 h-full' : 'aspect-square'
}`}
onClick={() => openLightbox(i)}
/>
))}
</div>
)}
</div>
)}
{/* 날짜/시간 */}
<div className="px-5 py-4 border-t border-gray-100">
<span className="text-gray-500 text-[15px]">{formatXDateTime(schedule.datetime)}</span>
</div>
{/* X에서 보기 버튼 */}
<div className="px-5 py-4 border-t border-gray-100 bg-gray-50/50">
<a
href={schedule.postUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-5 py-2.5 bg-gray-900 hover:bg-black text-white rounded-full font-medium transition-colors"
>
<svg className="w-4 h-4" 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>
X에서 보기
</a>
</div>
</motion.div>
{/* 라이트박스 */}
<Lightbox
images={schedule.imageUrls || []}
currentIndex={lightboxIndex}
isOpen={lightboxOpen}
onClose={closeLightbox}
onIndexChange={setLightboxIndex}
/>
</div>
);
}
export default XSection;

View file

@ -0,0 +1,145 @@
import { motion } from 'framer-motion';
import { Calendar, Link2 } from 'lucide-react';
import { decodeHtmlEntities, formatXDateTime } from './utils';
/**
* 영상 정보 컴포넌트 (공통)
*/
function VideoInfo({ schedule, isShorts }) {
const members = schedule.members || [];
const isFullGroup = members.length === 5;
return (
<div className={`bg-gradient-to-br from-gray-100 to-gray-200/80 rounded-2xl p-6 ${isShorts ? 'flex-1' : ''}`}>
{/* 제목 */}
<h1 className={`font-bold text-gray-900 mb-4 leading-relaxed ${isShorts ? 'text-lg' : 'text-xl'}`}>
{decodeHtmlEntities(schedule.title)}
</h1>
{/* 메타 정보 */}
<div className={`flex flex-wrap items-center gap-4 text-sm ${isShorts ? 'gap-3' : ''}`}>
{/* 날짜/시간 */}
<div className="flex items-center gap-1.5 text-gray-500">
<Calendar size={14} />
<span>{formatXDateTime(schedule.datetime)}</span>
</div>
{/* 채널명 */}
{schedule.channelName && (
<>
<div className="w-px h-4 bg-gray-300" />
<div className="flex items-center gap-2 text-gray-500">
<Link2 size={14} className="opacity-60" />
<span className="font-medium">{schedule.channelName}</span>
</div>
</>
)}
</div>
{/* 멤버 목록 */}
{members.length > 0 && (
<div className="flex flex-wrap gap-2 mt-4">
{isFullGroup ? (
<span className="px-3 py-1 bg-primary/10 text-primary text-sm font-medium rounded-full">프로미스나인</span>
) : (
members.map((member) => (
<span key={member.id} className="px-3 py-1 bg-primary/10 text-primary text-sm font-medium rounded-full">
{member.name}
</span>
))
)}
</div>
)}
{/* 유튜브에서 보기 버튼 */}
<div className="mt-6 pt-5 border-t border-gray-300/60">
<a
href={schedule.videoUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-5 py-2.5 bg-red-500 hover:bg-red-600 text-white rounded-xl font-medium transition-colors shadow-lg shadow-red-500/20"
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
</svg>
YouTube에서 보기
</a>
</div>
</div>
);
}
/**
* PC 유튜브 섹션 컴포넌트
*/
function YoutubeSection({ schedule }) {
const videoId = schedule.videoId;
const isShorts = schedule.videoType === 'shorts';
if (!videoId) return null;
// : ( + )
if (isShorts) {
return (
<div className="flex gap-6 items-start">
{/* 영상 임베드 */}
<motion.div
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.1 }}
className="w-[420px] flex-shrink-0"
>
<div className="relative aspect-[9/16] bg-gray-900 rounded-2xl overflow-hidden shadow-lg shadow-black/10">
<iframe
src={`https://www.youtube.com/embed/${videoId}?rel=0`}
title={schedule.title}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
className="absolute inset-0 w-full h-full"
/>
</div>
</motion.div>
{/* 영상 정보 카드 */}
<motion.div
initial={{ opacity: 0, x: 10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.2 }}
className="flex-1"
>
<VideoInfo schedule={schedule} isShorts={true} />
</motion.div>
</div>
);
}
// : ( , )
return (
<div className="space-y-6">
{/* 영상 임베드 */}
<motion.div
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.1 }}
className="w-full"
>
<div className="relative aspect-video bg-gray-900 rounded-2xl overflow-hidden shadow-lg shadow-black/10">
<iframe
src={`https://www.youtube.com/embed/${videoId}?rel=0`}
title={schedule.title}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
className="absolute inset-0 w-full h-full"
/>
</div>
</motion.div>
{/* 영상 정보 카드 */}
<motion.div initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.2 }}>
<VideoInfo schedule={schedule} isShorts={false} />
</motion.div>
</div>
);
}
export default YoutubeSection;

View file

@ -0,0 +1,4 @@
export { default as YoutubeSection } from './YoutubeSection';
export { default as XSection } from './XSection';
export { default as DefaultSection } from './DefaultSection';
export * from './utils';

View file

@ -0,0 +1,10 @@
/**
* 스케줄 상세 페이지 상수 re-export
*/
// @/utils에서 re-export
export { decodeHtmlEntities, formatTime } from '@/utils';
export { formatFullDate, formatXDateTime } from '@/utils';
// @/constants에서 re-export
export { CATEGORY_ID } from '@/constants';

View file

@ -106,21 +106,25 @@ export const formatFullDate = (date) => {
/** /**
* X(트위터) 스타일 날짜/시간 포맷팅 * X(트위터) 스타일 날짜/시간 포맷팅
* @param {string} date - 날짜 문자열 (YYYY-MM-DD) * @param {string} datetime - datetime 문자열 (YYYY-MM-DDTHH:mm:ss 또는 YYYY-MM-DD)
* @param {string} [time] - 시간 문자열 (HH:mm 또는 HH:mm:ss)
* @returns {string} "오후 7:00 · 2026년 1월 18일" 또는 "2026년 1월 18일" * @returns {string} "오후 7:00 · 2026년 1월 18일" 또는 "2026년 1월 18일"
*/ */
export const formatXDateTime = (date, time) => { export const formatXDateTime = (datetime) => {
if (!date) return ''; if (!datetime) return '';
const d = dayjs(date).tz(TIMEZONE); const d = dayjs(datetime).tz(TIMEZONE);
const datePart = `${d.year()}${d.month() + 1}${d.date()}`; const datePart = `${d.year()}${d.month() + 1}${d.date()}`;
if (time) { // datetime에 T가 포함되고 시간이 00:00:00이 아니면 시간 표시
const [hours, minutes] = time.split(':').map(Number); if (datetime.includes('T') && !datetime.endsWith('T00:00:00')) {
const period = hours < 12 ? '오전' : '오후'; const hours = d.hour();
const hour12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours; const minutes = d.minute();
return `${period} ${hour12}:${String(minutes).padStart(2, '0')} · ${datePart}`; // 00:00인 경우 시간 표시 안함
if (hours !== 0 || minutes !== 0) {
const period = hours < 12 ? '오전' : '오후';
const hour12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
return `${period} ${hour12}:${String(minutes).padStart(2, '0')} · ${datePart}`;
}
} }
return datePart; return datePart;