+ {/* 브레드크럼 네비게이션 */}
+
+
+ /
+ {album?.title}
+
+
+ {/* 앨범 정보 헤더 */}
+
+ {/* 앨범 커버 */}
+
openLightbox([album.cover_original_url || album.cover_medium_url], 0)}
+ >
+
+
+
+ {/* 앨범 정보 */}
+
+
+
+
+ {album.album_type}
+
+ {/* 메뉴 버튼 */}
+ {album.description && (
+
+
+
+ {showMenu && (
+ <>
+ setShowMenu(false)} />
+
+
+
+ >
+ )}
+
+
+ )}
+
+
{album.title}
+
+
+
+
+ {formatDate(album.release_date, 'YYYY.MM.DD')}
+
+
+
+ {album.tracks?.length || 0}곡
+
+
+
+ {getTotalDuration()}
+
+
+
+
+ {/* 앨범 티저 이미지/영상 */}
+ {album.teasers && album.teasers.length > 0 && (
+
+
티저 이미지
+
+ {album.teasers.map((teaser, index) => (
+
+ openLightbox(
+ album.teasers.map((t) =>
+ t.media_type === 'video' ? t.video_url || t.original_url : t.original_url
+ ),
+ index,
+ { teasers: album.teasers }
+ )
+ }
+ className="w-24 h-24 bg-gray-200 rounded-lg overflow-hidden cursor-pointer transition-all duration-300 ease-out hover:scale-105 hover:shadow-xl hover:z-10 relative"
+ >
+

+ {teaser.media_type === 'video' && (
+
+ )}
+
+ ))}
+
+
+ )}
+
+
+
+ {/* 수록곡 리스트 */}
+
+ 수록곡
+
+ {album.tracks?.map((track, index) => (
+
+ navigate(`/album/${encodeURIComponent(album.title)}/track/${encodeURIComponent(track.title)}`)
+ }
+ className={`group flex items-center gap-4 p-4 hover:bg-primary/5 transition-all duration-200 cursor-pointer ${
+ index !== album.tracks.length - 1 ? 'border-b border-gray-100' : ''
+ }`}
+ >
+ {/* 트랙 번호 */}
+
+
+ {String(track.track_number).padStart(2, '0')}
+
+
+
+ {/* 트랙 정보 */}
+
+
+
{track.title}
+ {track.is_title_track === 1 && (
+
+ TITLE
+
+ )}
+
+
+
+ {/* 재생 시간 */}
+
{track.duration || '-'}
+
+ ))}
+
+
+
+ {/* 컨셉 포토 섹션 */}
+ {album.conceptPhotos && Object.keys(album.conceptPhotos).length > 0 && (
+
+ {(() => {
+ const allPhotos = Object.values(album.conceptPhotos).flat();
+ const previewPhotos = allPhotos.slice(0, 4);
+ const totalCount = allPhotos.length;
+
+ return (
+ <>
+
+
컨셉 포토
+
+
+
+ {previewPhotos.map((photo, idx) => (
+
openLightbox([photo.original_url], 0)}
+ className="aspect-square bg-gray-200 rounded-xl overflow-hidden cursor-pointer transition-all duration-300 ease-out hover:scale-[1.03] hover:shadow-xl hover:z-10"
+ >
+

+
+ ))}
+
+ >
+ );
+ })()}
+
+ )}
+
+
+
+ {/* 라이트박스 모달 */}
+
+ {lightbox.open && (
+
+
+ {/* 상단 버튼들 */}
+
+
+
+
+
+ {/* 이전 버튼 */}
+ {lightbox.images.length > 1 && (
+
+ )}
+
+ {/* 로딩 스피너 */}
+ {!imageLoaded && (
+
+ )}
+
+ {/* 이미지 또는 비디오 */}
+
+ {lightbox.teasers?.[lightbox.index]?.media_type === 'video' ? (
+ e.stopPropagation()}
+ onCanPlay={() => setImageLoaded(true)}
+ initial={{ x: slideDirection * 100 }}
+ animate={{ x: 0 }}
+ transition={{ duration: 0.25, ease: 'easeOut' }}
+ controls
+ autoPlay
+ />
+ ) : (
+ e.stopPropagation()}
+ onLoad={() => setImageLoaded(true)}
+ initial={{ x: slideDirection * 100 }}
+ animate={{ x: 0 }}
+ transition={{ duration: 0.25, ease: 'easeOut' }}
+ />
+ )}
+
+
+ {/* 다음 버튼 */}
+ {lightbox.images.length > 1 && (
+
+ )}
+
+ {/* 인디케이터 */}
+ {lightbox.images.length > 1 && (
+
setLightbox((prev) => ({ ...prev, index: i }))}
+ />
+ )}
+
+
+ )}
+
+
+ {/* 앨범 소개 다이얼로그 */}
+
+ {showDescriptionModal && album?.description && (
+ window.history.back()}
+ >
+ e.stopPropagation()}
+ >
+ {/* 헤더 */}
+
+
앨범 소개
+
+
+ {/* 내용 */}
+
+
+
+ )}
+
+ >
+ );
+}
+
+export default PCAlbumDetail;
diff --git a/frontend-temp/src/pages/album/pc/AlbumGallery.jsx b/frontend-temp/src/pages/album/pc/AlbumGallery.jsx
new file mode 100644
index 0000000..b56f729
--- /dev/null
+++ b/frontend-temp/src/pages/album/pc/AlbumGallery.jsx
@@ -0,0 +1,372 @@
+import { useState, useEffect, useCallback, useMemo } from 'react';
+import { useParams, useNavigate } from 'react-router-dom';
+import { useQuery } from '@tanstack/react-query';
+import { motion, AnimatePresence } from 'framer-motion';
+import { X, ChevronLeft, ChevronRight, Download } from 'lucide-react';
+import { RowsPhotoAlbum } from 'react-photo-album';
+import 'react-photo-album/rows.css';
+import { getAlbumByName } from '@/api/albums';
+import { LightboxIndicator } from '@/components/common';
+
+// 갤러리 CSS 스타일
+const galleryStyles = `
+@keyframes fadeInUp {
+ from {
+ opacity: 0;
+ transform: translateY(20px) scale(0.95);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
+}
+.react-photo-album {
+ overflow: visible !important;
+}
+.react-photo-album--row {
+ overflow: visible !important;
+}
+.react-photo-album--photo {
+ transition: transform 0.3s ease, filter 0.3s ease !important;
+ cursor: pointer;
+ overflow: visible !important;
+}
+.react-photo-album--photo:hover {
+ transform: scale(1.05);
+ filter: brightness(0.9);
+ z-index: 10;
+}
+`;
+
+/**
+ * PC 앨범 갤러리 페이지
+ */
+function PCAlbumGallery() {
+ const { name } = useParams();
+ const navigate = useNavigate();
+ const [lightbox, setLightbox] = useState({ open: false, index: 0 });
+ const [imageLoaded, setImageLoaded] = useState(false);
+ const [slideDirection, setSlideDirection] = useState(0);
+ const [preloadedImages] = useState(() => new Set());
+
+ // 앨범 데이터 로드
+ const { data: album, isLoading: loading } = useQuery({
+ queryKey: ['album', name],
+ queryFn: () => getAlbumByName(name),
+ enabled: !!name,
+ });
+
+ // 앨범 데이터에서 사진 목록 추출
+ const photos = useMemo(() => {
+ if (!album?.conceptPhotos) return [];
+ const allPhotos = [];
+ Object.entries(album.conceptPhotos).forEach(([concept, conceptPhotos]) => {
+ conceptPhotos.forEach((p) =>
+ allPhotos.push({
+ mediumUrl: p.medium_url,
+ originalUrl: p.original_url,
+ width: p.width || 800,
+ height: p.height || 1200,
+ title: concept,
+ members: p.members ? p.members.split(', ') : [],
+ })
+ );
+ });
+ return allPhotos;
+ }, [album]);
+
+ // 라이트박스 열기
+ const openLightbox = useCallback((index) => {
+ setImageLoaded(false);
+ setLightbox({ open: true, index });
+ window.history.pushState({ lightbox: true }, '');
+ }, []);
+
+ // 뒤로가기 처리
+ useEffect(() => {
+ const handlePopState = () => {
+ if (lightbox.open) {
+ setLightbox((prev) => ({ ...prev, open: false }));
+ }
+ };
+
+ window.addEventListener('popstate', handlePopState);
+ return () => window.removeEventListener('popstate', handlePopState);
+ }, [lightbox.open]);
+
+ // 라이트박스 열릴 때 body 스크롤 숨기기
+ useEffect(() => {
+ if (lightbox.open) {
+ document.documentElement.style.overflow = 'hidden';
+ document.body.style.overflow = 'hidden';
+ } else {
+ document.documentElement.style.overflow = '';
+ document.body.style.overflow = '';
+ }
+ return () => {
+ document.documentElement.style.overflow = '';
+ document.body.style.overflow = '';
+ };
+ }, [lightbox.open]);
+
+ // 이전/다음 이미지
+ const goToPrev = useCallback(() => {
+ if (photos.length <= 1) return;
+ setImageLoaded(false);
+ setSlideDirection(-1);
+ setLightbox((prev) => ({
+ ...prev,
+ index: (prev.index - 1 + photos.length) % photos.length,
+ }));
+ }, [photos.length]);
+
+ const goToNext = useCallback(() => {
+ if (photos.length <= 1) return;
+ setImageLoaded(false);
+ setSlideDirection(1);
+ setLightbox((prev) => ({
+ ...prev,
+ index: (prev.index + 1) % photos.length,
+ }));
+ }, [photos.length]);
+
+ // 다운로드
+ const downloadImage = useCallback(async () => {
+ const photo = photos[lightbox.index];
+ if (!photo) return;
+
+ try {
+ const response = await fetch(photo.originalUrl);
+ const blob = await response.blob();
+ const url = window.URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = `fromis9_${album?.title || 'photo'}_${String(lightbox.index + 1).padStart(2, '0')}.webp`;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ window.URL.revokeObjectURL(url);
+ } catch (error) {
+ console.error('다운로드 오류:', error);
+ }
+ }, [photos, lightbox.index, album?.title]);
+
+ // 키보드 이벤트
+ useEffect(() => {
+ if (!lightbox.open) return;
+
+ const handleKeyDown = (e) => {
+ switch (e.key) {
+ case 'ArrowLeft':
+ goToPrev();
+ break;
+ case 'ArrowRight':
+ goToNext();
+ break;
+ case 'Escape':
+ window.history.back();
+ break;
+ default:
+ break;
+ }
+ };
+
+ window.addEventListener('keydown', handleKeyDown);
+ return () => window.removeEventListener('keydown', handleKeyDown);
+ }, [lightbox.open, goToPrev, goToNext]);
+
+ // 프리로딩
+ useEffect(() => {
+ if (!lightbox.open || photos.length <= 1) return;
+
+ const indicesToPreload = [];
+ for (let offset = -2; offset <= 2; offset++) {
+ if (offset === 0) continue;
+ const idx = (lightbox.index + offset + photos.length) % photos.length;
+ indicesToPreload.push(idx);
+ }
+
+ indicesToPreload.forEach((idx) => {
+ const url = photos[idx].originalUrl;
+ if (preloadedImages.has(url)) return;
+
+ const img = new Image();
+ img.onload = () => preloadedImages.add(url);
+ img.src = url;
+ });
+ }, [lightbox.open, lightbox.index, photos, preloadedImages]);
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+ <>
+
+
+ {/* 브레드크럼 스타일 헤더 */}
+
+
+
+ /
+
+ /
+ 컨셉 포토
+
+
컨셉 포토
+
{photos.length}장의 사진
+
+
+ {/* CSS 스타일 주입 */}
+
+
+ {/* Justified 갤러리 */}
+
({
+ src: photo.mediumUrl,
+ width: photo.width || 800,
+ height: photo.height || 1200,
+ key: idx.toString(),
+ }))}
+ targetRowHeight={280}
+ spacing={16}
+ rowConstraints={{ singleRowMaxHeight: 400, minPhotos: 1 }}
+ onClick={({ index }) => openLightbox(index)}
+ componentsProps={{
+ image: {
+ loading: 'lazy',
+ style: { borderRadius: '12px' },
+ },
+ }}
+ />
+
+
+
+ {/* 라이트박스 */}
+
+ {lightbox.open && (
+
+
+ {/* 상단 버튼들 */}
+
+
+
+
+
+ {/* 카운터 */}
+
+ {lightbox.index + 1} / {photos.length}
+
+
+ {/* 이전 버튼 */}
+ {photos.length > 1 && (
+
+ )}
+
+ {/* 로딩 스피너 */}
+ {!imageLoaded && (
+
+ )}
+
+ {/* 이미지 + 컨셉 정보 */}
+
+
setImageLoaded(true)}
+ initial={{ x: slideDirection * 100 }}
+ animate={{ x: 0 }}
+ transition={{ duration: 0.25, ease: 'easeOut' }}
+ />
+ {/* 컨셉 정보 + 멤버 */}
+ {imageLoaded &&
+ (() => {
+ const title = photos[lightbox.index]?.title;
+ const hasValidTitle = title && title.trim() && title !== 'Default';
+ const members = photos[lightbox.index]?.members;
+ const hasMembers = members && String(members).trim();
+
+ if (!hasValidTitle && !hasMembers) return null;
+
+ return (
+
+ {hasValidTitle && (
+
+ {title}
+
+ )}
+ {hasMembers && (
+
+ {String(members)
+ .split(',')
+ .map((member, idx) => (
+
+ {member.trim()}
+
+ ))}
+
+ )}
+
+ );
+ })()}
+
+
+ {/* 다음 버튼 */}
+ {photos.length > 1 && (
+
+ )}
+
+ {/* 하단 점 인디케이터 */}
+
setLightbox((prev) => ({ ...prev, index: i }))}
+ />
+
+
+ )}
+
+ >
+ );
+}
+
+export default PCAlbumGallery;
diff --git a/frontend-temp/src/pages/album/pc/TrackDetail.jsx b/frontend-temp/src/pages/album/pc/TrackDetail.jsx
new file mode 100644
index 0000000..b8b2be4
--- /dev/null
+++ b/frontend-temp/src/pages/album/pc/TrackDetail.jsx
@@ -0,0 +1,308 @@
+import { useMemo } from 'react';
+import { useParams, useNavigate, Link } from 'react-router-dom';
+import { useQuery } from '@tanstack/react-query';
+import { motion } from 'framer-motion';
+import { Clock, User, Music, Mic2, ChevronRight } from 'lucide-react';
+import { getTrack } from '@/api/albums';
+
+/**
+ * 유튜브 URL에서 비디오 ID 추출
+ */
+function getYoutubeVideoId(url) {
+ if (!url) return null;
+ const patterns = [/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/];
+ for (const pattern of patterns) {
+ const match = url.match(pattern);
+ if (match) return match[1];
+ }
+ return null;
+}
+
+/**
+ * 쉼표 기준 줄바꿈 처리
+ */
+function formatCredit(text) {
+ if (!text) return null;
+ return text.split(',').map((item, index) => (
+
+ {item.trim()}
+
+ ));
+}
+
+/**
+ * PC 곡 상세 페이지
+ */
+function PCTrackDetail() {
+ const { name: albumName, trackTitle } = useParams();
+ const navigate = useNavigate();
+
+ // 트랙 데이터 로드
+ const {
+ data: track,
+ isLoading: loading,
+ error,
+ } = useQuery({
+ queryKey: ['track', albumName, trackTitle],
+ queryFn: () => getTrack(albumName, trackTitle),
+ enabled: !!albumName && !!trackTitle,
+ });
+
+ const youtubeVideoId = useMemo(() => getYoutubeVideoId(track?.music_video_url), [track?.music_video_url]);
+
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ if (error || !track) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+ {/* 브레드크럼 네비게이션 */}
+
+
+ 앨범
+
+
+
+ {track.album?.title}
+
+
+ {track.title}
+
+
+ {/* 트랙 정보 헤더 */}
+
+ {/* 앨범 커버 */}
+
+
+
+
+ {/* 트랙 정보 */}
+
+
+ {track.is_title_track === 1 && (
+ TITLE
+ )}
+ Track {String(track.track_number).padStart(2, '0')}
+
+
+ {track.title}
+
+ {/* 앨범 정보 */}
+
+ {track.album?.album_type} · {track.album?.title}
+
+
+ {/* 재생 시간 */}
+ {track.duration && (
+
+
+ {track.duration}
+
+ )}
+
+
+
+ {/* 뮤직비디오 섹션 */}
+ {youtubeVideoId && (
+
+
+
+ 뮤직비디오
+
+
+
+
+
+ )}
+
+ {/* 메인 콘텐츠 */}
+
+ {/* 왼쪽: 크레딧 + 수록곡 */}
+
+ {/* 크레딧 */}
+ {(track.lyricist || track.composer || track.arranger) && (
+
+
+
+ 크레딧
+
+
+ {track.lyricist && (
+
+
+
+
+
+
작사
+
{formatCredit(track.lyricist)}
+
+
+ )}
+ {track.composer && (
+
+
+
+
+
+
작곡
+
{formatCredit(track.composer)}
+
+
+ )}
+ {track.arranger && (
+
+
+
+
+
+
편곡
+
{formatCredit(track.arranger)}
+
+
+ )}
+
+
+ )}
+
+ {/* 수록곡 */}
+
+
+
+ 수록곡
+
+
+ {track.otherTracks?.map((t) => {
+ const isCurrent = t.title === track.title;
+ return (
+
+ {/* 트랙 번호 / 재생 아이콘 */}
+
+ {isCurrent ? (
+
+ ) : (
+ String(t.track_number).padStart(2, '0')
+ )}
+
+
+ {/* 곡 제목 + 타이틀 배지 */}
+
+
+ {t.title}
+
+
+ {/* 타이틀 배지 */}
+ {t.is_title_track === 1 && (
+
+ TITLE
+
+ )}
+
+
+ {/* 재생 시간 */}
+
+ {t.duration || ''}
+
+
+ );
+ })}
+
+
+
+
+ {/* 오른쪽: 가사 */}
+
+
+
+
+ 가사
+
+ {track.lyrics ? (
+
+ {track.lyrics}
+
+ ) : (
+
+ )}
+
+
+
+
+
+ );
+}
+
+export default PCTrackDetail;
diff --git a/frontend-temp/src/pages/schedule/MobileSchedule.jsx b/frontend-temp/src/pages/schedule/MobileSchedule.jsx
deleted file mode 100644
index 09a7b54..0000000
--- a/frontend-temp/src/pages/schedule/MobileSchedule.jsx
+++ /dev/null
@@ -1,516 +0,0 @@
-import { useState, useEffect, useRef, useMemo } from 'react';
-import { useNavigate } from 'react-router-dom';
-import { motion, AnimatePresence } from 'framer-motion';
-import { ChevronLeft, ChevronRight, ChevronDown, Search, X, Calendar as CalendarIcon, List } from 'lucide-react';
-import { useQuery, useInfiniteQuery } from '@tanstack/react-query';
-import { useInView } from 'react-intersection-observer';
-
-import {
- MobileScheduleListCard,
- MobileScheduleSearchCard,
- MobileBirthdayCard,
- fireBirthdayConfetti,
-} from '@/components/schedule';
-import { getSchedules, searchSchedules } from '@/api/schedules';
-import { useScheduleStore } from '@/stores';
-import { getTodayKST, dayjs, getCategoryInfo } from '@/utils';
-
-const WEEKDAYS = ['일', '월', '화', '수', '목', '금', '토'];
-const MONTHS = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월'];
-const SEARCH_LIMIT = 20;
-const MIN_YEAR = 2017;
-
-/**
- * Mobile 스케줄 페이지
- */
-function MobileSchedule() {
- const navigate = useNavigate();
- const scrollContainerRef = useRef(null);
-
- // 상태 관리 (zustand store)
- const {
- currentDate,
- setCurrentDate,
- selectedDate: storedSelectedDate,
- setSelectedDate: setStoredSelectedDate,
- selectedCategories,
- setSelectedCategories,
- isSearchMode,
- setIsSearchMode,
- searchInput,
- setSearchInput,
- searchTerm,
- setSearchTerm,
- } = useScheduleStore();
-
- const selectedDate = storedSelectedDate === undefined ? getTodayKST() : storedSelectedDate;
- const setSelectedDate = setStoredSelectedDate;
-
- // 로컬 상태
- const [viewMode, setViewMode] = useState('calendar'); // 'calendar' | 'list'
- const [showMonthPicker, setShowMonthPicker] = useState(false);
-
- const year = currentDate.getFullYear();
- const month = currentDate.getMonth();
-
- // 월별 일정 데이터
- const { data: schedules = [], isLoading: loading } = useQuery({
- queryKey: ['schedules', year, month + 1],
- queryFn: () => getSchedules(year, month + 1),
- });
-
- // 검색 무한 스크롤
- const { ref: loadMoreRef, inView } = useInView({ threshold: 0, rootMargin: '100px' });
-
- const {
- data: searchData,
- fetchNextPage,
- hasNextPage,
- isFetchingNextPage,
- } = useInfiniteQuery({
- queryKey: ['scheduleSearch', searchTerm],
- queryFn: async ({ pageParam = 0 }) => {
- return searchSchedules(searchTerm, { offset: pageParam, limit: SEARCH_LIMIT });
- },
- getNextPageParam: (lastPage) => {
- if (lastPage.hasMore) {
- return lastPage.offset + lastPage.schedules.length;
- }
- return undefined;
- },
- enabled: !!searchTerm && isSearchMode,
- });
-
- const searchResults = useMemo(() => {
- if (!searchData?.pages) return [];
- return searchData.pages.flatMap((page) => page.schedules);
- }, [searchData]);
-
- // 무한 스크롤 트리거
- const prevInViewRef = useRef(false);
- useEffect(() => {
- if (inView && !prevInViewRef.current && hasNextPage && !isFetchingNextPage && isSearchMode && searchTerm) {
- fetchNextPage();
- }
- prevInViewRef.current = inView;
- }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage, isSearchMode, searchTerm]);
-
- // 오늘 생일 폭죽
- useEffect(() => {
- if (loading || schedules.length === 0) return;
- const today = getTodayKST();
- const confettiKey = `birthday-confetti-${today}`;
- if (localStorage.getItem(confettiKey)) return;
- const hasBirthdayToday = schedules.some((s) => s.is_birthday && s.date === today);
- if (hasBirthdayToday) {
- const timer = setTimeout(() => {
- fireBirthdayConfetti();
- localStorage.setItem(confettiKey, 'true');
- }, 500);
- return () => clearTimeout(timer);
- }
- }, [schedules, loading]);
-
- // 달력 계산
- const getDaysInMonth = (y, m) => new Date(y, m + 1, 0).getDate();
- const getFirstDayOfMonth = (y, m) => new Date(y, m, 1).getDay();
-
- const daysInMonth = getDaysInMonth(year, month);
- const firstDay = getFirstDayOfMonth(year, month);
-
- // 일정 날짜별 맵
- const scheduleDateMap = useMemo(() => {
- const map = new Map();
- schedules.forEach((s) => {
- const dateStr = s.date;
- if (!map.has(dateStr)) {
- map.set(dateStr, []);
- }
- map.get(dateStr).push(s);
- });
- return map;
- }, [schedules]);
-
- // 카테고리 추출
- const categories = useMemo(() => {
- const categoryMap = new Map();
- schedules.forEach((s) => {
- if (s.category_id && !categoryMap.has(s.category_id)) {
- categoryMap.set(s.category_id, {
- id: s.category_id,
- name: s.category_name,
- color: s.category_color,
- });
- }
- });
- return Array.from(categoryMap.values());
- }, [schedules]);
-
- // 필터링된 스케줄
- const filteredSchedules = useMemo(() => {
- if (isSearchMode && searchTerm) {
- if (selectedCategories.length === 0) return searchResults;
- return searchResults.filter((s) => selectedCategories.includes(s.category_id));
- }
-
- return schedules
- .filter((s) => {
- const matchesDate = selectedDate ? s.date === selectedDate : true;
- const matchesCategory = selectedCategories.length === 0 || selectedCategories.includes(s.category_id);
- return matchesDate && matchesCategory;
- })
- .sort((a, b) => {
- // 생일 우선
- if (a.is_birthday && !b.is_birthday) return -1;
- if (!a.is_birthday && b.is_birthday) return 1;
- // 시간순
- return (a.time || '00:00:00').localeCompare(b.time || '00:00:00');
- });
- }, [schedules, selectedDate, selectedCategories, isSearchMode, searchTerm, searchResults]);
-
- // 날짜별 그룹화 (리스트 모드용)
- const groupedSchedules = useMemo(() => {
- if (isSearchMode && searchTerm) {
- const groups = new Map();
- searchResults.forEach((s) => {
- if (!groups.has(s.date)) {
- groups.set(s.date, []);
- }
- groups.get(s.date).push(s);
- });
- return Array.from(groups.entries()).sort((a, b) => a[0].localeCompare(b[0]));
- }
-
- const groups = new Map();
- schedules.forEach((s) => {
- if (selectedCategories.length > 0 && !selectedCategories.includes(s.category_id)) return;
- if (!groups.has(s.date)) {
- groups.set(s.date, []);
- }
- groups.get(s.date).push(s);
- });
- return Array.from(groups.entries()).sort((a, b) => a[0].localeCompare(b[0]));
- }, [schedules, selectedCategories, isSearchMode, searchTerm, searchResults]);
-
- // 월 이동
- const canGoPrevMonth = !(year === MIN_YEAR && month === 0);
-
- const prevMonth = () => {
- if (!canGoPrevMonth) return;
- const newDate = new Date(year, month - 1, 1);
- setCurrentDate(newDate);
- };
-
- const nextMonth = () => {
- const newDate = new Date(year, month + 1, 1);
- setCurrentDate(newDate);
- };
-
- // 날짜 선택
- const selectDate = (day) => {
- const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
- setSelectedDate(dateStr);
- };
-
- // 일정 클릭
- const handleScheduleClick = (schedule) => {
- if (schedule.is_birthday) {
- const scheduleYear = new Date(schedule.date).getFullYear();
- navigate(`/birthday/${encodeURIComponent(schedule.member_names)}/${scheduleYear}`);
- return;
- }
- if ([2, 3, 6].includes(schedule.category_id)) {
- navigate(`/schedule/${schedule.id}`);
- return;
- }
- if (!schedule.description && schedule.source?.url) {
- window.open(schedule.source.url, '_blank');
- } else {
- navigate(`/schedule/${schedule.id}`);
- }
- };
-
- // 검색 모드 종료
- const exitSearchMode = () => {
- setIsSearchMode(false);
- setSearchInput('');
- setSearchTerm('');
- };
-
- return (
-
- {/* 헤더 */}
-
- {isSearchMode ? (
- // 검색 모드 헤더
-
-
-
- setSearchInput(e.target.value)}
- onKeyDown={(e) => {
- if (e.key === 'Enter' && searchInput.trim()) {
- setSearchTerm(searchInput);
- }
- }}
- className="w-full pl-10 pr-10 py-2 bg-gray-100 rounded-full text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
- />
-
- {searchInput && (
-
- )}
-
-
- ) : (
- // 일반 모드 헤더
- <>
-
-
-
-
-
-
-
-
- {/* 월 선택 드롭다운 */}
-
- {showMonthPicker && (
-
-
-
-
- {year}년
-
-
-
- {MONTHS.map((m, i) => (
-
- ))}
-
-
-
- )}
-
-
- {/* 달력 모드 - 달력 그리드 */}
- {viewMode === 'calendar' && (
-
- {/* 월 네비게이션 */}
-
-
- {month + 1}월
-
-
-
- {/* 요일 헤더 */}
-
- {WEEKDAYS.map((day, i) => (
-
- {day}
-
- ))}
-
-
- {/* 날짜 그리드 */}
-
- {/* 전달 빈 칸 */}
- {Array.from({ length: firstDay }).map((_, i) => (
-
- ))}
-
- {/* 현재 달 날짜 */}
- {Array.from({ length: daysInMonth }).map((_, i) => {
- const day = i + 1;
- const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
- const isSelected = selectedDate === dateStr;
- const isToday = dateStr === getTodayKST();
- const daySchedules = scheduleDateMap.get(dateStr) || [];
- const dayOfWeek = (firstDay + i) % 7;
-
- return (
-
- );
- })}
-
-
- )}
- >
- )}
-
-
- {/* 일정 목록 */}
-
- {loading ? (
-
- ) : isSearchMode && searchTerm ? (
- // 검색 결과
-
- {searchResults.length > 0 ? (
- <>
- {searchResults.map((schedule) => (
-
- {schedule.is_birthday ? (
- handleScheduleClick(schedule)} />
- ) : (
- handleScheduleClick(schedule)} />
- )}
-
- ))}
-
- {isFetchingNextPage && (
-
- )}
-
- >
- ) : (
-
검색 결과가 없습니다
- )}
-
- ) : viewMode === 'calendar' ? (
- // 달력 모드 - 선택된 날짜의 일정
-
- {filteredSchedules.length > 0 ? (
- filteredSchedules.map((schedule) => (
-
- {schedule.is_birthday ? (
- handleScheduleClick(schedule)} />
- ) : (
- handleScheduleClick(schedule)} />
- )}
-
- ))
- ) : (
-
- {selectedDate ? '이 날짜에 일정이 없습니다' : '이번 달에 일정이 없습니다'}
-
- )}
-
- ) : (
- // 리스트 모드 - 날짜별 그룹화
-
- {groupedSchedules.length > 0 ? (
- groupedSchedules.map(([date, daySchedules]) => {
- const d = dayjs(date);
- return (
-
-
- {d.format('M월 D일')} ({WEEKDAYS[d.day()]})
-
-
- {daySchedules.map((schedule) => (
-
- {schedule.is_birthday ? (
- handleScheduleClick(schedule)} />
- ) : (
- handleScheduleClick(schedule)} />
- )}
-
- ))}
-
-
- );
- })
- ) : (
-
이번 달에 일정이 없습니다
- )}
-
- )}
-
-
- );
-}
-
-export default MobileSchedule;
diff --git a/frontend-temp/src/pages/schedule/index.js b/frontend-temp/src/pages/schedule/index.js
index 473884f..45e2600 100644
--- a/frontend-temp/src/pages/schedule/index.js
+++ b/frontend-temp/src/pages/schedule/index.js
@@ -1,2 +1,6 @@
-export { default as PCSchedule } from './PCSchedule';
-export { default as MobileSchedule } from './MobileSchedule';
+export { default as PCSchedule } from './pc/Schedule';
+export { default as MobileSchedule } from './mobile/Schedule';
+export { default as PCScheduleDetail } from './pc/ScheduleDetail';
+export { default as MobileScheduleDetail } from './mobile/ScheduleDetail';
+export { default as PCBirthday } from './pc/Birthday';
+export { default as MobileBirthday } from './mobile/Birthday';
diff --git a/frontend-temp/src/pages/schedule/MobileBirthday.jsx b/frontend-temp/src/pages/schedule/mobile/Birthday.jsx
similarity index 94%
rename from frontend-temp/src/pages/schedule/MobileBirthday.jsx
rename to frontend-temp/src/pages/schedule/mobile/Birthday.jsx
index d62162e..420c9e1 100644
--- a/frontend-temp/src/pages/schedule/MobileBirthday.jsx
+++ b/frontend-temp/src/pages/schedule/mobile/Birthday.jsx
@@ -3,19 +3,7 @@ import { useQuery } from '@tanstack/react-query';
import { motion } from 'framer-motion';
import { ChevronLeft } from 'lucide-react';
import { fetchApi } from '@/api/client';
-
-// 한글 이름 → 영어 이름 매핑
-const memberEnglishName = {
- 송하영: 'HAYOUNG',
- 박지원: 'JIWON',
- 이채영: 'CHAEYOUNG',
- 이나경: 'NAKYUNG',
- 백지헌: 'JIHEON',
- 장규리: 'GYURI',
- 이새롬: 'SAEROM',
- 노지선: 'JISUN',
- 이서연: 'SEOYEON',
-};
+import { MEMBER_ENGLISH_NAMES } from '@/constants';
/**
* Mobile 생일 페이지
@@ -25,7 +13,7 @@ function MobileBirthday() {
// URL 디코딩
const decodedMemberName = decodeURIComponent(memberName || '');
- const englishName = memberEnglishName[decodedMemberName];
+ const englishName = MEMBER_ENGLISH_NAMES[decodedMemberName];
// 멤버 정보 조회
const {
diff --git a/frontend-temp/src/pages/schedule/mobile/Schedule.jsx b/frontend-temp/src/pages/schedule/mobile/Schedule.jsx
new file mode 100644
index 0000000..27d4ac9
--- /dev/null
+++ b/frontend-temp/src/pages/schedule/mobile/Schedule.jsx
@@ -0,0 +1,779 @@
+import { useState, useEffect, useMemo, useRef } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { motion, AnimatePresence } from 'framer-motion';
+import { ChevronLeft, ChevronRight, ChevronDown, Search, X, Calendar } from 'lucide-react';
+import { useQuery, useInfiniteQuery } from '@tanstack/react-query';
+import { useInView } from 'react-intersection-observer';
+import { useVirtualizer } from '@tanstack/react-virtual';
+
+import { getTodayKST, getCategoryInfo } from '@/utils';
+import { getSchedules, searchSchedules } from '@/api/schedules';
+import { useScheduleStore } from '@/stores';
+import { MIN_YEAR, SEARCH_LIMIT } from '@/constants';
+import {
+ MobileCalendar,
+ MobileScheduleListCard,
+ MobileScheduleSearchCard,
+ MobileBirthdayCard,
+ fireBirthdayConfetti,
+} from '@/components/schedule';
+
+/**
+ * 모바일 일정 페이지
+ */
+function MobileSchedule() {
+ const navigate = useNavigate();
+
+ // 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('');
+ const [showCalendar, setShowCalendar] = useState(false);
+ const [calendarViewDate, setCalendarViewDate] = useState(() => new Date(selectedDate));
+ const [calendarShowYearMonth, setCalendarShowYearMonth] = useState(false);
+ const contentRef = useRef(null);
+ const searchContainerRef = useRef(null);
+ const searchInputRef = useRef(null);
+
+ // 검색 추천 관련 상태
+ const [showSuggestions, setShowSuggestions] = useState(false);
+ const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1);
+ const [originalSearchQuery, setOriginalSearchQuery] = useState('');
+ const [suggestions, setSuggestions] = useState([]);
+ const [lastSearchTerm, setLastSearchTerm] = useState('');
+ const [showSuggestionsScreen, setShowSuggestionsScreen] = useState(false);
+
+ // 검색 모드 진입/종료
+ const enterSearchMode = () => {
+ setIsSearchMode(true);
+ window.history.pushState({ searchMode: true }, '');
+ };
+
+ const exitSearchMode = () => {
+ setIsSearchMode(false);
+ setSearchInput('');
+ setOriginalSearchQuery('');
+ setSearchTerm('');
+ setLastSearchTerm('');
+ setShowSuggestions(false);
+ setShowSuggestionsScreen(false);
+ setSelectedSuggestionIndex(-1);
+ };
+
+ const hideSuggestionsScreen = () => {
+ setShowSuggestionsScreen(false);
+ setSearchInput(lastSearchTerm);
+ setOriginalSearchQuery(lastSearchTerm);
+ };
+
+ // 뒤로가기 버튼 처리
+ useEffect(() => {
+ const handlePopState = () => {
+ if (isSearchMode) {
+ if (showSuggestionsScreen && searchTerm) {
+ hideSuggestionsScreen();
+ window.history.pushState({ searchMode: true }, '');
+ } else {
+ exitSearchMode();
+ }
+ }
+ };
+
+ window.addEventListener('popstate', handlePopState);
+ return () => window.removeEventListener('popstate', handlePopState);
+ }, [isSearchMode, showSuggestionsScreen, searchTerm, lastSearchTerm]);
+
+ // 달력 월 변경
+ const changeCalendarMonth = (delta) => {
+ const newDate = new Date(calendarViewDate);
+ newDate.setMonth(newDate.getMonth() + delta);
+ setCalendarViewDate(newDate);
+ };
+
+ const scrollContainerRef = useRef(null);
+ const { ref: loadMoreRef, inView } = useInView({ threshold: 0, rootMargin: '100px' });
+
+ // 검색 무한 스크롤
+ const {
+ data: searchData,
+ fetchNextPage,
+ hasNextPage,
+ isFetchingNextPage,
+ isLoading: searchLoading,
+ } = useInfiniteQuery({
+ queryKey: ['mobileScheduleSearch', searchTerm],
+ queryFn: async ({ pageParam = 0 }) => {
+ return searchSchedules(searchTerm, { offset: pageParam, limit: SEARCH_LIMIT });
+ },
+ getNextPageParam: (lastPage) => {
+ if (lastPage.hasMore) {
+ return lastPage.offset + lastPage.schedules.length;
+ }
+ return undefined;
+ },
+ enabled: !!searchTerm && isSearchMode,
+ });
+
+ const searchResults = useMemo(() => {
+ if (!searchData?.pages) return [];
+ return searchData.pages.flatMap((page) => page.schedules);
+ }, [searchData]);
+
+ // 가상 스크롤 설정
+ const virtualizer = useVirtualizer({
+ count: isSearchMode && searchTerm ? searchResults.length : 0,
+ getScrollElement: () => scrollContainerRef.current,
+ estimateSize: () => 100,
+ overscan: 5,
+ });
+
+ // 검색어 변경 시 스크롤 위치 초기화
+ useEffect(() => {
+ if (searchTerm && !showSuggestionsScreen) {
+ requestAnimationFrame(() => {
+ virtualizer.scrollToOffset(0);
+ if (scrollContainerRef.current) {
+ scrollContainerRef.current.scrollTop = 0;
+ }
+ });
+ }
+ }, [searchTerm, showSuggestionsScreen]);
+
+ useEffect(() => {
+ if (inView && hasNextPage && !isFetchingNextPage && isSearchMode && searchTerm) {
+ fetchNextPage();
+ }
+ }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage, isSearchMode, searchTerm]);
+
+ // 일정 데이터 로드
+ const viewYear = selectedDate.getFullYear();
+ const viewMonth = selectedDate.getMonth() + 1;
+
+ const { data: schedules = [], isLoading: loading } = useQuery({
+ queryKey: ['schedules', viewYear, viewMonth],
+ queryFn: () => getSchedules(viewYear, viewMonth),
+ });
+
+ // 달력 표시용 일정 데이터
+ const calendarYear = calendarViewDate.getFullYear();
+ const calendarMonth = calendarViewDate.getMonth() + 1;
+ const isSameMonth = viewYear === calendarYear && viewMonth === calendarMonth;
+
+ const { data: calendarSchedules = [] } = useQuery({
+ queryKey: ['schedules', calendarYear, calendarMonth],
+ queryFn: () => getSchedules(calendarYear, calendarMonth),
+ enabled: !isSameMonth,
+ });
+
+ // 생일 폭죽 효과
+ useEffect(() => {
+ if (loading || schedules.length === 0) return;
+
+ const today = getTodayKST();
+ const confettiKey = `birthday-confetti-${today}`;
+
+ if (localStorage.getItem(confettiKey)) return;
+
+ const hasBirthdayToday = schedules.some((s) => {
+ if (!s.is_birthday) return false;
+ const scheduleDate = s.date ? s.date.split('T')[0] : '';
+ return scheduleDate === today;
+ });
+
+ if (hasBirthdayToday) {
+ const timer = setTimeout(() => {
+ fireBirthdayConfetti();
+ localStorage.setItem(confettiKey, 'true');
+ }, 500);
+ return () => clearTimeout(timer);
+ }
+ }, [schedules, loading]);
+
+ // 2017년 1월 이전으로 이동 불가
+ const canGoPrevMonth = !(selectedDate.getFullYear() === MIN_YEAR && selectedDate.getMonth() === 0);
+
+ // 월 변경
+ const changeMonth = (delta) => {
+ if (delta < 0 && !canGoPrevMonth) return;
+
+ const newDate = new Date(selectedDate);
+ newDate.setMonth(newDate.getMonth() + delta);
+
+ const today = new Date();
+ if (newDate.getFullYear() === today.getFullYear() && newDate.getMonth() === today.getMonth()) {
+ newDate.setDate(today.getDate());
+ } else {
+ newDate.setDate(1);
+ }
+
+ setSelectedDate(newDate);
+ setCalendarViewDate(newDate);
+ };
+
+ // 날짜 변경 시 스크롤 초기화
+ useEffect(() => {
+ if (contentRef.current) {
+ contentRef.current.scrollTop = 0;
+ }
+ }, [selectedDate]);
+
+ // 캘린더 열릴 때 배경 스크롤 방지
+ useEffect(() => {
+ const preventScroll = (e) => e.preventDefault();
+
+ if (showCalendar) {
+ document.addEventListener('touchmove', preventScroll, { passive: false });
+ } else {
+ document.removeEventListener('touchmove', preventScroll);
+ }
+ return () => {
+ document.removeEventListener('touchmove', preventScroll);
+ };
+ }, [showCalendar]);
+
+ // 검색 추천 드롭다운 외부 클릭 감지
+ useEffect(() => {
+ const handleClickOutside = (event) => {
+ if (searchContainerRef.current && !searchContainerRef.current.contains(event.target)) {
+ setShowSuggestions(false);
+ setSelectedSuggestionIndex(-1);
+ }
+ };
+
+ if (showSuggestions) {
+ document.addEventListener('mousedown', handleClickOutside);
+ document.addEventListener('touchstart', handleClickOutside);
+ }
+
+ return () => {
+ document.removeEventListener('mousedown', handleClickOutside);
+ document.removeEventListener('touchstart', handleClickOutside);
+ };
+ }, [showSuggestions]);
+
+ // 검색어 자동완성 API 호출
+ useEffect(() => {
+ if (!originalSearchQuery || originalSearchQuery.trim().length === 0) {
+ setSuggestions([]);
+ return;
+ }
+
+ const timeoutId = setTimeout(async () => {
+ try {
+ const response = await fetch(
+ `/api/schedules/suggestions?q=${encodeURIComponent(originalSearchQuery)}&limit=10`
+ );
+ if (response.ok) {
+ const data = await response.json();
+ setSuggestions(data.suggestions || []);
+ }
+ } catch (error) {
+ console.error('추천 검색어 API 오류:', error);
+ setSuggestions([]);
+ }
+ }, 200);
+
+ return () => clearTimeout(timeoutId);
+ }, [originalSearchQuery]);
+
+ // 해당 달의 모든 날짜 배열
+ const daysInMonth = useMemo(() => {
+ const year = selectedDate.getFullYear();
+ const month = selectedDate.getMonth();
+ const lastDay = new Date(year, month + 1, 0).getDate();
+ const days = [];
+ for (let d = 1; d <= lastDay; d++) {
+ days.push(new Date(year, month, d));
+ }
+ return days;
+ }, [selectedDate]);
+
+ // 선택된 날짜의 일정 (생일 우선)
+ const selectedDateSchedules = useMemo(() => {
+ const year = selectedDate.getFullYear();
+ const month = String(selectedDate.getMonth() + 1).padStart(2, '0');
+ const day = String(selectedDate.getDate()).padStart(2, '0');
+ const dateStr = `${year}-${month}-${day}`;
+ return schedules
+ .filter((s) => s.date.split('T')[0] === dateStr)
+ .sort((a, b) => {
+ const aIsBirthday = a.is_birthday || String(a.id).startsWith('birthday-');
+ const bIsBirthday = b.is_birthday || String(b.id).startsWith('birthday-');
+ if (aIsBirthday && !bIsBirthday) return -1;
+ if (!aIsBirthday && bIsBirthday) return 1;
+ return 0;
+ });
+ }, [schedules, selectedDate]);
+
+ // 요일 이름
+ const getDayName = (date) => ['일', '월', '화', '수', '목', '금', '토'][date.getDay()];
+
+ // 오늘 여부
+ const isToday = (date) => {
+ const today = new Date();
+ return (
+ date.getDate() === today.getDate() &&
+ date.getMonth() === today.getMonth() &&
+ date.getFullYear() === today.getFullYear()
+ );
+ };
+
+ // 선택된 날짜 여부
+ const isSelected = (date) => {
+ return (
+ date.getDate() === selectedDate.getDate() &&
+ date.getMonth() === selectedDate.getMonth() &&
+ date.getFullYear() === selectedDate.getFullYear()
+ );
+ };
+
+ // 날짜 선택 컨테이너 ref
+ const dateScrollRef = useRef(null);
+
+ // 선택된 날짜로 자동 스크롤
+ useEffect(() => {
+ if (!isSearchMode && dateScrollRef.current) {
+ const selectedDay = selectedDate.getDate();
+ const buttons = dateScrollRef.current.querySelectorAll('button');
+ if (buttons[selectedDay - 1]) {
+ setTimeout(() => {
+ buttons[selectedDay - 1].scrollIntoView({
+ behavior: 'smooth',
+ inline: 'center',
+ block: 'nearest',
+ });
+ }, 50);
+ }
+ }
+ }, [selectedDate, isSearchMode]);
+
+ // 검색 실행 핸들러
+ const handleSearch = (term) => {
+ if (term) {
+ setSearchInput(term);
+ setSearchTerm(term);
+ setLastSearchTerm(term);
+ setShowSuggestionsScreen(false);
+ }
+ setShowSuggestions(false);
+ setSelectedSuggestionIndex(-1);
+ searchInputRef.current?.blur();
+ };
+
+ return (
+ <>
+ {/* 툴바 (헤더 + 날짜 선택기) */}
+
+ {isSearchMode ? (
+
+
+
+ {
+ setSearchInput(e.target.value);
+ setOriginalSearchQuery(e.target.value);
+ setShowSuggestions(true);
+ setShowSuggestionsScreen(true);
+ setSelectedSuggestionIndex(-1);
+ }}
+ onFocus={() => {
+ setShowSuggestions(true);
+ setShowSuggestionsScreen(true);
+ }}
+ onKeyDown={(e) => {
+ if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ const newIndex =
+ selectedSuggestionIndex < suggestions.length - 1
+ ? selectedSuggestionIndex + 1
+ : 0;
+ setSelectedSuggestionIndex(newIndex);
+ if (suggestions[newIndex]) {
+ setSearchInput(suggestions[newIndex]);
+ }
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ const newIndex =
+ selectedSuggestionIndex > 0
+ ? selectedSuggestionIndex - 1
+ : suggestions.length - 1;
+ setSelectedSuggestionIndex(newIndex);
+ if (suggestions[newIndex]) {
+ setSearchInput(suggestions[newIndex]);
+ }
+ } else if (e.key === 'Enter') {
+ e.preventDefault();
+ const term =
+ selectedSuggestionIndex >= 0 && suggestions[selectedSuggestionIndex]
+ ? suggestions[selectedSuggestionIndex]
+ : searchInput.trim();
+ handleSearch(term);
+ }
+ }}
+ className="flex-1 bg-transparent outline-none text-sm min-w-0 [&::-webkit-search-cancel-button]:hidden"
+ autoFocus={!searchTerm}
+ />
+ {searchInput && (
+
+ )}
+
+
+
+ ) : (
+
+ {showCalendar ? (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ ) : (
+ <>
+
+
+
+
+
+ {selectedDate.getFullYear()}년 {selectedDate.getMonth() + 1}월
+
+
+
+
+
+ >
+ )}
+
+ )}
+
+ {/* 가로 스크롤 날짜 선택기 */}
+ {!isSearchMode && (
+
+ {daysInMonth.map((date) => {
+ const dayOfWeek = date.getDay();
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, '0');
+ const day = String(date.getDate()).padStart(2, '0');
+ const dateStr = `${year}-${month}-${day}`;
+
+ const daySchedules = schedules
+ .filter((s) => s.date?.split('T')[0] === dateStr)
+ .slice(0, 3);
+
+ return (
+
+ );
+ })}
+
+ )}
+
+
+ {/* 달력 팝업 */}
+
+ {showCalendar && !isSearchMode && (
+
+ {
+ setSelectedDate(date);
+ setCalendarViewDate(date);
+ setCalendarShowYearMonth(false);
+ setShowCalendar(false);
+ }}
+ />
+
+ )}
+
+
+ {/* 캘린더 배경 오버레이 */}
+
+ {showCalendar && !isSearchMode && (
+ setShowCalendar(false)}
+ className="fixed inset-0 bg-black/40 z-40"
+ style={{ top: 0 }}
+ />
+ )}
+
+
+ {/* 컨텐츠 영역 */}
+
+
+ {isSearchMode ? (
+ showSuggestionsScreen ? (
+ // 추천 검색어 화면
+
+ {suggestions.length === 0 ? (
+
검색어를 입력하세요
+ ) : (
+ suggestions.map((suggestion, index) => (
+
+ ))
+ )}
+
+ ) : !searchTerm ? (
+
검색어를 입력하세요
+ ) : searchLoading ? (
+
+ ) : searchResults.length === 0 ? (
+
검색 결과가 없습니다
+ ) : (
+ <>
+
+ {virtualizer.getVirtualItems().map((virtualItem) => {
+ const schedule = searchResults[virtualItem.index];
+ if (!schedule) return null;
+
+ return (
+
+
+ navigate(`/schedule/${schedule.id}`)}
+ />
+
+
+ );
+ })}
+
+
+ {isFetchingNextPage && (
+
+ )}
+
+ >
+ )
+ ) : loading ? (
+
+ ) : selectedDateSchedules.length === 0 ? (
+
+ {selectedDate.getMonth() + 1}월 {selectedDate.getDate()}일 일정이 없습니다
+
+ ) : (
+
+ {selectedDateSchedules.map((schedule, index) => {
+ const isBirthday = schedule.is_birthday || String(schedule.id).startsWith('birthday-');
+
+ if (isBirthday) {
+ return (
+ {
+ const scheduleYear = new Date(schedule.date).getFullYear();
+ const memberName = schedule.member_names;
+ navigate(`/birthday/${encodeURIComponent(memberName)}/${scheduleYear}`);
+ }}
+ />
+ );
+ }
+
+ return (
+ navigate(`/schedule/${schedule.id}`)}
+ />
+ );
+ })}
+
+ )}
+
+
+ >
+ );
+}
+
+export default MobileSchedule;
diff --git a/frontend-temp/src/pages/schedule/mobile/ScheduleDetail.jsx b/frontend-temp/src/pages/schedule/mobile/ScheduleDetail.jsx
new file mode 100644
index 0000000..f510a22
--- /dev/null
+++ b/frontend-temp/src/pages/schedule/mobile/ScheduleDetail.jsx
@@ -0,0 +1,542 @@
+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, Link2, X, ChevronRight } from 'lucide-react';
+import Linkify from 'react-linkify';
+import { getSchedule } from '@/api/schedules';
+import { CATEGORY_ID, decodeHtmlEntities, formatFullDate, formatTime, formatXDateTime } from '../sections/utils';
+
+/**
+ * 전체화면 시 자동 가로 회전 훅 (숏츠가 아닐 때만)
+ */
+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]);
+}
+
+/**
+ * Mobile 유튜브 섹션
+ */
+function MobileYoutubeSection({ schedule }) {
+ const videoId = schedule.videoId;
+ const isShorts = schedule.videoType === 'shorts';
+
+ // 숏츠가 아닐 때만 가로 회전 (숏츠는 전체화면에서 세로 유지)
+ useFullscreenOrientation(isShorts);
+ const members = schedule.members || [];
+ const isFullGroup = members.length === 5;
+
+ if (!videoId) return null;
+
+ return (
+
+ {/* 영상 임베드 - 숏츠도 가로 비율로 표시 (전체화면에서는 유튜브가 세로로 처리) */}
+
+
+
+
+
+
+ {/* 영상 정보 */}
+
+ {decodeHtmlEntities(schedule.title)}
+
+ {/* 메타 정보 */}
+
+
+
+ {formatXDateTime(schedule.datetime)}
+
+ {schedule.channelName && (
+
+
+ {schedule.channelName}
+
+ )}
+
+
+ {/* 멤버 목록 */}
+ {members.length > 0 && (
+
+ {isFullGroup ? (
+
+ 프로미스나인
+
+ ) : (
+ members.map((member) => (
+
+ {member.name}
+
+ ))
+ )}
+
+ )}
+
+ {/* 유튜브에서 보기 버튼 */}
+
+
+
+ );
+}
+
+/**
+ * Mobile X(트위터) 섹션
+ */
+function MobileXSection({ 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 historyPushedRef = useRef(false);
+
+ const openLightbox = (index) => {
+ setLightboxIndex(index);
+ setLightboxOpen(true);
+ window.history.pushState({ lightbox: true }, '');
+ historyPushedRef.current = true;
+ };
+
+ const closeLightbox = () => {
+ setLightboxOpen(false);
+ if (historyPushedRef.current) {
+ historyPushedRef.current = false;
+ window.history.back();
+ }
+ };
+
+ 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) {
+ historyPushedRef.current = false;
+ 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 && (
+
+
+
+ e.stopPropagation()}
+ />
+
+ {schedule.imageUrls.length > 1 && (
+ <>
+
+
+ >
+ )}
+
+ {schedule.imageUrls.length > 1 && (
+
+ {schedule.imageUrls.map((_, i) => (
+
+ )}
+
+ )}
+
+ >
+ );
+}
+
+/**
+ * Mobile 기본 섹션
+ */
+function MobileDefaultSection({ schedule }) {
+ return (
+
+
{decodeHtmlEntities(schedule.title)}
+
+
+
+ {formatFullDate(schedule.datetime)}
+
+ {schedule.datetime?.includes('T') && schedule.datetime.split('T')[1] !== '00:00:00' && (
+
+
+ {formatTime(schedule.datetime?.split('T')[1])}
+
+ )}
+
+ {schedule.description && (
+
{decodeHtmlEntities(schedule.description)}
+ )}
+
+ );
+}
+
+/**
+ * Mobile 일정 상세 페이지
+ */
+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
;
+ default:
+ return
;
+ }
+ };
+
+ return (
+
+ {/* 헤더 */}
+
+
+
+
+
+ {schedule.category?.name}
+
+
+
+
+
+
+ {/* 메인 컨텐츠 */}
+
{renderCategorySection()}
+
+ );
+}
+
+export default MobileScheduleDetail;
diff --git a/frontend-temp/src/pages/schedule/PCBirthday.jsx b/frontend-temp/src/pages/schedule/pc/Birthday.jsx
similarity index 93%
rename from frontend-temp/src/pages/schedule/PCBirthday.jsx
rename to frontend-temp/src/pages/schedule/pc/Birthday.jsx
index 5d95bcd..77da9cd 100644
--- a/frontend-temp/src/pages/schedule/PCBirthday.jsx
+++ b/frontend-temp/src/pages/schedule/pc/Birthday.jsx
@@ -3,19 +3,7 @@ import { useQuery } from '@tanstack/react-query';
import { motion } from 'framer-motion';
import { ChevronRight } from 'lucide-react';
import { fetchApi } from '@/api/client';
-
-// 한글 이름 → 영어 이름 매핑
-const memberEnglishName = {
- 송하영: 'HAYOUNG',
- 박지원: 'JIWON',
- 이채영: 'CHAEYOUNG',
- 이나경: 'NAKYUNG',
- 백지헌: 'JIHEON',
- 장규리: 'GYURI',
- 이새롬: 'SAEROM',
- 노지선: 'JISUN',
- 이서연: 'SEOYEON',
-};
+import { MEMBER_ENGLISH_NAMES } from '@/constants';
/**
* PC 생일 페이지
@@ -25,7 +13,7 @@ function PCBirthday() {
// URL 디코딩
const decodedMemberName = decodeURIComponent(memberName || '');
- const englishName = memberEnglishName[decodedMemberName];
+ const englishName = MEMBER_ENGLISH_NAMES[decodedMemberName];
// 멤버 정보 조회
const {
diff --git a/frontend-temp/src/pages/schedule/PCSchedule.jsx b/frontend-temp/src/pages/schedule/pc/Schedule.jsx
similarity index 99%
rename from frontend-temp/src/pages/schedule/PCSchedule.jsx
rename to frontend-temp/src/pages/schedule/pc/Schedule.jsx
index 0b42a5e..73139cf 100644
--- a/frontend-temp/src/pages/schedule/PCSchedule.jsx
+++ b/frontend-temp/src/pages/schedule/pc/Schedule.jsx
@@ -16,8 +16,7 @@ import {
import { getSchedules, searchSchedules } from '@/api/schedules';
import { useScheduleStore } from '@/stores';
import { getTodayKST } from '@/utils';
-
-const SEARCH_LIMIT = 20;
+import { SEARCH_LIMIT } from '@/constants';
/**
* PC 스케줄 페이지
diff --git a/frontend-temp/src/pages/schedule/pc/ScheduleDetail.jsx b/frontend-temp/src/pages/schedule/pc/ScheduleDetail.jsx
new file mode 100644
index 0000000..9d9df60
--- /dev/null
+++ b/frontend-temp/src/pages/schedule/pc/ScheduleDetail.jsx
@@ -0,0 +1,169 @@
+import { useParams, Link } from 'react-router-dom';
+import { useQuery, keepPreviousData } from '@tanstack/react-query';
+import { motion } from 'framer-motion';
+import { Calendar, ChevronRight } from 'lucide-react';
+import { getSchedule } from '@/api/schedules';
+
+// 섹션 컴포넌트들
+import { YoutubeSection, XSection, DefaultSection, CATEGORY_ID, decodeHtmlEntities } from '../sections';
+
+/**
+ * PC 일정 상세 페이지
+ */
+function PCScheduleDetail() {
+ const { id } = useParams();
+
+ const {
+ data: schedule,
+ isLoading,
+ error,
+ } = useQuery({
+ queryKey: ['schedule', id],
+ queryFn: () => getSchedule(id),
+ placeholderData: keepPreviousData,
+ retry: false,
+ });
+
+ if (isLoading) {
+ 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
;
+ default:
+ return
;
+ }
+ };
+
+ const isYoutube = categoryId === CATEGORY_ID.YOUTUBE;
+ const isX = categoryId === CATEGORY_ID.X;
+ const hasCustomLayout = isYoutube || isX;
+
+ return (
+
+
+ {/* 브레드크럼 네비게이션 */}
+
+
+ 일정
+
+
+
+ {schedule.category?.name}
+
+
+ {decodeHtmlEntities(schedule.title)}
+
+
+ {/* 메인 컨텐츠 */}
+
+ {renderCategorySection()}
+
+
+
+ );
+}
+
+export default PCScheduleDetail;
diff --git a/frontend-temp/src/pages/schedule/sections/DefaultSection.jsx b/frontend-temp/src/pages/schedule/sections/DefaultSection.jsx
new file mode 100644
index 0000000..060b414
--- /dev/null
+++ b/frontend-temp/src/pages/schedule/sections/DefaultSection.jsx
@@ -0,0 +1,55 @@
+import { Calendar, Clock } from 'lucide-react';
+import { decodeHtmlEntities, formatFullDate, formatTime } from './utils';
+
+/**
+ * 기본 일정 섹션 컴포넌트 (일반 카테고리용)
+ */
+function DefaultSection({ schedule }) {
+ return (
+
+ {/* 제목 */}
+
{decodeHtmlEntities(schedule.title)}
+
+ {/* 메타 정보 */}
+
+
+
+ {formatFullDate(schedule.datetime)}
+
+ {schedule.datetime?.includes('T') && schedule.datetime.split('T')[1] !== '00:00:00' && (
+
+
+ {formatTime(schedule.datetime?.split('T')[1])}
+
+ )}
+
+
+ {/* 멤버 */}
+ {schedule.members && schedule.members.length > 0 && (
+
+
참여 멤버
+
+ {schedule.members.length === 5 ? (
+ 프로미스나인
+ ) : (
+ schedule.members.map((member) => (
+
+ {member.name}
+
+ ))
+ )}
+
+
+ )}
+
+ {/* 설명 */}
+ {schedule.description && (
+
+
{decodeHtmlEntities(schedule.description)}
+
+ )}
+
+ );
+}
+
+export default DefaultSection;
diff --git a/frontend-temp/src/pages/schedule/sections/XSection.jsx b/frontend-temp/src/pages/schedule/sections/XSection.jsx
new file mode 100644
index 0000000..73d704f
--- /dev/null
+++ b/frontend-temp/src/pages/schedule/sections/XSection.jsx
@@ -0,0 +1,164 @@
+import { useState, useEffect, useCallback, useRef } from 'react';
+import { motion } from 'framer-motion';
+import Linkify from 'react-linkify';
+import { decodeHtmlEntities, formatXDateTime } from './utils';
+import { Lightbox } from '@/components/common';
+
+/**
+ * PC 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 historyPushedRef = useRef(false);
+
+ const openLightbox = useCallback((index) => {
+ setLightboxIndex(index);
+ setLightboxOpen(true);
+ window.history.pushState({ lightbox: true }, '');
+ historyPushedRef.current = true;
+ }, []);
+
+ const closeLightbox = useCallback(() => {
+ setLightboxOpen(false);
+ if (historyPushedRef.current) {
+ historyPushedRef.current = false;
+ window.history.back();
+ }
+ }, []);
+
+ // 뒤로가기 처리 (하드웨어 백버튼)
+ useEffect(() => {
+ const handlePopState = () => {
+ if (lightboxOpen) {
+ historyPushedRef.current = false;
+ setLightboxOpen(false);
+ }
+ };
+
+ window.addEventListener('popstate', handlePopState);
+ return () => window.removeEventListener('popstate', handlePopState);
+ }, [lightboxOpen]);
+
+ // 링크 데코레이터 (새 탭에서 열기)
+ const linkDecorator = (href, text, key) => (
+
+ {text}
+
+ );
+
+ return (
+
+ {/* X 스타일 카드 */}
+
+ {/* 헤더 */}
+
+
+ {/* 프로필 이미지 */}
+ {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에서 보기 버튼 */}
+
+
+
+ {/* 라이트박스 */}
+
+
+ );
+}
+
+export default XSection;
diff --git a/frontend-temp/src/pages/schedule/sections/YoutubeSection.jsx b/frontend-temp/src/pages/schedule/sections/YoutubeSection.jsx
new file mode 100644
index 0000000..7bdb54f
--- /dev/null
+++ b/frontend-temp/src/pages/schedule/sections/YoutubeSection.jsx
@@ -0,0 +1,145 @@
+import { motion } from 'framer-motion';
+import { Calendar, Link2 } from 'lucide-react';
+import { decodeHtmlEntities, formatXDateTime } from './utils';
+
+/**
+ * 영상 정보 컴포넌트 (공통)
+ */
+function VideoInfo({ schedule, isShorts }) {
+ const members = schedule.members || [];
+ const isFullGroup = members.length === 5;
+
+ return (
+
+ {/* 제목 */}
+
+ {decodeHtmlEntities(schedule.title)}
+
+
+ {/* 메타 정보 */}
+
+ {/* 날짜/시간 */}
+
+
+ {formatXDateTime(schedule.datetime)}
+
+
+ {/* 채널명 */}
+ {schedule.channelName && (
+ <>
+
+
+
+ {schedule.channelName}
+
+ >
+ )}
+
+
+ {/* 멤버 목록 */}
+ {members.length > 0 && (
+
+ {isFullGroup ? (
+ 프로미스나인
+ ) : (
+ members.map((member) => (
+
+ {member.name}
+
+ ))
+ )}
+
+ )}
+
+ {/* 유튜브에서 보기 버튼 */}
+
+
+ );
+}
+
+/**
+ * PC 유튜브 섹션 컴포넌트
+ */
+function YoutubeSection({ schedule }) {
+ const videoId = schedule.videoId;
+ const isShorts = schedule.videoType === 'shorts';
+
+ if (!videoId) return null;
+
+ // 숏츠: 가로 레이아웃 (영상 + 정보)
+ if (isShorts) {
+ return (
+
+ {/* 영상 임베드 */}
+
+
+
+
+
+
+ {/* 영상 정보 카드 */}
+
+
+
+
+ );
+ }
+
+ // 일반 영상: 세로 레이아웃 (영상 위, 정보 아래)
+ return (
+
+ {/* 영상 임베드 */}
+
+
+
+
+
+
+ {/* 영상 정보 카드 */}
+
+
+
+
+ );
+}
+
+export default YoutubeSection;
diff --git a/frontend-temp/src/pages/schedule/sections/index.js b/frontend-temp/src/pages/schedule/sections/index.js
new file mode 100644
index 0000000..01aa592
--- /dev/null
+++ b/frontend-temp/src/pages/schedule/sections/index.js
@@ -0,0 +1,4 @@
+export { default as YoutubeSection } from './YoutubeSection';
+export { default as XSection } from './XSection';
+export { default as DefaultSection } from './DefaultSection';
+export * from './utils';
diff --git a/frontend-temp/src/pages/schedule/sections/utils.js b/frontend-temp/src/pages/schedule/sections/utils.js
new file mode 100644
index 0000000..e7360c1
--- /dev/null
+++ b/frontend-temp/src/pages/schedule/sections/utils.js
@@ -0,0 +1,10 @@
+/**
+ * 스케줄 상세 페이지 상수 및 re-export
+ */
+
+// @/utils에서 re-export
+export { decodeHtmlEntities, formatTime } from '@/utils';
+export { formatFullDate, formatXDateTime } from '@/utils';
+
+// @/constants에서 re-export
+export { CATEGORY_ID } from '@/constants';
diff --git a/frontend-temp/src/utils/date.js b/frontend-temp/src/utils/date.js
index ce32a4f..c4d7ce5 100644
--- a/frontend-temp/src/utils/date.js
+++ b/frontend-temp/src/utils/date.js
@@ -106,21 +106,25 @@ export const formatFullDate = (date) => {
/**
* X(트위터) 스타일 날짜/시간 포맷팅
- * @param {string} date - 날짜 문자열 (YYYY-MM-DD)
- * @param {string} [time] - 시간 문자열 (HH:mm 또는 HH:mm:ss)
+ * @param {string} datetime - datetime 문자열 (YYYY-MM-DDTHH:mm:ss 또는 YYYY-MM-DD)
* @returns {string} "오후 7:00 · 2026년 1월 18일" 또는 "2026년 1월 18일"
*/
-export const formatXDateTime = (date, time) => {
- if (!date) return '';
+export const formatXDateTime = (datetime) => {
+ if (!datetime) return '';
- const d = dayjs(date).tz(TIMEZONE);
+ const d = dayjs(datetime).tz(TIMEZONE);
const datePart = `${d.year()}년 ${d.month() + 1}월 ${d.date()}일`;
- if (time) {
- const [hours, minutes] = time.split(':').map(Number);
- const period = hours < 12 ? '오전' : '오후';
- const hour12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
- return `${period} ${hour12}:${String(minutes).padStart(2, '0')} · ${datePart}`;
+ // datetime에 T가 포함되고 시간이 00:00:00이 아니면 시간 표시
+ if (datetime.includes('T') && !datetime.endsWith('T00:00:00')) {
+ const hours = d.hour();
+ const minutes = d.minute();
+ // 00:00인 경우 시간 표시 안함
+ if (hours !== 0 || minutes !== 0) {
+ const period = hours < 12 ? '오전' : '오후';
+ const hour12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
+ return `${period} ${hour12}:${String(minutes).padStart(2, '0')} · ${datePart}`;
+ }
}
return datePart;