feat(concert): 콘서트 상세 페이지 추가 (몰입형 디자인)
기존 DefaultSection으로 빈약하게 표시되던 콘서트를 전용 상세 페이지로. 백엔드: - getScheduleDetail 콘서트 분기: 포스터/장소/세트리스트(곡별 멤버)/굿즈/다른 회차 프론트엔드: - ConcertSection(PC) / MobileConcertSection(모바일) 신규 - 블러 포스터 히어로 + 회차 드롭다운 + 장소 지도 다이얼로그 - 세트리스트 펼치기/접기 애니메이션, 유닛/솔로만 멤버 태그 - 굿즈 masonry 갤러리(라이트박스), 회차 전환은 history replace - 모바일은 세로 중앙형 전용 히어로 + 바텀시트 - Lightbox/MobileLightbox를 createPortal로 변경 (오버레이 전체화면 보장) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
dd5ef48592
commit
2ddc16d532
7 changed files with 719 additions and 10 deletions
|
|
@ -354,6 +354,109 @@ export async function getScheduleDetail(db, id, getXProfile = null) {
|
|||
} else {
|
||||
result.venue = null;
|
||||
}
|
||||
} else if (s.category_id === CATEGORY_IDS.CONCERT) {
|
||||
// 콘서트: 시리즈/포스터/장소/세트리스트(곡별 멤버)/굿즈/다른 회차
|
||||
const [conRows] = await db.query(`
|
||||
SELECT sc.id AS concert_id, sc.series_id,
|
||||
cs.title AS series_title, cs.poster_id,
|
||||
cv.id AS venue_id, cv.name AS venue_name, cv.address AS venue_address,
|
||||
cv.country AS venue_country, cv.lat AS venue_lat, cv.lng AS venue_lng
|
||||
FROM schedule_concert sc
|
||||
LEFT JOIN concert_series cs ON sc.series_id = cs.id
|
||||
LEFT JOIN concert_venues cv ON sc.venue_id = cv.id
|
||||
WHERE sc.schedule_id = ?
|
||||
`, [id]);
|
||||
|
||||
if (conRows.length > 0) {
|
||||
const con = conRows[0];
|
||||
result.seriesId = con.series_id;
|
||||
result.seriesTitle = con.series_title || null;
|
||||
result.activeMemberCount = activeMemberCount; // 유닛/솔로 판별용
|
||||
|
||||
// 포스터
|
||||
if (con.poster_id) {
|
||||
const [posterRows] = await db.query(
|
||||
'SELECT original_url, medium_url, thumb_url FROM images WHERE id = ?',
|
||||
[con.poster_id]
|
||||
);
|
||||
result.poster = posterRows.length > 0 ? {
|
||||
originalUrl: posterRows[0].original_url,
|
||||
mediumUrl: posterRows[0].medium_url,
|
||||
thumbUrl: posterRows[0].thumb_url,
|
||||
} : null;
|
||||
} else {
|
||||
result.poster = null;
|
||||
}
|
||||
|
||||
// 장소
|
||||
result.venue = con.venue_id ? {
|
||||
id: con.venue_id,
|
||||
name: con.venue_name,
|
||||
address: con.venue_address,
|
||||
country: con.venue_country,
|
||||
lat: con.venue_lat,
|
||||
lng: con.venue_lng,
|
||||
} : null;
|
||||
|
||||
// 세트리스트 (이 회차) + 곡별 멤버
|
||||
const [songs] = await db.query(
|
||||
`SELECT id, order_num, song_name, album_name
|
||||
FROM concert_setlists WHERE concert_id = ? ORDER BY order_num ASC`,
|
||||
[con.concert_id]
|
||||
);
|
||||
const memberMap = {};
|
||||
if (songs.length > 0) {
|
||||
const songIds = songs.map(x => x.id);
|
||||
const [allMem] = await db.query(
|
||||
`SELECT csm.setlist_id, m.id, m.name
|
||||
FROM concert_setlist_members csm JOIN members m ON csm.member_id = m.id
|
||||
WHERE csm.setlist_id IN (?) ORDER BY m.id`,
|
||||
[songIds]
|
||||
);
|
||||
for (const row of allMem) {
|
||||
(memberMap[row.setlist_id] ||= []).push({ id: row.id, name: row.name });
|
||||
}
|
||||
}
|
||||
result.setlist = songs.map(song => ({
|
||||
id: song.id,
|
||||
order: song.order_num,
|
||||
songName: song.song_name,
|
||||
albumName: song.album_name || null,
|
||||
members: memberMap[song.id] || [],
|
||||
}));
|
||||
|
||||
// 굿즈 + 다른 회차 (시리즈 기준)
|
||||
if (con.series_id) {
|
||||
const [md] = await db.query(
|
||||
`SELECT csm.id, i.original_url, i.medium_url, i.thumb_url
|
||||
FROM concert_series_md csm JOIN images i ON csm.image_id = i.id
|
||||
WHERE csm.series_id = ? ORDER BY csm.sort_order ASC`,
|
||||
[con.series_id]
|
||||
);
|
||||
result.merchandise = md.map(x => ({
|
||||
id: x.id,
|
||||
originalUrl: x.original_url,
|
||||
mediumUrl: x.medium_url,
|
||||
thumbUrl: x.thumb_url,
|
||||
}));
|
||||
|
||||
const [rounds] = await db.query(
|
||||
`SELECT s2.id AS schedule_id, s2.date, s2.time
|
||||
FROM schedule_concert sc2 JOIN schedules s2 ON sc2.schedule_id = s2.id
|
||||
WHERE sc2.series_id = ? AND s2.id != ?
|
||||
ORDER BY s2.date ASC, s2.time ASC`,
|
||||
[con.series_id, id]
|
||||
);
|
||||
result.otherRounds = rounds.map(r => ({
|
||||
scheduleId: r.schedule_id,
|
||||
date: normalizeDate(r.date),
|
||||
time: r.time ? r.time.substring(0, 5) : null,
|
||||
}));
|
||||
} else {
|
||||
result.merchandise = [];
|
||||
result.otherRounds = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, useEffect, useCallback, memo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, ChevronLeft, ChevronRight, Download } from 'lucide-react';
|
||||
import LightboxIndicator from './LightboxIndicator';
|
||||
|
|
@ -131,7 +132,7 @@ function Lightbox({
|
|||
const photoMembers = currentPhoto?.members;
|
||||
const hasMembers = photoMembers && String(photoMembers).trim();
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<AnimatePresence>
|
||||
{isOpen && images.length > 0 && (
|
||||
<motion.div
|
||||
|
|
@ -283,7 +284,8 @@ function Lightbox({
|
|||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</AnimatePresence>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, Download, Info, Users, Tag } from 'lucide-react';
|
||||
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||
|
|
@ -112,7 +113,7 @@ function MobileLightbox({
|
|||
// 이미지가 없으면 렌더링하지 않음
|
||||
if (!images?.length) return null;
|
||||
|
||||
return (
|
||||
return createPortal(
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
|
|
@ -261,7 +262,8 @@ function MobileLightbox({
|
|||
</AnimatePresence>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</AnimatePresence>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { useParams, Link } from 'react-router-dom';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useParams, Link, useNavigate } 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, Tv, ExternalLink, Play, MapPin, PartyPopper } from 'lucide-react';
|
||||
import { Calendar, Clock, ChevronLeft, Link2, X, ChevronRight, ChevronDown, Tv, ExternalLink, Play, MapPin, PartyPopper, ShoppingBag, Ticket, Disc3 } from 'lucide-react';
|
||||
import { getSchedule } from '@/api';
|
||||
import { KakaoMap } from '@/components/common';
|
||||
import { KakaoMap, MobileLightbox } from '@/components/common';
|
||||
import { decodeHtmlEntities, formatFullDate, formatTime, formatXDateTimeWithTime } from '@/utils';
|
||||
import Birthday from './Birthday';
|
||||
|
||||
|
|
@ -807,6 +808,275 @@ function MobileVarietySection({ schedule }) {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mobile 콘서트 섹션
|
||||
*/
|
||||
function MobileConcertSection({ schedule }) {
|
||||
const navigate = useNavigate();
|
||||
const poster = schedule.poster || null;
|
||||
const venue = schedule.venue || null;
|
||||
const setlist = schedule.setlist || [];
|
||||
const merchandise = schedule.merchandise || [];
|
||||
const otherRounds = schedule.otherRounds || [];
|
||||
const activeCount = schedule.activeMemberCount || 5;
|
||||
const ACCENT = '#f97316';
|
||||
const COLLAPSE_COUNT = 5;
|
||||
const kakaoMapUrl = venue && venue.lat && venue.lng
|
||||
? `https://map.kakao.com/link/map/${encodeURIComponent(venue.name)},${venue.lat},${venue.lng}`
|
||||
: null;
|
||||
const isUnit = (m) => m.length > 0 && m.length < activeCount;
|
||||
const posterImg = poster ? (poster.mediumUrl || poster.originalUrl) : null;
|
||||
|
||||
const [mapOpen, setMapOpen] = useState(false);
|
||||
const [roundsOpen, setRoundsOpen] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [lightbox, setLightbox] = useState({ open: false, images: [], index: 0 });
|
||||
const openLightbox = (images, index) => {
|
||||
setLightbox({ open: true, images, index });
|
||||
window.history.pushState({ lightbox: true }, '');
|
||||
};
|
||||
|
||||
// 세트리스트 높이 (1열, 행 52px) — 펼치기/접기 애니메이션
|
||||
const ROW_H = 52;
|
||||
const collapsible = setlist.length > COLLAPSE_COUNT;
|
||||
const collapsedHeight = COLLAPSE_COUNT * ROW_H;
|
||||
const fullHeight = setlist.length * ROW_H;
|
||||
|
||||
const days = ['일', '월', '화', '수', '목', '금', '토'];
|
||||
const shortDate = (ds) => {
|
||||
const d = new Date(ds);
|
||||
return `${d.getMonth() + 1}.${d.getDate()} (${days[d.getDay()]})`;
|
||||
};
|
||||
const allRounds = [
|
||||
{ scheduleId: schedule.id, date: schedule.date, time: schedule.time, current: true },
|
||||
...otherRounds.map((r) => ({ ...r, current: false })),
|
||||
].sort((a, b) => a.date.localeCompare(b.date));
|
||||
const hasMultiRounds = allRounds.length > 1;
|
||||
const currentRoundNum = allRounds.findIndex((r) => r.current) + 1;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* 히어로 (모바일 전용 세로형) */}
|
||||
<div className="relative rounded-2xl overflow-hidden shadow-lg">
|
||||
{posterImg ? (
|
||||
<div className="absolute inset-0">
|
||||
<img src={posterImg} alt="" className="w-full h-full object-cover scale-110 blur-xl opacity-80" />
|
||||
<div className="absolute inset-0" style={{ background: 'linear-gradient(180deg, rgba(15,11,8,0.45) 0%, rgba(15,11,8,0.72) 58%, rgba(15,11,8,0.9) 100%)' }} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="absolute inset-0" style={{ background: 'linear-gradient(180deg, #1a1410 0%, #3a1d0c 100%)' }} />
|
||||
)}
|
||||
|
||||
<div className="relative px-5 pt-7 pb-7 text-white flex flex-col items-center text-center">
|
||||
{/* 포스터 (중앙, 크게) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => posterImg && openLightbox([poster.originalUrl || poster.mediumUrl], 0)}
|
||||
className="w-52 mb-5"
|
||||
>
|
||||
{posterImg ? (
|
||||
<span className="block rounded-2xl overflow-hidden shadow-2xl ring-1 ring-white/15">
|
||||
<img src={posterImg} alt={schedule.title} className="w-full h-auto object-cover" />
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex w-full aspect-[3/4] rounded-2xl bg-white/5 ring-1 ring-white/10 items-center justify-center">
|
||||
<Ticket size={48} className="text-white/30" strokeWidth={1.5} />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* 제목 */}
|
||||
<h1 className="text-xl font-black leading-snug tracking-tight mb-4 px-2">
|
||||
{decodeHtmlEntities(schedule.title)}
|
||||
</h1>
|
||||
|
||||
{/* 날짜(+회차) · 장소 */}
|
||||
<div className="flex flex-col items-center gap-2.5 text-white">
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar size={16} className="text-white/55" />
|
||||
<span className="text-base font-bold">{shortDate(schedule.date)}{schedule.time && ` · ${formatTime(schedule.time)}`}</span>
|
||||
{hasMultiRounds && (
|
||||
<button type="button" onClick={() => setRoundsOpen(true)}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full bg-white/15 text-xs font-bold border border-white/20">
|
||||
{currentRoundNum}회차<ChevronDown size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{venue && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => venue.lat && venue.lng && setMapOpen(true)}
|
||||
className="flex items-center gap-2 max-w-full"
|
||||
>
|
||||
<MapPin size={16} className="text-white/55 flex-shrink-0" />
|
||||
<span className="text-base font-bold truncate">{venue.name}</span>
|
||||
{venue.lat && venue.lng && <ChevronRight size={15} className="text-white/45 flex-shrink-0" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 세트리스트 */}
|
||||
{setlist.length > 0 && (
|
||||
<div className="bg-white rounded-xl shadow-sm p-4">
|
||||
<div className="flex items-center gap-1.5 mb-3">
|
||||
<Disc3 size={16} style={{ color: ACCENT }} />
|
||||
<h2 className="text-sm font-bold text-gray-900">세트리스트</h2>
|
||||
<span className="text-xs text-gray-400 ml-auto">{setlist.length}곡</span>
|
||||
</div>
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{ height: collapsible && !expanded ? collapsedHeight : fullHeight }}
|
||||
transition={{ duration: 0.4, ease: [0.4, 0, 0.2, 1] }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
{setlist.map((song, idx) => (
|
||||
<div key={song.id} className="flex items-center gap-2.5 h-[52px] px-1">
|
||||
<span className="flex-shrink-0 w-7 text-right text-lg font-black tabular-nums text-gray-200 leading-none">
|
||||
{String(idx + 1).padStart(2, '0')}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">{song.songName}</p>
|
||||
{song.albumName && <p className="text-[11px] text-gray-400 truncate mt-0.5">{song.albumName}</p>}
|
||||
</div>
|
||||
{isUnit(song.members) && (
|
||||
<div className="flex-shrink-0 flex flex-wrap justify-end gap-1 max-w-[110px]">
|
||||
{song.members.map((m) => (
|
||||
<span key={m.id} className="px-2 py-0.5 rounded-full text-[11px] font-semibold bg-orange-100 text-orange-700">{m.name}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
{collapsible && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="mt-2 w-full flex items-center justify-center gap-1 py-2.5 rounded-lg text-xs font-semibold text-gray-500 bg-gray-50 active:bg-gray-100"
|
||||
>
|
||||
{expanded ? '접기' : `${setlist.length - COLLAPSE_COUNT}곡 더 보기`}
|
||||
<ChevronDown size={14} className={`transition-transform ${expanded ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 굿즈 (원본 비율 유지, masonry) */}
|
||||
{merchandise.length > 0 && (
|
||||
<div className="bg-white rounded-xl shadow-sm p-4">
|
||||
<div className="flex items-center gap-1.5 mb-3">
|
||||
<ShoppingBag size={16} style={{ color: ACCENT }} />
|
||||
<h2 className="text-sm font-bold text-gray-900">MD</h2>
|
||||
<span className="text-xs text-gray-400">{merchandise.length}</span>
|
||||
</div>
|
||||
<div className="columns-2 gap-2">
|
||||
{merchandise.map((md, idx) => (
|
||||
<button key={md.id} type="button"
|
||||
onClick={() => openLightbox(merchandise.map((x) => x.originalUrl || x.mediumUrl), idx)}
|
||||
className="block w-full mb-2 break-inside-avoid rounded-lg overflow-hidden border border-gray-100">
|
||||
<img src={md.mediumUrl || md.thumbUrl} alt="" className="w-full h-auto" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 회차 선택 바텀시트 */}
|
||||
{createPortal(
|
||||
<AnimatePresence>
|
||||
{roundsOpen && hasMultiRounds && (
|
||||
<div className="fixed inset-0 z-[60] flex items-end">
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||
className="absolute inset-0 bg-black/50" onClick={() => setRoundsOpen(false)} />
|
||||
<motion.div
|
||||
initial={{ y: '100%' }} animate={{ y: 0 }} exit={{ y: '100%' }}
|
||||
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
|
||||
className="relative w-full bg-white rounded-t-3xl p-4"
|
||||
>
|
||||
<div className="w-10 h-1 bg-gray-200 rounded-full mx-auto mb-4" />
|
||||
<p className="text-sm font-bold text-gray-900 mb-3 px-1">공연 일정</p>
|
||||
<div className="space-y-1">
|
||||
{allRounds.map((round, i) => (
|
||||
round.current ? (
|
||||
<div key={round.scheduleId} className="flex items-center justify-between px-3 py-3 rounded-xl font-bold"
|
||||
style={{ backgroundColor: `${ACCENT}14`, color: '#9a3412' }}>
|
||||
<span className="flex items-center gap-2.5">
|
||||
<span className="text-xs font-bold tabular-nums w-9" style={{ color: ACCENT }}>{i + 1}회차</span>
|
||||
{shortDate(round.date)}
|
||||
</span>
|
||||
{round.time && <span className="text-xs">{formatTime(round.time)}</span>}
|
||||
</div>
|
||||
) : (
|
||||
<button key={round.scheduleId} type="button"
|
||||
onClick={() => { setRoundsOpen(false); navigate(`/schedule/${round.scheduleId}`, { replace: true }); }}
|
||||
className="w-full flex items-center justify-between px-3 py-3 rounded-xl text-gray-600 active:bg-gray-50">
|
||||
<span className="flex items-center gap-2.5">
|
||||
<span className="text-xs font-bold tabular-nums w-9 text-gray-400">{i + 1}회차</span>
|
||||
{shortDate(round.date)}
|
||||
</span>
|
||||
{round.time && <span className="text-xs text-gray-400">{formatTime(round.time)}</span>}
|
||||
</button>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* 장소 지도 다이얼로그 */}
|
||||
{createPortal(
|
||||
<AnimatePresence>
|
||||
{mapOpen && venue && (
|
||||
<div className="fixed inset-0 z-[60] flex items-end">
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||
className="absolute inset-0 bg-black/50" onClick={() => setMapOpen(false)} />
|
||||
<motion.div
|
||||
initial={{ y: '100%' }} animate={{ y: 0 }} exit={{ y: '100%' }}
|
||||
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
|
||||
className="relative w-full bg-white rounded-t-3xl p-4"
|
||||
>
|
||||
<div className="w-10 h-1 bg-gray-200 rounded-full mx-auto mb-4" />
|
||||
<div className="flex items-start gap-2 mb-3 px-1">
|
||||
<MapPin size={16} style={{ color: ACCENT }} className="mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-bold text-gray-900">{venue.name}</p>
|
||||
{venue.address && <p className="text-xs text-gray-500 mt-0.5">{venue.address}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<KakaoMap lat={Number(venue.lat)} lng={Number(venue.lng)} name={venue.name} className="w-full h-56 rounded-2xl overflow-hidden border border-gray-100" />
|
||||
{kakaoMapUrl && (
|
||||
<a href={kakaoMapUrl} target="_blank" rel="noopener noreferrer"
|
||||
className="mt-3 flex items-center justify-center gap-1.5 py-3 rounded-xl text-sm font-semibold text-white"
|
||||
style={{ backgroundColor: ACCENT }}>
|
||||
카카오맵에서 길찾기<ExternalLink size={14} />
|
||||
</a>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* Lightbox */}
|
||||
<MobileLightbox
|
||||
images={lightbox.images}
|
||||
currentIndex={lightbox.index}
|
||||
isOpen={lightbox.open}
|
||||
onClose={() => setLightbox((prev) => ({ ...prev, open: false }))}
|
||||
onIndexChange={(index) => setLightbox((prev) => ({ ...prev, index }))}
|
||||
showCounter={lightbox.images.length > 1}
|
||||
showDownload
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mobile 기본 섹션
|
||||
*/
|
||||
|
|
@ -967,6 +1237,8 @@ function MobileScheduleDetail() {
|
|||
return <MobileVarietySection schedule={schedule} />;
|
||||
case '행사':
|
||||
return <MobileEventSection schedule={schedule} />;
|
||||
case '콘서트':
|
||||
return <MobileConcertSection schedule={schedule} />;
|
||||
default:
|
||||
return <MobileDefaultSection schedule={schedule} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { Calendar, ChevronRight } from 'lucide-react';
|
|||
import { getSchedule } from '@/api';
|
||||
|
||||
// 섹션 컴포넌트들
|
||||
import { YoutubeSection, XSection, VarietySection, EventSection, DefaultSection, decodeHtmlEntities } from './sections';
|
||||
import { YoutubeSection, XSection, VarietySection, EventSection, ConcertSection, DefaultSection, decodeHtmlEntities } from './sections';
|
||||
import Birthday from './Birthday';
|
||||
|
||||
/**
|
||||
|
|
@ -157,6 +157,8 @@ function PCScheduleDetail() {
|
|||
return <VarietySection schedule={schedule} />;
|
||||
case '행사':
|
||||
return <EventSection schedule={schedule} />;
|
||||
case '콘서트':
|
||||
return <ConcertSection schedule={schedule} />;
|
||||
default:
|
||||
return <DefaultSection schedule={schedule} />;
|
||||
}
|
||||
|
|
@ -166,11 +168,12 @@ function PCScheduleDetail() {
|
|||
const isX = categoryName === 'X';
|
||||
const isVariety = categoryName === '예능';
|
||||
const isEvent = categoryName === '행사';
|
||||
const hasCustomLayout = isYoutube || isX || isVariety || isEvent;
|
||||
const isConcert = categoryName === '콘서트';
|
||||
const hasCustomLayout = isYoutube || isX || isVariety || isEvent || isConcert;
|
||||
|
||||
return (
|
||||
<div className="min-h-[calc(100vh-64px)] bg-gray-50">
|
||||
<div className={`${isYoutube || isEvent ? 'max-w-5xl' : 'max-w-3xl'} mx-auto px-6 py-8`}>
|
||||
<div className={`${isYoutube || isEvent || isConcert ? 'max-w-5xl' : 'max-w-3xl'} mx-auto px-6 py-8`}>
|
||||
{/* 브레드크럼 네비게이션 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,326 @@
|
|||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Calendar, Clock, MapPin, ShoppingBag,
|
||||
ExternalLink, Ticket, Disc3, ChevronDown, ChevronRight, X,
|
||||
} from 'lucide-react';
|
||||
import { Lightbox, KakaoMap } from '@/components/common';
|
||||
import { decodeHtmlEntities, formatFullDate, formatTime } from './utils';
|
||||
|
||||
const ACCENT = '#f97316';
|
||||
const COLLAPSE_COUNT = 12;
|
||||
|
||||
const DAYS = ['일', '월', '화', '수', '목', '금', '토'];
|
||||
function shortDate(dateStr) {
|
||||
const d = new Date(dateStr);
|
||||
return `${d.getMonth() + 1}.${d.getDate()} (${DAYS[d.getDay()]})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 콘서트 일정 섹션 — 몰입형 포스터 히어로 디자인
|
||||
*/
|
||||
function ConcertSection({ schedule }) {
|
||||
const navigate = useNavigate();
|
||||
const members = schedule.members || [];
|
||||
const poster = schedule.poster || null;
|
||||
const venue = schedule.venue || null;
|
||||
const setlist = schedule.setlist || [];
|
||||
const merchandise = schedule.merchandise || [];
|
||||
const otherRounds = schedule.otherRounds || [];
|
||||
const activeCount = schedule.activeMemberCount || 5;
|
||||
|
||||
const kakaoMapUrl = venue && venue.lat && venue.lng
|
||||
? `https://map.kakao.com/link/map/${encodeURIComponent(venue.name)},${venue.lat},${venue.lng}`
|
||||
: null;
|
||||
|
||||
const [lightbox, setLightbox] = useState({ open: false, images: [], index: 0 });
|
||||
const [mapOpen, setMapOpen] = useState(false);
|
||||
const [roundsOpen, setRoundsOpen] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const openLightbox = (images, index) => setLightbox({ open: true, images, index });
|
||||
const isUnit = (m) => m.length > 0 && m.length < activeCount;
|
||||
const posterImg = poster ? (poster.mediumUrl || poster.originalUrl) : null;
|
||||
|
||||
const allRounds = [
|
||||
{ scheduleId: schedule.id, date: schedule.date, time: schedule.time, current: true },
|
||||
...otherRounds.map((r) => ({ ...r, current: false })),
|
||||
].sort((a, b) => a.date.localeCompare(b.date));
|
||||
const hasMultiRounds = allRounds.length > 1;
|
||||
const currentRoundNum = allRounds.findIndex((r) => r.current) + 1;
|
||||
|
||||
// 세트리스트 높이 (2단 그리드, 행 60px) — 펼치기/접기 애니메이션용
|
||||
const ROW_H = 60;
|
||||
const collapsible = setlist.length > COLLAPSE_COUNT;
|
||||
const collapsedHeight = Math.ceil(COLLAPSE_COUNT / 2) * ROW_H;
|
||||
const fullHeight = Math.ceil(setlist.length / 2) * ROW_H;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* ===== 히어로 ===== */}
|
||||
<div className="relative rounded-[28px] overflow-hidden shadow-xl">
|
||||
{posterImg ? (
|
||||
<div className="absolute inset-0">
|
||||
<img src={posterImg} alt="" className="w-full h-full object-cover scale-110 blur-xl opacity-90" />
|
||||
<div className="absolute inset-0" style={{
|
||||
background: 'linear-gradient(110deg, rgba(12,9,7,0.82) 0%, rgba(12,9,7,0.55) 48%, rgba(124,45,18,0.4) 100%)',
|
||||
}} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="absolute inset-0" style={{ background: 'linear-gradient(120deg, #1a1410 0%, #3a1d0c 100%)' }} />
|
||||
)}
|
||||
|
||||
<div className="relative flex gap-8 p-8 md:p-10">
|
||||
{/* 포스터 */}
|
||||
<div className="flex-shrink-0 w-[300px]">
|
||||
{posterImg ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openLightbox([poster.originalUrl || poster.mediumUrl], 0)}
|
||||
className="block w-full rounded-2xl overflow-hidden shadow-2xl ring-1 ring-white/15 cursor-pointer transition-transform duration-300 hover:scale-[1.02]"
|
||||
>
|
||||
<img src={posterImg} alt={`${schedule.title} 포스터`} className="w-full h-auto object-cover" />
|
||||
</button>
|
||||
) : (
|
||||
<div className="w-full aspect-[3/4] rounded-2xl bg-white/5 ring-1 ring-white/10 flex items-center justify-center">
|
||||
<Ticket size={64} className="text-white/30" strokeWidth={1.5} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 정보 */}
|
||||
<div className="flex-1 min-w-0 flex flex-col justify-center py-2 text-white">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-bold tracking-wider uppercase"
|
||||
style={{ backgroundColor: ACCENT, color: '#1a0f06' }}>
|
||||
<Ticket size={12} />
|
||||
Concert
|
||||
</span>
|
||||
<span className="h-px flex-1 bg-white/15" />
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl md:text-[40px] font-black leading-[1.08] tracking-tight mb-8">
|
||||
{decodeHtmlEntities(schedule.title)}
|
||||
</h1>
|
||||
|
||||
{/* 날짜/시간/장소 (강조) */}
|
||||
<div className="space-y-4">
|
||||
{/* 날짜 + 회차 드롭다운 */}
|
||||
<div className="relative w-fit">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => hasMultiRounds && setRoundsOpen((v) => !v)}
|
||||
className={`flex items-center gap-2.5 text-white ${hasMultiRounds ? 'cursor-pointer group' : 'cursor-default'}`}
|
||||
>
|
||||
<Calendar size={20} className="text-white/55" />
|
||||
<span className="text-xl font-bold">{formatFullDate(schedule.date)}</span>
|
||||
{schedule.time && (
|
||||
<>
|
||||
<span className="text-white/30">·</span>
|
||||
<span className="text-xl font-bold">{formatTime(schedule.time)}</span>
|
||||
</>
|
||||
)}
|
||||
{hasMultiRounds && (
|
||||
<span className="ml-1.5 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-white/15 text-sm font-bold border border-white/20 group-hover:bg-white/25 transition-colors">
|
||||
{currentRoundNum}회차
|
||||
<ChevronDown size={15} className={`transition-transform ${roundsOpen ? 'rotate-180' : ''}`} />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{roundsOpen && hasMultiRounds && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-20" onClick={() => setRoundsOpen(false)} />
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -8 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute top-full left-0 mt-2 z-30 w-64 bg-white rounded-2xl shadow-2xl border border-gray-100 p-2"
|
||||
>
|
||||
{allRounds.map((round, i) => (
|
||||
<button
|
||||
key={round.scheduleId}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setRoundsOpen(false);
|
||||
if (!round.current) navigate(`/schedule/${round.scheduleId}`, { replace: true });
|
||||
}}
|
||||
className={`w-full flex items-center justify-between px-3 py-2.5 rounded-xl text-sm transition-colors ${
|
||||
round.current ? 'font-bold' : 'text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
style={round.current ? { backgroundColor: `${ACCENT}14`, color: '#9a3412' } : undefined}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="text-xs font-bold tabular-nums w-9" style={{ color: round.current ? ACCENT : '#9ca3af' }}>{i + 1}회차</span>
|
||||
{shortDate(round.date)}
|
||||
</span>
|
||||
{round.time && <span className="text-xs text-gray-400">{formatTime(round.time)}</span>}
|
||||
</button>
|
||||
))}
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* 장소 (클릭 → 지도 다이얼로그) */}
|
||||
{venue && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => venue.lat && venue.lng && setMapOpen(true)}
|
||||
className={`flex items-center gap-2.5 text-white ${venue.lat && venue.lng ? 'group cursor-pointer' : 'cursor-default'}`}
|
||||
>
|
||||
<MapPin size={20} className="text-white/55" />
|
||||
<span className="text-xl font-bold group-hover:underline underline-offset-4 decoration-white/40">{venue.name}</span>
|
||||
{venue.lat && venue.lng && (
|
||||
<ChevronRight size={18} className="text-white/40 group-hover:text-white/70 transition-colors" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ===== 세트리스트 (2단) ===== */}
|
||||
{setlist.length > 0 && (
|
||||
<div className="bg-white rounded-3xl shadow-sm border border-gray-100 overflow-hidden">
|
||||
<div className="flex items-center gap-3 px-8 pt-7 pb-5">
|
||||
<div className="w-10 h-10 rounded-full flex items-center justify-center" style={{ backgroundColor: `${ACCENT}1a` }}>
|
||||
<Disc3 size={20} style={{ color: ACCENT }} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-bold tracking-[0.18em] uppercase" style={{ color: ACCENT }}>Setlist</p>
|
||||
<h2 className="text-xl font-bold text-gray-900 -mt-0.5">세트리스트</h2>
|
||||
</div>
|
||||
<span className="ml-auto text-sm text-gray-400">{setlist.length}곡</span>
|
||||
</div>
|
||||
|
||||
<div className="px-6 pb-2">
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{ height: collapsible && !expanded ? collapsedHeight : fullHeight }}
|
||||
transition={{ duration: 0.4, ease: [0.4, 0, 0.2, 1] }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-x-6 auto-rows-[60px]">
|
||||
{setlist.map((song, idx) => (
|
||||
<div key={song.id} className="flex items-center gap-3 px-2 rounded-xl hover:bg-gray-50 transition-colors">
|
||||
<span className="flex-shrink-0 w-8 text-right text-xl font-black tabular-nums leading-none text-gray-200">
|
||||
{String(idx + 1).padStart(2, '0')}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[15px] font-semibold text-gray-900 truncate">{song.songName}</p>
|
||||
{song.albumName && <p className="text-xs text-gray-400 truncate mt-0.5">{song.albumName}</p>}
|
||||
</div>
|
||||
{isUnit(song.members) && (
|
||||
<div className="flex-shrink-0 flex flex-wrap justify-end gap-1 max-w-[140px]">
|
||||
{song.members.map((m) => (
|
||||
<span key={m.id} className="px-2 py-0.5 rounded-full text-xs font-semibold bg-orange-100 text-orange-700">
|
||||
{m.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* 펼치기/접기 */}
|
||||
{collapsible && (
|
||||
<div className="px-6 pb-6 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="w-full flex items-center justify-center gap-1.5 py-3 rounded-xl text-sm font-semibold text-gray-500 bg-gray-50 hover:bg-gray-100 transition-colors"
|
||||
>
|
||||
{expanded ? '접기' : `${setlist.length - COLLAPSE_COUNT}곡 더 보기`}
|
||||
<ChevronDown size={16} className={`transition-transform ${expanded ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===== 굿즈 (MD) — 원본 비율 유지, masonry ===== */}
|
||||
{merchandise.length > 0 && (
|
||||
<div className="bg-white rounded-3xl shadow-sm border border-gray-100 p-7">
|
||||
<div className="flex items-center gap-2.5 mb-5">
|
||||
<ShoppingBag size={18} style={{ color: ACCENT }} />
|
||||
<h2 className="text-base font-bold text-gray-900">MD</h2>
|
||||
<span className="text-sm text-gray-400">{merchandise.length}</span>
|
||||
</div>
|
||||
<div className="columns-3 gap-3">
|
||||
{merchandise.map((md, idx) => (
|
||||
<button key={md.id} type="button"
|
||||
onClick={() => openLightbox(merchandise.map((x) => x.originalUrl || x.mediumUrl), idx)}
|
||||
className="block w-full mb-3 break-inside-avoid rounded-xl overflow-hidden border border-gray-100 cursor-pointer hover:opacity-95 transition-opacity">
|
||||
<img src={md.mediumUrl || md.thumbUrl} alt="" className="w-full h-auto" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ===== 장소 지도 다이얼로그 ===== */}
|
||||
<AnimatePresence>
|
||||
{mapOpen && venue && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}
|
||||
className="absolute inset-0 bg-black/50" onClick={() => setMapOpen(false)} />
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.96, y: 10 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.96, y: 10 }}
|
||||
transition={{ duration: 0.18 }}
|
||||
className="relative bg-white rounded-3xl shadow-2xl w-full max-w-lg overflow-hidden"
|
||||
>
|
||||
<div className="flex items-start justify-between p-5 pb-4">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<MapPin size={20} style={{ color: ACCENT }} className="mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="font-bold text-gray-900 text-lg">{venue.name}</p>
|
||||
{venue.address && <p className="text-sm text-gray-500 mt-0.5">{venue.address}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => setMapOpen(false)} className="p-1.5 rounded-lg hover:bg-gray-100 transition-colors">
|
||||
<X size={18} className="text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-5 pb-5">
|
||||
<KakaoMap lat={Number(venue.lat)} lng={Number(venue.lng)} name={venue.name}
|
||||
className="w-full h-64 rounded-2xl overflow-hidden border border-gray-100" />
|
||||
{kakaoMapUrl && (
|
||||
<a href={kakaoMapUrl} target="_blank" rel="noopener noreferrer"
|
||||
className="mt-3 flex items-center justify-center gap-1.5 py-2.5 rounded-xl text-sm font-semibold text-white transition-opacity hover:opacity-90"
|
||||
style={{ backgroundColor: ACCENT }}>
|
||||
카카오맵에서 길찾기
|
||||
<ExternalLink size={14} />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Lightbox */}
|
||||
<Lightbox
|
||||
images={lightbox.images}
|
||||
currentIndex={lightbox.index}
|
||||
isOpen={lightbox.open}
|
||||
onClose={() => setLightbox((prev) => ({ ...prev, open: false }))}
|
||||
onIndexChange={(index) => setLightbox((prev) => ({ ...prev, index }))}
|
||||
showCounter={lightbox.images.length > 1}
|
||||
showDownload
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConcertSection;
|
||||
|
|
@ -2,5 +2,6 @@ export { default as YoutubeSection } from './YoutubeSection';
|
|||
export { default as XSection } from './XSection';
|
||||
export { default as VarietySection } from './VarietySection';
|
||||
export { default as EventSection } from './EventSection';
|
||||
export { default as ConcertSection } from './ConcertSection';
|
||||
export { default as DefaultSection } from './DefaultSection';
|
||||
export * from './utils';
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue