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:
caadiq 2026-01-21 17:39:48 +09:00
parent 27c41b0af0
commit 84030019cd
13 changed files with 813 additions and 116 deletions

View file

@ -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 (
<BrowserRouter>
<Routes>
<Route
path="/"
element={
<div className="min-h-screen flex items-center justify-center bg-gray-50 p-4">
<div className="text-center space-y-4 max-w-md w-full">
<h1 className="text-2xl font-bold text-primary mb-2">
fromis_9 Frontend Refactoring
</h1>
<p className="text-gray-600">Phase 5 완료 - 커스텀 </p>
<p className={cn("text-sm", isMobile ? "text-blue-500" : "text-green-500")}>
디바이스: {isMobile ? "모바일" : "PC"} (useIsMobile )
</p>
<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}개 카테고리`}
<ErrorBoundary>
<Routes>
<Route
path="/"
element={
<div className="min-h-screen bg-gray-50 p-4">
<div className="max-w-2xl mx-auto space-y-4">
<div className="text-center">
<h1 className="text-2xl font-bold text-primary mb-2">
fromis_9 Frontend Refactoring
</h1>
<p className="text-gray-600">Phase 6 완료 - 공통 컴포넌트</p>
<p className={cn("text-sm", isMobile ? "text-blue-500" : "text-green-500")}>
디바이스: {isMobile ? "모바일" : "PC"}
</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 className="border-t pt-3">
<p className="font-semibold mb-2">useCalendar </p>
<p><strong>현재:</strong> {calendar.year} {calendar.monthName}</p>
<p><strong>선택:</strong> {calendar.selectedDate}</p>
<div className="flex gap-2 mt-2">
<button
onClick={calendar.goToPrevMonth}
disabled={!calendar.canGoPrevMonth}
className={cn(
"px-3 py-1 rounded text-xs",
calendar.canGoPrevMonth ? "bg-primary text-white" : "bg-gray-200 text-gray-400"
)}
>
이전
</button>
<button
onClick={calendar.goToToday}
className="px-3 py-1 bg-gray-500 text-white rounded text-xs"
>
오늘
</button>
<button
onClick={calendar.goToNextMonth}
className="px-3 py-1 bg-primary text-white rounded text-xs"
>
다음
</button>
<div className="p-4 bg-white rounded-lg shadow text-sm space-y-3">
<p><strong>오늘:</strong> {formatFullDate(today)}</p>
<div className="border-t pt-3">
<p className="font-semibold mb-2">카테고리 ({categories?.length || 0})</p>
{categoriesLoading ? (
<Loading size="sm" />
) : (
<div className="flex flex-wrap gap-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 className="border-t pt-3">
<p className="font-semibold mb-2">토스트 테스트</p>
<div className="flex gap-2">
<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 className="border-t pt-3">
<p className="font-semibold mb-2">멤버 데이터</p>
<p>{membersLoading ? "로딩 중..." : `${members?.length || 0}`}</p>
{members && (
<div className="flex flex-wrap gap-1 mt-1">
{members.map((m) => (
<span key={m.id} className="px-2 py-0.5 bg-gray-100 rounded text-xs">
{m.name}
</span>
))}
{/* ScheduleCard 컴포넌트 테스트 */}
{schedulesLoading ? (
<div className="p-4 bg-white rounded-lg shadow">
<Loading size="sm" text="스케줄 로딩 중..." />
</div>
) : schedules?.length > 0 ? (
<>
{/* Public Variant (공개 페이지용) */}
<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 className="fixed bottom-4 right-4 space-y-2">
{toasts.map((toast) => (
<div
key={toast.id}
className={cn(
"px-4 py-2 rounded shadow-lg text-white text-sm",
toast.type === "success" && "bg-green-500",
toast.type === "error" && "bg-red-500",
toast.type === "warning" && "bg-yellow-500",
toast.type === "info" && "bg-blue-500"
)}
>
{toast.message}
</div>
))}
{/* Admin Variant (관리자 페이지용) */}
<div className="p-4 bg-white rounded-lg shadow">
<p className="font-semibold mb-3 text-sm">variant="admin" (관리자 페이지용)</p>
<div className="divide-y">
{schedules.slice(0, 2).map((schedule) => (
<ScheduleCard
key={schedule.id}
schedule={schedule}
variant="admin"
onClick={() => showSuccess(`${schedule.title} 클릭`)}
onEdit={(s) => showSuccess(`${s.title} 수정`)}
onDelete={(s) => showError(`${s.title} 삭제`)}
/>
))}
</div>
</div>
</>
) : (
<div className="p-4 bg-white rounded-lg shadow">
<p className="text-gray-500 text-sm">이번 스케줄이 없습니다.</p>
</div>
)}
</div>
{/* 토스트 컨테이너 */}
<ToastContainer />
</div>
</div>
}
/>
</Routes>
}
/>
</Routes>
</ErrorBoundary>
</BrowserRouter>
);
}

View 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;

View 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;

View 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
)}
/>
);
}

View 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>
);
}

View 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';

View file

@ -0,0 +1,9 @@
/**
* 컴포넌트 통합 export
*/
// 공통 컴포넌트
export * from './common';
// 스케줄 컴포넌트
export * from './schedule';

View 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;

View file

@ -0,0 +1,4 @@
/**
* 스케줄 컴포넌트 export
*/
export { default as ScheduleCard } from './ScheduleCard';