diff --git a/frontend-temp/src/App.jsx b/frontend-temp/src/App.jsx index 4e5ffcb..5ce2828 100644 --- a/frontend-temp/src/App.jsx +++ b/frontend-temp/src/App.jsx @@ -14,8 +14,24 @@ import { Layout as MobileLayout } from '@/components/mobile'; // 페이지 import { PCHome, MobileHome } from '@/pages/home'; import { PCMembers, MobileMembers } from '@/pages/members'; -import { PCSchedule, MobileSchedule } from '@/pages/schedule'; -import { PCAlbum, MobileAlbum } from '@/pages/album'; +import { + PCSchedule, + MobileSchedule, + PCScheduleDetail, + MobileScheduleDetail, + PCBirthday, + MobileBirthday, +} from '@/pages/schedule'; +import { + PCAlbum, + MobileAlbum, + PCAlbumDetail, + MobileAlbumDetail, + PCTrackDetail, + MobileTrackDetail, + PCAlbumGallery, + MobileAlbumGallery, +} from '@/pages/album'; /** * PC 환경에서 body에 클래스 추가하는 래퍼 @@ -53,9 +69,13 @@ function App() { } /> } /> } /> + } /> + } /> } /> - {/* 추가 페이지는 Phase 11에서 구현 */} - {/* } /> */} + } /> + } /> + } /> + {/* 추가 페이지 */} {/* } /> */} @@ -92,6 +112,8 @@ function App() { } /> + } /> + } /> } /> - {/* 추가 페이지는 Phase 11에서 구현 */} - {/* } /> */} + + + + } + /> + + + + } + /> + + + + } + /> + {/* 추가 페이지 */} diff --git a/frontend-temp/src/components/pc/Layout.jsx b/frontend-temp/src/components/pc/Layout.jsx index d962908..f8d102d 100644 --- a/frontend-temp/src/components/pc/Layout.jsx +++ b/frontend-temp/src/components/pc/Layout.jsx @@ -10,7 +10,7 @@ function Layout({ children }) { const location = useLocation(); // Footer 숨김 페이지 (화면 고정 레이아웃) - const hideFooterPages = ['/schedule', '/members', '/album']; + const hideFooterPages = ['/schedule', '/members', '/album', '/birthday']; const hideFooter = hideFooterPages.some( (path) => location.pathname === path || location.pathname.startsWith(path + '/') diff --git a/frontend-temp/src/components/schedule/BirthdayCard.jsx b/frontend-temp/src/components/schedule/BirthdayCard.jsx index 31956a0..bd4afda 100644 --- a/frontend-temp/src/components/schedule/BirthdayCard.jsx +++ b/frontend-temp/src/components/schedule/BirthdayCard.jsx @@ -1,5 +1,6 @@ +import { motion } from 'framer-motion'; 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용 생일 카드 컴포넌트 + * @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 formatted = { year: scheduleDate.year(), @@ -127,23 +132,20 @@ export function MobileBirthdayCard({ schedule, showYear = false, onClick }) { day: scheduleDate.date(), }; - return ( -
+ const CardContent = ( +
{/* 배경 장식 */}
-
+
-
🎉
+
🎉
-
+
{/* 멤버 사진 */} {schedule.member_image && (
-
+
{schedule.member_names} - 🎂 -

{schedule.title}

+ 🎂 +

+ {decodeHtmlEntities(schedule.title)} +

- {/* 날짜 뱃지 */} -
- {showYear && ( + {/* 날짜 뱃지 (showYear가 true일 때만 표시) */} + {showYear && ( +
{formatted.year}
- )} -
{formatted.month}월
-
{formatted.day}
-
+
{formatted.month}월
+
{formatted.day}
+
+ )}
); + + // delay가 있으면 motion 사용 + if (delay > 0) { + return ( + + {CardContent} + + ); + } + + return ( +
+ {CardContent} +
+ ); } export default BirthdayCard; diff --git a/frontend-temp/src/components/schedule/Calendar.jsx b/frontend-temp/src/components/schedule/Calendar.jsx index a6a0b31..e2fe616 100644 --- a/frontend-temp/src/components/schedule/Calendar.jsx +++ b/frontend-temp/src/components/schedule/Calendar.jsx @@ -2,10 +2,9 @@ import { useState, useRef, useEffect, useMemo } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { ChevronLeft, ChevronRight, ChevronDown } from 'lucide-react'; import { getTodayKST, dayjs } from '@/utils'; +import { MIN_YEAR, WEEKDAYS, MONTH_NAMES } from '@/constants'; -const WEEKDAYS = ['일', '월', '화', '수', '목', '금', '토']; -const MONTHS = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월']; -const MIN_YEAR = 2017; +const MONTHS = MONTH_NAMES; /** * 달력 컴포넌트 diff --git a/frontend-temp/src/components/schedule/MobileCalendar.jsx b/frontend-temp/src/components/schedule/MobileCalendar.jsx new file mode 100644 index 0000000..cbcd1f0 --- /dev/null +++ b/frontend-temp/src/components/schedule/MobileCalendar.jsx @@ -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) => ( +
+ {/* 요일 헤더 */} +
+ {WEEKDAYS.map((day, i) => ( +
+ {day} +
+ ))} +
+ + {/* 날짜 그리드 */} +
+ {days.map((item, index) => { + const dayOfWeek = index % 7; + const isSunday = dayOfWeek === 0; + const isSaturday = dayOfWeek === 6; + const daySchedules = item.isCurrentMonth ? getDaySchedules(item.date) : []; + + return ( + + ); + })} +
+
+ ); + + return ( +
+ + {showYearMonth ? ( + // 년월 선택 UI + + {/* 년도 범위 헤더 */} +
+ + + {yearRangeStart} - {yearRangeStart + 11} + + +
+ + {/* 년도 선택 */} +
년도
+
+ {yearRange.map((y) => { + const isCurrentYear = y === new Date().getFullYear(); + return ( + + ); + })} +
+ + {/* 월 선택 */} +
+
+ {Array.from({ length: 12 }, (_, i) => i + 1).map((m) => { + const today = new Date(); + const isCurrentMonth = year === today.getFullYear() && m === today.getMonth() + 1; + return ( + + ); + })} +
+
+ ) : ( + + {/* 달력 헤더 - hideHeader일 때 숨김 */} + {!hideHeader && ( +
+ + + +
+ )} + + {/* 달력 (터치 스와이프 지원) */} + {renderMonth(currentMonthDays)} + + {/* 오늘 버튼 */} +
+ +
+
+ )} +
+
+ ); +} + +export default MobileCalendar; diff --git a/frontend-temp/src/components/schedule/index.js b/frontend-temp/src/components/schedule/index.js index 107e66b..a4838aa 100644 --- a/frontend-temp/src/components/schedule/index.js +++ b/frontend-temp/src/components/schedule/index.js @@ -1,13 +1,14 @@ // PC 컴포넌트 export { default as ScheduleCard } from './ScheduleCard'; export { default as AdminScheduleCard } from './AdminScheduleCard'; +export { default as Calendar } from './Calendar'; // Mobile 컴포넌트 export { default as MobileScheduleCard } from './MobileScheduleCard'; export { default as MobileScheduleListCard } from './MobileScheduleListCard'; 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 BirthdayCard, MobileBirthdayCard, fireBirthdayConfetti } from './BirthdayCard'; diff --git a/frontend-temp/src/constants/index.js b/frontend-temp/src/constants/index.js index 7b52724..5f325f5 100644 --- a/frontend-temp/src/constants/index.js +++ b/frontend-temp/src/constants/index.js @@ -57,3 +57,16 @@ export const MONTH_NAMES = [ '1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월', ]; + +/** 멤버 한글 이름 → 영어 이름 매핑 */ +export const MEMBER_ENGLISH_NAMES = { + 송하영: 'HAYOUNG', + 박지원: 'JIWON', + 이채영: 'CHAEYOUNG', + 이나경: 'NAKYUNG', + 백지헌: 'JIHEON', + 장규리: 'GYURI', + 이새롬: 'SAEROM', + 노지선: 'JISUN', + 이서연: 'SEOYEON', +}; diff --git a/frontend-temp/src/pages/album/index.js b/frontend-temp/src/pages/album/index.js index 1b4df39..8551119 100644 --- a/frontend-temp/src/pages/album/index.js +++ b/frontend-temp/src/pages/album/index.js @@ -1,2 +1,8 @@ -export { default as PCAlbum } from './PCAlbum'; -export { default as MobileAlbum } from './MobileAlbum'; +export { default as PCAlbum } from './pc/Album'; +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'; diff --git a/frontend-temp/src/pages/album/MobileAlbum.jsx b/frontend-temp/src/pages/album/mobile/Album.jsx similarity index 100% rename from frontend-temp/src/pages/album/MobileAlbum.jsx rename to frontend-temp/src/pages/album/mobile/Album.jsx diff --git a/frontend-temp/src/pages/album/mobile/AlbumDetail.jsx b/frontend-temp/src/pages/album/mobile/AlbumDetail.jsx new file mode 100644 index 0000000..4ef8495 --- /dev/null +++ b/frontend-temp/src/pages/album/mobile/AlbumDetail.jsx @@ -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 ( +
+
+
+ ); + } + + if (!album) { + return ( +
+

앨범을 찾을 수 없습니다

+
+ ); + } + + const allPhotos = album.conceptPhotos ? Object.values(album.conceptPhotos).flat() : []; + const displayTracks = showAllTracks ? album.tracks : album.tracks?.slice(0, 5); + + return ( + <> +
+ {/* 앨범 히어로 섹션 */} +
+ {/* 배경 블러 이미지 */} +
+ +
+
+ + {/* 콘텐츠 */} +
+
+ {/* 앨범 커버 */} + openLightbox([album.cover_original_url || album.cover_medium_url], 0, { showNav: false })} + > + {album.title} + + + {/* 앨범 정보 */} + + + {album.album_type} + +

{album.title}

+ + {/* 메타 정보 */} +
+
+ + {formatDate(album.release_date, 'YYYY.MM.DD')} +
+
+ + {album.tracks?.length || 0}곡 +
+
+ + {getTotalDuration()} +
+
+ + {/* 앨범 소개 버튼 */} + {album.description && ( + + )} +
+
+
+
+ + {/* 티저 이미지 */} + {album.teasers && album.teasers.length > 0 && ( + +

티저 이미지

+
+ {album.teasers.map((teaser, index) => ( +
+ 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" + > + {`Teaser + {teaser.media_type === 'video' && ( +
+
+ +
+
+ )} +
+ ))} +
+
+ )} + + {/* 수록곡 */} + {album.tracks && album.tracks.length > 0 && ( + +

수록곡

+
+ {displayTracks?.map((track) => ( +
+ 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" + > + + {String(track.track_number).padStart(2, '0')} + +
+

+ {track.title} +

+ {track.is_title_track === 1 && ( + + TITLE + + )} +
+ {track.duration || '-'} +
+ ))} +
+ {/* 더보기/접기 버튼 */} + {album.tracks.length > 5 && ( + + )} +
+ )} + + {/* 컨셉 포토 */} + {allPhotos.length > 0 && ( + +

컨셉 포토

+
+ {allPhotos.slice(0, 6).map((photo, idx) => ( +
openLightbox([photo.original_url], 0, { showNav: false })} + className="aspect-square bg-gray-100 rounded-xl overflow-hidden shadow-sm" + > + {`컨셉 +
+ ))} +
+ {/* 전체보기 버튼 */} + +
+ )} +
+ + {/* 앨범 소개 다이얼로그 */} + + {showDescriptionModal && album?.description && ( + window.history.back()} + > + { + 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()} + > + {/* 드래그 핸들 */} +
+
+
+ {/* 헤더 */} +
+

앨범 소개

+ +
+ {/* 내용 */} +
+

{album.description}

+
+ + + )} + + + {/* 라이트박스 - Swiper ViewPager 스타일 */} + + {lightbox.open && ( + + {/* 상단 헤더 */} +
+
+ +
+ {lightbox.showNav && lightbox.images.length > 1 && ( + + {lightbox.index + 1} / {lightbox.images.length} + + )} +
+ +
+
+ + {/* 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) => ( + +
+ {lightbox.teasers?.[index]?.media_type === 'video' ? ( +
+
+ ))} +
+ + {/* 모바일용 인디케이터 */} + {lightbox.showNav && lightbox.images.length > 1 && ( + swiperRef.current?.slideTo(i)} + width={120} + /> + )} +
+ )} +
+ + ); +} + +export default MobileAlbumDetail; diff --git a/frontend-temp/src/pages/album/mobile/AlbumGallery.jsx b/frontend-temp/src/pages/album/mobile/AlbumGallery.jsx new file mode 100644 index 0000000..828bb53 --- /dev/null +++ b/frontend-temp/src/pages/album/mobile/AlbumGallery.jsx @@ -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 ( +
+
+
+ ); + } + + return ( + <> +
+ {/* 앨범 헤더 카드 */} +
navigate(-1)} + > + {album?.cover_thumb_url && ( + {album.title} + )} +
+

컨셉 포토

+

{album?.title}

+

{photos.length}장의 사진

+
+ +
+ + {/* 2열 그리드 */} +
+
+ {leftColumn.map((photo) => ( + openLightbox(photo.originalIndex)} + className="cursor-pointer overflow-hidden rounded-xl bg-gray-100" + > + + + ))} +
+
+ {rightColumn.map((photo) => ( + openLightbox(photo.originalIndex)} + className="cursor-pointer overflow-hidden rounded-xl bg-gray-100" + > + + + ))} +
+
+
+ + {/* 풀스크린 라이트박스 */} + + {selectedIndex !== null && ( + + {/* 상단 헤더 */} +
+
+ +
+ + {selectedIndex + 1} / {photos.length} + +
+ {hasInfo && ( + + )} + +
+
+ + {/* 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) => ( + +
+ +
+
+ ))} +
+ + {/* 모바일용 인디케이터 */} + swiperRef.current?.slideTo(i)} + width={120} + /> + + {/* 정보 바텀시트 */} + + {showInfo && hasInfo && ( + window.history.back()} + > + e.stopPropagation()} + > + {/* 드래그 핸들 */} +
+
+
+ + {/* 정보 내용 */} +
+

사진 정보

+ + {currentPhoto?.members && ( +
+
+ +
+
+

멤버

+

{currentPhoto.members}

+
+
+ )} + + {currentPhoto?.concept && ( +
+
+ +
+
+

컨셉

+

{currentPhoto.concept}

+
+
+ )} +
+ + + )} + + + )} + + + ); +} + +export default MobileAlbumGallery; diff --git a/frontend-temp/src/pages/album/mobile/TrackDetail.jsx b/frontend-temp/src/pages/album/mobile/TrackDetail.jsx new file mode 100644 index 0000000..9354e8c --- /dev/null +++ b/frontend-temp/src/pages/album/mobile/TrackDetail.jsx @@ -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) => ( + + {item.trim()} + + )); +} + +/** + * 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 ( +
+
+
+ ); + } + + if (error || !track) { + return ( +
+

트랙을 찾을 수 없습니다.

+
+ ); + } + + return ( + + {/* 트랙 정보 헤더 */} +
+
+ {/* 앨범 커버 */} + + {track.album?.title} + + + {/* 트랙 정보 */} +
+
+ {track.is_title_track === 1 && ( + TITLE + )} + Track {String(track.track_number).padStart(2, '0')} +
+ +

{track.title}

+ +

+ {track.album?.album_type} · {track.album?.title} +

+ + {track.duration && ( +
+ + {track.duration} +
+ )} +
+
+
+ + {/* 뮤직비디오 섹션 */} + {youtubeVideoId && ( + +

+
+ 뮤직비디오 +

+
+