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:
parent
d660340cc5
commit
9b96c475a7
6 changed files with 236 additions and 185 deletions
|
|
@ -1,6 +1,9 @@
|
||||||
// 공통 컴포넌트 (디바이스 무관)
|
// 공통 컴포넌트 (디바이스 무관)
|
||||||
export * from './common';
|
export * from './common';
|
||||||
|
|
||||||
|
// 스케줄 컴포넌트
|
||||||
|
export * from './schedule';
|
||||||
|
|
||||||
// PC 컴포넌트
|
// PC 컴포넌트
|
||||||
export * as PC from './pc';
|
export * as PC from './pc';
|
||||||
|
|
||||||
|
|
|
||||||
101
frontend-temp/src/components/schedule/MobileScheduleCard.jsx
Normal file
101
frontend-temp/src/components/schedule/MobileScheduleCard.jsx
Normal 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;
|
||||||
98
frontend-temp/src/components/schedule/ScheduleCard.jsx
Normal file
98
frontend-temp/src/components/schedule/ScheduleCard.jsx
Normal 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;
|
||||||
2
frontend-temp/src/components/schedule/index.js
Normal file
2
frontend-temp/src/components/schedule/index.js
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { default as ScheduleCard } from './ScheduleCard';
|
||||||
|
export { default as MobileScheduleCard } from './MobileScheduleCard';
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { ChevronRight, Clock, Tag } from 'lucide-react';
|
import { ChevronRight } from 'lucide-react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useMembers, useAlbums, useUpcomingSchedules } from '@/hooks';
|
import { useMembers, useAlbums, useUpcomingSchedules } from '@/hooks';
|
||||||
|
import { MobileScheduleCard } from '@/components';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mobile 홈 페이지
|
* Mobile 홈 페이지
|
||||||
|
|
@ -157,101 +158,20 @@ function MobileHome() {
|
||||||
</div>
|
</div>
|
||||||
{schedules.length > 0 ? (
|
{schedules.length > 0 ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{schedules.map((schedule, index) => {
|
{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
|
<motion.div
|
||||||
key={schedule.id}
|
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 }}
|
initial={{ opacity: 0, x: -20 }}
|
||||||
animate={{ opacity: 1, x: 0 }}
|
animate={{ opacity: 1, x: 0 }}
|
||||||
transition={{ delay: 0.8 + index * 0.1, duration: 0.3 }}
|
transition={{ delay: 0.8 + index * 0.1, duration: 0.3 }}
|
||||||
whileTap={{ scale: 0.98 }}
|
whileTap={{ scale: 0.98 }}
|
||||||
|
>
|
||||||
|
<MobileScheduleCard
|
||||||
|
schedule={schedule}
|
||||||
onClick={() => navigate('/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>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-8 text-gray-400">
|
<div className="text-center py-8 text-gray-400">
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { Link } from 'react-router-dom';
|
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 { useMembers, useAlbums, useUpcomingSchedules } from '@/hooks';
|
||||||
|
import { ScheduleCard } from '@/components';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PC 홈 페이지
|
* PC 홈 페이지
|
||||||
|
|
@ -218,30 +219,7 @@ function Home() {
|
||||||
visible: { opacity: 1, transition: { staggerChildren: 0.1 } },
|
visible: { opacity: 1, transition: { staggerChildren: 0.1 } },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{upcomingSchedules.map((schedule) => {
|
{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
|
<motion.div
|
||||||
key={schedule.id}
|
key={schedule.id}
|
||||||
variants={{
|
variants={{
|
||||||
|
|
@ -252,61 +230,10 @@ function Home() {
|
||||||
transition: { duration: 0.4, ease: 'easeOut' },
|
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"
|
|
||||||
>
|
>
|
||||||
{/* 날짜 영역 */}
|
<ScheduleCard schedule={schedule} />
|
||||||
<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>
|
</motion.div>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue