diff --git a/backend/routes/schedules.js b/backend/routes/schedules.js index 3261eef..e72e41b 100644 --- a/backend/routes/schedules.js +++ b/backend/routes/schedules.js @@ -140,7 +140,7 @@ router.get("/:id", async (req, res) => { const [schedules] = await pool.query( ` - SELECT + SELECT s.*, c.name as category_name, c.color as category_color @@ -155,7 +155,16 @@ router.get("/:id", async (req, res) => { return res.status(404).json({ error: "일정을 찾을 수 없습니다." }); } - res.json(schedules[0]); + // 이미지 조회 + const [images] = await pool.query( + `SELECT image_url FROM schedule_images WHERE schedule_id = ? ORDER BY sort_order ASC`, + [id] + ); + + const schedule = schedules[0]; + schedule.images = images.map((img) => img.image_url); + + res.json(schedule); } catch (error) { console.error("일정 조회 오류:", error); res.status(500).json({ error: "일정 조회 중 오류가 발생했습니다." }); diff --git a/frontend/src/pages/pc/public/Schedule.jsx b/frontend/src/pages/pc/public/Schedule.jsx index 483177f..ec6e53a 100644 --- a/frontend/src/pages/pc/public/Schedule.jsx +++ b/frontend/src/pages/pc/public/Schedule.jsx @@ -366,8 +366,8 @@ function Schedule() { // 일정 클릭 핸들러 const handleScheduleClick = (schedule) => { - // 유튜브(id=2), X(id=3) 카테고리는 상세 페이지로 이동 - if (schedule.category_id === 2 || schedule.category_id === 3) { + // 유튜브(id=2), X(id=3), 콘서트(id=6) 카테고리는 상세 페이지로 이동 + if (schedule.category_id === 2 || schedule.category_id === 3 || schedule.category_id === 6) { navigate(`/schedule/${schedule.id}`); return; } diff --git a/frontend/src/pages/pc/public/ScheduleDetail.jsx b/frontend/src/pages/pc/public/ScheduleDetail.jsx index 5e0dfed..11396cb 100644 --- a/frontend/src/pages/pc/public/ScheduleDetail.jsx +++ b/frontend/src/pages/pc/public/ScheduleDetail.jsx @@ -1,9 +1,13 @@ import { useParams, Link } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; +import { useEffect, useRef, useState } from 'react'; import { motion } from 'framer-motion'; -import { Clock, Calendar, ExternalLink, ChevronRight, Link2 } from 'lucide-react'; +import { Clock, Calendar, ExternalLink, ChevronRight, Link2, MapPin, Navigation } from 'lucide-react'; import { getSchedule, getXProfile } from '../../../api/public/schedules'; +// 카카오맵 SDK 키 +const KAKAO_MAP_KEY = import.meta.env.VITE_KAKAO_MAP_KEY; + // 카테고리 ID 상수 const CATEGORY_ID = { YOUTUBE: 2, @@ -309,6 +313,195 @@ function XSection({ schedule }) { ); } +// 카카오맵 컴포넌트 +function KakaoMap({ lat, lng, name }) { + const mapRef = useRef(null); + const [mapLoaded, setMapLoaded] = useState(false); + + useEffect(() => { + // 카카오맵 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)); + }; + document.head.appendChild(script); + } else { + setMapLoaded(true); + } + }, []); + + useEffect(() => { + if (!mapLoaded || !mapRef.current) return; + + 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); + } + }, [mapLoaded, lat, lng, name]); + + return ( +
+ ); +} + +// 콘서트 섹션 컴포넌트 +function ConcertSection({ schedule }) { + const hasLocation = schedule.location_lat && schedule.location_lng; + + // 날짜 범위 포맷팅 + const formatDateRange = () => { + const start = formatFullDate(schedule.date); + if (schedule.end_date && schedule.end_date !== schedule.date) { + const end = formatFullDate(schedule.end_date); + return `${start} ~ ${end}`; + } + return start; + }; + + return ( +
+ {/* 포스터 이미지 */} + {schedule.images?.length > 0 && ( + + {schedule.title} + + )} + + {/* 콘서트 정보 카드 */} + + {/* 제목 */} +
+

+ {decodeHtmlEntities(schedule.title)} +

+
+ + {/* 정보 목록 */} +
+ {/* 날짜 */} +
+ +
+

일시

+

{formatDateRange()}

+ {schedule.time && ( +

{formatTime(schedule.time)} 시작

+ )} +
+
+ + {/* 장소 */} + {schedule.location_name && ( +
+ +
+

장소

+

{schedule.location_name}

+ {schedule.location_address && ( +

{schedule.location_address}

+ )} + {schedule.location_detail && ( +

{schedule.location_detail}

+ )} +
+
+ )} + + {/* 설명 */} + {schedule.description && ( +
+

+ {decodeHtmlEntities(schedule.description)} +

+
+ )} +
+ + {/* 카카오맵 또는 장소 정보 */} + {schedule.location_name && ( +
+ {hasLocation ? ( +
+ + + + 카카오맵에서 길찾기 + +
+ ) : ( +
+ +

{schedule.location_name}

+ {schedule.location_address && ( +

{schedule.location_address}

+ )} +
+ )} +
+ )} + + {/* 원본 링크 */} + {schedule.source_url && ( +
+ + + 상세 정보 보기 + +
+ )} +
+
+ ); +} + // 기본 섹션 컴포넌트 (다른 카테고리용) function DefaultSection({ schedule }) { return ( @@ -397,6 +590,8 @@ function ScheduleDetail() { return ; case CATEGORY_ID.X: return ; + case CATEGORY_ID.CONCERT: + return ; default: return ; } @@ -404,7 +599,8 @@ function ScheduleDetail() { const isYoutube = schedule.category_id === CATEGORY_ID.YOUTUBE; const isX = schedule.category_id === CATEGORY_ID.X; - const hasCustomLayout = isYoutube || isX; + const isConcert = schedule.category_id === CATEGORY_ID.CONCERT; + const hasCustomLayout = isYoutube || isX || isConcert; return (