refactor(frontend): 일정 카드를 공유 컴포넌트로 분리

- ScheduleCard (PC): 카테고리와 source name 분리, Link2 아이콘 사용
- MobileScheduleCard (Mobile): 동일한 구조로 분리
- Home 페이지에서 컴포넌트 사용하도록 변경
- 멤버 5명 이상이면 '프로미스나인'으로 표시

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-01-21 20:31:28 +09:00
parent d660340cc5
commit 9b96c475a7
6 changed files with 236 additions and 185 deletions

View file

@ -1,6 +1,9 @@
// 공통 컴포넌트 (디바이스 무관)
export * from './common';
// 스케줄 컴포넌트
export * from './schedule';
// PC 컴포넌트
export * as PC from './pc';

View file

@ -0,0 +1,101 @@
import { Clock, Tag, Link2 } from 'lucide-react';
/**
* Mobile 일정 카드 컴포넌트
* , 스케줄 페이지 등에서 공통으로 사용
*/
function MobileScheduleCard({ schedule, onClick, className = '' }) {
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 memberList = schedule.member_names
? schedule.member_names.split(',').map((n) => n.trim()).filter(Boolean)
: schedule.members?.map((m) => m.name) || [];
// 5 ''
const displayMembers = memberList.length >= 5 ? ['프로미스나인'] : memberList;
const categoryName = schedule.category_name || schedule.category?.name;
const sourceName = schedule.source?.name;
return (
<div
onClick={onClick}
className={`flex gap-4 bg-white p-4 rounded-xl shadow-sm border border-gray-100 overflow-hidden ${className}`}
>
{/* 날짜 영역 */}
<div className="flex flex-col items-center justify-center min-w-[50px]">
{!isCurrentYear && (
<span className="text-[10px] text-gray-400 font-medium">
{scheduleYear}.{scheduleMonth + 1}
</span>
)}
{isCurrentYear && !isCurrentMonth && (
<span className="text-[10px] text-gray-400 font-medium">
{scheduleMonth + 1}
</span>
)}
<span className="text-2xl font-bold text-primary">
{scheduleDate.getDate()}
</span>
<span className="text-xs text-gray-400 font-medium">
{['일', '월', '화', '수', '목', '금', '토'][scheduleDate.getDay()]}
</span>
</div>
{/* 세로 구분선 */}
<div className="w-px bg-gray-100" />
{/* 내용 영역 */}
<div className="flex-1 min-w-0">
<p className="font-semibold text-sm text-gray-800 line-clamp-2 leading-snug">
{schedule.title}
</p>
{/* 시간 + 카테고리 + 소스 */}
<div className="flex flex-wrap items-center gap-3 mt-2 text-xs text-gray-400">
{schedule.time && (
<span className="flex items-center gap-1">
<Clock size={12} />
{schedule.time.slice(0, 5)}
</span>
)}
{categoryName && (
<span className="flex items-center gap-1">
<Tag size={12} />
{categoryName}
</span>
)}
{sourceName && (
<span className="flex items-center gap-1">
<Link2 size={12} />
{sourceName}
</span>
)}
</div>
{/* 멤버 */}
{displayMembers.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{displayMembers.map((name, i) => (
<span
key={i}
className="px-2 py-0.5 bg-primary/10 text-primary text-[10px] rounded-full font-medium"
>
{name}
</span>
))}
</div>
)}
</div>
</div>
);
}
export default MobileScheduleCard;

View file

@ -0,0 +1,98 @@
import { Clock, Tag, Link2 } from 'lucide-react';
/**
* PC 일정 카드 컴포넌트
* , 스케줄 페이지 등에서 공통으로 사용
*/
function ScheduleCard({ schedule, onClick, className = '' }) {
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(',').map((n) => n.trim()).filter(Boolean)
: schedule.members?.map((m) => m.name) || [];
// 5 ''
const displayMembers = memberList.length >= 5 ? ['프로미스나인'] : memberList;
const categoryColor = schedule.category_color || '#6366f1';
const categoryName = schedule.category_name || schedule.category?.name;
const sourceName = schedule.source?.name;
return (
<div
onClick={onClick}
className={`flex items-stretch bg-white rounded-2xl shadow-sm hover:shadow-md transition-shadow overflow-hidden cursor-pointer ${className}`}
>
{/* 날짜 영역 */}
<div
className="w-24 flex flex-col items-center justify-center text-white py-6"
style={{ backgroundColor: categoryColor }}
>
{!isCurrentYear && (
<span className="text-xs font-medium opacity-70">
{scheduleYear}.{scheduleMonth + 1}
</span>
)}
{isCurrentYear && !isCurrentMonth && (
<span className="text-xs font-medium opacity-70">
{scheduleMonth + 1}
</span>
)}
<span className="text-3xl font-bold">{day}</span>
<span className="text-sm font-medium opacity-80">{weekday}</span>
</div>
{/* 내용 영역 */}
<div className="flex-1 p-6 flex flex-col justify-center">
<h3 className="font-bold text-lg mb-2">{schedule.title}</h3>
<div className="flex flex-wrap gap-3 text-base text-gray-500">
{schedule.time && (
<span className="flex items-center gap-1">
<Clock size={16} className="opacity-60" />
{schedule.time.slice(0, 5)}
</span>
)}
{categoryName && (
<span className="flex items-center gap-1">
<Tag size={16} className="opacity-60" />
{categoryName}
</span>
)}
{sourceName && (
<span className="flex items-center gap-1">
<Link2 size={16} className="opacity-60" />
{sourceName}
</span>
)}
</div>
{displayMembers.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{displayMembers.map((name, i) => (
<span
key={i}
className="px-2 py-0.5 bg-primary/10 text-primary text-sm font-medium rounded-full"
>
{name}
</span>
))}
</div>
)}
</div>
</div>
);
}
export default ScheduleCard;

View file

@ -0,0 +1,2 @@
export { default as ScheduleCard } from './ScheduleCard';
export { default as MobileScheduleCard } from './MobileScheduleCard';

View file

@ -1,7 +1,8 @@
import { motion } from 'framer-motion';
import { ChevronRight, Clock, Tag } from 'lucide-react';
import { ChevronRight } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { useMembers, useAlbums, useUpcomingSchedules } from '@/hooks';
import { MobileScheduleCard } from '@/components';
/**
* Mobile 페이지
@ -157,101 +158,20 @@ function MobileHome() {
</div>
{schedules.length > 0 ? (
<div className="space-y-3">
{schedules.map((schedule, index) => {
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 memberList = schedule.member_names
? schedule.member_names
.split(',')
.map((n) => n.trim())
.filter(Boolean)
: schedule.members?.map((m) => m.name) || [];
return (
<motion.div
key={schedule.id}
className="flex gap-4 bg-white p-4 rounded-xl shadow-sm border border-gray-100 overflow-hidden"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.8 + index * 0.1, duration: 0.3 }}
whileTap={{ scale: 0.98 }}
{schedules.map((schedule, index) => (
<motion.div
key={schedule.id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.8 + index * 0.1, duration: 0.3 }}
whileTap={{ scale: 0.98 }}
>
<MobileScheduleCard
schedule={schedule}
onClick={() => navigate('/schedule')}
>
{/* 날짜 영역 */}
<div className="flex flex-col items-center justify-center min-w-[50px]">
{!isCurrentYear && (
<span className="text-[10px] text-gray-400 font-medium">
{scheduleYear}.{scheduleMonth + 1}
</span>
)}
{isCurrentYear && !isCurrentMonth && (
<span className="text-[10px] text-gray-400 font-medium">
{scheduleMonth + 1}
</span>
)}
<span className="text-2xl font-bold text-primary">
{scheduleDate.getDate()}
</span>
<span className="text-xs text-gray-400 font-medium">
{
['일', '월', '화', '수', '목', '금', '토'][
scheduleDate.getDay()
]
}
</span>
</div>
{/* 세로 구분선 */}
<div className="w-px bg-gray-100" />
{/* 내용 영역 */}
<div className="flex-1 min-w-0">
<p className="font-semibold text-sm text-gray-800 line-clamp-2 leading-snug">
{schedule.title}
</p>
{/* 시간 + 카테고리 */}
<div className="flex items-center gap-3 mt-2 text-xs text-gray-400">
{schedule.time && (
<span className="flex items-center gap-1">
<Clock size={12} />
{schedule.time.slice(0, 5)}
</span>
)}
{schedule.category_name && (
<span className="flex items-center gap-1">
<Tag size={12} />
{schedule.category_name}
{schedule.source?.name && ` · ${schedule.source.name}`}
</span>
)}
</div>
{/* 멤버 */}
{memberList.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{memberList.map((name, i) => (
<span
key={i}
className="px-2 py-0.5 bg-primary/10 text-primary text-[10px] rounded-full font-medium"
>
{name.trim()}
</span>
))}
</div>
)}
</div>
</motion.div>
);
})}
/>
</motion.div>
))}
</div>
) : (
<div className="text-center py-8 text-gray-400">

View file

@ -1,7 +1,8 @@
import { motion } from 'framer-motion';
import { Link } from 'react-router-dom';
import { Calendar, ArrowRight, Clock, Tag, Music } from 'lucide-react';
import { Calendar, ArrowRight, Music } from 'lucide-react';
import { useMembers, useAlbums, useUpcomingSchedules } from '@/hooks';
import { ScheduleCard } from '@/components';
/**
* PC 페이지
@ -218,95 +219,21 @@ function Home() {
visible: { opacity: 1, transition: { staggerChildren: 0.1 } },
}}
>
{upcomingSchedules.map((schedule) => {
const scheduleDate = new Date(schedule.date);
const todayDate = new Date();
const currentYear = todayDate.getFullYear();
const currentMonth = todayDate.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 categoryColor = schedule.category_color || '#6366f1';
return (
<motion.div
key={schedule.id}
variants={{
hidden: { opacity: 0, x: -30 },
visible: {
opacity: 1,
x: 0,
transition: { duration: 0.4, ease: 'easeOut' },
},
}}
className="flex items-stretch bg-white rounded-2xl shadow-sm hover:shadow-md transition-shadow overflow-hidden cursor-pointer"
>
{/* 날짜 영역 */}
<div
className="w-24 flex flex-col items-center justify-center text-white py-6"
style={{ backgroundColor: categoryColor }}
>
{!isCurrentYear && (
<span className="text-xs font-medium opacity-70">
{scheduleYear}.{scheduleMonth + 1}
</span>
)}
{isCurrentYear && !isCurrentMonth && (
<span className="text-xs font-medium opacity-70">
{scheduleMonth + 1}
</span>
)}
<span className="text-3xl font-bold">{day}</span>
<span className="text-sm font-medium opacity-80">
{weekday}
</span>
</div>
{/* 내용 영역 */}
<div className="flex-1 p-6 flex flex-col justify-center">
<h3 className="font-bold text-lg mb-2">{schedule.title}</h3>
<div className="flex flex-wrap gap-3 text-base text-gray-500">
{schedule.time && (
<span className="flex items-center gap-1">
<Clock size={16} className="opacity-60" />
{schedule.time.slice(0, 5)}
</span>
)}
<span className="flex items-center gap-1">
<Tag size={16} className="opacity-60" />
{schedule.category_name}
{schedule.source?.name && ` · ${schedule.source.name}`}
</span>
</div>
{memberList.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{memberList.map((name, i) => (
<span
key={i}
className="px-2 py-0.5 bg-primary/10 text-primary text-sm font-medium rounded-full"
>
{name.trim()}
</span>
))}
</div>
)}
</div>
</motion.div>
);
})}
{upcomingSchedules.map((schedule) => (
<motion.div
key={schedule.id}
variants={{
hidden: { opacity: 0, x: -30 },
visible: {
opacity: 1,
x: 0,
transition: { duration: 0.4, ease: 'easeOut' },
},
}}
>
<ScheduleCard schedule={schedule} />
</motion.div>
))}
</motion.div>
)}
</div>