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 { PCMembers, MobileMembers } from '@/pages/members';
|
||||
import { PCSchedule, MobileSchedule } from '@/pages/schedule';
|
||||
import { PCAlbum, MobileAlbum } from '@/pages/album';
|
||||
import {
|
||||
PCSchedule,
|
||||
MobileSchedule,
|
||||
PCScheduleDetail,
|
||||
MobileScheduleDetail,
|
||||
PCBirthday,
|
||||
MobileBirthday,
|
||||
} from '@/pages/schedule';
|
||||
import {
|
||||
PCAlbum,
|
||||
MobileAlbum,
|
||||
PCAlbumDetail,
|
||||
MobileAlbumDetail,
|
||||
PCTrackDetail,
|
||||
MobileTrackDetail,
|
||||
PCAlbumGallery,
|
||||
MobileAlbumGallery,
|
||||
} from '@/pages/album';
|
||||
|
||||
/**
|
||||
* PC 환경에서 body에 클래스 추가하는 래퍼
|
||||
|
|
@ -53,9 +69,13 @@ function App() {
|
|||
<Route path="/" element={<PCHome />} />
|
||||
<Route path="/members" element={<PCMembers />} />
|
||||
<Route path="/schedule" element={<PCSchedule />} />
|
||||
<Route path="/schedule/:id" element={<PCScheduleDetail />} />
|
||||
<Route path="/birthday/:memberName/:year" element={<PCBirthday />} />
|
||||
<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 />} /> */}
|
||||
</Routes>
|
||||
</PCLayout>
|
||||
|
|
@ -92,6 +112,8 @@ function App() {
|
|||
</MobileLayout>
|
||||
}
|
||||
/>
|
||||
<Route path="/schedule/:id" element={<MobileScheduleDetail />} />
|
||||
<Route path="/birthday/:memberName/:year" element={<MobileBirthday />} />
|
||||
<Route
|
||||
path="/album"
|
||||
element={
|
||||
|
|
@ -100,8 +122,31 @@ function App() {
|
|||
</MobileLayout>
|
||||
}
|
||||
/>
|
||||
{/* 추가 페이지는 Phase 11에서 구현 */}
|
||||
{/* <Route path="/album/:name" element={<MobileLayout><MobileAlbumDetail /></MobileLayout>} /> */}
|
||||
<Route
|
||||
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>
|
||||
</MobileView>
|
||||
</BrowserRouter>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ function Layout({ children }) {
|
|||
const location = useLocation();
|
||||
|
||||
// Footer 숨김 페이지 (화면 고정 레이아웃)
|
||||
const hideFooterPages = ['/schedule', '/members', '/album'];
|
||||
const hideFooterPages = ['/schedule', '/members', '/album', '/birthday'];
|
||||
const hideFooter = hideFooterPages.some(
|
||||
(path) =>
|
||||
location.pathname === path || location.pathname.startsWith(path + '/')
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { motion } from 'framer-motion';
|
||||
import confetti from 'canvas-confetti';
|
||||
import { dayjs } from '@/utils';
|
||||
import { dayjs, decodeHtmlEntities } from '@/utils';
|
||||
|
||||
/**
|
||||
* 생일 폭죽 애니메이션
|
||||
|
|
@ -118,8 +119,12 @@ function BirthdayCard({ schedule, showYear = false, onClick }) {
|
|||
|
||||
/**
|
||||
* Mobile용 생일 카드 컴포넌트
|
||||
* @param {Object} schedule - 일정 데이터
|
||||
* @param {boolean} showYear - 년도 표시 여부
|
||||
* @param {number} delay - 애니메이션 딜레이 (초)
|
||||
* @param {function} onClick - 클릭 핸들러
|
||||
*/
|
||||
export function MobileBirthdayCard({ schedule, showYear = false, onClick }) {
|
||||
export function MobileBirthdayCard({ schedule, showYear = false, delay = 0, onClick }) {
|
||||
const scheduleDate = dayjs(schedule.date);
|
||||
const formatted = {
|
||||
year: scheduleDate.year(),
|
||||
|
|
@ -127,23 +132,20 @@ export function MobileBirthdayCard({ schedule, showYear = false, onClick }) {
|
|||
day: scheduleDate.date(),
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
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"
|
||||
>
|
||||
const CardContent = (
|
||||
<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">
|
||||
{/* 배경 장식 */}
|
||||
<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-2 left-8 text-base animate-pulse">🎉</div>
|
||||
<div className="absolute bottom-3 left-8 text-sm">🎉</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 && (
|
||||
<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
|
||||
src={schedule.member_image}
|
||||
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">
|
||||
<span className="text-2xl">🎂</span>
|
||||
<h3 className="font-bold text-lg truncate">{schedule.title}</h3>
|
||||
<span className="text-2xl flex-shrink-0">🎂</span>
|
||||
<h3 className="font-bold text-base tracking-wide truncate">
|
||||
{decodeHtmlEntities(schedule.title)}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* 날짜 뱃지 */}
|
||||
<div className="flex-shrink-0 bg-white/20 backdrop-blur-sm rounded-lg px-3 py-1.5 text-center">
|
||||
{showYear && (
|
||||
{/* 날짜 뱃지 (showYear가 true일 때만 표시) */}
|
||||
{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.month}월</div>
|
||||
<div className="text-white text-xl font-bold">{formatted.day}</div>
|
||||
</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>
|
||||
)}
|
||||
</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;
|
||||
|
|
|
|||
|
|
@ -2,10 +2,9 @@ import { useState, useRef, useEffect, useMemo } from 'react';
|
|||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { ChevronLeft, ChevronRight, ChevronDown } from 'lucide-react';
|
||||
import { getTodayKST, dayjs } from '@/utils';
|
||||
import { MIN_YEAR, WEEKDAYS, MONTH_NAMES } from '@/constants';
|
||||
|
||||
const WEEKDAYS = ['일', '월', '화', '수', '목', '금', '토'];
|
||||
const MONTHS = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월'];
|
||||
const MIN_YEAR = 2017;
|
||||
const MONTHS = MONTH_NAMES;
|
||||
|
||||
/**
|
||||
* 달력 컴포넌트
|
||||
|
|
|
|||
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 컴포넌트
|
||||
export { default as ScheduleCard } from './ScheduleCard';
|
||||
export { default as AdminScheduleCard } from './AdminScheduleCard';
|
||||
export { default as Calendar } from './Calendar';
|
||||
|
||||
// Mobile 컴포넌트
|
||||
export { default as MobileScheduleCard } from './MobileScheduleCard';
|
||||
export { default as MobileScheduleListCard } from './MobileScheduleListCard';
|
||||
export { default as MobileScheduleSearchCard } from './MobileScheduleSearchCard';
|
||||
export { default as MobileCalendar } from './MobileCalendar';
|
||||
|
||||
// 공통 컴포넌트
|
||||
export { default as Calendar } from './Calendar';
|
||||
export { default as CategoryFilter } from './CategoryFilter';
|
||||
export { default as BirthdayCard, MobileBirthdayCard, fireBirthdayConfetti } from './BirthdayCard';
|
||||
|
|
|
|||
|
|
@ -57,3 +57,16 @@ export const MONTH_NAMES = [
|
|||
'1월', '2월', '3월', '4월', '5월', '6월',
|
||||
'7월', '8월', '9월', '10월', '11월', '12월',
|
||||
];
|
||||
|
||||
/** 멤버 한글 이름 → 영어 이름 매핑 */
|
||||
export const MEMBER_ENGLISH_NAMES = {
|
||||
송하영: 'HAYOUNG',
|
||||
박지원: 'JIWON',
|
||||
이채영: 'CHAEYOUNG',
|
||||
이나경: 'NAKYUNG',
|
||||
백지헌: 'JIHEON',
|
||||
장규리: 'GYURI',
|
||||
이새롬: 'SAEROM',
|
||||
노지선: 'JISUN',
|
||||
이서연: 'SEOYEON',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,2 +1,8 @@
|
|||
export { default as PCAlbum } from './PCAlbum';
|
||||
export { default as MobileAlbum } from './MobileAlbum';
|
||||
export { default as PCAlbum } from './pc/Album';
|
||||
export { default as MobileAlbum } from './mobile/Album';
|
||||
export { default as PCAlbumDetail } from './pc/AlbumDetail';
|
||||
export { default as MobileAlbumDetail } from './mobile/AlbumDetail';
|
||||
export { default as PCTrackDetail } from './pc/TrackDetail';
|
||||
export { default as MobileTrackDetail } from './mobile/TrackDetail';
|
||||
export { default as PCAlbumGallery } from './pc/AlbumGallery';
|
||||
export { default as MobileAlbumGallery } from './mobile/AlbumGallery';
|
||||
|
|
|
|||
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 MobileSchedule } from './MobileSchedule';
|
||||
export { default as PCSchedule } from './pc/Schedule';
|
||||
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 { ChevronLeft } from 'lucide-react';
|
||||
import { fetchApi } from '@/api/client';
|
||||
|
||||
// 한글 이름 → 영어 이름 매핑
|
||||
const memberEnglishName = {
|
||||
송하영: 'HAYOUNG',
|
||||
박지원: 'JIWON',
|
||||
이채영: 'CHAEYOUNG',
|
||||
이나경: 'NAKYUNG',
|
||||
백지헌: 'JIHEON',
|
||||
장규리: 'GYURI',
|
||||
이새롬: 'SAEROM',
|
||||
노지선: 'JISUN',
|
||||
이서연: 'SEOYEON',
|
||||
};
|
||||
import { MEMBER_ENGLISH_NAMES } from '@/constants';
|
||||
|
||||
/**
|
||||
* Mobile 생일 페이지
|
||||
|
|
@ -25,7 +13,7 @@ function MobileBirthday() {
|
|||
|
||||
// URL 디코딩
|
||||
const decodedMemberName = decodeURIComponent(memberName || '');
|
||||
const englishName = memberEnglishName[decodedMemberName];
|
||||
const englishName = MEMBER_ENGLISH_NAMES[decodedMemberName];
|
||||
|
||||
// 멤버 정보 조회
|
||||
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 { ChevronRight } from 'lucide-react';
|
||||
import { fetchApi } from '@/api/client';
|
||||
|
||||
// 한글 이름 → 영어 이름 매핑
|
||||
const memberEnglishName = {
|
||||
송하영: 'HAYOUNG',
|
||||
박지원: 'JIWON',
|
||||
이채영: 'CHAEYOUNG',
|
||||
이나경: 'NAKYUNG',
|
||||
백지헌: 'JIHEON',
|
||||
장규리: 'GYURI',
|
||||
이새롬: 'SAEROM',
|
||||
노지선: 'JISUN',
|
||||
이서연: 'SEOYEON',
|
||||
};
|
||||
import { MEMBER_ENGLISH_NAMES } from '@/constants';
|
||||
|
||||
/**
|
||||
* PC 생일 페이지
|
||||
|
|
@ -25,7 +13,7 @@ function PCBirthday() {
|
|||
|
||||
// URL 디코딩
|
||||
const decodedMemberName = decodeURIComponent(memberName || '');
|
||||
const englishName = memberEnglishName[decodedMemberName];
|
||||
const englishName = MEMBER_ENGLISH_NAMES[decodedMemberName];
|
||||
|
||||
// 멤버 정보 조회
|
||||
const {
|
||||
|
|
@ -16,8 +16,7 @@ import {
|
|||
import { getSchedules, searchSchedules } from '@/api/schedules';
|
||||
import { useScheduleStore } from '@/stores';
|
||||
import { getTodayKST } from '@/utils';
|
||||
|
||||
const SEARCH_LIMIT = 20;
|
||||
import { SEARCH_LIMIT } from '@/constants';
|
||||
|
||||
/**
|
||||
* 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(트위터) 스타일 날짜/시간 포맷팅
|
||||
* @param {string} date - 날짜 문자열 (YYYY-MM-DD)
|
||||
* @param {string} [time] - 시간 문자열 (HH:mm 또는 HH:mm:ss)
|
||||
* @param {string} datetime - datetime 문자열 (YYYY-MM-DDTHH:mm:ss 또는 YYYY-MM-DD)
|
||||
* @returns {string} "오후 7:00 · 2026년 1월 18일" 또는 "2026년 1월 18일"
|
||||
*/
|
||||
export const formatXDateTime = (date, time) => {
|
||||
if (!date) return '';
|
||||
export const formatXDateTime = (datetime) => {
|
||||
if (!datetime) return '';
|
||||
|
||||
const d = dayjs(date).tz(TIMEZONE);
|
||||
const d = dayjs(datetime).tz(TIMEZONE);
|
||||
const datePart = `${d.year()}년 ${d.month() + 1}월 ${d.date()}일`;
|
||||
|
||||
if (time) {
|
||||
const [hours, minutes] = time.split(':').map(Number);
|
||||
const period = hours < 12 ? '오전' : '오후';
|
||||
const hour12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
|
||||
return `${period} ${hour12}:${String(minutes).padStart(2, '0')} · ${datePart}`;
|
||||
// datetime에 T가 포함되고 시간이 00:00:00이 아니면 시간 표시
|
||||
if (datetime.includes('T') && !datetime.endsWith('T00:00:00')) {
|
||||
const hours = d.hour();
|
||||
const minutes = d.minute();
|
||||
// 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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue