From c94849d42a63ca1a01d4e690065974ce1c860d6b Mon Sep 17 00:00:00 2001 From: caadiq Date: Thu, 15 Jan 2026 14:42:34 +0900 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20PC=20=EC=9D=BC=EC=A0=95=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?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 유튜브 카테고리 상세 페이지 구현 (영상 임베드 + 정보) - 숏츠/일반 영상 레이아웃 분리 - 브레드크럼 네비게이션 적용 Co-Authored-By: Claude Opus 4.5 --- frontend/src/App.jsx | 2 + frontend/src/pages/pc/public/Schedule.jsx | 6 + .../src/pages/pc/public/ScheduleDetail.jsx | 316 ++++++++++++++++++ 3 files changed, 324 insertions(+) create mode 100644 frontend/src/pages/pc/public/ScheduleDetail.jsx diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index c11878c..22fbf1c 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -13,6 +13,7 @@ import PCAlbumDetail from './pages/pc/public/AlbumDetail'; import PCAlbumGallery from './pages/pc/public/AlbumGallery'; import PCTrackDetail from './pages/pc/public/TrackDetail'; import PCSchedule from './pages/pc/public/Schedule'; +import PCScheduleDetail from './pages/pc/public/ScheduleDetail'; import PCNotFound from './pages/pc/public/NotFound'; // 모바일 페이지 @@ -84,6 +85,7 @@ function App() { } /> } /> } /> + } /> } /> diff --git a/frontend/src/pages/pc/public/Schedule.jsx b/frontend/src/pages/pc/public/Schedule.jsx index e5a2c54..a664ac3 100644 --- a/frontend/src/pages/pc/public/Schedule.jsx +++ b/frontend/src/pages/pc/public/Schedule.jsx @@ -354,6 +354,12 @@ function Schedule() { // 일정 클릭 핸들러 const handleScheduleClick = (schedule) => { + // 유튜브 카테고리(id=2)는 상세 페이지로 이동 + if (schedule.category_id === 2) { + navigate(`/schedule/${schedule.id}`); + return; + } + // 설명이 없고 URL만 있으면 바로 링크 열기 if (!schedule.description && schedule.source_url) { window.open(schedule.source_url, '_blank'); diff --git a/frontend/src/pages/pc/public/ScheduleDetail.jsx b/frontend/src/pages/pc/public/ScheduleDetail.jsx new file mode 100644 index 0000000..b998b5a --- /dev/null +++ b/frontend/src/pages/pc/public/ScheduleDetail.jsx @@ -0,0 +1,316 @@ +import { useParams, Link } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import { motion } from 'framer-motion'; +import { Clock, Calendar, ExternalLink, ChevronRight, Link2 } from 'lucide-react'; +import { getSchedule } from '../../../api/public/schedules'; + +// 카테고리 ID 상수 +const CATEGORY_ID = { + YOUTUBE: 2, + X: 3, + ALBUM: 4, + FANSIGN: 5, + CONCERT: 6, + TICKET: 7, +}; + +// HTML 엔티티 디코딩 함수 +const decodeHtmlEntities = (text) => { + if (!text) return ''; + const textarea = document.createElement('textarea'); + textarea.innerHTML = text; + return textarea.value; +}; + +// 유튜브 비디오 ID 추출 +const extractYoutubeVideoId = (url) => { + if (!url) return null; + const shortMatch = url.match(/youtu\.be\/([a-zA-Z0-9_-]{11})/); + if (shortMatch) return shortMatch[1]; + const watchMatch = url.match(/youtube\.com\/watch\?v=([a-zA-Z0-9_-]{11})/); + if (watchMatch) return watchMatch[1]; + const shortsMatch = url.match(/youtube\.com\/shorts\/([a-zA-Z0-9_-]{11})/); + if (shortsMatch) return shortsMatch[1]; + return null; +}; + +// 날짜 포맷팅 +const formatFullDate = (dateStr) => { + if (!dateStr) return ''; + const date = new Date(dateStr); + const dayNames = ['일', '월', '화', '수', '목', '금', '토']; + return `${date.getFullYear()}. ${date.getMonth() + 1}. ${date.getDate()}. (${dayNames[date.getDay()]})`; +}; + +// 시간 포맷팅 +const formatTime = (timeStr) => { + if (!timeStr) return null; + return timeStr.slice(0, 5); +}; + +// 영상 정보 컴포넌트 (공통) +function VideoInfo({ schedule, isShorts }) { + return ( +
+ {/* 제목 */} +

+ {decodeHtmlEntities(schedule.title)} +

+ + {/* 메타 정보 */} +
+ {/* 날짜 */} +
+ + {formatFullDate(schedule.date)} +
+ + {/* 시간 */} + {schedule.time && ( + <> +
+
+ + {formatTime(schedule.time)} +
+ + )} + + {/* 채널명 */} + {schedule.source_name && ( + <> +
+
+ + {schedule.source_name} +
+ + )} +
+ + {/* 유튜브에서 보기 버튼 */} + +
+ ); +} + +// 유튜브 섹션 컴포넌트 +function YoutubeSection({ schedule }) { + const videoId = extractYoutubeVideoId(schedule.source_url); + const isShorts = schedule.source_url?.includes('/shorts/'); + + if (!videoId) return null; + + // 숏츠: 가로 레이아웃 (영상 + 정보) + if (isShorts) { + return ( +
+ {/* 영상 임베드 */} + +
+