From 2ddc16d532956d61726a8fd4322cc4b978c7be18 Mon Sep 17 00:00:00 2001 From: caadiq Date: Sun, 31 May 2026 23:20:48 +0900 Subject: [PATCH] =?UTF-8?q?feat(concert):=20=EC=BD=98=EC=84=9C=ED=8A=B8=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(=EB=AA=B0=EC=9E=85=ED=98=95=20=EB=94=94=EC=9E=90?= =?UTF-8?q?=EC=9D=B8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 DefaultSection으로 빈약하게 표시되던 콘서트를 전용 상세 페이지로. 백엔드: - getScheduleDetail 콘서트 분기: 포스터/장소/세트리스트(곡별 멤버)/굿즈/다른 회차 프론트엔드: - ConcertSection(PC) / MobileConcertSection(모바일) 신규 - 블러 포스터 히어로 + 회차 드롭다운 + 장소 지도 다이얼로그 - 세트리스트 펼치기/접기 애니메이션, 유닛/솔로만 멤버 태그 - 굿즈 masonry 갤러리(라이트박스), 회차 전환은 history replace - 모바일은 세로 중앙형 전용 히어로 + 바텀시트 - Lightbox/MobileLightbox를 createPortal로 변경 (오버레이 전체화면 보장) Co-Authored-By: Claude Opus 4.7 --- backend/src/services/schedule.js | 103 ++++++ frontend/src/components/common/Lightbox.jsx | 6 +- .../src/components/common/MobileLightbox.jsx | 6 +- .../pages/mobile/schedule/ScheduleDetail.jsx | 278 ++++++++++++++- .../pc/public/schedule/ScheduleDetail.jsx | 9 +- .../schedule/sections/ConcertSection.jsx | 326 ++++++++++++++++++ .../pc/public/schedule/sections/index.js | 1 + 7 files changed, 719 insertions(+), 10 deletions(-) create mode 100644 frontend/src/pages/pc/public/schedule/sections/ConcertSection.jsx diff --git a/backend/src/services/schedule.js b/backend/src/services/schedule.js index 94db54a..818ae50 100644 --- a/backend/src/services/schedule.js +++ b/backend/src/services/schedule.js @@ -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; diff --git a/frontend/src/components/common/Lightbox.jsx b/frontend/src/components/common/Lightbox.jsx index 9d794a8..52ae13b 100644 --- a/frontend/src/components/common/Lightbox.jsx +++ b/frontend/src/components/common/Lightbox.jsx @@ -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( {isOpen && images.length > 0 && ( )} - + , + document.body ); } diff --git a/frontend/src/components/common/MobileLightbox.jsx b/frontend/src/components/common/MobileLightbox.jsx index e9c6d73..6ac481b 100644 --- a/frontend/src/components/common/MobileLightbox.jsx +++ b/frontend/src/components/common/MobileLightbox.jsx @@ -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( {isOpen && ( )} - + , + document.body ); } diff --git a/frontend/src/pages/mobile/schedule/ScheduleDetail.jsx b/frontend/src/pages/mobile/schedule/ScheduleDetail.jsx index ebd10e0..3b595fe 100644 --- a/frontend/src/pages/mobile/schedule/ScheduleDetail.jsx +++ b/frontend/src/pages/mobile/schedule/ScheduleDetail.jsx @@ -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 ( +
+ {/* 히어로 (모바일 전용 세로형) */} +
+ {posterImg ? ( +
+ +
+
+ ) : ( +
+ )} + +
+ {/* 포스터 (중앙, 크게) */} + + + {/* 제목 */} +

+ {decodeHtmlEntities(schedule.title)} +

+ + {/* 날짜(+회차) · 장소 */} +
+
+ + {shortDate(schedule.date)}{schedule.time && ` · ${formatTime(schedule.time)}`} + {hasMultiRounds && ( + + )} +
+ {venue && ( + + )} +
+
+
+ + {/* 세트리스트 */} + {setlist.length > 0 && ( +
+
+ +

세트리스트

+ {setlist.length}곡 +
+ + {setlist.map((song, idx) => ( +
+ + {String(idx + 1).padStart(2, '0')} + +
+

{song.songName}

+ {song.albumName &&

{song.albumName}

} +
+ {isUnit(song.members) && ( +
+ {song.members.map((m) => ( + {m.name} + ))} +
+ )} +
+ ))} +
+ {collapsible && ( + + )} +
+ )} + + {/* 굿즈 (원본 비율 유지, masonry) */} + {merchandise.length > 0 && ( +
+
+ +

MD

+ {merchandise.length} +
+
+ {merchandise.map((md, idx) => ( + + ))} +
+
+ )} + + {/* 회차 선택 바텀시트 */} + {createPortal( + + {roundsOpen && hasMultiRounds && ( +
+ setRoundsOpen(false)} /> + +
+

공연 일정

+
+ {allRounds.map((round, i) => ( + round.current ? ( +
+ + {i + 1}회차 + {shortDate(round.date)} + + {round.time && {formatTime(round.time)}} +
+ ) : ( + + ) + ))} +
+ +
+ )} + , + document.body + )} + + {/* 장소 지도 다이얼로그 */} + {createPortal( + + {mapOpen && venue && ( +
+ setMapOpen(false)} /> + +
+
+ +
+

{venue.name}

+ {venue.address &&

{venue.address}

} +
+
+ + {kakaoMapUrl && ( + + 카카오맵에서 길찾기 + + )} + +
+ )} + , + document.body + )} + + {/* Lightbox */} + setLightbox((prev) => ({ ...prev, open: false }))} + onIndexChange={(index) => setLightbox((prev) => ({ ...prev, index }))} + showCounter={lightbox.images.length > 1} + showDownload + /> +
+ ); +} + /** * Mobile 기본 섹션 */ @@ -967,6 +1237,8 @@ function MobileScheduleDetail() { return ; case '행사': return ; + case '콘서트': + return ; default: return ; } diff --git a/frontend/src/pages/pc/public/schedule/ScheduleDetail.jsx b/frontend/src/pages/pc/public/schedule/ScheduleDetail.jsx index b8b7e40..b992486 100644 --- a/frontend/src/pages/pc/public/schedule/ScheduleDetail.jsx +++ b/frontend/src/pages/pc/public/schedule/ScheduleDetail.jsx @@ -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 ; case '행사': return ; + case '콘서트': + return ; default: return ; } @@ -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 (
-
+
{/* 브레드크럼 네비게이션 */} 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 ( +
+ {/* ===== 히어로 ===== */} +
+ {posterImg ? ( +
+ +
+
+ ) : ( +
+ )} + +
+ {/* 포스터 */} +
+ {posterImg ? ( + + ) : ( +
+ +
+ )} +
+ + {/* 정보 */} +
+
+ + + Concert + + +
+ +

+ {decodeHtmlEntities(schedule.title)} +

+ + {/* 날짜/시간/장소 (강조) */} +
+ {/* 날짜 + 회차 드롭다운 */} +
+ + + + {roundsOpen && hasMultiRounds && ( + <> +
setRoundsOpen(false)} /> + + {allRounds.map((round, i) => ( + + ))} + + + )} + +
+ + {/* 장소 (클릭 → 지도 다이얼로그) */} + {venue && ( + + )} +
+
+
+
+ + {/* ===== 세트리스트 (2단) ===== */} + {setlist.length > 0 && ( +
+
+
+ +
+
+

Setlist

+

세트리스트

+
+ {setlist.length}곡 +
+ +
+ +
+ {setlist.map((song, idx) => ( +
+ + {String(idx + 1).padStart(2, '0')} + +
+

{song.songName}

+ {song.albumName &&

{song.albumName}

} +
+ {isUnit(song.members) && ( +
+ {song.members.map((m) => ( + + {m.name} + + ))} +
+ )} +
+ ))} +
+
+
+ + {/* 펼치기/접기 */} + {collapsible && ( +
+ +
+ )} +
+ )} + + {/* ===== 굿즈 (MD) — 원본 비율 유지, masonry ===== */} + {merchandise.length > 0 && ( +
+
+ +

MD

+ {merchandise.length} +
+
+ {merchandise.map((md, idx) => ( + + ))} +
+
+ )} + + {/* ===== 장소 지도 다이얼로그 ===== */} + + {mapOpen && venue && ( +
+ setMapOpen(false)} /> + +
+
+ +
+

{venue.name}

+ {venue.address &&

{venue.address}

} +
+
+ +
+
+ + {kakaoMapUrl && ( + + 카카오맵에서 길찾기 + + + )} +
+
+
+ )} +
+ + {/* Lightbox */} + setLightbox((prev) => ({ ...prev, open: false }))} + onIndexChange={(index) => setLightbox((prev) => ({ ...prev, index }))} + showCounter={lightbox.images.length > 1} + showDownload + /> +
+ ); +} + +export default ConcertSection; diff --git a/frontend/src/pages/pc/public/schedule/sections/index.js b/frontend/src/pages/pc/public/schedule/sections/index.js index bdbda57..35b128b 100644 --- a/frontend/src/pages/pc/public/schedule/sections/index.js +++ b/frontend/src/pages/pc/public/schedule/sections/index.js @@ -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';