From 84030019cda879fc4e26fbdfb49799f48341bd11 Mon Sep 17 00:00:00 2001 From: caadiq Date: Wed, 21 Jan 2026 17:39:48 +0900 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20Phase=206=20-=20=EA=B3=B5?= =?UTF-8?q?=ED=86=B5=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ErrorBoundary: 에러 경계 컴포넌트 - Loading: 로딩 스피너 (sm/md/lg, FullPageLoading, InlineLoading) - Toast, ToastContainer: 토스트 알림 (useUIStore 연동) - Lightbox: 이미지 라이트박스 (키보드 네비게이션) - ScheduleCard: 스케줄 카드 (public/admin variant) - public: 공개 페이지용 카드 스타일 - admin: 관리자 페이지용 리스트 스타일 (수정/삭제 버튼 포함) Co-Authored-By: Claude Opus 4.5 --- frontend-temp/src/App.jsx | 233 +++++++++--------- frontend-temp/src/components/album/.gitkeep | 0 frontend-temp/src/components/common/.gitkeep | 0 .../src/components/common/ErrorBoundary.jsx | 62 +++++ .../src/components/common/Lightbox.jsx | 225 +++++++++++++++++ .../src/components/common/Loading.jsx | 53 ++++ frontend-temp/src/components/common/Toast.jsx | 103 ++++++++ frontend-temp/src/components/common/index.js | 7 + frontend-temp/src/components/index.js | 9 + frontend-temp/src/components/layout/.gitkeep | 0 .../src/components/schedule/.gitkeep | 0 .../src/components/schedule/ScheduleCard.jsx | 233 ++++++++++++++++++ .../src/components/schedule/index.js | 4 + 13 files changed, 813 insertions(+), 116 deletions(-) delete mode 100644 frontend-temp/src/components/album/.gitkeep delete mode 100644 frontend-temp/src/components/common/.gitkeep create mode 100644 frontend-temp/src/components/common/ErrorBoundary.jsx create mode 100644 frontend-temp/src/components/common/Lightbox.jsx create mode 100644 frontend-temp/src/components/common/Loading.jsx create mode 100644 frontend-temp/src/components/common/Toast.jsx create mode 100644 frontend-temp/src/components/common/index.js create mode 100644 frontend-temp/src/components/index.js delete mode 100644 frontend-temp/src/components/layout/.gitkeep delete mode 100644 frontend-temp/src/components/schedule/.gitkeep create mode 100644 frontend-temp/src/components/schedule/ScheduleCard.jsx create mode 100644 frontend-temp/src/components/schedule/index.js diff --git a/frontend-temp/src/App.jsx b/frontend-temp/src/App.jsx index 767b4a6..fb2c379 100644 --- a/frontend-temp/src/App.jsx +++ b/frontend-temp/src/App.jsx @@ -1,144 +1,145 @@ import { BrowserRouter, Routes, Route } from "react-router-dom"; import { cn, getTodayKST, formatFullDate } from "@/utils"; -import { useAuthStore, useUIStore } from "@/stores"; -import { useIsMobile, useCategories, useCalendar } from "@/hooks"; -import { memberApi } from "@/api"; -import { useQuery } from "@tanstack/react-query"; +import { useUIStore } from "@/stores"; +import { useIsMobile, useCategories, useScheduleData } from "@/hooks"; +import { ErrorBoundary, Loading, ToastContainer, ScheduleCard } from "@/components"; /** * 프로미스나인 팬사이트 메인 앱 * - * Phase 5: 커스텀 훅 완료 - * - useMediaQuery, useIsMobile, useIsDesktop - * - useScheduleData, useScheduleDetail, useCategories - * - useScheduleSearch (무한 스크롤) - * - useScheduleFiltering, useCategoryCounts - * - useCalendar - * - useAdminAuth + * Phase 6: 공통 컴포넌트 완료 + * - ErrorBoundary: 에러 경계 + * - Loading, FullPageLoading, InlineLoading: 로딩 스피너 + * - Toast, ToastContainer: 토스트 알림 + * - Lightbox: 이미지 라이트박스 + * - ScheduleCard: 스케줄 카드 (list/card/compact) */ function App() { const today = getTodayKST(); const isMobile = useIsMobile(); - const { isAuthenticated } = useAuthStore(); - const { showSuccess, toasts } = useUIStore(); + const { showSuccess, showError } = useUIStore(); // 커스텀 훅 사용 const { data: categories, isLoading: categoriesLoading } = useCategories(); - const calendar = useCalendar(); - - // 멤버 데이터 (기존 방식 유지) - const { data: members, isLoading: membersLoading } = useQuery({ - queryKey: ["members"], - queryFn: memberApi.getMembers, - }); + const currentDate = new Date(); + const { data: schedules, isLoading: schedulesLoading } = useScheduleData( + currentDate.getFullYear(), + currentDate.getMonth() + 1 + ); return ( - - -
-

- fromis_9 Frontend Refactoring -

-

Phase 5 완료 - 커스텀 훅

-

- 디바이스: {isMobile ? "모바일" : "PC"} (useIsMobile 훅) -

- -
-

오늘: {formatFullDate(today)}

-

인증: {isAuthenticated ? "✅" : "❌"}

- -
-

useCategories 훅

-

- {categoriesLoading ? "로딩 중..." : `${categories?.length || 0}개 카테고리`} + + + +

+
+

+ fromis_9 Frontend Refactoring +

+

Phase 6 완료 - 공통 컴포넌트

+

+ 디바이스: {isMobile ? "모바일" : "PC"}

- {categories && ( -
- {categories.map((c) => ( - - {c.name} - - ))} -
- )}
-
-

useCalendar 훅

-

현재: {calendar.year}년 {calendar.monthName}

-

선택: {calendar.selectedDate}

-
- - - +
+

오늘: {formatFullDate(today)}

+ +
+

카테고리 ({categories?.length || 0}개)

+ {categoriesLoading ? ( + + ) : ( +
+ {categories?.map((c) => ( + + {c.name} + + ))} +
+ )} +
+ +
+

토스트 테스트

+
+ + +
-
-

멤버 데이터

-

{membersLoading ? "로딩 중..." : `${members?.length || 0}명`}

- {members && ( -
- {members.map((m) => ( - - {m.name} - - ))} + {/* ScheduleCard 컴포넌트 테스트 */} + {schedulesLoading ? ( +
+ +
+ ) : schedules?.length > 0 ? ( + <> + {/* Public Variant (공개 페이지용) */} +
+

variant="public" (공개 페이지용)

+
+ {schedules.slice(0, 2).map((schedule) => ( + showSuccess(`${schedule.title} 클릭`)} + /> + ))} +
- )} -
-
-
- {/* 토스트 표시 */} -
- {toasts.map((toast) => ( -
- {toast.message} -
- ))} + {/* Admin Variant (관리자 페이지용) */} +
+

variant="admin" (관리자 페이지용)

+
+ {schedules.slice(0, 2).map((schedule) => ( + showSuccess(`${schedule.title} 클릭`)} + onEdit={(s) => showSuccess(`${s.title} 수정`)} + onDelete={(s) => showError(`${s.title} 삭제`)} + /> + ))} +
+
+ + ) : ( +
+

이번 달 스케줄이 없습니다.

+
+ )} +
+ + {/* 토스트 컨테이너 */} +
-
- } - /> - + } + /> + + ); } diff --git a/frontend-temp/src/components/album/.gitkeep b/frontend-temp/src/components/album/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/frontend-temp/src/components/common/.gitkeep b/frontend-temp/src/components/common/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/frontend-temp/src/components/common/ErrorBoundary.jsx b/frontend-temp/src/components/common/ErrorBoundary.jsx new file mode 100644 index 0000000..a4aff59 --- /dev/null +++ b/frontend-temp/src/components/common/ErrorBoundary.jsx @@ -0,0 +1,62 @@ +import { Component } from 'react'; +import { AlertTriangle, RefreshCw } from 'lucide-react'; + +/** + * 에러 경계 컴포넌트 + * React 컴포넌트 트리에서 발생하는 에러를 캐치하여 폴백 UI를 표시 + */ +class ErrorBoundary extends Component { + state = { hasError: false, error: null }; + + static getDerivedStateFromError(error) { + return { hasError: true, error }; + } + + componentDidCatch(error, errorInfo) { + console.error('ErrorBoundary:', error, errorInfo); + } + + handleReset = () => { + this.setState({ hasError: false, error: null }); + }; + + render() { + if (this.state.hasError) { + // 커스텀 폴백이 있으면 사용 + if (this.props.fallback) { + return this.props.fallback; + } + + return ( +
+ +

+ 문제가 발생했습니다 +

+

+ {this.state.error?.message || '알 수 없는 오류'} +

+
+ + +
+
+ ); + } + + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/frontend-temp/src/components/common/Lightbox.jsx b/frontend-temp/src/components/common/Lightbox.jsx new file mode 100644 index 0000000..4fa2a20 --- /dev/null +++ b/frontend-temp/src/components/common/Lightbox.jsx @@ -0,0 +1,225 @@ +import { useState, useEffect, useCallback, memo } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { X, ChevronLeft, ChevronRight } from 'lucide-react'; +import { useUIStore } from '@/stores'; + +/** + * 라이트박스 인디케이터 + */ +const LightboxIndicator = memo(function LightboxIndicator({ + count, + currentIndex, + goToIndex, +}) { + const translateX = -(currentIndex * 18) + 100 - 6; + + return ( +
+ {/* 양옆 페이드 그라데이션 */} +
+ {/* 슬라이딩 컨테이너 */} +
+ {Array.from({ length: count }).map((_, i) => ( +
+
+ ); +}); + +/** + * 라이트박스 컴포넌트 + * useUIStore와 연동하거나 props로 직접 제어 가능 + */ +function Lightbox({ images: propImages, currentIndex: propIndex, isOpen: propIsOpen, onClose: propOnClose, onIndexChange: propOnIndexChange }) { + // useUIStore에서 상태 가져오기 (props가 없으면 스토어 사용) + const store = useUIStore(); + + const images = propImages ?? store.lightboxImages; + const currentIndex = propIndex ?? store.lightboxIndex; + const isOpen = propIsOpen ?? store.lightboxOpen; + const onClose = propOnClose ?? store.closeLightbox; + const onIndexChange = propOnIndexChange ?? store.setLightboxIndex; + + const [imageLoaded, setImageLoaded] = useState(false); + const [slideDirection, setSlideDirection] = useState(0); + + // 이전/다음 네비게이션 + const goToPrev = useCallback(() => { + if (images.length <= 1) return; + setImageLoaded(false); + setSlideDirection(-1); + onIndexChange((currentIndex - 1 + images.length) % images.length); + }, [images.length, currentIndex, onIndexChange]); + + const goToNext = useCallback(() => { + if (images.length <= 1) return; + setImageLoaded(false); + setSlideDirection(1); + onIndexChange((currentIndex + 1) % images.length); + }, [images.length, currentIndex, onIndexChange]); + + const goToIndex = useCallback( + (index) => { + if (index === currentIndex) return; + setImageLoaded(false); + setSlideDirection(index > currentIndex ? 1 : -1); + onIndexChange(index); + }, + [currentIndex, onIndexChange] + ); + + // 라이트박스 열릴 때 body 스크롤 숨기기 + useEffect(() => { + if (isOpen) { + 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 = ''; + }; + }, [isOpen]); + + // 키보드 이벤트 핸들러 + useEffect(() => { + if (!isOpen) return; + + const handleKeyDown = (e) => { + switch (e.key) { + case 'ArrowLeft': + goToPrev(); + break; + case 'ArrowRight': + goToNext(); + break; + case 'Escape': + onClose(); + break; + default: + break; + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isOpen, goToPrev, goToNext, onClose]); + + // 이미지가 바뀔 때 로딩 상태 리셋 + useEffect(() => { + setImageLoaded(false); + }, [currentIndex]); + + return ( + + {isOpen && images.length > 0 && ( + +
+ {/* 닫기 버튼 */} + + + {/* 이전 버튼 */} + {images.length > 1 && ( + + )} + + {/* 로딩 스피너 */} + {!imageLoaded && ( +
+
+
+ )} + + {/* 이미지 */} +
+ e.stopPropagation()} + onLoad={() => setImageLoaded(true)} + initial={{ x: slideDirection * 100 }} + animate={{ x: 0 }} + transition={{ duration: 0.25, ease: 'easeOut' }} + /> +
+ + {/* 다음 버튼 */} + {images.length > 1 && ( + + )} + + {/* 인디케이터 */} + {images.length > 1 && ( + + )} +
+ + )} + + ); +} + +export default Lightbox; diff --git a/frontend-temp/src/components/common/Loading.jsx b/frontend-temp/src/components/common/Loading.jsx new file mode 100644 index 0000000..ce23d97 --- /dev/null +++ b/frontend-temp/src/components/common/Loading.jsx @@ -0,0 +1,53 @@ +import { cn } from '@/utils'; + +/** + * 로딩 스피너 컴포넌트 + * @param {object} props + * @param {'sm'|'md'|'lg'} props.size - 크기 + * @param {string} props.className - 추가 클래스 + * @param {string} props.text - 로딩 텍스트 + */ +export default function Loading({ size = 'md', className = '', text = '' }) { + const sizeClasses = { + sm: 'h-6 w-6 border-2', + md: 'h-10 w-10 border-3', + lg: 'h-12 w-12 border-4', + }; + + return ( +
+
+ {text &&

{text}

} +
+ ); +} + +/** + * 전체 화면 로딩 + */ +export function FullPageLoading({ text = '로딩 중...' }) { + return ( +
+ +
+ ); +} + +/** + * 인라인 로딩 (버튼 등에 사용) + */ +export function InlineLoading({ className = '' }) { + return ( +
+ ); +} diff --git a/frontend-temp/src/components/common/Toast.jsx b/frontend-temp/src/components/common/Toast.jsx new file mode 100644 index 0000000..33556b2 --- /dev/null +++ b/frontend-temp/src/components/common/Toast.jsx @@ -0,0 +1,103 @@ +import { motion, AnimatePresence } from 'framer-motion'; +import { X, CheckCircle, AlertCircle, AlertTriangle, Info } from 'lucide-react'; +import { cn } from '@/utils'; +import { useUIStore } from '@/stores'; + +/** + * 토스트 아이콘 매핑 + */ +const icons = { + success: CheckCircle, + error: AlertCircle, + warning: AlertTriangle, + info: Info, +}; + +/** + * 토스트 스타일 매핑 + */ +const styles = { + success: 'bg-emerald-500/90', + error: 'bg-red-500/90', + warning: 'bg-amber-500/90', + info: 'bg-blue-500/90', +}; + +/** + * 단일 토스트 아이템 + */ +function ToastItem({ toast, onClose }) { + const Icon = icons[toast.type] || icons.info; + + return ( + + + {toast.message} + + + ); +} + +/** + * 토스트 컨테이너 + * useUIStore와 연동하여 전역 토스트 관리 + */ +export default function ToastContainer() { + const { toasts, removeToast } = useUIStore(); + + return ( +
+ + {toasts.map((toast) => ( + removeToast(toast.id)} + /> + ))} + +
+ ); +} + +/** + * 단일 토스트 (레거시 호환) + */ +export function Toast({ toast, onClose }) { + if (!toast) return null; + + return ( + + + {toast.message} + + + ); +} diff --git a/frontend-temp/src/components/common/index.js b/frontend-temp/src/components/common/index.js new file mode 100644 index 0000000..1ec1edd --- /dev/null +++ b/frontend-temp/src/components/common/index.js @@ -0,0 +1,7 @@ +/** + * 공통 컴포넌트 export + */ +export { default as ErrorBoundary } from './ErrorBoundary'; +export { default as Loading, FullPageLoading, InlineLoading } from './Loading'; +export { default as ToastContainer, Toast } from './Toast'; +export { default as Lightbox } from './Lightbox'; diff --git a/frontend-temp/src/components/index.js b/frontend-temp/src/components/index.js new file mode 100644 index 0000000..07f0599 --- /dev/null +++ b/frontend-temp/src/components/index.js @@ -0,0 +1,9 @@ +/** + * 컴포넌트 통합 export + */ + +// 공통 컴포넌트 +export * from './common'; + +// 스케줄 컴포넌트 +export * from './schedule'; diff --git a/frontend-temp/src/components/layout/.gitkeep b/frontend-temp/src/components/layout/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/frontend-temp/src/components/schedule/.gitkeep b/frontend-temp/src/components/schedule/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/frontend-temp/src/components/schedule/ScheduleCard.jsx b/frontend-temp/src/components/schedule/ScheduleCard.jsx new file mode 100644 index 0000000..ccdfe72 --- /dev/null +++ b/frontend-temp/src/components/schedule/ScheduleCard.jsx @@ -0,0 +1,233 @@ +import { memo } from 'react'; +import { Clock, Tag, Link2, ExternalLink, Edit2, Trash2 } from 'lucide-react'; +import { cn, decodeHtmlEntities } from '@/utils'; +import { + getCategoryInfo, + getScheduleDate, + getScheduleTime, + getMemberList, +} from '@/utils/schedule'; +import { WEEKDAYS } from '@/constants'; + +/** + * 멤버 뱃지 컴포넌트 + */ +const MemberBadges = memo(function MemberBadges({ members }) { + if (!members || members.length === 0) return null; + + // 5명 이상이면 "프로미스나인"으로 표시 + if (members.length >= 5) { + return ( + + 프로미스나인 + + ); + } + + return members.map((member, i) => ( + + {typeof member === 'string' ? member : member.name} + + )); +}); + +/** + * 스케줄 카드 컴포넌트 + * PC/Mobile 공용, variant로 스타일 변경 + * + * @param {object} props + * @param {object} props.schedule - 스케줄 데이터 + * @param {'public'|'admin'} props.variant - 카드 스타일 (public: 공개 페이지용 카드, admin: 관리자 페이지용 리스트) + * @param {boolean} props.showDate - 날짜 표시 여부 + * @param {boolean} props.showYear - 연도 표시 여부 + * @param {function} props.onClick - 클릭 핸들러 + * @param {function} props.onEdit - 수정 버튼 클릭 핸들러 (admin variant) + * @param {function} props.onDelete - 삭제 버튼 클릭 핸들러 (admin variant) + * @param {string} props.className - 추가 클래스 + */ +const ScheduleCard = memo(function ScheduleCard({ + schedule, + variant = 'public', + showDate = true, + showYear = false, + onClick, + onEdit, + onDelete, + className, +}) { + const dateStr = getScheduleDate(schedule); + const date = dateStr ? new Date(dateStr) : null; + const categoryInfo = getCategoryInfo(schedule); + const timeStr = getScheduleTime(schedule); + const memberList = getMemberList(schedule); + const title = decodeHtmlEntities(schedule.title || ''); + + // 관리자 페이지용 리스트 스타일 + if (variant === 'admin') { + return ( +
+
+ {/* 날짜 - 세로 중앙 정렬 */} + {showDate && date && ( +
+ {showYear && ( +
+ {date.getFullYear()}.{date.getMonth() + 1} +
+ )} +
+ {date.getDate()} +
+
+ {WEEKDAYS[date.getDay()]}요일 +
+
+ )} + + {/* 카테고리 바 */} +
+ + {/* 내용 */} +
+

{title}

+
+ {timeStr && ( + + + {timeStr} + + )} + + + {categoryInfo.name} + + {schedule.source?.name && ( + + + {schedule.source.name} + + )} +
+ {memberList.length > 0 && ( +
+ +
+ )} +
+ + {/* 액션 버튼 - 생일 일정이 아닐 때만 표시 */} + {!schedule.is_birthday && !String(schedule.id).startsWith('birthday-') && ( +
+ {schedule.source?.url && ( + e.stopPropagation()} + className="p-2 hover:bg-blue-100 rounded-lg transition-colors text-blue-500" + > + + + )} + {onEdit && ( + + )} + {onDelete && ( + + )} +
+ )} +
+
+ ); + } + + // 공개 페이지용 카드 스타일 (기본) + return ( +
+ {/* 좌측 날짜/카테고리 영역 */} +
+ {date && ( + <> + {showYear && ( + + {date.getFullYear()}.{date.getMonth() + 1} + + )} + {date.getDate()} + {WEEKDAYS[date.getDay()]} + + )} +
+ + {/* 우측 내용 영역 */} +
+

{title}

+
+ {timeStr && ( + + + {timeStr} + + )} + + + {categoryInfo.name} + + {schedule.source?.name && ( + + + {schedule.source.name} + + )} +
+ {memberList.length > 0 && ( +
+ +
+ )} +
+
+ ); +}); + +export default ScheduleCard; diff --git a/frontend-temp/src/components/schedule/index.js b/frontend-temp/src/components/schedule/index.js new file mode 100644 index 0000000..02edebc --- /dev/null +++ b/frontend-temp/src/components/schedule/index.js @@ -0,0 +1,4 @@ +/** + * 스케줄 컴포넌트 export + */ +export { default as ScheduleCard } from './ScheduleCard';