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:
parent
dd0e508117
commit
97850b12c1
30 changed files with 4736 additions and 591 deletions
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 + '/')
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 달력 컴포넌트
|
* 달력 컴포넌트
|
||||||
|
|
|
||||||
383
frontend-temp/src/components/schedule/MobileCalendar.jsx
Normal file
383
frontend-temp/src/components/schedule/MobileCalendar.jsx
Normal 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;
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
455
frontend-temp/src/pages/album/mobile/AlbumDetail.jsx
Normal file
455
frontend-temp/src/pages/album/mobile/AlbumDetail.jsx
Normal 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;
|
||||||
346
frontend-temp/src/pages/album/mobile/AlbumGallery.jsx
Normal file
346
frontend-temp/src/pages/album/mobile/AlbumGallery.jsx
Normal 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;
|
||||||
269
frontend-temp/src/pages/album/mobile/TrackDetail.jsx
Normal file
269
frontend-temp/src/pages/album/mobile/TrackDetail.jsx
Normal 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;
|
||||||
588
frontend-temp/src/pages/album/pc/AlbumDetail.jsx
Normal file
588
frontend-temp/src/pages/album/pc/AlbumDetail.jsx
Normal 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;
|
||||||
372
frontend-temp/src/pages/album/pc/AlbumGallery.jsx
Normal file
372
frontend-temp/src/pages/album/pc/AlbumGallery.jsx
Normal 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;
|
||||||
308
frontend-temp/src/pages/album/pc/TrackDetail.jsx
Normal file
308
frontend-temp/src/pages/album/pc/TrackDetail.jsx
Normal 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;
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
779
frontend-temp/src/pages/schedule/mobile/Schedule.jsx
Normal file
779
frontend-temp/src/pages/schedule/mobile/Schedule.jsx
Normal 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;
|
||||||
542
frontend-temp/src/pages/schedule/mobile/ScheduleDetail.jsx
Normal file
542
frontend-temp/src/pages/schedule/mobile/ScheduleDetail.jsx
Normal 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;
|
||||||
|
|
@ -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 {
|
||||||
|
|
@ -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 스케줄 페이지
|
||||||
169
frontend-temp/src/pages/schedule/pc/ScheduleDetail.jsx
Normal file
169
frontend-temp/src/pages/schedule/pc/ScheduleDetail.jsx
Normal 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;
|
||||||
55
frontend-temp/src/pages/schedule/sections/DefaultSection.jsx
Normal file
55
frontend-temp/src/pages/schedule/sections/DefaultSection.jsx
Normal 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;
|
||||||
164
frontend-temp/src/pages/schedule/sections/XSection.jsx
Normal file
164
frontend-temp/src/pages/schedule/sections/XSection.jsx
Normal 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;
|
||||||
145
frontend-temp/src/pages/schedule/sections/YoutubeSection.jsx
Normal file
145
frontend-temp/src/pages/schedule/sections/YoutubeSection.jsx
Normal 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;
|
||||||
4
frontend-temp/src/pages/schedule/sections/index.js
Normal file
4
frontend-temp/src/pages/schedule/sections/index.js
Normal 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';
|
||||||
10
frontend-temp/src/pages/schedule/sections/utils.js
Normal file
10
frontend-temp/src/pages/schedule/sections/utils.js
Normal 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';
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue