feat(frontend): Phase 6 - 공통 컴포넌트 구현
- ErrorBoundary: 에러 경계 컴포넌트 - Loading: 로딩 스피너 (sm/md/lg, FullPageLoading, InlineLoading) - Toast, ToastContainer: 토스트 알림 (useUIStore 연동) - Lightbox: 이미지 라이트박스 (키보드 네비게이션) - ScheduleCard: 스케줄 카드 (public/admin variant) - public: 공개 페이지용 카드 스타일 - admin: 관리자 페이지용 리스트 스타일 (수정/삭제 버튼 포함) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
27c41b0af0
commit
84030019cd
13 changed files with 813 additions and 116 deletions
|
|
@ -1,144 +1,145 @@
|
||||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||||
import { cn, getTodayKST, formatFullDate } from "@/utils";
|
import { cn, getTodayKST, formatFullDate } from "@/utils";
|
||||||
import { useAuthStore, useUIStore } from "@/stores";
|
import { useUIStore } from "@/stores";
|
||||||
import { useIsMobile, useCategories, useCalendar } from "@/hooks";
|
import { useIsMobile, useCategories, useScheduleData } from "@/hooks";
|
||||||
import { memberApi } from "@/api";
|
import { ErrorBoundary, Loading, ToastContainer, ScheduleCard } from "@/components";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 프로미스나인 팬사이트 메인 앱
|
* 프로미스나인 팬사이트 메인 앱
|
||||||
*
|
*
|
||||||
* Phase 5: 커스텀 훅 완료
|
* Phase 6: 공통 컴포넌트 완료
|
||||||
* - useMediaQuery, useIsMobile, useIsDesktop
|
* - ErrorBoundary: 에러 경계
|
||||||
* - useScheduleData, useScheduleDetail, useCategories
|
* - Loading, FullPageLoading, InlineLoading: 로딩 스피너
|
||||||
* - useScheduleSearch (무한 스크롤)
|
* - Toast, ToastContainer: 토스트 알림
|
||||||
* - useScheduleFiltering, useCategoryCounts
|
* - Lightbox: 이미지 라이트박스
|
||||||
* - useCalendar
|
* - ScheduleCard: 스케줄 카드 (list/card/compact)
|
||||||
* - useAdminAuth
|
|
||||||
*/
|
*/
|
||||||
function App() {
|
function App() {
|
||||||
const today = getTodayKST();
|
const today = getTodayKST();
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const { isAuthenticated } = useAuthStore();
|
const { showSuccess, showError } = useUIStore();
|
||||||
const { showSuccess, toasts } = useUIStore();
|
|
||||||
|
|
||||||
// 커스텀 훅 사용
|
// 커스텀 훅 사용
|
||||||
const { data: categories, isLoading: categoriesLoading } = useCategories();
|
const { data: categories, isLoading: categoriesLoading } = useCategories();
|
||||||
const calendar = useCalendar();
|
const currentDate = new Date();
|
||||||
|
const { data: schedules, isLoading: schedulesLoading } = useScheduleData(
|
||||||
// 멤버 데이터 (기존 방식 유지)
|
currentDate.getFullYear(),
|
||||||
const { data: members, isLoading: membersLoading } = useQuery({
|
currentDate.getMonth() + 1
|
||||||
queryKey: ["members"],
|
);
|
||||||
queryFn: memberApi.getMembers,
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<ErrorBoundary>
|
||||||
<Route
|
<Routes>
|
||||||
path="/"
|
<Route
|
||||||
element={
|
path="/"
|
||||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 p-4">
|
element={
|
||||||
<div className="text-center space-y-4 max-w-md w-full">
|
<div className="min-h-screen bg-gray-50 p-4">
|
||||||
<h1 className="text-2xl font-bold text-primary mb-2">
|
<div className="max-w-2xl mx-auto space-y-4">
|
||||||
fromis_9 Frontend Refactoring
|
<div className="text-center">
|
||||||
</h1>
|
<h1 className="text-2xl font-bold text-primary mb-2">
|
||||||
<p className="text-gray-600">Phase 5 완료 - 커스텀 훅</p>
|
fromis_9 Frontend Refactoring
|
||||||
<p className={cn("text-sm", isMobile ? "text-blue-500" : "text-green-500")}>
|
</h1>
|
||||||
디바이스: {isMobile ? "모바일" : "PC"} (useIsMobile 훅)
|
<p className="text-gray-600">Phase 6 완료 - 공통 컴포넌트</p>
|
||||||
</p>
|
<p className={cn("text-sm", isMobile ? "text-blue-500" : "text-green-500")}>
|
||||||
|
디바이스: {isMobile ? "모바일" : "PC"}
|
||||||
<div className="mt-6 p-4 bg-white rounded-lg shadow text-left text-sm space-y-3">
|
|
||||||
<p><strong>오늘:</strong> {formatFullDate(today)}</p>
|
|
||||||
<p><strong>인증:</strong> {isAuthenticated ? "✅" : "❌"}</p>
|
|
||||||
|
|
||||||
<div className="border-t pt-3">
|
|
||||||
<p className="font-semibold mb-2">useCategories 훅</p>
|
|
||||||
<p>
|
|
||||||
{categoriesLoading ? "로딩 중..." : `${categories?.length || 0}개 카테고리`}
|
|
||||||
</p>
|
</p>
|
||||||
{categories && (
|
|
||||||
<div className="flex flex-wrap gap-1 mt-1">
|
|
||||||
{categories.map((c) => (
|
|
||||||
<span
|
|
||||||
key={c.id}
|
|
||||||
className="px-2 py-0.5 rounded text-xs text-white"
|
|
||||||
style={{ backgroundColor: c.color }}
|
|
||||||
>
|
|
||||||
{c.name}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t pt-3">
|
<div className="p-4 bg-white rounded-lg shadow text-sm space-y-3">
|
||||||
<p className="font-semibold mb-2">useCalendar 훅</p>
|
<p><strong>오늘:</strong> {formatFullDate(today)}</p>
|
||||||
<p><strong>현재:</strong> {calendar.year}년 {calendar.monthName}</p>
|
|
||||||
<p><strong>선택:</strong> {calendar.selectedDate}</p>
|
<div className="border-t pt-3">
|
||||||
<div className="flex gap-2 mt-2">
|
<p className="font-semibold mb-2">카테고리 ({categories?.length || 0}개)</p>
|
||||||
<button
|
{categoriesLoading ? (
|
||||||
onClick={calendar.goToPrevMonth}
|
<Loading size="sm" />
|
||||||
disabled={!calendar.canGoPrevMonth}
|
) : (
|
||||||
className={cn(
|
<div className="flex flex-wrap gap-1">
|
||||||
"px-3 py-1 rounded text-xs",
|
{categories?.map((c) => (
|
||||||
calendar.canGoPrevMonth ? "bg-primary text-white" : "bg-gray-200 text-gray-400"
|
<span
|
||||||
)}
|
key={c.id}
|
||||||
>
|
className="px-2 py-0.5 rounded text-xs text-white"
|
||||||
이전
|
style={{ backgroundColor: c.color }}
|
||||||
</button>
|
>
|
||||||
<button
|
{c.name}
|
||||||
onClick={calendar.goToToday}
|
</span>
|
||||||
className="px-3 py-1 bg-gray-500 text-white rounded text-xs"
|
))}
|
||||||
>
|
</div>
|
||||||
오늘
|
)}
|
||||||
</button>
|
</div>
|
||||||
<button
|
|
||||||
onClick={calendar.goToNextMonth}
|
<div className="border-t pt-3">
|
||||||
className="px-3 py-1 bg-primary text-white rounded text-xs"
|
<p className="font-semibold mb-2">토스트 테스트</p>
|
||||||
>
|
<div className="flex gap-2">
|
||||||
다음
|
<button
|
||||||
</button>
|
onClick={() => showSuccess("성공 메시지!")}
|
||||||
|
className="px-3 py-1 bg-emerald-500 text-white rounded text-xs"
|
||||||
|
>
|
||||||
|
성공
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => showError("에러 메시지!")}
|
||||||
|
className="px-3 py-1 bg-red-500 text-white rounded text-xs"
|
||||||
|
>
|
||||||
|
에러
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t pt-3">
|
{/* ScheduleCard 컴포넌트 테스트 */}
|
||||||
<p className="font-semibold mb-2">멤버 데이터</p>
|
{schedulesLoading ? (
|
||||||
<p>{membersLoading ? "로딩 중..." : `${members?.length || 0}명`}</p>
|
<div className="p-4 bg-white rounded-lg shadow">
|
||||||
{members && (
|
<Loading size="sm" text="스케줄 로딩 중..." />
|
||||||
<div className="flex flex-wrap gap-1 mt-1">
|
</div>
|
||||||
{members.map((m) => (
|
) : schedules?.length > 0 ? (
|
||||||
<span key={m.id} className="px-2 py-0.5 bg-gray-100 rounded text-xs">
|
<>
|
||||||
{m.name}
|
{/* Public Variant (공개 페이지용) */}
|
||||||
</span>
|
<div className="p-4 bg-white rounded-lg shadow">
|
||||||
))}
|
<p className="font-semibold mb-3 text-sm">variant="public" (공개 페이지용)</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{schedules.slice(0, 2).map((schedule) => (
|
||||||
|
<ScheduleCard
|
||||||
|
key={schedule.id}
|
||||||
|
schedule={schedule}
|
||||||
|
variant="public"
|
||||||
|
onClick={() => showSuccess(`${schedule.title} 클릭`)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 토스트 표시 */}
|
{/* Admin Variant (관리자 페이지용) */}
|
||||||
<div className="fixed bottom-4 right-4 space-y-2">
|
<div className="p-4 bg-white rounded-lg shadow">
|
||||||
{toasts.map((toast) => (
|
<p className="font-semibold mb-3 text-sm">variant="admin" (관리자 페이지용)</p>
|
||||||
<div
|
<div className="divide-y">
|
||||||
key={toast.id}
|
{schedules.slice(0, 2).map((schedule) => (
|
||||||
className={cn(
|
<ScheduleCard
|
||||||
"px-4 py-2 rounded shadow-lg text-white text-sm",
|
key={schedule.id}
|
||||||
toast.type === "success" && "bg-green-500",
|
schedule={schedule}
|
||||||
toast.type === "error" && "bg-red-500",
|
variant="admin"
|
||||||
toast.type === "warning" && "bg-yellow-500",
|
onClick={() => showSuccess(`${schedule.title} 클릭`)}
|
||||||
toast.type === "info" && "bg-blue-500"
|
onEdit={(s) => showSuccess(`${s.title} 수정`)}
|
||||||
)}
|
onDelete={(s) => showError(`${s.title} 삭제`)}
|
||||||
>
|
/>
|
||||||
{toast.message}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="p-4 bg-white rounded-lg shadow">
|
||||||
|
<p className="text-gray-500 text-sm">이번 달 스케줄이 없습니다.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 토스트 컨테이너 */}
|
||||||
|
<ToastContainer />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
}
|
||||||
}
|
/>
|
||||||
/>
|
</Routes>
|
||||||
</Routes>
|
</ErrorBoundary>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
62
frontend-temp/src/components/common/ErrorBoundary.jsx
Normal file
62
frontend-temp/src/components/common/ErrorBoundary.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="flex flex-col items-center justify-center p-8 text-center min-h-[200px]">
|
||||||
|
<AlertTriangle size={48} className="text-red-500 mb-4" />
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 mb-2">
|
||||||
|
문제가 발생했습니다
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-500 mb-4">
|
||||||
|
{this.state.error?.message || '알 수 없는 오류'}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={this.handleReset}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors"
|
||||||
|
>
|
||||||
|
다시 시도
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} />
|
||||||
|
새로고침
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ErrorBoundary;
|
||||||
225
frontend-temp/src/components/common/Lightbox.jsx
Normal file
225
frontend-temp/src/components/common/Lightbox.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<div
|
||||||
|
className="absolute bottom-6 left-1/2 -translate-x-1/2 overflow-hidden"
|
||||||
|
style={{ width: '200px' }}
|
||||||
|
>
|
||||||
|
{/* 양옆 페이드 그라데이션 */}
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 pointer-events-none z-10"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
'linear-gradient(to right, rgba(0,0,0,1) 0%, transparent 20%, transparent 80%, rgba(0,0,0,1) 100%)',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/* 슬라이딩 컨테이너 */}
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 justify-center"
|
||||||
|
style={{
|
||||||
|
width: `${count * 18}px`,
|
||||||
|
transform: `translateX(${translateX}px)`,
|
||||||
|
transition: 'transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
className={`rounded-full flex-shrink-0 transition-all duration-300 ${
|
||||||
|
i === currentIndex
|
||||||
|
? 'w-3 h-3 bg-white'
|
||||||
|
: 'w-2.5 h-2.5 bg-white/40 hover:bg-white/60'
|
||||||
|
}`}
|
||||||
|
onClick={() => goToIndex(i)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 라이트박스 컴포넌트
|
||||||
|
* 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 (
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && images.length > 0 && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className="fixed inset-0 bg-black/95 z-50 overflow-scroll lightbox-no-scrollbar"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<div className="min-w-[800px] min-h-[600px] w-full h-full relative flex items-center justify-center">
|
||||||
|
{/* 닫기 버튼 */}
|
||||||
|
<button
|
||||||
|
className="absolute top-6 right-6 text-white/70 hover:text-white transition-colors z-10"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<X size={32} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 이전 버튼 */}
|
||||||
|
{images.length > 1 && (
|
||||||
|
<button
|
||||||
|
className="absolute left-6 p-2 text-white/70 hover:text-white transition-colors z-10"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
goToPrev();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronLeft size={48} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 로딩 스피너 */}
|
||||||
|
{!imageLoaded && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-4 border-white border-t-transparent" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 이미지 */}
|
||||||
|
<div className="flex flex-col items-center mx-24">
|
||||||
|
<motion.img
|
||||||
|
key={currentIndex}
|
||||||
|
src={images[currentIndex]}
|
||||||
|
alt={`이미지 ${currentIndex + 1}`}
|
||||||
|
className={`max-w-[90vw] max-h-[85vh] object-contain transition-opacity duration-200 ${
|
||||||
|
imageLoaded ? 'opacity-100' : 'opacity-0'
|
||||||
|
}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onLoad={() => setImageLoaded(true)}
|
||||||
|
initial={{ x: slideDirection * 100 }}
|
||||||
|
animate={{ x: 0 }}
|
||||||
|
transition={{ duration: 0.25, ease: 'easeOut' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 다음 버튼 */}
|
||||||
|
{images.length > 1 && (
|
||||||
|
<button
|
||||||
|
className="absolute right-6 p-2 text-white/70 hover:text-white transition-colors z-10"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
goToNext();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronRight size={48} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 인디케이터 */}
|
||||||
|
{images.length > 1 && (
|
||||||
|
<LightboxIndicator
|
||||||
|
count={images.length}
|
||||||
|
currentIndex={currentIndex}
|
||||||
|
goToIndex={goToIndex}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Lightbox;
|
||||||
53
frontend-temp/src/components/common/Loading.jsx
Normal file
53
frontend-temp/src/components/common/Loading.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className={cn('flex flex-col justify-center items-center gap-3', className)}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'animate-spin rounded-full border-primary border-t-transparent',
|
||||||
|
sizeClasses[size]
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{text && <p className="text-sm text-gray-500">{text}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 화면 로딩
|
||||||
|
*/
|
||||||
|
export function FullPageLoading({ text = '로딩 중...' }) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||||
|
<Loading size="lg" text={text} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 인라인 로딩 (버튼 등에 사용)
|
||||||
|
*/
|
||||||
|
export function InlineLoading({ className = '' }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
103
frontend-temp/src/components/common/Toast.jsx
Normal file
103
frontend-temp/src/components/common/Toast.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<motion.div
|
||||||
|
layout
|
||||||
|
initial={{ opacity: 0, y: 50, scale: 0.9 }}
|
||||||
|
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, y: 20, scale: 0.9 }}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 px-4 py-3 rounded-xl shadow-lg backdrop-blur-sm text-white cursor-pointer min-w-[200px] max-w-[400px]',
|
||||||
|
styles[toast.type]
|
||||||
|
)}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<Icon size={20} className="flex-shrink-0" />
|
||||||
|
<span className="flex-1 text-sm font-medium">{toast.message}</span>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
className="flex-shrink-0 hover:bg-white/20 rounded p-0.5 transition-colors"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 토스트 컨테이너
|
||||||
|
* useUIStore와 연동하여 전역 토스트 관리
|
||||||
|
*/
|
||||||
|
export default function ToastContainer() {
|
||||||
|
const { toasts, removeToast } = useUIStore();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-4 right-4 z-[9999] flex flex-col gap-2">
|
||||||
|
<AnimatePresence mode="popLayout">
|
||||||
|
{toasts.map((toast) => (
|
||||||
|
<ToastItem
|
||||||
|
key={toast.id}
|
||||||
|
toast={toast}
|
||||||
|
onClose={() => removeToast(toast.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 단일 토스트 (레거시 호환)
|
||||||
|
*/
|
||||||
|
export function Toast({ toast, onClose }) {
|
||||||
|
if (!toast) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 50 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: 50 }}
|
||||||
|
onClick={onClose}
|
||||||
|
className={cn(
|
||||||
|
'fixed bottom-8 inset-x-0 mx-auto w-fit z-[9999] backdrop-blur-sm text-white px-6 py-3 rounded-xl text-center font-medium shadow-lg cursor-pointer',
|
||||||
|
styles[toast.type]
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{toast.message}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
}
|
||||||
7
frontend-temp/src/components/common/index.js
Normal file
7
frontend-temp/src/components/common/index.js
Normal file
|
|
@ -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';
|
||||||
9
frontend-temp/src/components/index.js
Normal file
9
frontend-temp/src/components/index.js
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
/**
|
||||||
|
* 컴포넌트 통합 export
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 공통 컴포넌트
|
||||||
|
export * from './common';
|
||||||
|
|
||||||
|
// 스케줄 컴포넌트
|
||||||
|
export * from './schedule';
|
||||||
233
frontend-temp/src/components/schedule/ScheduleCard.jsx
Normal file
233
frontend-temp/src/components/schedule/ScheduleCard.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<span className="px-2 py-0.5 bg-primary/10 text-primary text-xs font-medium rounded-full">
|
||||||
|
프로미스나인
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return members.map((member, i) => (
|
||||||
|
<span
|
||||||
|
key={member.id || i}
|
||||||
|
className="px-2 py-0.5 bg-primary/10 text-primary text-xs font-medium rounded-full"
|
||||||
|
>
|
||||||
|
{typeof member === 'string' ? member : member.name}
|
||||||
|
</span>
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스케줄 카드 컴포넌트
|
||||||
|
* 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 (
|
||||||
|
<div
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
'p-6 hover:bg-gray-50 transition-colors group',
|
||||||
|
onClick && 'cursor-pointer',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
{/* 날짜 - 세로 중앙 정렬 */}
|
||||||
|
{showDate && date && (
|
||||||
|
<div className="w-20 text-center flex-shrink-0 self-center">
|
||||||
|
{showYear && (
|
||||||
|
<div className="text-xs text-gray-400 mb-0.5">
|
||||||
|
{date.getFullYear()}.{date.getMonth() + 1}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-2xl font-bold text-gray-900">
|
||||||
|
{date.getDate()}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
{WEEKDAYS[date.getDay()]}요일
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 카테고리 바 */}
|
||||||
|
<div
|
||||||
|
className="w-1.5 rounded-full flex-shrink-0 self-stretch"
|
||||||
|
style={{ backgroundColor: categoryInfo.color }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 내용 */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-semibold text-gray-900 line-clamp-2">{title}</h3>
|
||||||
|
<div className="flex items-center flex-wrap gap-3 mt-1 text-sm text-gray-500">
|
||||||
|
{timeStr && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock size={14} />
|
||||||
|
{timeStr}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Tag size={14} />
|
||||||
|
{categoryInfo.name}
|
||||||
|
</span>
|
||||||
|
{schedule.source?.name && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Link2 size={14} />
|
||||||
|
{schedule.source.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{memberList.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||||
|
<MemberBadges members={memberList} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 액션 버튼 - 생일 일정이 아닐 때만 표시 */}
|
||||||
|
{!schedule.is_birthday && !String(schedule.id).startsWith('birthday-') && (
|
||||||
|
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
{schedule.source?.url && (
|
||||||
|
<a
|
||||||
|
href={schedule.source.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="p-2 hover:bg-blue-100 rounded-lg transition-colors text-blue-500"
|
||||||
|
>
|
||||||
|
<ExternalLink size={18} />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{onEdit && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onEdit(schedule);
|
||||||
|
}}
|
||||||
|
className="p-2 hover:bg-gray-200 rounded-lg transition-colors text-gray-500"
|
||||||
|
>
|
||||||
|
<Edit2 size={18} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onDelete && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete(schedule);
|
||||||
|
}}
|
||||||
|
className="p-2 hover:bg-red-100 rounded-lg transition-colors text-red-500"
|
||||||
|
>
|
||||||
|
<Trash2 size={18} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 공개 페이지용 카드 스타일 (기본)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
'flex items-stretch bg-white rounded-2xl shadow-sm hover:shadow-md transition-shadow overflow-hidden',
|
||||||
|
onClick && 'cursor-pointer',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* 좌측 날짜/카테고리 영역 */}
|
||||||
|
<div
|
||||||
|
className="w-24 flex flex-col items-center justify-center text-white py-6"
|
||||||
|
style={{ backgroundColor: categoryInfo.color }}
|
||||||
|
>
|
||||||
|
{date && (
|
||||||
|
<>
|
||||||
|
{showYear && (
|
||||||
|
<span className="text-xs font-medium opacity-60">
|
||||||
|
{date.getFullYear()}.{date.getMonth() + 1}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-3xl font-bold">{date.getDate()}</span>
|
||||||
|
<span className="text-sm font-medium opacity-80">{WEEKDAYS[date.getDay()]}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 우측 내용 영역 */}
|
||||||
|
<div className="flex-1 p-6 flex flex-col justify-center">
|
||||||
|
<h3 className="font-bold text-lg mb-2 line-clamp-2">{title}</h3>
|
||||||
|
<div className="flex flex-wrap gap-3 text-base text-gray-500">
|
||||||
|
{timeStr && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock size={16} className="opacity-60" />
|
||||||
|
{timeStr}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Tag size={16} className="opacity-60" />
|
||||||
|
{categoryInfo.name}
|
||||||
|
</span>
|
||||||
|
{schedule.source?.name && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Link2 size={16} className="opacity-60" />
|
||||||
|
{schedule.source.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{memberList.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||||
|
<MemberBadges members={memberList} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ScheduleCard;
|
||||||
4
frontend-temp/src/components/schedule/index.js
Normal file
4
frontend-temp/src/components/schedule/index.js
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
/**
|
||||||
|
* 스케줄 컴포넌트 export
|
||||||
|
*/
|
||||||
|
export { default as ScheduleCard } from './ScheduleCard';
|
||||||
Loading…
Add table
Reference in a new issue