diff --git a/frontend/src/pages/pc/public/ScheduleDetail.jsx b/frontend/src/pages/pc/public/ScheduleDetail.jsx
index 298085d..b530f1e 100644
--- a/frontend/src/pages/pc/public/ScheduleDetail.jsx
+++ b/frontend/src/pages/pc/public/ScheduleDetail.jsx
@@ -1,854 +1,23 @@
import { useParams, Link } from 'react-router-dom';
import { useQuery, keepPreviousData } from '@tanstack/react-query';
-import { useEffect, useRef, useState } from 'react';
-import { motion, AnimatePresence } from 'framer-motion';
-import { Clock, Calendar, ExternalLink, ChevronRight, ChevronDown, ChevronLeft, Check, Link2, MapPin, Navigation } from 'lucide-react';
-import { getSchedule, getXProfile } from '../../../api/public/schedules';
-
-// 카카오맵 SDK 키
-const KAKAO_MAP_KEY = import.meta.env.VITE_KAKAO_JS_KEY;
-
-// 카테고리 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);
-};
-
-// X용 날짜/시간 포맷팅 (오후 2:30 · 2026년 1월 15일)
-const formatXDateTime = (dateStr, timeStr) => {
- if (!dateStr) return '';
- const date = new Date(dateStr);
- const year = date.getFullYear();
- const month = date.getMonth() + 1;
- const day = date.getDate();
-
- let result = `${year}년 ${month}월 ${day}일`;
-
- if (timeStr) {
- const [hours, minutes] = timeStr.split(':').map(Number);
- const period = hours < 12 ? '오전' : '오후';
- const hour12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
- result = `${period} ${hour12}:${String(minutes).padStart(2, '0')} · ${result}`;
- }
-
- return result;
-};
-
-// 영상 정보 컴포넌트 (공통)
-function VideoInfo({ schedule, isShorts }) {
- const members = schedule.members || [];
- const isFullGroup = members.length === 5;
-
- return (
-
- {/* 제목 */}
-
- {decodeHtmlEntities(schedule.title)}
-
-
- {/* 메타 정보 */}
-
- {/* 날짜 */}
-
-
- {formatFullDate(schedule.date)}
-
-
- {/* 시간 */}
- {schedule.time && (
- <>
-
-
-
- {formatTime(schedule.time)}
-
- >
- )}
-
- {/* 채널명 */}
- {schedule.source_name && (
- <>
-
-
-
- {schedule.source_name}
-
- >
- )}
-
-
- {/* 멤버 목록 */}
- {members.length > 0 && (
-
- {isFullGroup ? (
-
- 프로미스나인
-
- ) : (
- members.map((member) => (
-
- {member.name}
-
- ))
- )}
-
- )}
-
- {/* 유튜브에서 보기 버튼 */}
-
-
- );
-}
-
-// 유튜브 섹션 컴포넌트
-function YoutubeSection({ schedule }) {
- const videoId = extractYoutubeVideoId(schedule.source_url);
- const isShorts = schedule.source_url?.includes('/shorts/');
-
- if (!videoId) return null;
-
- // 숏츠: 가로 레이아웃 (영상 + 정보)
- if (isShorts) {
- return (
-
- {/* 영상 임베드 */}
-
-
-
-
-
-
- {/* 영상 정보 카드 */}
-
-
-
-
- );
- }
-
- // 일반 영상: 세로 레이아웃 (영상 위, 정보 아래)
- return (
-
- {/* 영상 임베드 */}
-
-
-
-
-
-
- {/* 영상 정보 카드 */}
-
-
-
-
- );
-}
-
-// X URL에서 username 추출
-const extractXUsername = (url) => {
- if (!url) return null;
- const match = url.match(/(?:twitter\.com|x\.com)\/([^/]+)/);
- return match ? match[1] : null;
-};
-
-// X(트위터) 섹션 컴포넌트
-function XSection({ schedule }) {
- const username = extractXUsername(schedule.source_url);
-
- // 프로필 정보 조회
- const { data: profile } = useQuery({
- queryKey: ['x-profile', username],
- queryFn: () => getXProfile(username),
- enabled: !!username,
- staleTime: 1000 * 60 * 60, // 1시간
- });
-
- const displayName = profile?.displayName || schedule.source_name || username || 'Unknown';
- const avatarUrl = profile?.avatarUrl;
-
- return (
-
- {/* X 스타일 카드 */}
-
- {/* 헤더 */}
-
-
- {/* 프로필 이미지 */}
- {avatarUrl ? (
-

- ) : (
-
-
- {displayName.charAt(0).toUpperCase()}
-
-
- )}
-
-
-
- {displayName}
-
-
-
- {username && (
-
@{username}
- )}
-
-
-
-
- {/* 본문 */}
-
-
- {decodeHtmlEntities(schedule.description || schedule.title)}
-
-
-
- {/* 이미지 */}
- {schedule.image_url && (
-
-

-
- )}
-
- {/* 날짜/시간 */}
-
-
- {formatXDateTime(schedule.date, schedule.time)}
-
-
-
- {/* X에서 보기 버튼 */}
-
-
-
- );
-}
-
-// 카카오맵 컴포넌트
-function KakaoMap({ lat, lng, name }) {
- const mapRef = useRef(null);
- const [mapLoaded, setMapLoaded] = useState(false);
- const [mapError, setMapError] = useState(false);
-
- useEffect(() => {
- // API 키가 없으면 에러
- if (!KAKAO_MAP_KEY) {
- setMapError(true);
- return;
- }
-
- // 카카오맵 SDK 동적 로드
- if (!window.kakao?.maps) {
- const script = document.createElement('script');
- script.src = `//dapi.kakao.com/v2/maps/sdk.js?appkey=${KAKAO_MAP_KEY}&autoload=false`;
- script.onload = () => {
- window.kakao.maps.load(() => setMapLoaded(true));
- };
- script.onerror = () => setMapError(true);
- document.head.appendChild(script);
- } else {
- setMapLoaded(true);
- }
- }, []);
-
- useEffect(() => {
- if (!mapLoaded || !mapRef.current || mapError) return;
-
- try {
- const position = new window.kakao.maps.LatLng(lat, lng);
- const map = new window.kakao.maps.Map(mapRef.current, {
- center: position,
- level: 3,
- });
-
- // 마커 추가
- const marker = new window.kakao.maps.Marker({
- position,
- map,
- });
-
- // 인포윈도우 추가
- if (name) {
- const infowindow = new window.kakao.maps.InfoWindow({
- content: `${name}
`,
- });
- infowindow.open(map, marker);
- }
- } catch (e) {
- setMapError(true);
- }
- }, [mapLoaded, lat, lng, name, mapError]);
-
- // 에러 시 정적 이미지 또는 링크로 대체
- if (mapError) {
- return (
-
-
-
- );
- }
-
- return (
-
- );
-}
-
-// 콘서트 섹션 컴포넌트
-function ConcertSection({ schedule }) {
- // 현재 선택된 회차 ID (내부 state로 관리 - URL 변경 없음)
- const [selectedDateId, setSelectedDateId] = useState(schedule.id);
- const [isDropdownOpen, setIsDropdownOpen] = useState(false);
- const dropdownRef = useRef(null);
- const dropdownListRef = useRef(null);
-
- // 표시할 데이터 state (변경된 부분만 업데이트)
- const [displayData, setDisplayData] = useState({
- posterUrl: schedule.images?.[0] || null,
- title: schedule.title,
- date: schedule.date,
- time: schedule.time,
- locationName: schedule.location_name,
- locationAddress: schedule.location_address,
- locationLat: schedule.location_lat,
- locationLng: schedule.location_lng,
- description: schedule.description,
- sourceUrl: schedule.source_url,
- });
-
- // 선택된 회차 데이터 조회
- const { data: selectedSchedule } = useQuery({
- queryKey: ['schedule', selectedDateId],
- queryFn: () => getSchedule(selectedDateId),
- placeholderData: keepPreviousData,
- enabled: selectedDateId !== schedule.id,
- });
-
- // 데이터 비교 후 변경된 부분만 업데이트
- useEffect(() => {
- const newData = selectedDateId === schedule.id ? schedule : selectedSchedule;
- if (!newData) return;
-
- setDisplayData(prev => {
- const updates = {};
- const newPosterUrl = newData.images?.[0] || null;
-
- if (prev.posterUrl !== newPosterUrl) updates.posterUrl = newPosterUrl;
- if (prev.title !== newData.title) updates.title = newData.title;
- if (prev.date !== newData.date) updates.date = newData.date;
- if (prev.time !== newData.time) updates.time = newData.time;
- if (prev.locationName !== newData.location_name) updates.locationName = newData.location_name;
- if (prev.locationAddress !== newData.location_address) updates.locationAddress = newData.location_address;
- if (prev.locationLat !== newData.location_lat) updates.locationLat = newData.location_lat;
- if (prev.locationLng !== newData.location_lng) updates.locationLng = newData.location_lng;
- if (prev.description !== newData.description) updates.description = newData.description;
- if (prev.sourceUrl !== newData.source_url) updates.sourceUrl = newData.source_url;
-
- if (Object.keys(updates).length > 0) {
- return { ...prev, ...updates };
- }
- return prev;
- });
- }, [selectedDateId, schedule, selectedSchedule]);
-
- // 드롭다운 외부 클릭 감지
- useEffect(() => {
- const handleClickOutside = (event) => {
- if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
- setIsDropdownOpen(false);
- }
- };
- document.addEventListener('mousedown', handleClickOutside);
- return () => document.removeEventListener('mousedown', handleClickOutside);
- }, []);
-
- // 드롭다운 열릴 때 선택된 항목으로 자동 스크롤
- useEffect(() => {
- if (isDropdownOpen && dropdownListRef.current) {
- const selectedElement = dropdownListRef.current.querySelector('[data-selected="true"]');
- if (selectedElement) {
- // 약간의 지연 후 스크롤 (애니메이션 후)
- setTimeout(() => {
- selectedElement.scrollIntoView({ block: 'center', behavior: 'smooth' });
- }, 50);
- }
- }
- }, [isDropdownOpen]);
-
- const relatedDates = schedule.related_dates || [];
- const hasMultipleDates = relatedDates.length > 1;
- const hasLocation = displayData.locationLat && displayData.locationLng;
- const hasPoster = !!displayData.posterUrl;
- const hasDescription = !!displayData.description;
-
- // 현재 선택된 회차 인덱스
- const selectedIndex = relatedDates.findIndex(d => d.id === selectedDateId);
- const selectedDisplayIndex = selectedIndex >= 0 ? selectedIndex + 1 : 1;
-
- // 회차 선택 핸들러
- const handleSelectDate = (id) => {
- setSelectedDateId(id);
- setIsDropdownOpen(false);
- };
-
- // 짧은 날짜 포맷팅 (회차 목록용)
- const formatShortDate = (dateStr, timeStr) => {
- const date = new Date(dateStr);
- const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
- const month = date.getMonth() + 1;
- const day = date.getDate();
- const weekday = dayNames[date.getDay()];
-
- let result = `${month}/${day} (${weekday})`;
- if (timeStr) {
- result += ` ${timeStr.slice(0, 5)}`;
- }
- return result;
- };
-
- return (
-
- {/* ========== 히어로 섹션 ========== */}
-
- {/* 배경 레이어 - 포스터 확대 + 블러 */}
-
- {hasPoster ? (
- <>
-

-
- >
- ) : (
-
- )}
-
-
- {/* 메인 콘텐츠 */}
-
-
- {/* 포스터 */}
- {hasPoster && (
-
-
-
- )}
-
- {/* 제목 및 회차 선택 */}
-
- {/* 제목 */}
-
- {decodeHtmlEntities(displayData.title)}
-
-
- {/* 회차 선택 드롭다운 */}
- {hasMultipleDates && (
-
-
-
-
- {isDropdownOpen && (
-
- {/* 드롭다운 헤더 */}
-
- 공연 일정 선택
-
-
-
- {relatedDates.map((item, index) => {
- const isSelected = item.id === selectedDateId;
- return (
-
- );
- })}
-
-
- )}
-
-
- )}
-
-
-
-
-
- {/* ========== 장소 정보 카드 ========== */}
- {displayData.locationName && (
-
- {/* 헤더 */}
-
-
-
- {/* 장소 아이콘 */}
-
-
-
- {/* 텍스트 */}
-
-
{displayData.locationName}
- {displayData.locationAddress && (
-
{displayData.locationAddress}
- )}
-
-
-
- {/* 길찾기 버튼 - 카카오맵(국내) / 구글맵(해외) */}
-
-
- 길찾기
-
-
-
-
- {/* 지도 - 높이 2배 */}
-
- {hasLocation ? (
-
- ) : (
-
- )}
-
-
- )}
-
- {/* ========== 공연 정보 카드 ========== */}
- {hasDescription && (
-
-
-
- {decodeHtmlEntities(displayData.description)}
-
-
- )}
-
- {/* ========== 외부 링크 버튼 ========== */}
- {displayData.sourceUrl && (
-
-
-
- 티켓 예매 및 상세 정보
-
-
- )}
-
- );
-}
-
-// 기본 섹션 컴포넌트 (다른 카테고리용)
-function DefaultSection({ schedule }) {
- return (
-
- {/* 제목 */}
-
- {decodeHtmlEntities(schedule.title)}
-
-
- {/* 메타 정보 */}
-
-
-
- {formatFullDate(schedule.date)}
-
- {schedule.time && (
-
-
- {formatTime(schedule.time)}
-
- )}
-
-
- {/* 설명 */}
- {schedule.description && (
-
-
- {decodeHtmlEntities(schedule.description)}
-
-
- )}
-
- {/* 원본 링크 */}
- {schedule.source_url && (
-
-
- 원본 보기
-
- )}
-
- );
-}
+import { motion } from 'framer-motion';
+import { Calendar, ChevronRight } from 'lucide-react';
+import { getSchedule } from '../../../api/public/schedules';
+
+// 분리된 카테고리별 섹션 컴포넌트들
+import {
+ YoutubeSection,
+ XSection,
+ ConcertSection,
+ DefaultSection,
+ CATEGORY_ID,
+ decodeHtmlEntities,
+} from './schedule-sections';
function ScheduleDetail() {
const { id } = useParams();
- const { data: schedule, isLoading, error, isFetching } = useQuery({
+ const { data: schedule, isLoading, error } = useQuery({
queryKey: ['schedule', id],
queryFn: () => getSchedule(id),
placeholderData: keepPreviousData,
diff --git a/frontend/src/pages/pc/public/schedule-sections/ConcertSection.jsx b/frontend/src/pages/pc/public/schedule-sections/ConcertSection.jsx
new file mode 100644
index 0000000..46252af
--- /dev/null
+++ b/frontend/src/pages/pc/public/schedule-sections/ConcertSection.jsx
@@ -0,0 +1,389 @@
+import { useEffect, useRef, useState } from 'react';
+import { useQuery, keepPreviousData } from '@tanstack/react-query';
+import { motion, AnimatePresence } from 'framer-motion';
+import { ExternalLink, ChevronDown, Check, MapPin, Navigation } from 'lucide-react';
+import { getSchedule } from '../../../../api/public/schedules';
+import { decodeHtmlEntities } from './utils';
+import KakaoMap from './KakaoMap';
+
+// 콘서트 섹션 컴포넌트
+function ConcertSection({ schedule }) {
+ // 현재 선택된 회차 ID (내부 state로 관리 - URL 변경 없음)
+ const [selectedDateId, setSelectedDateId] = useState(schedule.id);
+ const [isDropdownOpen, setIsDropdownOpen] = useState(false);
+ const dropdownRef = useRef(null);
+ const dropdownListRef = useRef(null);
+
+ // 표시할 데이터 state (변경된 부분만 업데이트)
+ const [displayData, setDisplayData] = useState({
+ posterUrl: schedule.images?.[0] || null,
+ title: schedule.title,
+ date: schedule.date,
+ time: schedule.time,
+ locationName: schedule.location_name,
+ locationAddress: schedule.location_address,
+ locationLat: schedule.location_lat,
+ locationLng: schedule.location_lng,
+ description: schedule.description,
+ sourceUrl: schedule.source_url,
+ });
+
+ // 선택된 회차 데이터 조회
+ const { data: selectedSchedule } = useQuery({
+ queryKey: ['schedule', selectedDateId],
+ queryFn: () => getSchedule(selectedDateId),
+ placeholderData: keepPreviousData,
+ enabled: selectedDateId !== schedule.id,
+ });
+
+ // 데이터 비교 후 변경된 부분만 업데이트
+ useEffect(() => {
+ const newData = selectedDateId === schedule.id ? schedule : selectedSchedule;
+ if (!newData) return;
+
+ setDisplayData(prev => {
+ const updates = {};
+ const newPosterUrl = newData.images?.[0] || null;
+
+ if (prev.posterUrl !== newPosterUrl) updates.posterUrl = newPosterUrl;
+ if (prev.title !== newData.title) updates.title = newData.title;
+ if (prev.date !== newData.date) updates.date = newData.date;
+ if (prev.time !== newData.time) updates.time = newData.time;
+ if (prev.locationName !== newData.location_name) updates.locationName = newData.location_name;
+ if (prev.locationAddress !== newData.location_address) updates.locationAddress = newData.location_address;
+ if (prev.locationLat !== newData.location_lat) updates.locationLat = newData.location_lat;
+ if (prev.locationLng !== newData.location_lng) updates.locationLng = newData.location_lng;
+ if (prev.description !== newData.description) updates.description = newData.description;
+ if (prev.sourceUrl !== newData.source_url) updates.sourceUrl = newData.source_url;
+
+ if (Object.keys(updates).length > 0) {
+ return { ...prev, ...updates };
+ }
+ return prev;
+ });
+ }, [selectedDateId, schedule, selectedSchedule]);
+
+ // 드롭다운 외부 클릭 감지
+ useEffect(() => {
+ const handleClickOutside = (event) => {
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
+ setIsDropdownOpen(false);
+ }
+ };
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, []);
+
+ // 드롭다운 열릴 때 선택된 항목으로 자동 스크롤
+ useEffect(() => {
+ if (isDropdownOpen && dropdownListRef.current) {
+ const selectedElement = dropdownListRef.current.querySelector('[data-selected="true"]');
+ if (selectedElement) {
+ // 약간의 지연 후 스크롤 (애니메이션 후)
+ setTimeout(() => {
+ selectedElement.scrollIntoView({ block: 'center', behavior: 'smooth' });
+ }, 50);
+ }
+ }
+ }, [isDropdownOpen]);
+
+ const relatedDates = schedule.related_dates || [];
+ const hasMultipleDates = relatedDates.length > 1;
+ const hasLocation = displayData.locationLat && displayData.locationLng;
+ const hasPoster = !!displayData.posterUrl;
+ const hasDescription = !!displayData.description;
+
+ // 현재 선택된 회차 인덱스
+ const selectedIndex = relatedDates.findIndex(d => d.id === selectedDateId);
+ const selectedDisplayIndex = selectedIndex >= 0 ? selectedIndex + 1 : 1;
+
+ // 회차 선택 핸들러
+ const handleSelectDate = (id) => {
+ setSelectedDateId(id);
+ setIsDropdownOpen(false);
+ };
+
+ // 짧은 날짜 포맷팅 (회차 목록용)
+ const formatShortDate = (dateStr, timeStr) => {
+ const date = new Date(dateStr);
+ const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
+ const month = date.getMonth() + 1;
+ const day = date.getDate();
+ const weekday = dayNames[date.getDay()];
+
+ let result = `${month}/${day} (${weekday})`;
+ if (timeStr) {
+ result += ` ${timeStr.slice(0, 5)}`;
+ }
+ return result;
+ };
+
+ return (
+
+ {/* ========== 히어로 섹션 ========== */}
+
+ {/* 배경 레이어 - 포스터 확대 + 블러 */}
+
+ {hasPoster ? (
+ <>
+

+
+ >
+ ) : (
+
+ )}
+
+
+ {/* 메인 콘텐츠 */}
+
+
+ {/* 포스터 */}
+ {hasPoster && (
+
+
+
+ )}
+
+ {/* 제목 및 회차 선택 */}
+
+ {/* 제목 */}
+
+ {decodeHtmlEntities(displayData.title)}
+
+
+ {/* 회차 선택 드롭다운 */}
+ {hasMultipleDates && (
+
+
+
+
+ {isDropdownOpen && (
+
+ {/* 드롭다운 헤더 */}
+
+ 공연 일정 선택
+
+
+
+ {relatedDates.map((item, index) => {
+ const isSelected = item.id === selectedDateId;
+ return (
+
+ );
+ })}
+
+
+ )}
+
+
+ )}
+
+
+
+
+
+ {/* ========== 장소 정보 카드 ========== */}
+ {displayData.locationName && (
+
+ {/* 헤더 */}
+
+
+
+ {/* 장소 아이콘 */}
+
+
+
+ {/* 텍스트 */}
+
+
{displayData.locationName}
+ {displayData.locationAddress && (
+
{displayData.locationAddress}
+ )}
+
+
+
+ {/* 길찾기 버튼 - 카카오맵(국내) / 구글맵(해외) */}
+
+
+ 길찾기
+
+
+
+
+ {/* 지도 - 높이 2배 */}
+
+ {hasLocation ? (
+
+ ) : (
+
+ )}
+
+
+ )}
+
+ {/* ========== 공연 정보 카드 ========== */}
+ {hasDescription && (
+
+
+
+ {decodeHtmlEntities(displayData.description)}
+
+
+ )}
+
+ {/* ========== 외부 링크 버튼 ========== */}
+ {displayData.sourceUrl && (
+
+
+
+ 티켓 예매 및 상세 정보
+
+
+ )}
+
+ );
+}
+
+export default ConcertSection;
diff --git a/frontend/src/pages/pc/public/schedule-sections/DefaultSection.jsx b/frontend/src/pages/pc/public/schedule-sections/DefaultSection.jsx
new file mode 100644
index 0000000..293d8bf
--- /dev/null
+++ b/frontend/src/pages/pc/public/schedule-sections/DefaultSection.jsx
@@ -0,0 +1,52 @@
+import { Calendar, Clock, ExternalLink } from 'lucide-react';
+import { decodeHtmlEntities, formatFullDate, formatTime } from './utils';
+
+// 기본 섹션 컴포넌트 (다른 카테고리용)
+function DefaultSection({ schedule }) {
+ return (
+
+ {/* 제목 */}
+
+ {decodeHtmlEntities(schedule.title)}
+
+
+ {/* 메타 정보 */}
+
+
+
+ {formatFullDate(schedule.date)}
+
+ {schedule.time && (
+
+
+ {formatTime(schedule.time)}
+
+ )}
+
+
+ {/* 설명 */}
+ {schedule.description && (
+
+
+ {decodeHtmlEntities(schedule.description)}
+
+
+ )}
+
+ {/* 원본 링크 */}
+ {schedule.source_url && (
+
+
+ 원본 보기
+
+ )}
+
+ );
+}
+
+export default DefaultSection;
diff --git a/frontend/src/pages/pc/public/schedule-sections/KakaoMap.jsx b/frontend/src/pages/pc/public/schedule-sections/KakaoMap.jsx
new file mode 100644
index 0000000..5d196bb
--- /dev/null
+++ b/frontend/src/pages/pc/public/schedule-sections/KakaoMap.jsx
@@ -0,0 +1,88 @@
+import { useEffect, useRef, useState } from 'react';
+import { Navigation } from 'lucide-react';
+
+// 카카오맵 SDK 키
+const KAKAO_MAP_KEY = import.meta.env.VITE_KAKAO_JS_KEY;
+
+// 카카오맵 컴포넌트
+function KakaoMap({ lat, lng, name }) {
+ const mapRef = useRef(null);
+ const [mapLoaded, setMapLoaded] = useState(false);
+ const [mapError, setMapError] = useState(false);
+
+ useEffect(() => {
+ // API 키가 없으면 에러
+ if (!KAKAO_MAP_KEY) {
+ setMapError(true);
+ return;
+ }
+
+ // 카카오맵 SDK 동적 로드
+ if (!window.kakao?.maps) {
+ const script = document.createElement('script');
+ script.src = `//dapi.kakao.com/v2/maps/sdk.js?appkey=${KAKAO_MAP_KEY}&autoload=false`;
+ script.onload = () => {
+ window.kakao.maps.load(() => setMapLoaded(true));
+ };
+ script.onerror = () => setMapError(true);
+ document.head.appendChild(script);
+ } else {
+ setMapLoaded(true);
+ }
+ }, []);
+
+ useEffect(() => {
+ if (!mapLoaded || !mapRef.current || mapError) return;
+
+ try {
+ const position = new window.kakao.maps.LatLng(lat, lng);
+ const map = new window.kakao.maps.Map(mapRef.current, {
+ center: position,
+ level: 3,
+ });
+
+ // 마커 추가
+ const marker = new window.kakao.maps.Marker({
+ position,
+ map,
+ });
+
+ // 인포윈도우 추가
+ if (name) {
+ const infowindow = new window.kakao.maps.InfoWindow({
+ content: `${name}
`,
+ });
+ infowindow.open(map, marker);
+ }
+ } catch (e) {
+ setMapError(true);
+ }
+ }, [mapLoaded, lat, lng, name, mapError]);
+
+ // 에러 시 정적 이미지 또는 링크로 대체
+ if (mapError) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ );
+}
+
+export default KakaoMap;
diff --git a/frontend/src/pages/pc/public/schedule-sections/XSection.jsx b/frontend/src/pages/pc/public/schedule-sections/XSection.jsx
new file mode 100644
index 0000000..16c5cb8
--- /dev/null
+++ b/frontend/src/pages/pc/public/schedule-sections/XSection.jsx
@@ -0,0 +1,114 @@
+import { useQuery } from '@tanstack/react-query';
+import { motion } from 'framer-motion';
+import { getXProfile } from '../../../../api/public/schedules';
+import { decodeHtmlEntities, formatXDateTime } from './utils';
+
+// X URL에서 username 추출
+const extractXUsername = (url) => {
+ if (!url) return null;
+ const match = url.match(/(?:twitter\.com|x\.com)\/([^/]+)/);
+ return match ? match[1] : null;
+};
+
+// X(트위터) 섹션 컴포넌트
+function XSection({ schedule }) {
+ const username = extractXUsername(schedule.source_url);
+
+ // 프로필 정보 조회
+ const { data: profile } = useQuery({
+ queryKey: ['x-profile', username],
+ queryFn: () => getXProfile(username),
+ enabled: !!username,
+ staleTime: 1000 * 60 * 60, // 1시간
+ });
+
+ const displayName = profile?.displayName || schedule.source_name || username || 'Unknown';
+ const avatarUrl = profile?.avatarUrl;
+
+ return (
+
+ {/* X 스타일 카드 */}
+
+ {/* 헤더 */}
+
+
+ {/* 프로필 이미지 */}
+ {avatarUrl ? (
+

+ ) : (
+
+
+ {displayName.charAt(0).toUpperCase()}
+
+
+ )}
+
+
+
+ {displayName}
+
+
+
+ {username && (
+
@{username}
+ )}
+
+
+
+
+ {/* 본문 */}
+
+
+ {decodeHtmlEntities(schedule.description || schedule.title)}
+
+
+
+ {/* 이미지 */}
+ {schedule.image_url && (
+
+

+
+ )}
+
+ {/* 날짜/시간 */}
+
+
+ {formatXDateTime(schedule.date, schedule.time)}
+
+
+
+ {/* X에서 보기 버튼 */}
+
+
+
+ );
+}
+
+export default XSection;
diff --git a/frontend/src/pages/pc/public/schedule-sections/YoutubeSection.jsx b/frontend/src/pages/pc/public/schedule-sections/YoutubeSection.jsx
new file mode 100644
index 0000000..76aaf90
--- /dev/null
+++ b/frontend/src/pages/pc/public/schedule-sections/YoutubeSection.jsx
@@ -0,0 +1,173 @@
+import { motion } from 'framer-motion';
+import { Calendar, Clock, Link2 } from 'lucide-react';
+import { decodeHtmlEntities, formatFullDate, formatTime } from './utils';
+
+// 영상 정보 컴포넌트 (공통)
+function VideoInfo({ schedule, isShorts }) {
+ const members = schedule.members || [];
+ const isFullGroup = members.length === 5;
+
+ return (
+
+ {/* 제목 */}
+
+ {decodeHtmlEntities(schedule.title)}
+
+
+ {/* 메타 정보 */}
+
+ {/* 날짜 */}
+
+
+ {formatFullDate(schedule.date)}
+
+
+ {/* 시간 */}
+ {schedule.time && (
+ <>
+
+
+
+ {formatTime(schedule.time)}
+
+ >
+ )}
+
+ {/* 채널명 */}
+ {schedule.source_name && (
+ <>
+
+
+
+ {schedule.source_name}
+
+ >
+ )}
+
+
+ {/* 멤버 목록 */}
+ {members.length > 0 && (
+
+ {isFullGroup ? (
+
+ 프로미스나인
+
+ ) : (
+ members.map((member) => (
+
+ {member.name}
+
+ ))
+ )}
+
+ )}
+
+ {/* 유튜브에서 보기 버튼 */}
+
+
+ );
+}
+
+// 유튜브 비디오 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;
+};
+
+// 유튜브 섹션 컴포넌트
+function YoutubeSection({ schedule }) {
+ const videoId = extractYoutubeVideoId(schedule.source_url);
+ const isShorts = schedule.source_url?.includes('/shorts/');
+
+ if (!videoId) return null;
+
+ // 숏츠: 가로 레이아웃 (영상 + 정보)
+ if (isShorts) {
+ return (
+
+ {/* 영상 임베드 */}
+
+
+
+
+
+
+ {/* 영상 정보 카드 */}
+
+
+
+
+ );
+ }
+
+ // 일반 영상: 세로 레이아웃 (영상 위, 정보 아래)
+ return (
+
+ {/* 영상 임베드 */}
+
+
+
+
+
+
+ {/* 영상 정보 카드 */}
+
+
+
+
+ );
+}
+
+export default YoutubeSection;
diff --git a/frontend/src/pages/pc/public/schedule-sections/index.js b/frontend/src/pages/pc/public/schedule-sections/index.js
new file mode 100644
index 0000000..42ea1f5
--- /dev/null
+++ b/frontend/src/pages/pc/public/schedule-sections/index.js
@@ -0,0 +1,6 @@
+export { default as YoutubeSection } from "./YoutubeSection";
+export { default as XSection } from "./XSection";
+export { default as ConcertSection } from "./ConcertSection";
+export { default as DefaultSection } from "./DefaultSection";
+export { default as KakaoMap } from "./KakaoMap";
+export * from "./utils";
diff --git a/frontend/src/pages/pc/public/schedule-sections/utils.js b/frontend/src/pages/pc/public/schedule-sections/utils.js
new file mode 100644
index 0000000..75c1142
--- /dev/null
+++ b/frontend/src/pages/pc/public/schedule-sections/utils.js
@@ -0,0 +1,56 @@
+// HTML 엔티티 디코딩 함수
+export const decodeHtmlEntities = (text) => {
+ if (!text) return "";
+ const textarea = document.createElement("textarea");
+ textarea.innerHTML = text;
+ return textarea.value;
+};
+
+// 날짜 포맷팅
+export const formatFullDate = (dateStr) => {
+ if (!dateStr) return "";
+ const date = new Date(dateStr);
+ const dayNames = ["일", "월", "화", "수", "목", "금", "토"];
+ return `${date.getFullYear()}. ${date.getMonth() + 1}. ${date.getDate()}. (${
+ dayNames[date.getDay()]
+ })`;
+};
+
+// 시간 포맷팅
+export const formatTime = (timeStr) => {
+ if (!timeStr) return null;
+ return timeStr.slice(0, 5);
+};
+
+// X용 날짜/시간 포맷팅 (오후 2:30 · 2026년 1월 15일)
+export const formatXDateTime = (dateStr, timeStr) => {
+ if (!dateStr) return "";
+ const date = new Date(dateStr);
+ const year = date.getFullYear();
+ const month = date.getMonth() + 1;
+ const day = date.getDate();
+
+ let result = `${year}년 ${month}월 ${day}일`;
+
+ if (timeStr) {
+ const [hours, minutes] = timeStr.split(":").map(Number);
+ const period = hours < 12 ? "오전" : "오후";
+ const hour12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
+ result = `${period} ${hour12}:${String(minutes).padStart(
+ 2,
+ "0"
+ )} · ${result}`;
+ }
+
+ return result;
+};
+
+// 카테고리 ID 상수
+export const CATEGORY_ID = {
+ YOUTUBE: 2,
+ X: 3,
+ ALBUM: 4,
+ FANSIGN: 5,
+ CONCERT: 6,
+ TICKET: 7,
+};