- {/* 브레드크럼 네비게이션 */}
-
-
- /
- {album?.title}
-
-
- {/* 앨범 정보 헤더 */}
-
- {/* 앨범 커버 - 클릭하면 라이트박스 */}
-
openLightbox(
- [album.cover_original_url || album.cover_medium_url],
- 0
- )}
- >
-
-
-
- {/* 앨범 정보 */}
-
-
-
-
- {album.album_type}
-
- {/* 점3개 메뉴 - 소개글이 있을 때만 */}
- {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 && (
-
- {(() => {
- // 모든 컨셉 포토를 하나의 배열로 합치고 처음 4개만 표시
- 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 && (
-
- {/* 내부 컨테이너 - min-width, min-height 적용 */}
-
- {/* 상단 버튼들 */}
-
- {/* 다운로드 버튼 */}
-
- {/* 닫기 버튼 */}
-
-
-
- {/* 이전 버튼 */}
- {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()}
- >
- {/* 헤더 */}
-
-
앨범 소개
-
-
- {/* 내용 */}
-
-
- {album.description}
-
-
-
-
- )}
-
- >
- );
-}
-
-export default AlbumDetail;
-
diff --git a/frontend/src/pages/pc/public/AlbumGallery.jsx b/frontend/src/pages/pc/public/AlbumGallery.jsx
deleted file mode 100644
index d533900..0000000
--- a/frontend/src/pages/pc/public/AlbumGallery.jsx
+++ /dev/null
@@ -1,381 +0,0 @@
-import { useState, useEffect, useCallback, memo, 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/public/albums';
-import LightboxIndicator from '../../../components/common/LightboxIndicator';
-
-// CSS로 호버 효과 추가 + overflow 문제 수정 + 로드 애니메이션
-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;
-}
-`;
-
-function AlbumGallery() {
- 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());
-
- // useQuery로 앨범 데이터 로드
- 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 }, '');
- }, []);
-
- // 라이트박스 닫기
- const closeLightbox = useCallback(() => {
- setLightbox(prev => ({ ...prev, open: false }));
- }, []);
-
- // 뒤로가기 처리
- 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, closeLightbox]);
-
- // 프리로딩 (이전/다음 2개씩) - 백그라운드에서 프리로드만
- 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 && (
-
- {/* 내부 컨테이너 - min-width, min-height 적용 (화면 줄여도 크기 유지, 스크롤) */}
-
- {/* 상단 버튼들 */}
-
-
-
-
-
- {/* 카운터 */}
-
- {lightbox.index + 1} / {photos.length}
-
-
- {/* 이전 버튼 - margin으로 이미지와 간격 */}
- {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()}
-
- ))}
-
- )}
-
- );
- })()
- )}
-
-
- {/* 다음 버튼 - margin으로 이미지와 간격 */}
- {photos.length > 1 && (
-
- )}
-
- {/* 하단 점 인디케이터 - 공통 컴포넌트 사용 */}
-
setLightbox(prev => ({ ...prev, index: i }))}
- />
-
-
- )}
-
- >
- );
-}
-
-export default AlbumGallery;
diff --git a/frontend/src/pages/pc/public/Birthday.jsx b/frontend/src/pages/pc/public/Birthday.jsx
deleted file mode 100644
index 03cfebb..0000000
--- a/frontend/src/pages/pc/public/Birthday.jsx
+++ /dev/null
@@ -1,207 +0,0 @@
-import { useParams, useNavigate } from 'react-router-dom';
-import { useQuery } from '@tanstack/react-query';
-import { motion } from 'framer-motion';
-import { ArrowLeft, Calendar, MapPin, Clock } from 'lucide-react';
-import { fetchApi } from '../../../api';
-
-// 한글 이름 → 영어 이름 매핑
-const memberEnglishName = {
- '송하영': 'HAYOUNG',
- '박지원': 'JIWON',
- '이채영': 'CHAEYOUNG',
- '이나경': 'NAKYUNG',
- '백지헌': 'JIHEON',
- '장규리': 'GYURI',
- '이새롬': 'SAEROM',
- '노지선': 'JISUN',
- '이서연': 'SEOYEON',
-};
-
-function Birthday() {
- const { memberName, year } = useParams();
- const navigate = useNavigate();
-
- // URL 디코딩
- const decodedMemberName = decodeURIComponent(memberName || '');
- const englishName = memberEnglishName[decodedMemberName];
-
- // 멤버 정보 조회
- const { data: member, isLoading: memberLoading, error } = useQuery({
- queryKey: ['member', decodedMemberName],
- queryFn: () => fetchApi(`/api/members/${encodeURIComponent(decodedMemberName)}`),
- enabled: !!decodedMemberName,
- });
-
- // 해당 년도 생일카페 정보 조회 (나중에 구현)
- // const { data: cafes } = useQuery({
- // queryKey: ['birthdayCafes', decodedMemberName, year],
- // queryFn: () => fetchApi(`/api/birthday-cafes?member=${encodeURIComponent(decodedMemberName)}&year=${year}`),
- // });
-
- if (!decodedMemberName || error) {
- return (
-
-
-
멤버를 찾을 수 없습니다
-
-
-
- );
- }
-
- if (memberLoading) {
- return (
-
- );
- }
-
- // 생일 계산
- const birthDate = member?.birth_date ? new Date(member.birth_date) : null;
- const birthdayThisYear = birthDate ? new Date(parseInt(year), birthDate.getMonth(), birthDate.getDate()) : null;
-
- return (
-
-
- {/* 뒤로가기 */}
-
-
- {/* 헤더 카드 */}
-
- {/* 배경 장식 */}
-
-
-
- {/* 멤버 사진 */}
- {member?.image_url && (
-
-
-

-
-
- )}
-
- {/* 내용 */}
-
-
- 🎂
-
- HAPPY {englishName} DAY
-
-
-
- {year}년 {birthdayThisYear?.getMonth() + 1}월 {birthdayThisYear?.getDate()}일
-
-
-
- {/* 년도 뱃지 */}
-
-
-
-
- {/* 생일카페 섹션 */}
-
-
- ☕
- 생일카페
-
-
- {/* 준비 중 메시지 */}
-
-
🎁
-
- {year}년 {decodedMemberName} 생일카페 정보가 준비 중입니다
-
-
- 생일카페 정보가 등록되면 이곳에 표시됩니다
-
-
-
- {/* 생일카페 목록 (나중에 구현) */}
- {/* {cafes?.length > 0 ? (
-
- {cafes.map((cafe) => (
-
-
{cafe.name}
-
-
-
- {cafe.start_date} ~ {cafe.end_date}
-
-
-
- {cafe.open_time} - {cafe.close_time}
-
-
-
- {cafe.location}
-
-
-
- ))}
-
- ) : null} */}
-
-
- {/* 다른 년도 보기 (나중에 구현) */}
- {/*
- {[2023, 2024, 2025, 2026].map((y) => (
-
- ))}
- */}
-
-
- );
-}
-
-export default Birthday;
diff --git a/frontend/src/pages/pc/public/Home.jsx b/frontend/src/pages/pc/public/Home.jsx
deleted file mode 100644
index 6040cb2..0000000
--- a/frontend/src/pages/pc/public/Home.jsx
+++ /dev/null
@@ -1,333 +0,0 @@
-import { useState } from "react";
-import { useQuery } from "@tanstack/react-query";
-import { motion } from "framer-motion";
-import { Link } from "react-router-dom";
-import { Calendar, ArrowRight, Clock, Link2, Tag, Music } from "lucide-react";
-import { getTodayKST } from "../../../utils/date";
-import { getMembers } from "../../../api/public/members";
-import { getAlbums } from "../../../api/public/albums";
-import { getUpcomingSchedules } from "../../../api/public/schedules";
-
-function Home() {
- // useQuery로 멤버 데이터 로드
- const { data: members = [] } = useQuery({
- queryKey: ["members"],
- queryFn: getMembers,
- });
-
- // useQuery로 앨범 로드 (최신 4개)
- const { data: albums = [] } = useQuery({
- queryKey: ["albums"],
- queryFn: getAlbums,
- select: (data) => data.slice(0, 4),
- });
-
- // useQuery로 다가오는 일정 로드 (오늘 이후 3개)
- const { data: upcomingSchedules = [] } = useQuery({
- queryKey: ["upcomingSchedules", 3],
- queryFn: () => getUpcomingSchedules(3),
- });
-
- return (
-
- {/* 히어로 섹션 */}
-
-
-
-
- fromis_9
- 프로미스나인
-
- 인사드리겠습니다. 둘, 셋!
-
- 이제는 약속해 소중히 간직해,
-
- 당신의 아이돌로 성장하겠습니다!
-
-
-
-
- {/* 장식 */}
-
-
-
- {/* 그룹 통계 섹션 */}
-
-
-
- {[
- { value: "2018.01.24", label: "데뷔일" },
- {
- value: `D+${(
- Math.floor(
- (new Date() - new Date("2018-01-24")) /
- (1000 * 60 * 60 * 24)
- ) + 1
- ).toLocaleString()}`,
- label: "D+Day",
- },
- { value: "5", label: "멤버 수" },
- { value: "flover", label: "팬덤명" },
- ].map((stat, index) => (
-
- {stat.value}
- {stat.label}
-
- ))}
-
-
-
-
- {/* 멤버 미리보기 */}
-
-
-
-
- {members
- .filter((m) => !m.is_former)
- .map((member, index) => (
-
- {/* 이미지 컨테이너 */}
-
-

-
-
- {/* 그라데이션 오버레이 */}
-
-
- {/* 멤버 정보 */}
-
-
- {member.name}
-
-
-
- ))}
-
-
-
-
- {/* 앨범 미리보기 */}
-
-
-
-
- {albums.map((album, index) => (
-
window.location.href = `/album/${encodeURIComponent(album.title)}`}
- >
-
-

- {/* 호버 오버레이 */}
-
-
-
-
{album.tracks?.length || 0}곡 수록
-
-
-
-
-
{album.title}
-
{album.release_date?.slice(0, 4)}
-
-
- ))}
-
-
-
-
- {/* 일정 미리보기 */}
-
-
-
- {upcomingSchedules.length === 0 ? (
-
- ) : (
-
- {upcomingSchedules.map((schedule) => {
- const scheduleDate = new Date(schedule.date);
- const today = new Date();
- const currentYear = today.getFullYear();
- const currentMonth = today.getMonth();
-
- const scheduleYear = scheduleDate.getFullYear();
- const scheduleMonth = scheduleDate.getMonth();
- const isCurrentYear = scheduleYear === currentYear;
- const isCurrentMonth =
- isCurrentYear && scheduleMonth === currentMonth;
-
- const day = scheduleDate.getDate();
- const weekdays = ["일", "월", "화", "수", "목", "금", "토"];
- const weekday = weekdays[scheduleDate.getDay()];
-
- // 멤버 처리
- const memberList = schedule.member_names
- ? schedule.member_names.split(",")
- : schedule.members?.map(m => m.name) || [];
- const displayMembers = memberList;
-
- const categoryColor = schedule.category_color || '#6366f1';
-
- return (
-
- {/* 날짜 영역 - 카테고리 색상 */}
-
- {!isCurrentYear && (
-
- {scheduleYear}.{scheduleMonth + 1}
-
- )}
- {isCurrentYear && !isCurrentMonth && (
-
- {scheduleMonth + 1}월
-
- )}
- {day}
- {weekday}
-
-
- {/* 내용 영역 */}
-
-
{schedule.title}
-
- {schedule.time && (
-
-
- {schedule.time.slice(0, 5)}
-
- )}
-
-
- {schedule.category_name}
-
-
- {displayMembers.length > 0 && (
-
- {displayMembers.map((name, i) => (
-
- {name.trim()}
-
- ))}
-
- )}
-
-
- );
- })}
-
- )}
-
-
-
- );
-}
-
-export default Home;
diff --git a/frontend/src/pages/pc/public/Members.jsx b/frontend/src/pages/pc/public/Members.jsx
deleted file mode 100644
index 9f1fd85..0000000
--- a/frontend/src/pages/pc/public/Members.jsx
+++ /dev/null
@@ -1,148 +0,0 @@
-import { useQuery } from '@tanstack/react-query';
-import { motion } from 'framer-motion';
-import { Instagram, Calendar } from 'lucide-react';
-import { getMembers } from '../../../api/public/members';
-import { formatDate } from '../../../utils/date';
-
-function Members() {
- // useQuery로 멤버 데이터 로드
- const { data: members = [], isLoading: loading } = useQuery({
- queryKey: ['members'],
- queryFn: getMembers,
- });
-
-
- if (loading) {
- return (
-
- );
- }
-
- return (
-
-
- {/* 헤더 */}
-
-
- 멤버
-
-
- 프로미스나인의 멤버를 소개합니다
-
-
-
- {/* 현재 멤버 그리드 */}
-
- {members.filter(m => !m.is_former).map((member, index) => (
-
-
- {/* 이미지 */}
-
-

-
-
- {/* 정보 */}
-
-
{member.name}
-
-
-
- {formatDate(member.birth_date, 'YYYY.MM.DD')}
-
-
- {/* 인스타그램 링크 */}
- {member.instagram && (
-
-
- Instagram
-
- )}
-
-
- {/* 호버 효과 - 컬러 바 */}
-
-
-
- ))}
-
-
- {/* 전 멤버 섹션 - 현재 멤버와 동일한 카드 UI */}
- {members.filter(m => m.is_former).length > 0 && (
-
- 전 멤버
-
- {members.filter(m => m.is_former).map((member, index) => (
-
-
- {/* 이미지 - grayscale */}
-
-

-
-
- {/* 정보 */}
-
-
{member.name}
-
-
-
- {formatDate(member.birth_date, 'YYYY.MM.DD')}
-
-
-
- {/* 호버 효과 - 컬러 바 */}
-
-
-
- ))}
-
-
- )}
-
-
-
-
- );
-}
-
-export default Members;
diff --git a/frontend/src/pages/pc/public/NotFound.jsx b/frontend/src/pages/pc/public/NotFound.jsx
deleted file mode 100644
index 5ba7609..0000000
--- a/frontend/src/pages/pc/public/NotFound.jsx
+++ /dev/null
@@ -1,90 +0,0 @@
-import { motion } from "framer-motion";
-import { Link } from "react-router-dom";
-import { Home, ArrowLeft } from "lucide-react";
-
-function NotFound() {
- return (
-
-
- {/* 404 숫자 */}
-
-
- 404
-
-
-
- {/* 메시지 */}
-
-
- 페이지를 찾을 수 없습니다
-
-
- 요청하신 페이지가 존재하지 않거나 이동되었을 수 있습니다.
-
- 주소를 다시 확인해 주세요.
-
-
-
- {/* 장식 요소 */}
-
- {[...Array(5)].map((_, i) => (
-
- ))}
-
-
- {/* 버튼들 */}
-
-
-
-
- 홈으로 가기
-
-
-
-
- );
-}
-
-export default NotFound;
diff --git a/frontend/src/pages/pc/public/Schedule.jsx b/frontend/src/pages/pc/public/Schedule.jsx
deleted file mode 100644
index eb97a38..0000000
--- a/frontend/src/pages/pc/public/Schedule.jsx
+++ /dev/null
@@ -1,1395 +0,0 @@
-import { useState, useEffect, useRef, useMemo, useDeferredValue, memo, useCallback } from 'react';
-import { useNavigate } from 'react-router-dom';
-import { motion, AnimatePresence } from 'framer-motion';
-import { Clock, ChevronLeft, ChevronRight, ChevronDown, Tag, Search, ArrowLeft, Link2, X } from 'lucide-react';
-import { useQuery, useInfiniteQuery } from '@tanstack/react-query';
-import { useVirtualizer } from '@tanstack/react-virtual';
-import { useInView } from 'react-intersection-observer';
-import confetti from 'canvas-confetti';
-import { getTodayKST } from '../../../utils/date';
-import { getSchedules, searchSchedules } from '../../../api/public/schedules';
-import useScheduleStore from '../../../stores/useScheduleStore';
-
-// HTML 엔티티 디코딩 함수
-const decodeHtmlEntities = (text) => {
- if (!text) return '';
- const textarea = document.createElement('textarea');
- textarea.innerHTML = text;
- return textarea.value;
-};
-
-// 멤버 리스트 추출 (검색 결과와 일반 데이터 모두 처리)
-const getMemberList = (schedule) => {
- // member_names 문자열이 있으면 사용
- if (schedule.member_names) {
- return schedule.member_names.split(',').map(n => n.trim()).filter(Boolean);
- }
- // members 배열이 있으면
- if (Array.isArray(schedule.members) && schedule.members.length > 0) {
- // 문자열 배열인 경우 (검색 결과)
- if (typeof schedule.members[0] === 'string') {
- return schedule.members.filter(Boolean);
- }
- // 객체 배열인 경우 (일반 데이터)
- return schedule.members.map(m => m.name).filter(Boolean);
- }
- return [];
-};
-
-// 폭죽 애니메이션 함수
-const fireBirthdayConfetti = () => {
- const duration = 3000;
- const animationEnd = Date.now() + duration;
- const colors = ['#ff69b4', '#ff1493', '#da70d6', '#ba55d3', '#9370db', '#8a2be2', '#ffd700', '#ff6347'];
-
- const randomInRange = (min, max) => Math.random() * (max - min) + min;
-
- const interval = setInterval(() => {
- const timeLeft = animationEnd - Date.now();
-
- if (timeLeft <= 0) {
- clearInterval(interval);
- return;
- }
-
- const particleCount = 50 * (timeLeft / duration);
-
- // 왼쪽에서 발사
- confetti({
- particleCount: Math.floor(particleCount),
- startVelocity: 30,
- spread: 60,
- origin: { x: randomInRange(0.1, 0.3), y: Math.random() - 0.2 },
- colors: colors,
- shapes: ['circle', 'square'],
- gravity: 1.2,
- scalar: randomInRange(0.8, 1.2),
- drift: randomInRange(-0.5, 0.5),
- });
-
- // 오른쪽에서 발사
- confetti({
- particleCount: Math.floor(particleCount),
- startVelocity: 30,
- spread: 60,
- origin: { x: randomInRange(0.7, 0.9), y: Math.random() - 0.2 },
- colors: colors,
- shapes: ['circle', 'square'],
- gravity: 1.2,
- scalar: randomInRange(0.8, 1.2),
- drift: randomInRange(-0.5, 0.5),
- });
- }, 250);
-
- // 초기 대형 폭죽
- confetti({
- particleCount: 100,
- spread: 100,
- origin: { x: 0.5, y: 0.6 },
- colors: colors,
- shapes: ['circle', 'square'],
- startVelocity: 45,
- });
-};
-
-// 생일 카드 컴포넌트
-function BirthdayCard({ schedule, formatted, showYear = false, onClick }) {
- return (
-
- {/* 배경 장식 */}
-
-
-
- {/* 멤버 사진 */}
- {schedule.member_image && (
-
-
-

-
-
- )}
-
- {/* 내용 */}
-
- 🎂
-
- {schedule.title}
-
-
-
- {/* 날짜 뱃지 */}
-
- {showYear && (
-
- {new Date(schedule.date).getFullYear()}
-
- )}
-
- {new Date(schedule.date).getMonth() + 1}월
-
-
- {formatted.day}
-
-
-
-
- );
-}
-
-function Schedule() {
- const navigate = useNavigate();
-
- // 상태 관리 (zustand store)
- const {
- currentDate,
- setCurrentDate,
- selectedDate: storedSelectedDate,
- setSelectedDate: setStoredSelectedDate,
- selectedCategories,
- setSelectedCategories,
- isSearchMode,
- setIsSearchMode,
- searchInput,
- setSearchInput,
- searchTerm,
- setSearchTerm,
- } = useScheduleStore();
-
- // 초기값 설정 (store에 값이 없으면 오늘 날짜)
- const selectedDate = storedSelectedDate === undefined ? getTodayKST() : storedSelectedDate;
- const setSelectedDate = setStoredSelectedDate;
-
- const [showYearMonthPicker, setShowYearMonthPicker] = useState(false);
- const [viewMode, setViewMode] = useState('yearMonth');
- const [slideDirection, setSlideDirection] = useState(0);
- const pickerRef = useRef(null);
-
- // 월별 일정 데이터 로드 (useQuery)
- 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 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]);
-
- // 오늘 생일이 있으면 폭죽 발사 (하루에 한 번만)
- 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]);
-
- // 카테고리 필터 툴팁
- const [showCategoryTooltip, setShowCategoryTooltip] = useState(false);
- const categoryRef = useRef(null);
- const scrollContainerRef = useRef(null); // 일정 목록 스크롤 컨테이너
- const searchContainerRef = useRef(null); // 검색 컨테이너 (외부 클릭 감지용)
-
- // 검색 관련 로컬 상태 (store에서 관리하지 않는 것들)
- const [originalSearchQuery, setOriginalSearchQuery] = useState(''); // 사용자가 직접 입력한 원본 값 (필터링용)
- const [showSuggestions, setShowSuggestions] = useState(false);
- const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1);
- const [suggestions, setSuggestions] = useState([]); // 추천 검색어 목록
- const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false);
- const SEARCH_LIMIT = 20; // 페이지당 20개
- const ESTIMATED_ITEM_HEIGHT = 120; // 아이템 추정 높이 (동적 측정)
-
- // Intersection Observer for infinite scroll
- const { ref: loadMoreRef, inView } = useInView({
- threshold: 0,
- rootMargin: '100px',
- });
-
- // useInfiniteQuery for search
- const {
- data: searchData,
- fetchNextPage,
- hasNextPage,
- isFetchingNextPage,
- isLoading: searchLoading,
- refetch: refetchSearch,
- } = 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,
- });
-
- // Flatten search results (already transformed by API layer)
- const searchResults = useMemo(() => {
- if (!searchData?.pages) return [];
- return searchData.pages.flatMap(page => page.schedules);
- }, [searchData]);
-
- const searchTotal = searchData?.pages?.[0]?.total || 0;
-
-
-
- // Auto fetch next page when scrolled to bottom
- // inView가 true로 변경될 때만 fetch (중복 요청 방지)
- const prevInViewRef = useRef(false);
- useEffect(() => {
- // inView가 false→true로 변경될 때만 fetch
- if (inView && !prevInViewRef.current && hasNextPage && !isFetchingNextPage && isSearchMode && searchTerm) {
- fetchNextPage();
- }
- prevInViewRef.current = inView;
- }, [inView, hasNextPage, isFetchingNextPage, fetchNextPage, isSearchMode, searchTerm]);
-
- // 검색어 자동완성 API 호출 (debounce 적용)
- useEffect(() => {
- // 검색어가 비어있으면 초기화
- if (!originalSearchQuery || originalSearchQuery.trim().length === 0) {
- setSuggestions([]);
- return;
- }
-
- // debounce: 200ms 후에 API 호출
- const timeoutId = setTimeout(async () => {
- setIsLoadingSuggestions(true);
- 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([]);
- } finally {
- setIsLoadingSuggestions(false);
- }
- }, 200);
-
- return () => clearTimeout(timeoutId);
- }, [originalSearchQuery]);
-
- // 카테고리/일정 데이터는 상단에서 useQuery로 관리됨
-
- // 외부 클릭시 팝업 닫기
- useEffect(() => {
- const handleClickOutside = (event) => {
- if (pickerRef.current && !pickerRef.current.contains(event.target)) {
- setShowYearMonthPicker(false);
- setViewMode('yearMonth');
- }
- if (categoryRef.current && !categoryRef.current.contains(event.target)) {
- setShowCategoryTooltip(false);
- }
- // 검색 추천 드롭다운 외부 클릭 시 닫기
- if (searchContainerRef.current && !searchContainerRef.current.contains(event.target)) {
- setShowSuggestions(false);
- setSelectedSuggestionIndex(-1);
- }
- };
-
- document.addEventListener('mousedown', handleClickOutside);
- return () => document.removeEventListener('mousedown', handleClickOutside);
- }, []);
-
- // 날짜 변경 시 스크롤 맨 위로 초기화
- useEffect(() => {
- if (scrollContainerRef.current) {
- scrollContainerRef.current.scrollTop = 0;
- }
- }, [selectedDate]);
-
- // 달력 관련 함수
- const getDaysInMonth = (y, m) => new Date(y, m + 1, 0).getDate();
- const getFirstDayOfMonth = (y, m) => new Date(y, m, 1).getDay();
-
- // year, month는 상단에서 이미 선언됨 (useQuery)
- const daysInMonth = getDaysInMonth(year, month);
- const firstDay = getFirstDayOfMonth(year, month);
-
- const days = ['일', '월', '화', '수', '목', '금', '토'];
-
- // 스케줄 데이터를 지연 처리하여 달력 UI 응답성 향상
- const deferredSchedules = useDeferredValue(schedules);
-
- // 일정 날짜별 맵 (O(1) 조회용) - 지연된 데이터로 점 표시
- const scheduleDateMap = useMemo(() => {
- const map = new Map();
- deferredSchedules.forEach(s => {
- const dateStr = s.date ? s.date.split('T')[0] : '';
- if (!map.has(dateStr)) {
- map.set(dateStr, s);
- }
- });
- return map;
- }, [deferredSchedules]);
-
- // 해당 날짜의 첫 번째 일정 카테고리 색상 (O(1))
- const getScheduleColor = (day) => {
- const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
- const schedule = scheduleDateMap.get(dateStr);
- if (!schedule) return null;
- // schedule에서 직접 색상 가져오기
- if (schedule.category_color) return schedule.category_color;
- const cat = categories.find(c => c.id === schedule.category_id);
- return cat?.color || '#4A7C59';
- };
-
- // 해당 날짜에 일정이 있는지 확인 (O(1))
- const hasSchedule = (day) => {
- const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
- return scheduleDateMap.has(dateStr);
- };
-
- // 2017년 1월 이전으로 이동 불가
- const canGoPrevMonth = !(year === 2017 && month === 0);
-
- const prevMonth = () => {
- if (!canGoPrevMonth) return;
- setSlideDirection(-1);
- const newDate = new Date(year, month - 1, 1);
- setCurrentDate(newDate);
- // 이번달이면 오늘, 다른 달이면 1일 선택
- const today = new Date();
- if (newDate.getFullYear() === today.getFullYear() && newDate.getMonth() === today.getMonth()) {
- setSelectedDate(getTodayKST());
- } else {
- const firstDay = `${newDate.getFullYear()}-${String(newDate.getMonth() + 1).padStart(2, '0')}-01`;
- setSelectedDate(firstDay);
- }
- };
-
- const nextMonth = () => {
- setSlideDirection(1);
- const newDate = new Date(year, month + 1, 1);
- setCurrentDate(newDate);
- // 이번달이면 오늘, 다른 달이면 1일 선택
- const today = new Date();
- if (newDate.getFullYear() === today.getFullYear() && newDate.getMonth() === today.getMonth()) {
- setSelectedDate(getTodayKST());
- } else {
- const firstDay = `${newDate.getFullYear()}-${String(newDate.getMonth() + 1).padStart(2, '0')}-01`;
- setSelectedDate(firstDay);
- }
- };
-
- // 날짜 선택 (토글 없이 항상 선택)
- const selectDate = (day) => {
- const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
- setSelectedDate(dateStr);
- };
-
- const selectYear = (newYear) => {
- setCurrentDate(new Date(newYear, month, 1));
- };
-
- const selectMonth = (newMonth) => {
- const newDate = new Date(year, newMonth, 1);
- setCurrentDate(newDate);
- // 이번달이면 오늘, 다른 달이면 1일 선택
- const today = new Date();
- if (newDate.getFullYear() === today.getFullYear() && newDate.getMonth() === today.getMonth()) {
- setSelectedDate(getTodayKST());
- } else {
- const firstDay = `${year}-${String(newMonth + 1).padStart(2, '0')}-01`;
- setSelectedDate(firstDay);
- }
- setShowYearMonthPicker(false);
- setViewMode('yearMonth');
- };
-
- // 카테고리 토글
- const toggleCategory = (categoryId) => {
- if (selectedCategories.includes(categoryId)) {
- setSelectedCategories(selectedCategories.filter(id => id !== categoryId));
- } else {
- setSelectedCategories([...selectedCategories, categoryId]);
- }
- };
-
- // 필터링된 스케줄 (useMemo로 성능 최적화, 시간순 정렬)
- const currentYearMonth = `${year}-${String(month + 1).padStart(2, '0')}`;
-
- const filteredSchedules = useMemo(() => {
- // 생일 우선 정렬 함수
- const sortWithBirthdayFirst = (list) => {
- return [...list].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;
- });
- };
-
- // 검색 모드일 때
- if (isSearchMode) {
- // 검색 전엔 빈 목록, 검색 후엔 API 결과 (Meilisearch 유사도순 유지)
- if (!searchTerm) return [];
- // 카테고리 필터링 적용
- if (selectedCategories.length === 0) return sortWithBirthdayFirst(searchResults);
- return sortWithBirthdayFirst(searchResults.filter(s => selectedCategories.includes(s.category_id)));
- }
-
-
- // 일반 모드: 기존 필터링
- const filtered = schedules
- .filter(s => {
- const scheduleDate = s.date ? s.date.split('T')[0] : '';
- const matchesDate = selectedDate
- ? scheduleDate === selectedDate
- : scheduleDate.startsWith(currentYearMonth);
- const matchesCategory = selectedCategories.length === 0 || selectedCategories.includes(s.category_id);
- return matchesDate && matchesCategory;
- })
- .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;
- // 날짜/시간순
- const dateA = a.date ? a.date.split('T')[0] : '';
- const dateB = b.date ? b.date.split('T')[0] : '';
- if (dateA !== dateB) return dateA.localeCompare(dateB);
- const timeA = a.time || '00:00:00';
- const timeB = b.time || '00:00:00';
- return timeA.localeCompare(timeB);
- });
- return filtered;
- }, [schedules, selectedDate, currentYearMonth, selectedCategories, isSearchMode, searchTerm, searchResults]);
-
- // 가상 스크롤 설정 (검색 모드에서만 활성화, 동적 높이 지원)
- const virtualizer = useVirtualizer({
- count: isSearchMode && searchTerm ? filteredSchedules.length : 0,
- getScrollElement: () => scrollContainerRef.current,
- estimateSize: () => ESTIMATED_ITEM_HEIGHT,
- overscan: 5, // 버퍼 아이템 수
- });
-
- // 카테고리별 카운트 맵 (useMemo로 미리 계산) - 선택된 날짜 기준
- const categoryCounts = useMemo(() => {
- // 검색어가 있을 때만 검색 결과 사용, 아니면 기존 schedules 사용
- const source = (isSearchMode && searchTerm) ? searchResults : schedules;
- const counts = new Map();
- let total = 0;
-
- source.forEach(s => {
- const scheduleDate = s.date ? s.date.split('T')[0] : '';
- // 검색 모드에서 검색어가 있을 때는 전체 대상
- // 그 외에는 선택된 날짜 기준으로 필터링
- if (!(isSearchMode && searchTerm) && selectedDate) {
- if (scheduleDate !== selectedDate) return;
- }
-
- const catId = s.category_id;
- if (catId) {
- counts.set(catId, (counts.get(catId) || 0) + 1);
- total++;
- }
- });
-
- counts.set('total', total);
- return counts;
- }, [schedules, searchResults, isSearchMode, searchTerm, selectedDate]);
-
- const formatDate = (dateStr) => {
- const date = new Date(dateStr);
- const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
- return {
- month: date.getMonth() + 1,
- day: date.getDate(),
- weekday: dayNames[date.getDay()],
- };
- };
-
- // 일정 클릭 핸들러
- const handleScheduleClick = (schedule) => {
- // 생일 일정은 생일 페이지로 이동
- if (schedule.is_birthday || String(schedule.id).startsWith('birthday-')) {
- const scheduleYear = new Date(schedule.date).getFullYear();
- const memberName = schedule.member_names; // 한글 이름
- navigate(`/birthday/${encodeURIComponent(memberName)}/${scheduleYear}`);
- return;
- }
-
- // 유튜브(id=2), X(id=3), 콘서트(id=6) 카테고리는 상세 페이지로 이동
- if (schedule.category_id === 2 || schedule.category_id === 3 || schedule.category_id === 6) {
- navigate(`/schedule/${schedule.id}`);
- return;
- }
-
- // 설명이 없고 URL만 있으면 바로 링크 열기
- if (!schedule.description && schedule.source?.url) {
- window.open(schedule.source?.url, '_blank');
- } else {
- // 상세 페이지로 이동
- navigate(`/schedule/${schedule.id}`);
- }
- };
-
- const currentYear = new Date().getFullYear();
- const isCurrentYear = (y) => y === currentYear;
- const isCurrentMonth = (m) => {
- const now = new Date();
- return year === now.getFullYear() && m === now.getMonth();
- };
-
- // 연도 선택 범위
- const MIN_YEAR = 2017;
- const [yearRangeStart, setYearRangeStart] = useState(MIN_YEAR);
- const yearRange = Array.from({ length: 12 }, (_, i) => yearRangeStart + i);
- const monthNames = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월'];
- const canGoPrevYearRange = yearRangeStart > MIN_YEAR;
- const prevYearRange = () => canGoPrevYearRange && setYearRangeStart(prev => Math.max(MIN_YEAR, prev - 12));
- const nextYearRange = () => setYearRangeStart(prev => prev + 12);
-
- // 선택된 카테고리 이름
- const getSelectedCategoryNames = () => {
- if (selectedCategories.length === 0) return '전체';
- const names = selectedCategories.map(id => {
- const cat = categories.find(c => c.id === id);
- return cat?.name || '';
- }).filter(Boolean);
- if (names.length <= 2) return names.join(', ');
- return `${names.slice(0, 2).join(', ')} 외 ${names.length - 2}개`;
- };
-
- // 카테고리 색상 가져오기 (schedule 객체에서 직접 가져오거나 categories에서 조회)
- const getCategoryColor = useCallback((categoryId, schedule = null) => {
- if (schedule?.category_color) return schedule.category_color;
- const cat = categories.find(c => c.id === categoryId);
- return cat?.color || '#808080';
- }, [categories]);
-
- const getCategoryName = useCallback((categoryId, schedule = null) => {
- if (schedule?.category_name) return schedule.category_name;
- const cat = categories.find(c => c.id === categoryId);
- return cat?.name || '';
- }, [categories]);
-
- // 정렬된 카테고리 목록 (메모이제이션으로 깜빡임 방지)
- const sortedCategories = useMemo(() => {
- return categories
- .map(category => ({
- ...category,
- count: categoryCounts.get(category.id) || 0
- }))
- .filter(category => category.count > 0)
- .sort((a, b) => {
- if (a.name === '기타') return 1;
- if (b.name === '기타') return -1;
- return b.count - a.count;
- });
- }, [categories, categoryCounts]);
-
- return (
-
-
- {/* 헤더 */}
-
-
- 일정
-
-
- 프로미스나인의 다가오는 일정을 확인하세요
-
-
-
-
- {/* 왼쪽: 달력 + 카테고리 */}
-
- {/* 달력 */}
-
-
- {/* 달력 헤더 */}
-
-
-
-
-
-
- {/* 년/월 선택 팝업 */}
-
- {showYearMonthPicker && (
-
-
-
-
- {viewMode === 'yearMonth' ? `${yearRange[0]} - ${yearRange[yearRange.length - 1]}` : `${year}년`}
-
-
-
-
-
- {viewMode === 'yearMonth' && (
-
- 년도
-
- {yearRange.map((y) => (
-
- ))}
-
- 월
-
- {monthNames.map((m, i) => (
-
- ))}
-
-
- )}
- {viewMode === 'months' && (
-
- 월 선택
-
- {monthNames.map((m, i) => (
-
- ))}
-
-
- )}
-
-
- )}
-
-
- {/* 요일 헤더 + 날짜 그리드 */}
-
-
-
- {days.map((day, i) => (
-
- {day}
-
- ))}
-
-
-
- {/* 전달 날짜 */}
- {Array.from({ length: firstDay }).map((_, i) => {
- const prevMonthDays = getDaysInMonth(year, month - 1);
- const day = prevMonthDays - firstDay + i + 1;
- return (
-
- {day}
-
- );
- })}
-
- {/* 현재 달 날짜 */}
- {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 hasEvent = hasSchedule(day);
- const eventColor = getScheduleColor(day);
- const dayOfWeek = (firstDay + i) % 7;
- const isToday = new Date().toDateString() === new Date(year, month, day).toDateString();
-
- // 해당 날짜의 일정 목록 (점 표시용, 최대 3개)
- const daySchedules = schedules.filter(s => {
- const scheduleDate = s.date ? s.date.split('T')[0] : '';
- const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
- return scheduleDate === dateStr;
- }).slice(0, 3);
-
- return (
-
- );
- })}
-
- {/* 다음달 날짜 */}
- {(() => {
- const totalCells = firstDay + daysInMonth;
- const remainder = totalCells % 7;
- const nextDays = remainder === 0 ? 0 : 7 - remainder;
- return Array.from({ length: nextDays }).map((_, i) => (
-
- {i + 1}
-
- ));
- })()}
-
-
-
-
- {/* 범례 */}
-
-
-
-
- {/* 카테고리 필터 */}
-
- 카테고리
-
- {/* 전체 */}
-
-
- {/* 개별 카테고리 - useMemo로 정렬됨 */}
- {sortedCategories.map(category => {
- const isSelected = selectedCategories.includes(category.id);
- return (
-
- );
- })}
-
-
-
-
- {/* 스케줄 리스트 */}
-
- {/* 헤더 */}
-
-
- {isSearchMode ? (
- /* 검색 모드 - 밑줄 스타일 */
-
- {/* 검색창 컨테이너 - 화살표와 검색창 일체형 */}
-
-
- {/* 뒤로가기 영역 */}
-
-
- {/* 검색 입력 영역 */}
-
- {
- setSearchInput(e.target.value);
- setOriginalSearchQuery(e.target.value); // 원본 쿼리도 업데이트
- setShowSuggestions(true);
- setSelectedSuggestionIndex(-1);
- }}
- onFocus={() => setShowSuggestions(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') {
- if (selectedSuggestionIndex >= 0 && suggestions[selectedSuggestionIndex]) {
- setSearchInput(suggestions[selectedSuggestionIndex]);
- setSearchTerm(suggestions[selectedSuggestionIndex]);
- } else if (searchInput.trim()) {
- setSearchTerm(searchInput);
- }
- setShowSuggestions(false);
- setSelectedSuggestionIndex(-1);
- } else if (e.key === 'Escape') {
- setIsSearchMode(false);
- setSearchInput('');
- setOriginalSearchQuery('');
- setSearchTerm('');
- setShowSuggestions(false);
- setSelectedSuggestionIndex(-1);
- // 스크롤 위치 초기화
- if (scrollContainerRef.current) {
- scrollContainerRef.current.scrollTop = 0;
- }
- }
- }}
- className="flex-1 bg-transparent focus:outline-none text-gray-700 placeholder-gray-400 text-sm"
- />
- {/* 입력 지우기 버튼 - 항상 공간 차지, 입력 있을 때만 보임 */}
-
-
-
- {/* 검색 버튼 영역 */}
-
-
-
- {/* 검색어 추천 드롭다운 */}
- {showSuggestions && !isLoadingSuggestions && suggestions.length > 0 && (
-
- {suggestions.map((suggestion, index) => (
-
- ))}
-
- )}
-
-
- ) : (
- /* 일반 모드 */
-
-
-
- {selectedDate
- ? (() => {
- const d = new Date(selectedDate);
- const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
- return `${d.getMonth() + 1}월 ${d.getDate()}일 ${dayNames[d.getDay()]}요일`;
- })()
- : `${month + 1}월 전체 일정`
- }
-
- {selectedCategories.length > 0 && (
-
-
-
- {showCategoryTooltip && (
-
- {selectedCategories.map(id => {
- const cat = categories.find(c => c.id === id);
- if (!cat) return null;
- return (
-
-
- {cat.name}
-
- );
- })}
-
- )}
-
-
- )}
-
- )}
-
-
- {!isSearchMode && (
-
- {filteredSchedules.length}개 일정
-
- )}
-
-
-
-
-
-
-
-
-
-
- );
-}
-
-export default Schedule;
diff --git a/frontend/src/pages/pc/public/ScheduleDetail.jsx b/frontend/src/pages/pc/public/ScheduleDetail.jsx
deleted file mode 100644
index 42ab0db..0000000
--- a/frontend/src/pages/pc/public/ScheduleDetail.jsx
+++ /dev/null
@@ -1,175 +0,0 @@
-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/public/schedules';
-
-// 분리된 카테고리별 섹션 컴포넌트들
-import {
- YoutubeSection,
- XSection,
- DefaultSection,
- CATEGORY_ID,
- decodeHtmlEntities,
-} from './schedule-sections';
-
-function ScheduleDetail() {
- const { id } = useParams();
-
- const { data: schedule, isLoading, error } = useQuery({
- queryKey: ['schedule', id],
- queryFn: () => getSchedule(id),
- placeholderData: keepPreviousData,
- retry: false, // 404 등 에러 시 재시도하지 않음
- });
-
- 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 ScheduleDetail;
diff --git a/frontend/src/pages/pc/public/TrackDetail.jsx b/frontend/src/pages/pc/public/TrackDetail.jsx
deleted file mode 100644
index bf98047..0000000
--- a/frontend/src/pages/pc/public/TrackDetail.jsx
+++ /dev/null
@@ -1,323 +0,0 @@
-import { useState, 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/public/albums';
-
-// 유튜브 URL에서 비디오 ID 추출
-const 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;
-};
-
-// 쉼표 기준 줄바꿈 처리
-const formatCredit = (text) => {
- if (!text) return null;
- return text.split(',').map((item, index) => (
-
{item.trim()}
- ));
-};
-
-// PC 곡 상세 페이지
-function TrackDetail() {
- const { name: albumName, trackTitle } = useParams();
- const navigate = useNavigate();
-
- // useQuery로 트랙 데이터 로드
- 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}
-
- )}
-
-
-
- {/* 뮤직비디오 섹션 (유튜브 embed) */}
- {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, index) => {
- 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 TrackDetail;
diff --git a/frontend-temp/src/pages/pc/public/album/Album.jsx b/frontend/src/pages/pc/public/album/Album.jsx
similarity index 100%
rename from frontend-temp/src/pages/pc/public/album/Album.jsx
rename to frontend/src/pages/pc/public/album/Album.jsx
diff --git a/frontend-temp/src/pages/pc/public/album/AlbumDetail.jsx b/frontend/src/pages/pc/public/album/AlbumDetail.jsx
similarity index 100%
rename from frontend-temp/src/pages/pc/public/album/AlbumDetail.jsx
rename to frontend/src/pages/pc/public/album/AlbumDetail.jsx
diff --git a/frontend-temp/src/pages/pc/public/album/AlbumGallery.jsx b/frontend/src/pages/pc/public/album/AlbumGallery.jsx
similarity index 100%
rename from frontend-temp/src/pages/pc/public/album/AlbumGallery.jsx
rename to frontend/src/pages/pc/public/album/AlbumGallery.jsx
diff --git a/frontend-temp/src/pages/pc/public/album/TrackDetail.jsx b/frontend/src/pages/pc/public/album/TrackDetail.jsx
similarity index 100%
rename from frontend-temp/src/pages/pc/public/album/TrackDetail.jsx
rename to frontend/src/pages/pc/public/album/TrackDetail.jsx
diff --git a/frontend-temp/src/pages/pc/public/common/NotFound.jsx b/frontend/src/pages/pc/public/common/NotFound.jsx
similarity index 100%
rename from frontend-temp/src/pages/pc/public/common/NotFound.jsx
rename to frontend/src/pages/pc/public/common/NotFound.jsx
diff --git a/frontend-temp/src/pages/pc/public/home/Home.jsx b/frontend/src/pages/pc/public/home/Home.jsx
similarity index 100%
rename from frontend-temp/src/pages/pc/public/home/Home.jsx
rename to frontend/src/pages/pc/public/home/Home.jsx
diff --git a/frontend-temp/src/pages/pc/public/members/Members.jsx b/frontend/src/pages/pc/public/members/Members.jsx
similarity index 100%
rename from frontend-temp/src/pages/pc/public/members/Members.jsx
rename to frontend/src/pages/pc/public/members/Members.jsx
diff --git a/frontend/src/pages/pc/public/schedule-sections/DefaultSection.jsx b/frontend/src/pages/pc/public/schedule-sections/DefaultSection.jsx
deleted file mode 100644
index 4cd7717..0000000
--- a/frontend/src/pages/pc/public/schedule-sections/DefaultSection.jsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import { Calendar, Clock, ExternalLink } from 'lucide-react';
-import { decodeHtmlEntities, formatFullDate, formatTime } from './utils';
-
-// 기본 섹션 컴포넌트 (다른 카테고리용)
-function DefaultSection({ schedule }) {
- return (
-
- {/* 제목 */}
-
- {decodeHtmlEntities(schedule.title)}
-
-
- {/* 메타 정보 */}
-
-
-
- {formatFullDate(schedule.date)}
-
- {schedule.time && (
-
-
- {formatTime(schedule.time)}
-
- )}
-
-
- {/* 설명 */}
- {schedule.description && (
-
-
- {decodeHtmlEntities(schedule.description)}
-
-
- )}
-
- {/* 원본 링크 */}
- {schedule.source?.url && (
-
-
- 원본 보기
-
- )}
-
- );
-}
-
-export default DefaultSection;
diff --git a/frontend/src/pages/pc/public/schedule-sections/XSection.jsx b/frontend/src/pages/pc/public/schedule-sections/XSection.jsx
deleted file mode 100644
index a56f334..0000000
--- a/frontend/src/pages/pc/public/schedule-sections/XSection.jsx
+++ /dev/null
@@ -1,177 +0,0 @@
-import { useState, useEffect, useCallback, useRef } from 'react';
-import { motion } from 'framer-motion';
-import Linkify from 'react-linkify';
-import { decodeHtmlEntities } from './utils';
-import Lightbox from '../../../../components/common/Lightbox';
-import { formatXDateTime } from '../../../../utils/date';
-
-// 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()}
-
-
- )}
-
-
-
- {displayName}
-
-
-
-
@{username}
-
-
-
-
- {/* 본문 */}
-
-
-
- {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/src/pages/pc/public/schedule-sections/YoutubeSection.jsx b/frontend/src/pages/pc/public/schedule-sections/YoutubeSection.jsx
deleted file mode 100644
index bc403cc..0000000
--- a/frontend/src/pages/pc/public/schedule-sections/YoutubeSection.jsx
+++ /dev/null
@@ -1,151 +0,0 @@
-import { motion } from 'framer-motion';
-import { Calendar, Clock, Link2 } from 'lucide-react';
-import { decodeHtmlEntities } from './utils';
-import { formatXDateTime } from '../../../../utils/date';
-
-// 영상 정보 컴포넌트 (공통)
-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}
-
- ))
- )}
-
- )}
-
- {/* 유튜브에서 보기 버튼 */}
-
-
- );
-}
-
-// 유튜브 섹션 컴포넌트
-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/src/pages/pc/public/schedule-sections/index.js b/frontend/src/pages/pc/public/schedule-sections/index.js
deleted file mode 100644
index af76dd4..0000000
--- a/frontend/src/pages/pc/public/schedule-sections/index.js
+++ /dev/null
@@ -1,4 +0,0 @@
-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/src/pages/pc/public/schedule-sections/utils.js b/frontend/src/pages/pc/public/schedule-sections/utils.js
deleted file mode 100644
index 5a0d942..0000000
--- a/frontend/src/pages/pc/public/schedule-sections/utils.js
+++ /dev/null
@@ -1,52 +0,0 @@
-// HTML 엔티티 디코딩 함수
-export const decodeHtmlEntities = (text) => {
- if (!text) return "";
- const textarea = document.createElement("textarea");
- textarea.innerHTML = text;
- return textarea.value;
-};
-
-// 날짜 포맷팅
-export const formatFullDate = (dateStr) => {
- if (!dateStr) return "";
- const date = new Date(dateStr);
- const dayNames = ["일", "월", "화", "수", "목", "금", "토"];
- return `${date.getFullYear()}. ${date.getMonth() + 1}. ${date.getDate()}. (${
- dayNames[date.getDay()]
- })`;
-};
-
-// 시간 포맷팅
-export const formatTime = (timeStr) => {
- if (!timeStr) return null;
- return timeStr.slice(0, 5);
-};
-
-// X용 날짜/시간 포맷팅 (오후 2:30 · 2026년 1월 15일)
-export const formatXDateTime = (dateStr, timeStr) => {
- if (!dateStr) return "";
- const date = new Date(dateStr);
- const year = date.getFullYear();
- const month = date.getMonth() + 1;
- const day = date.getDate();
-
- let result = `${year}년 ${month}월 ${day}일`;
-
- if (timeStr) {
- const [hours, minutes] = timeStr.split(":").map(Number);
- const period = hours < 12 ? "오전" : "오후";
- const hour12 = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
- result = `${period} ${hour12}:${String(minutes).padStart(
- 2,
- "0"
- )} · ${result}`;
- }
-
- return result;
-};
-
-// 카테고리 ID 상수
-export const CATEGORY_ID = {
- YOUTUBE: 2,
- X: 3,
-};
diff --git a/frontend-temp/src/pages/pc/public/schedule/Birthday.jsx b/frontend/src/pages/pc/public/schedule/Birthday.jsx
similarity index 100%
rename from frontend-temp/src/pages/pc/public/schedule/Birthday.jsx
rename to frontend/src/pages/pc/public/schedule/Birthday.jsx
diff --git a/frontend-temp/src/pages/pc/public/schedule/Schedule.jsx b/frontend/src/pages/pc/public/schedule/Schedule.jsx
similarity index 100%
rename from frontend-temp/src/pages/pc/public/schedule/Schedule.jsx
rename to frontend/src/pages/pc/public/schedule/Schedule.jsx
diff --git a/frontend-temp/src/pages/pc/public/schedule/ScheduleDetail.jsx b/frontend/src/pages/pc/public/schedule/ScheduleDetail.jsx
similarity index 100%
rename from frontend-temp/src/pages/pc/public/schedule/ScheduleDetail.jsx
rename to frontend/src/pages/pc/public/schedule/ScheduleDetail.jsx
diff --git a/frontend-temp/src/pages/pc/public/schedule/sections/DefaultSection.jsx b/frontend/src/pages/pc/public/schedule/sections/DefaultSection.jsx
similarity index 100%
rename from frontend-temp/src/pages/pc/public/schedule/sections/DefaultSection.jsx
rename to frontend/src/pages/pc/public/schedule/sections/DefaultSection.jsx
diff --git a/frontend-temp/src/pages/pc/public/schedule/sections/XSection.jsx b/frontend/src/pages/pc/public/schedule/sections/XSection.jsx
similarity index 100%
rename from frontend-temp/src/pages/pc/public/schedule/sections/XSection.jsx
rename to frontend/src/pages/pc/public/schedule/sections/XSection.jsx
diff --git a/frontend-temp/src/pages/pc/public/schedule/sections/YoutubeSection.jsx b/frontend/src/pages/pc/public/schedule/sections/YoutubeSection.jsx
similarity index 100%
rename from frontend-temp/src/pages/pc/public/schedule/sections/YoutubeSection.jsx
rename to frontend/src/pages/pc/public/schedule/sections/YoutubeSection.jsx
diff --git a/frontend-temp/src/pages/pc/public/schedule/sections/index.js b/frontend/src/pages/pc/public/schedule/sections/index.js
similarity index 100%
rename from frontend-temp/src/pages/pc/public/schedule/sections/index.js
rename to frontend/src/pages/pc/public/schedule/sections/index.js
diff --git a/frontend-temp/src/pages/pc/public/schedule/sections/utils.js b/frontend/src/pages/pc/public/schedule/sections/utils.js
similarity index 100%
rename from frontend-temp/src/pages/pc/public/schedule/sections/utils.js
rename to frontend/src/pages/pc/public/schedule/sections/utils.js
diff --git a/frontend-temp/src/stores/index.js b/frontend/src/stores/index.js
similarity index 100%
rename from frontend-temp/src/stores/index.js
rename to frontend/src/stores/index.js
diff --git a/frontend-temp/src/stores/useAuthStore.js b/frontend/src/stores/useAuthStore.js
similarity index 100%
rename from frontend-temp/src/stores/useAuthStore.js
rename to frontend/src/stores/useAuthStore.js
diff --git a/frontend/src/stores/useScheduleStore.js b/frontend/src/stores/useScheduleStore.js
index ecc5c20..076c060 100644
--- a/frontend/src/stores/useScheduleStore.js
+++ b/frontend/src/stores/useScheduleStore.js
@@ -1,39 +1,105 @@
-import { create } from "zustand";
+import { create } from 'zustand';
-// 일정 관리 페이지 상태 스토어
-// 메모리 기반이므로 SPA 내 페이지 이동 시 유지, 새로고침 시 초기화
-const useScheduleStore = create((set) => ({
- // 검색 관련
- searchInput: "",
- searchTerm: "",
+/**
+ * 스케줄 페이지 상태 스토어
+ * 메모리 기반 - SPA 내 페이지 이동 시 유지, 새로고침 시 초기화
+ */
+const useScheduleStore = create((set, get) => ({
+ // ===== 검색 관련 =====
+ searchInput: '',
+ searchTerm: '',
isSearchMode: false,
- // 필터 및 선택
+ // ===== 필터 관련 =====
selectedCategories: [],
- selectedDate: undefined, // undefined면 오늘 날짜 사용, null이면 전체보기
+ selectedMembers: [],
+
+ // ===== 날짜 관련 =====
+ selectedDate: undefined, // undefined: 오늘, null: 전체, Date: 특정 날짜
currentDate: new Date(),
- // 스크롤 위치
+ // ===== 뷰 관련 =====
+ viewMode: 'list', // 'list' | 'calendar'
scrollPosition: 0,
- // 상태 업데이트 함수
+ // ===== 검색 액션 =====
setSearchInput: (value) => set({ searchInput: value }),
setSearchTerm: (value) => set({ searchTerm: value }),
setIsSearchMode: (value) => set({ isSearchMode: value }),
+
+ startSearch: (term) => {
+ set({
+ searchTerm: term,
+ isSearchMode: true,
+ selectedDate: null, // 검색 시 날짜 필터 해제
+ });
+ },
+
+ clearSearch: () => {
+ set({
+ searchInput: '',
+ searchTerm: '',
+ isSearchMode: false,
+ });
+ },
+
+ // ===== 필터 액션 =====
setSelectedCategories: (value) => set({ selectedCategories: value }),
+ setSelectedMembers: (value) => set({ selectedMembers: value }),
+
+ toggleCategory: (categoryId) => {
+ const { selectedCategories } = get();
+ const isSelected = selectedCategories.includes(categoryId);
+ set({
+ selectedCategories: isSelected
+ ? selectedCategories.filter((id) => id !== categoryId)
+ : [...selectedCategories, categoryId],
+ });
+ },
+
+ toggleMember: (memberId) => {
+ const { selectedMembers } = get();
+ const isSelected = selectedMembers.includes(memberId);
+ set({
+ selectedMembers: isSelected
+ ? selectedMembers.filter((id) => id !== memberId)
+ : [...selectedMembers, memberId],
+ });
+ },
+
+ clearFilters: () => {
+ set({
+ selectedCategories: [],
+ selectedMembers: [],
+ });
+ },
+
+ // ===== 날짜 액션 =====
setSelectedDate: (value) => set({ selectedDate: value }),
setCurrentDate: (value) => set({ currentDate: value }),
- setScrollPosition: (value) => set({ scrollPosition: value }),
- // 상태 초기화
- reset: () =>
+ goToToday: () => {
set({
- searchInput: "",
- searchTerm: "",
- isSearchMode: false,
- selectedCategories: [],
selectedDate: undefined,
currentDate: new Date(),
+ });
+ },
+
+ // ===== 뷰 액션 =====
+ setViewMode: (mode) => set({ viewMode: mode }),
+ setScrollPosition: (value) => set({ scrollPosition: value }),
+
+ // ===== 전체 초기화 =====
+ reset: () =>
+ set({
+ searchInput: '',
+ searchTerm: '',
+ isSearchMode: false,
+ selectedCategories: [],
+ selectedMembers: [],
+ selectedDate: undefined,
+ currentDate: new Date(),
+ viewMode: 'list',
scrollPosition: 0,
}),
}));
diff --git a/frontend-temp/src/utils/cn.js b/frontend/src/utils/cn.js
similarity index 100%
rename from frontend-temp/src/utils/cn.js
rename to frontend/src/utils/cn.js
diff --git a/frontend-temp/src/utils/color.js b/frontend/src/utils/color.js
similarity index 100%
rename from frontend-temp/src/utils/color.js
rename to frontend/src/utils/color.js
diff --git a/frontend-temp/src/utils/confetti.js b/frontend/src/utils/confetti.js
similarity index 100%
rename from frontend-temp/src/utils/confetti.js
rename to frontend/src/utils/confetti.js
diff --git a/frontend/src/utils/date.js b/frontend/src/utils/date.js
index 04446da..d8dfa56 100644
--- a/frontend/src/utils/date.js
+++ b/frontend/src/utils/date.js
@@ -2,31 +2,21 @@
* 날짜 관련 유틸리티 함수
* dayjs를 사용하여 KST(한국 표준시) 기준으로 날짜 처리
*/
-import dayjs from "dayjs";
-import utc from "dayjs/plugin/utc";
-import timezone from "dayjs/plugin/timezone";
+import dayjs from 'dayjs';
+import utc from 'dayjs/plugin/utc';
+import timezone from 'dayjs/plugin/timezone';
+import { TIMEZONE, WEEKDAYS } from '@/constants';
// 플러그인 확장
dayjs.extend(utc);
dayjs.extend(timezone);
-// 기본 타임존 설정
-const KST = "Asia/Seoul";
-
/**
* KST 기준 오늘 날짜 (YYYY-MM-DD)
* @returns {string} 오늘 날짜 문자열
*/
export const getTodayKST = () => {
- return dayjs().tz(KST).format("YYYY-MM-DD");
-};
-
-/**
- * KST 기준 현재 시각
- * @returns {dayjs.Dayjs} dayjs 객체
- */
-export const nowKST = () => {
- return dayjs().tz(KST);
+ return dayjs().tz(TIMEZONE).format('YYYY-MM-DD');
};
/**
@@ -35,24 +25,9 @@ export const nowKST = () => {
* @param {string} format - 포맷 (기본: 'YYYY-MM-DD')
* @returns {string} 포맷된 날짜 문자열
*/
-export const formatDate = (date, format = "YYYY-MM-DD") => {
- return dayjs(date).tz(KST).format(format);
-};
-
-/**
- * 날짜에서 년, 월, 일, 요일 추출
- * @param {string|Date} date - 날짜
- * @returns {object} { year, month, day, weekday }
- */
-export const parseDateKST = (date) => {
- const d = dayjs(date).tz(KST);
- const weekdays = ["일", "월", "화", "수", "목", "금", "토"];
- return {
- year: d.year(),
- month: d.month() + 1,
- day: d.date(),
- weekday: weekdays[d.day()],
- };
+export const formatDate = (date, format = 'YYYY-MM-DD') => {
+ if (!date) return '';
+ return dayjs(date).tz(TIMEZONE).format(format);
};
/**
@@ -63,8 +38,8 @@ export const parseDateKST = (date) => {
*/
export const isSameDay = (date1, date2) => {
return (
- dayjs(date1).tz(KST).format("YYYY-MM-DD") ===
- dayjs(date2).tz(KST).format("YYYY-MM-DD")
+ dayjs(date1).tz(TIMEZONE).format('YYYY-MM-DD') ===
+ dayjs(date2).tz(TIMEZONE).format('YYYY-MM-DD')
);
};
@@ -78,29 +53,67 @@ export const isToday = (date) => {
};
/**
- * X(트위터) 스타일 날짜/시간 포맷팅
- * 입력: "2026-01-18 19:00" 또는 "2026-01-18"
- * 출력: "오후 7:00 · 2026년 1월 18일" 또는 "2026년 1월 18일"
- * @param {string} datetime - 날짜/시간 문자열
+ * 전체 날짜 포맷 (YYYY. M. D. (요일))
+ * @param {string|Date} date - 날짜
* @returns {string} 포맷된 문자열
*/
+export const formatFullDate = (date) => {
+ if (!date) return '';
+ const d = dayjs(date).tz(TIMEZONE);
+ return `${d.year()}. ${d.month() + 1}. ${d.date()}. (${WEEKDAYS[d.day()]})`;
+};
+
+/**
+ * X(트위터) 스타일 날짜/시간 포맷팅
+ * @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 = (datetime) => {
if (!datetime) return '';
- const d = dayjs(datetime).tz(KST);
- const datePart = d.format('YYYY년 M월 D일');
+ const d = dayjs(datetime).tz(TIMEZONE);
+ const datePart = `${d.year()}년 ${d.month() + 1}월 ${d.date()}일`;
- // 시간이 포함된 경우
- if (datetime.includes(' ') || datetime.includes('T')) {
- const hour = d.hour();
- const minute = d.minute();
- const period = hour >= 12 ? '오후' : '오전';
- const hour12 = hour > 12 ? hour - 12 : hour === 0 ? 12 : hour;
- return `${period} ${hour12}:${String(minute).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;
};
+/**
+ * datetime 문자열에서 date 추출
+ * @param {string} datetime - "YYYY-MM-DD HH:mm" 또는 "YYYY-MM-DD"
+ * @returns {string} "YYYY-MM-DD"
+ */
+export const extractDate = (datetime) => {
+ if (!datetime) return '';
+ return datetime.split(' ')[0].split('T')[0];
+};
+
+/**
+ * datetime 문자열에서 time 추출
+ * @param {string} datetime - "YYYY-MM-DD HH:mm" 또는 "YYYY-MM-DDTHH:mm"
+ * @returns {string|null} "HH:mm" 또는 null
+ */
+export const extractTime = (datetime) => {
+ if (!datetime) return null;
+ if (datetime.includes(' ')) {
+ return datetime.split(' ')[1]?.slice(0, 5) || null;
+ }
+ if (datetime.includes('T')) {
+ return datetime.split('T')[1]?.slice(0, 5) || null;
+ }
+ return null;
+};
+
// dayjs 인스턴스도 export (고급 사용용)
export { dayjs };
diff --git a/frontend-temp/src/utils/format.js b/frontend/src/utils/format.js
similarity index 100%
rename from frontend-temp/src/utils/format.js
rename to frontend/src/utils/format.js
diff --git a/frontend-temp/src/utils/index.js b/frontend/src/utils/index.js
similarity index 100%
rename from frontend-temp/src/utils/index.js
rename to frontend/src/utils/index.js
diff --git a/frontend-temp/src/utils/schedule.js b/frontend/src/utils/schedule.js
similarity index 100%
rename from frontend-temp/src/utils/schedule.js
rename to frontend/src/utils/schedule.js
diff --git a/frontend-temp/src/utils/youtube.js b/frontend/src/utils/youtube.js
similarity index 100%
rename from frontend-temp/src/utils/youtube.js
rename to frontend/src/utils/youtube.js
diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js
index c75301a..210016d 100644
--- a/frontend/tailwind.config.js
+++ b/frontend/tailwind.config.js
@@ -4,13 +4,11 @@ export default {
theme: {
extend: {
colors: {
- // 프로미스나인 팬덤 컬러
primary: {
DEFAULT: "#548360",
dark: "#456E50",
light: "#6A9A75",
},
- // 보조 컬러
secondary: "#F5F5F5",
accent: "#FFD700",
},
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
index fbbe8d1..b73e439 100644
--- a/frontend/vite.config.js
+++ b/frontend/vite.config.js
@@ -1,8 +1,14 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
+import path from "path";
export default defineConfig({
plugins: [react()],
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "./src"),
+ },
+ },
server: {
host: true,
port: 80,