refactor: 모바일 스케줄 페이지 수정
This commit is contained in:
parent
28e48614ce
commit
9d6d4a1ad3
2 changed files with 309 additions and 139 deletions
|
|
@ -1043,27 +1043,27 @@ function TimelineScheduleCard({ schedule, categoryColor, categories, delay = 0,
|
|||
<div className="relative bg-white rounded-md shadow-[0_2px_12px_rgba(0,0,0,0.06)] border border-gray-100/50 overflow-hidden active:bg-gray-50 transition-colors">
|
||||
<div className="p-4">
|
||||
|
||||
{/* 시간 뱃지 */}
|
||||
{schedule.time && (
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
<div
|
||||
{/* 시간 및 카테고리 뱃지 */}
|
||||
<div className="flex items-center gap-1.5 mb-2">
|
||||
{schedule.time && (
|
||||
<div
|
||||
className="flex items-center gap-1 px-2 py-0.5 rounded-full text-white text-xs font-medium"
|
||||
style={{ backgroundColor: categoryColor }}
|
||||
>
|
||||
<Clock size={10} />
|
||||
{schedule.time.slice(0, 5)}
|
||||
</div>
|
||||
<span
|
||||
className="px-2 py-0.5 rounded-full text-xs font-medium"
|
||||
style={{
|
||||
backgroundColor: `${categoryColor}15`,
|
||||
color: categoryColor
|
||||
}}
|
||||
>
|
||||
{categoryName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
<span
|
||||
className="px-2 py-0.5 rounded-full text-xs font-medium"
|
||||
style={{
|
||||
backgroundColor: `${categoryColor}15`,
|
||||
color: categoryColor
|
||||
}}
|
||||
>
|
||||
{categoryName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 제목 */}
|
||||
<h3 className="font-bold text-[15px] text-gray-800 leading-snug">
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { useQuery, keepPreviousData } from '@tanstack/react-query';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Calendar, Clock, ChevronLeft, Link2, MapPin, Navigation, ExternalLink } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Calendar, Clock, ChevronLeft, Check, Link2, MapPin, Navigation, ExternalLink } from 'lucide-react';
|
||||
import { getSchedule, getXProfile } from '../../../api/public/schedules';
|
||||
import '../../../mobile.css';
|
||||
|
||||
|
|
@ -398,11 +398,86 @@ function XSection({ schedule }) {
|
|||
}
|
||||
|
||||
// 콘서트 섹션 컴포넌트
|
||||
function ConcertSection({ schedule, onDateChange }) {
|
||||
const hasLocation = schedule.location_lat && schedule.location_lng;
|
||||
const hasPoster = schedule.images?.length > 0;
|
||||
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) => {
|
||||
|
|
@ -420,133 +495,233 @@ function ConcertSection({ schedule, onDateChange }) {
|
|||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="bg-white rounded-xl overflow-hidden shadow-sm"
|
||||
>
|
||||
{/* 헤더: 포스터 썸네일 + 제목 */}
|
||||
<div className="bg-gradient-to-r from-primary to-primary/80 flex">
|
||||
{hasPoster && (
|
||||
<img
|
||||
src={schedule.images[0]}
|
||||
alt={schedule.title}
|
||||
className="w-16 h-20 object-cover flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-w-0 text-white p-4 flex items-center">
|
||||
<h1 className="font-bold text-base leading-snug line-clamp-2">
|
||||
{decodeHtmlEntities(schedule.title)}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 정보 목록 */}
|
||||
<div className="p-4 space-y-4">
|
||||
{/* 공연 일정 */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500 mb-2">
|
||||
<Calendar size={14} />
|
||||
<span className="font-medium">공연 일정</span>
|
||||
</div>
|
||||
{hasMultipleDates ? (
|
||||
<div className="space-y-2">
|
||||
{relatedDates.map((item, index) => {
|
||||
const isCurrentDate = item.id === schedule.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => onDateChange(item.id)}
|
||||
className={`block w-full px-3 py-2 rounded-lg text-sm text-left transition-all ${
|
||||
isCurrentDate
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-gray-50 active:bg-gray-100 text-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span className="opacity-60 mr-1.5">
|
||||
{index + 1}회차
|
||||
</span>
|
||||
{formatSingleDate(item.date, item.time)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<>
|
||||
<div className="-mx-4 -mt-4">
|
||||
{/* 히어로 헤더 */}
|
||||
<div className="relative overflow-hidden">
|
||||
{/* 배경 블러 이미지 */}
|
||||
{displayData.posterUrl ? (
|
||||
<div className="absolute inset-0 scale-110 overflow-hidden">
|
||||
<img
|
||||
src={displayData.posterUrl}
|
||||
alt=""
|
||||
className="w-full h-full object-cover blur-[24px]"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-900 font-bold text-sm">
|
||||
{formatSingleDate(schedule.date, schedule.time)}
|
||||
</p>
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary via-primary/90 to-primary/70" />
|
||||
)}
|
||||
{/* 오버레이 그라디언트 */}
|
||||
<div className="absolute inset-0 bg-gradient-to-b from-black/40 via-black/50 to-black/70" />
|
||||
|
||||
{/* 콘텐츠 */}
|
||||
<div className="relative px-5 pt-6 pb-8">
|
||||
<div className="flex flex-col items-center text-center">
|
||||
{/* 포스터 */}
|
||||
{displayData.posterUrl && (
|
||||
<div className="mb-4 rounded-xl overflow-hidden shadow-2xl ring-1 ring-white/20">
|
||||
<img
|
||||
src={displayData.posterUrl}
|
||||
alt={displayData.title}
|
||||
className="w-32 h-44 object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* 제목 */}
|
||||
<h1 className="text-white font-bold text-lg leading-snug drop-shadow-lg max-w-xs">
|
||||
{decodeHtmlEntities(displayData.title)}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 장소 */}
|
||||
{schedule.location_name && (
|
||||
<div>
|
||||
{/* 카드 섹션 */}
|
||||
<div className="px-4 pt-4 space-y-4">
|
||||
{/* 공연 일정 카드 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="bg-white rounded-xl p-4 shadow-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500 mb-3">
|
||||
<Calendar size={14} />
|
||||
<span>공연 일정</span>
|
||||
</div>
|
||||
{/* 현재 회차 표시 */}
|
||||
<div className="px-4 py-3 bg-primary/10 rounded-lg">
|
||||
<p className="text-primary font-medium text-sm">
|
||||
{hasMultipleDates && <span className="mr-1">{selectedIndex + 1}회차 ·</span>}
|
||||
{formatSingleDate(displayData.date, displayData.time)}
|
||||
</p>
|
||||
</div>
|
||||
{/* 다른 회차 선택 버튼 */}
|
||||
{hasMultipleDates && (
|
||||
<button
|
||||
onClick={() => setIsDialogOpen(true)}
|
||||
className="w-full mt-2 py-2.5 text-sm text-gray-500 font-medium active:bg-gray-50 rounded-lg transition-colors"
|
||||
>
|
||||
다른 회차 선택
|
||||
</button>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* 장소 카드 */}
|
||||
{displayData.locationName && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.15 }}
|
||||
className="bg-white rounded-xl p-4 shadow-sm"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500 mb-2">
|
||||
<MapPin size={14} />
|
||||
<span className="font-medium">장소</span>
|
||||
<span>장소</span>
|
||||
</div>
|
||||
<p className="text-gray-900 font-medium text-sm">{schedule.location_name}</p>
|
||||
{schedule.location_address && (
|
||||
<p className="text-gray-500 text-xs mt-0.5">{schedule.location_address}</p>
|
||||
<p className="text-gray-900 font-medium">{displayData.locationName}</p>
|
||||
{displayData.locationAddress && (
|
||||
<p className="text-gray-500 text-sm mt-0.5">{displayData.locationAddress}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 지도 */}
|
||||
{hasLocation && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500 mb-2">
|
||||
<Navigation size={14} />
|
||||
<span className="font-medium">위치</span>
|
||||
</div>
|
||||
<KakaoMap
|
||||
lat={parseFloat(schedule.location_lat)}
|
||||
lng={parseFloat(schedule.location_lng)}
|
||||
name={schedule.location_name}
|
||||
/>
|
||||
</div>
|
||||
{/* 지도 - 좌표가 있으면 카카오맵, 없으면 구글맵 */}
|
||||
{hasLocation ? (
|
||||
<div className="mt-3 rounded-xl overflow-hidden">
|
||||
<KakaoMap
|
||||
lat={parseFloat(displayData.locationLat)}
|
||||
lng={parseFloat(displayData.locationLng)}
|
||||
name={displayData.locationName}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 rounded-xl overflow-hidden">
|
||||
<iframe
|
||||
src={`https://maps.google.com/maps?q=${encodeURIComponent(displayData.locationAddress || displayData.locationName)}&output=embed&hl=ko`}
|
||||
className="w-full h-40 border-0"
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer-when-downgrade"
|
||||
title="Google Maps"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* 설명 */}
|
||||
{schedule.description && (
|
||||
<div className="pt-3 border-t border-gray-100">
|
||||
{displayData.description && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="bg-white rounded-xl p-4 shadow-sm"
|
||||
>
|
||||
<p className="text-gray-600 text-sm leading-relaxed">
|
||||
{decodeHtmlEntities(schedule.description)}
|
||||
{decodeHtmlEntities(displayData.description)}
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* 버튼 영역 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.25 }}
|
||||
className="space-y-2"
|
||||
>
|
||||
{displayData.locationName && (
|
||||
<a
|
||||
href={hasLocation
|
||||
? `https://map.kakao.com/link/to/${encodeURIComponent(displayData.locationName)},${displayData.locationLat},${displayData.locationLng}`
|
||||
: `https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(displayData.locationAddress || displayData.locationName)}`
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`flex items-center justify-center gap-2 w-full py-3.5 text-white rounded-xl font-medium transition-colors ${
|
||||
hasLocation
|
||||
? 'bg-blue-500 active:bg-blue-600'
|
||||
: 'bg-[#4285F4] active:bg-[#3367D6]'
|
||||
}`}
|
||||
>
|
||||
<Navigation size={18} />
|
||||
길찾기
|
||||
</a>
|
||||
)}
|
||||
{displayData.sourceUrl && (
|
||||
<a
|
||||
href={displayData.sourceUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-2 w-full py-3.5 bg-gray-100 active:bg-gray-200 text-gray-900 rounded-xl font-medium transition-colors"
|
||||
>
|
||||
<ExternalLink size={18} />
|
||||
상세 정보
|
||||
</a>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 회차 선택 다이얼로그 */}
|
||||
<AnimatePresence>
|
||||
{isDialogOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
{/* 백드롭 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="absolute inset-0 bg-black/50"
|
||||
onClick={() => setIsDialogOpen(false)}
|
||||
/>
|
||||
{/* 다이얼로그 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="relative bg-white rounded-2xl w-full max-w-sm overflow-hidden shadow-xl"
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="px-5 py-4 border-b border-gray-100">
|
||||
<h3 className="text-base font-bold text-gray-900">회차 선택</h3>
|
||||
</div>
|
||||
{/* 회차 목록 */}
|
||||
<div ref={listRef} className="max-h-72 overflow-y-auto">
|
||||
{relatedDates.map((item, index) => {
|
||||
const isSelected = item.id === selectedDateId;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
ref={isSelected ? selectedItemRef : null}
|
||||
onClick={() => handleSelectDate(item.id)}
|
||||
className={`w-full flex items-center justify-between px-5 py-3.5 text-sm transition-colors ${
|
||||
isSelected
|
||||
? 'bg-primary/10'
|
||||
: 'active:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<span className={isSelected ? 'text-primary font-medium' : 'text-gray-700'}>
|
||||
{index + 1}회차 · {formatSingleDate(item.date, item.time)}
|
||||
</span>
|
||||
{isSelected && <Check size={18} className="text-primary" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{/* 닫기 버튼 */}
|
||||
<div className="px-5 py-4 border-t border-gray-100">
|
||||
<button
|
||||
onClick={() => setIsDialogOpen(false)}
|
||||
className="w-full py-3 bg-gray-100 active:bg-gray-200 text-gray-700 rounded-xl font-medium transition-colors"
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 버튼 영역 */}
|
||||
<div className="p-4 pt-0 space-y-2">
|
||||
{/* 길찾기 버튼 */}
|
||||
{hasLocation && (
|
||||
<a
|
||||
href={`https://map.kakao.com/link/to/${encodeURIComponent(schedule.location_name)},${schedule.location_lat},${schedule.location_lng}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-2 w-full py-3 bg-blue-500 active:bg-blue-600 text-white rounded-xl font-medium transition-colors"
|
||||
>
|
||||
<Navigation size={18} />
|
||||
길찾기
|
||||
</a>
|
||||
)}
|
||||
|
||||
{/* 상세 정보 버튼 */}
|
||||
{schedule.source_url && (
|
||||
<a
|
||||
href={schedule.source_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-2 w-full py-3 bg-gray-900 active:bg-black text-white rounded-xl font-medium transition-colors"
|
||||
>
|
||||
<ExternalLink size={18} />
|
||||
상세 정보 보기
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -580,12 +755,6 @@ function DefaultSection({ schedule }) {
|
|||
|
||||
function MobileScheduleDetail() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// 회차 변경 핸들러
|
||||
const handleDateChange = (newId) => {
|
||||
navigate(`/schedule/${newId}`, { replace: true });
|
||||
};
|
||||
|
||||
// 모바일 레이아웃 활성화
|
||||
useEffect(() => {
|
||||
|
|
@ -602,7 +771,8 @@ function MobileScheduleDetail() {
|
|||
retry: false,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
// 이전 데이터가 없을 때만 로딩 스피너 표시
|
||||
if (isLoading && !schedule) {
|
||||
return (
|
||||
<div className="mobile-layout-container bg-gray-50">
|
||||
<div className="mobile-content flex items-center justify-center">
|
||||
|
|
@ -707,7 +877,7 @@ function MobileScheduleDetail() {
|
|||
case CATEGORY_ID.X:
|
||||
return <XSection schedule={schedule} />;
|
||||
case CATEGORY_ID.CONCERT:
|
||||
return <ConcertSection schedule={schedule} onDateChange={handleDateChange} />;
|
||||
return <ConcertSection schedule={schedule} />;
|
||||
default:
|
||||
return <DefaultSection schedule={schedule} />;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue