From e4859471baaae1c811088edc137753f4e6df1e0c Mon Sep 17 00:00:00 2001 From: caadiq Date: Thu, 15 Jan 2026 20:57:55 +0900 Subject: [PATCH] =?UTF-8?q?=EB=AA=A8=EB=B0=94=EC=9D=BC=20=EC=9D=BC?= =?UTF-8?q?=EC=A0=95=20=EC=83=81=EC=84=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(=EC=9C=A0=ED=8A=9C=EB=B8=8C=20=EC=84=B9?= =?UTF-8?q?=EC=85=98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 모바일 일정 상세 페이지 기본 구조 구현 - 유튜브 섹션: 일반 영상/숏츠 구분 렌더링 - 전체화면 시 가로 회전 (숏츠 제외) - 일정 목록에서 상세 페이지로 이동 클릭 이벤트 추가 - mobile-layout 클래스 시스템으로 스크롤바 숨김 처리 - zustand store로 날짜 상태 유지 Co-Authored-By: Claude Opus 4.5 --- frontend/src/pages/mobile/public/Schedule.jsx | 50 +++- .../pages/mobile/public/ScheduleDetail.jsx | 281 +++++++++++++++++- 2 files changed, 305 insertions(+), 26 deletions(-) diff --git a/frontend/src/pages/mobile/public/Schedule.jsx b/frontend/src/pages/mobile/public/Schedule.jsx index 3b606ce..9096c9f 100644 --- a/frontend/src/pages/mobile/public/Schedule.jsx +++ b/frontend/src/pages/mobile/public/Schedule.jsx @@ -8,6 +8,7 @@ import { useVirtualizer } from '@tanstack/react-virtual'; import confetti from 'canvas-confetti'; import { getTodayKST } from '../../../utils/date'; import { getSchedules, getCategories, searchSchedules } from '../../../api/public/schedules'; +import useScheduleStore from '../../../stores/useScheduleStore'; // 폭죽 애니메이션 함수 const fireBirthdayConfetti = () => { @@ -121,7 +122,17 @@ function MobileBirthdayCard({ schedule, onClick, delay = 0 }) { // 모바일 일정 페이지 function MobileSchedule() { const navigate = useNavigate(); - const [selectedDate, setSelectedDate] = useState(new Date()); + + // zustand store에서 상태 가져오기 + const { + selectedDate: storedSelectedDate, + setSelectedDate: setStoredSelectedDate, + } = useScheduleStore(); + + // 선택된 날짜 (store에 없으면 오늘 날짜) + const selectedDate = storedSelectedDate || new Date(); + const setSelectedDate = (date) => setStoredSelectedDate(date); + const [isSearchMode, setIsSearchMode] = useState(false); const [searchInput, setSearchInput] = useState(''); // 입력값 const [searchTerm, setSearchTerm] = useState(''); // 실제 검색어 @@ -131,7 +142,7 @@ function MobileSchedule() { const contentRef = useRef(null); // 스크롤 초기화용 const searchContainerRef = useRef(null); // 검색 컨테이너 (외부 클릭 감지용) const searchInputRef = useRef(null); // 검색 입력 필드 (키패드 닫기용) - + // 검색 추천 관련 상태 const [showSuggestions, setShowSuggestions] = useState(false); const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1); @@ -442,19 +453,20 @@ function MobileSchedule() { // 날짜 선택 컨테이너 ref const dateScrollRef = useRef(null); - // 선택된 날짜로 자동 스크롤 + 페이지 스크롤 초기화 + // 선택된 날짜로 자동 스크롤 useEffect(() => { - // 페이지 스크롤을 맨 위로 즉시 이동 - window.scrollTo(0, 0); - - if (dateScrollRef.current) { + // 검색 모드가 아닐 때만 스크롤 조정 + if (!isSearchMode && dateScrollRef.current) { const selectedDay = selectedDate.getDate(); const buttons = dateScrollRef.current.querySelectorAll('button'); if (buttons[selectedDay - 1]) { - buttons[selectedDay - 1].scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' }); + // 약간의 지연 후 스크롤 (DOM 렌더링 완료 후) + setTimeout(() => { + buttons[selectedDay - 1].scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' }); + }, 50); } } - }, [selectedDate]); + }, [selectedDate, isSearchMode]); return ( <> @@ -823,10 +835,11 @@ function MobileSchedule() { }} >
- navigate(`/schedule/${schedule.id}`)} />
@@ -879,6 +892,7 @@ function MobileSchedule() { categoryColor={getCategoryColor(schedule.category_id)} categories={categories} delay={index * 0.05} + onClick={() => navigate(`/schedule/${schedule.id}`)} /> ); })} @@ -891,11 +905,11 @@ function MobileSchedule() { } // 일정 카드 컴포넌트 (검색용) - 날짜 포함 모던 디자인 -function ScheduleCard({ schedule, categoryColor, categories, delay = 0 }) { +function ScheduleCard({ schedule, categoryColor, categories, delay = 0, onClick }) { const categoryName = categories.find(c => c.id === schedule.category_id)?.name || '미분류'; const memberNames = schedule.member_names || schedule.members?.map(m => m.name).join(',') || ''; const memberList = memberNames.split(',').filter(name => name.trim()); - + // 날짜 파싱 const parseDate = (dateStr) => { if (!dateStr) return null; @@ -909,7 +923,7 @@ function ScheduleCard({ schedule, categoryColor, categories, delay = 0 }) { const isSunday = date.getDay() === 0; return { year, month, day, weekday, isWeekend, isSunday }; }; - + const dateInfo = parseDate(schedule.date); return ( @@ -917,9 +931,11 @@ function ScheduleCard({ schedule, categoryColor, categories, delay = 0 }) { initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay, type: "spring", stiffness: 300, damping: 30 }} + onClick={onClick} + className="cursor-pointer" > {/* 카드 본체 */} -
+
{/* 왼쪽 날짜 영역 */} {dateInfo && ( @@ -999,7 +1015,7 @@ function ScheduleCard({ schedule, categoryColor, categories, delay = 0 }) { } // 타임라인용 일정 카드 컴포넌트 - 모던 디자인 -function TimelineScheduleCard({ schedule, categoryColor, categories, delay = 0 }) { +function TimelineScheduleCard({ schedule, categoryColor, categories, delay = 0, onClick }) { const categoryName = categories.find(c => c.id === schedule.category_id)?.name || '미분류'; const memberNames = schedule.member_names || schedule.members?.map(m => m.name).join(',') || ''; const memberList = memberNames.split(',').filter(name => name.trim()); @@ -1009,9 +1025,11 @@ function TimelineScheduleCard({ schedule, categoryColor, categories, delay = 0 } initial={{ opacity: 0, x: -10 }} animate={{ opacity: 1, x: 0 }} transition={{ delay, type: "spring", stiffness: 300, damping: 30 }} + onClick={onClick} + className="cursor-pointer" > {/* 카드 본체 */} -
+
{/* 시간 뱃지 */} diff --git a/frontend/src/pages/mobile/public/ScheduleDetail.jsx b/frontend/src/pages/mobile/public/ScheduleDetail.jsx index eeb4287..1978462 100644 --- a/frontend/src/pages/mobile/public/ScheduleDetail.jsx +++ b/frontend/src/pages/mobile/public/ScheduleDetail.jsx @@ -1,12 +1,238 @@ import { useParams, Link } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; +import { useEffect } from 'react'; import { motion } from 'framer-motion'; -import { Calendar, ChevronLeft } from 'lucide-react'; -import { getSchedule } from '../../../api/public/schedules'; +import { Calendar, Clock, ChevronLeft, Link2 } from 'lucide-react'; +import { getSchedule, getXProfile } from '../../../api/public/schedules'; +import '../../../mobile.css'; + +// 전체화면 시 자동 가로 회전 훅 (숏츠가 아닐 때만) +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 추출 +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 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 ( +
+ {/* 영상 임베드 */} + +
+