import { useParams, Link } 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, Check, Link2, MapPin, Navigation, ExternalLink, X, ChevronRight } from 'lucide-react';
import Linkify from 'react-linkify';
import { getSchedule } from '../../../api/public/schedules';
import { formatXDateTime } from '../../../utils/date';
import '../../../mobile.css';
// 카카오맵 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(() => {
if (!KAKAO_MAP_KEY) {
setMapError(true);
return;
}
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 useFullscreenOrientation(isShorts) {
useEffect(() => {
// 숏츠는 세로 유지
if (isShorts) return;
const handleFullscreenChange = async () => {
const isFullscreen = !!document.fullscreenElement;
if (isFullscreen) {
// 전체화면 진입 시 가로 모드로 전환 시도
try {
if (screen.orientation && screen.orientation.lock) {
await screen.orientation.lock('landscape');
}
} catch (e) {
// 지원하지 않는 브라우저이거나 권한이 없는 경우 무시
}
} else {
// 전체화면 종료 시 세로 모드로 복귀
try {
if (screen.orientation && screen.orientation.unlock) {
screen.orientation.unlock();
}
} catch (e) {
// 무시
}
}
};
document.addEventListener('fullscreenchange', handleFullscreenChange);
document.addEventListener('webkitfullscreenchange', handleFullscreenChange);
return () => {
document.removeEventListener('fullscreenchange', handleFullscreenChange);
document.removeEventListener('webkitfullscreenchange', handleFullscreenChange);
};
}, [isShorts]);
}
// 카테고리 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 추출 (YoutubeSection용)
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 URL에서 username 추출
const extractXUsername = (url) => {
if (!url) return null;
const match = url.match(/(?:twitter\.com|x\.com)\/([^/]+)/);
return match ? match[1] : null;
};
// 유튜브 섹션 컴포넌트
function YoutubeSection({ schedule }) {
const videoId = extractYoutubeVideoId(schedule.source?.url);
const isShorts = schedule.source?.url?.includes('/shorts/');
// 전체화면 시 가로 회전 (숏츠 제외)
useFullscreenOrientation(isShorts);
const members = schedule.members || [];
const isFullGroup = members.length === 5;
if (!videoId) return null;
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}
))
)}
)}
{/* 유튜브에서 보기 버튼 */}
YouTube에서 보기
);
}
// X(트위터) 섹션 컴포넌트
function XSection({ schedule }) {
const profile = schedule.profile;
const username = profile?.username || 'realfromis_9';
const displayName = profile?.displayName || username;
const avatarUrl = profile?.avatarUrl;
// 라이트박스 상태
const [lightboxOpen, setLightboxOpen] = useState(false);
const [lightboxIndex, setLightboxIndex] = useState(0);
const openLightbox = (index) => {
setLightboxIndex(index);
setLightboxOpen(true);
window.history.pushState({ lightbox: true }, '');
};
const goToPrev = () => {
if (schedule.imageUrls?.length > 1) {
setLightboxIndex((lightboxIndex - 1 + schedule.imageUrls.length) % schedule.imageUrls.length);
}
};
const goToNext = () => {
if (schedule.imageUrls?.length > 1) {
setLightboxIndex((lightboxIndex + 1) % schedule.imageUrls.length);
}
};
// 라이트박스 열릴 때 body 스크롤 방지
useEffect(() => {
if (lightboxOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [lightboxOpen]);
// 뒤로가기 처리
useEffect(() => {
const handlePopState = () => {
if (lightboxOpen) {
setLightboxOpen(false);
}
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, [lightboxOpen]);
// 링크 데코레이터 (새 탭에서 열기)
const linkDecorator = (href, text, key) => (
{text}
);
return (
<>
{/* 헤더 */}
{/* 프로필 이미지 */}
{avatarUrl ? (

) : (
{displayName.charAt(0).toUpperCase()}
)}
{/* 본문 */}
{decodeHtmlEntities(schedule.content || schedule.title)}
{/* 이미지 */}
{schedule.imageUrls?.length > 0 && (
{schedule.imageUrls.length === 1 ? (

openLightbox(0)}
/>
) : (
{schedule.imageUrls.slice(0, 4).map((url, i) => (

openLightbox(i)}
/>
))}
)}
)}
{/* 날짜/시간 */}
{formatXDateTime(schedule.datetime)}
{/* X에서 보기 버튼 */}
{/* 모바일 라이트박스 */}
{lightboxOpen && schedule.imageUrls?.length > 0 && (
window.history.back()}
>
{/* 닫기 버튼 */}
{/* 이미지 */}
e.stopPropagation()}
/>
{/* 이전/다음 버튼 */}
{schedule.imageUrls.length > 1 && (
<>
>
)}
{/* 인디케이터 */}
{schedule.imageUrls.length > 1 && (
{schedule.imageUrls.map((_, i) => (
)}
)}
>
);
}
// 콘서트 섹션 컴포넌트
function ConcertSection({ schedule }) {
// 현재 선택된 회차 ID (내부 state로 관리 - URL 변경 없음)
const [selectedDateId, setSelectedDateId] = useState(schedule.id);
// 다이얼로그 열림 상태
const [isDialogOpen, setIsDialogOpen] = useState(false);
// 다이얼로그 목록 ref (자동 스크롤용)
const listRef = useRef(null);
const selectedItemRef = 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(() => {
if (isDialogOpen && selectedItemRef.current) {
setTimeout(() => {
selectedItemRef.current?.scrollIntoView({ block: 'center', behavior: 'instant' });
}, 50);
}
}, [isDialogOpen]);
const relatedDates = schedule.related_dates || [];
const hasMultipleDates = relatedDates.length > 1;
const hasLocation = displayData.locationLat && displayData.locationLng;
// 현재 선택된 회차 인덱스
const selectedIndex = relatedDates.findIndex(d => d.id === selectedDateId);
// 회차 선택 핸들러
const handleSelectDate = (id) => {
setSelectedDateId(id);
setIsDialogOpen(false);
};
// 개별 날짜 포맷팅
const formatSingleDate = (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 (
<>
{/* 히어로 헤더 */}
{/* 배경 블러 이미지 */}
{displayData.posterUrl ? (
) : (
)}
{/* 오버레이 그라디언트 */}
{/* 콘텐츠 */}
{/* 포스터 */}
{displayData.posterUrl && (
)}
{/* 제목 */}
{decodeHtmlEntities(displayData.title)}
{/* 카드 섹션 */}
{/* 공연 일정 카드 */}
공연 일정
{/* 현재 회차 표시 */}
{hasMultipleDates && {selectedIndex + 1}회차 ·}
{formatSingleDate(displayData.date, displayData.time)}
{/* 다른 회차 선택 버튼 */}
{hasMultipleDates && (
)}
{/* 장소 카드 */}
{displayData.locationName && (
장소
{displayData.locationName}
{displayData.locationAddress && (
{displayData.locationAddress}
)}
{/* 지도 - 좌표가 있으면 카카오맵, 없으면 구글맵 */}
{hasLocation ? (
) : (
)}
)}
{/* 설명 */}
{displayData.description && (
{decodeHtmlEntities(displayData.description)}
)}
{/* 버튼 영역 */}
{displayData.locationName && (
길찾기
)}
{displayData.sourceUrl && (
상세 정보
)}
{/* 회차 선택 다이얼로그 */}
{isDialogOpen && (
{/* 백드롭 */}
setIsDialogOpen(false)}
/>
{/* 다이얼로그 */}
{/* 헤더 */}
회차 선택
{/* 회차 목록 */}
{relatedDates.map((item, index) => {
const isSelected = item.id === selectedDateId;
return (
);
})}
{/* 닫기 버튼 */}
)}
>
);
}
// 기본 섹션 컴포넌트 (다른 카테고리용 - 임시)
function DefaultSection({ schedule }) {
return (
{decodeHtmlEntities(schedule.title)}
{formatFullDate(schedule.date)}
{schedule.time && (
{formatTime(schedule.time)}
)}
{schedule.description && (
{decodeHtmlEntities(schedule.description)}
)}
);
}
function MobileScheduleDetail() {
const { id } = useParams();
// 모바일 레이아웃 활성화
useEffect(() => {
document.documentElement.classList.add('mobile-layout');
return () => {
document.documentElement.classList.remove('mobile-layout');
};
}, []);
const { data: schedule, isLoading, error } = useQuery({
queryKey: ['schedule', id],
queryFn: () => getSchedule(id),
placeholderData: keepPreviousData,
retry: false,
});
// 이전 데이터가 없을 때만 로딩 스피너 표시
if (isLoading && !schedule) {
return (
);
}
if (error || !schedule) {
return (
{/* 아이콘 */}
{/* 메시지 */}
일정을 찾을 수 없습니다
요청하신 일정이 존재하지 않거나
삭제되었을 수 있습니다.
{/* 장식 요소 */}
{[...Array(5)].map((_, i) => (
))}
{/* 버튼들 */}
일정 목록
);
}
// 카테고리별 섹션 렌더링
const categoryId = schedule.category?.id;
const renderCategorySection = () => {
switch (categoryId) {
case CATEGORY_ID.YOUTUBE:
return ;
case CATEGORY_ID.X:
return ;
case CATEGORY_ID.CONCERT:
return ;
default:
return ;
}
};
return (
{/* 헤더 */}
{schedule.category?.name}
{/* 메인 컨텐츠 */}
{renderCategorySection()}
);
}
export default MobileScheduleDetail;