fromis_9/frontend/src/pages/mobile/public/Home.jsx
caadiq d6eb8d410c feat: 모바일 홈 화면 섹션별 애니메이션 추가
- 히어로 섹션: 페이드인 + 텍스트 슬라이드업
- 멤버 섹션: 프로필 팝 애니메이션
- 앨범 섹션: 카드 슬라이드업
- 일정 섹션: 카드 슬라이드인
- 순차적 딜레이로 자연스러운 로딩 효과
2026-01-10 00:11:04 +09:00

265 lines
14 KiB
JavaScript

import { motion } from 'framer-motion';
import { ChevronRight, Clock, Tag } from 'lucide-react';
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { getTodayKST } from '../../../utils/date';
import { getMembers } from '../../../api/public/members';
import { getAlbums } from '../../../api/public/albums';
import { getUpcomingSchedules } from '../../../api/public/schedules';
// 모바일 홈 페이지
function MobileHome() {
const navigate = useNavigate();
const [members, setMembers] = useState([]);
const [albums, setAlbums] = useState([]);
const [schedules, setSchedules] = useState([]);
// 데이터 로드
useEffect(() => {
// 멤버 로드
getMembers()
.then(data => setMembers(data.filter(m => !m.is_former)))
.catch(console.error);
// 앨범 로드 (최신 2개)
getAlbums()
.then(data => setAlbums(data.slice(0, 2)))
.catch(console.error);
// 다가오는 일정 로드
getUpcomingSchedules(3)
.then(data => setSchedules(data))
.catch(console.error);
}, []);
return (
<div>
{/* 히어로 섹션 */}
<motion.section
className="relative bg-gradient-to-br from-primary to-primary-dark py-12 px-4 overflow-hidden"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5 }}
>
<div className="absolute inset-0 bg-black/10" />
<motion.div
className="relative text-center text-white"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2, duration: 0.5 }}
>
<h1 className="text-3xl font-bold mb-1">fromis_9</h1>
<p className="text-lg font-light mb-3">프로미스나인</p>
<p className="text-sm opacity-80 leading-relaxed">
인사드리겠습니다. , !<br />
이제는 약속해 소중히 간직해,<br />
당신의 아이돌로 성장하겠습니다!
</p>
</motion.div>
{/* 장식 */}
<div className="absolute right-0 top-0 w-32 h-32 rounded-full bg-white/10 -translate-y-1/2 translate-x-1/2" />
<div className="absolute left-0 bottom-0 w-24 h-24 rounded-full bg-white/5 translate-y-1/2 -translate-x-1/2" />
</motion.section>
{/* 멤버 섹션 */}
<motion.section
className="px-4 py-6"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3, duration: 0.5 }}
>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold">멤버</h2>
<button
onClick={() => navigate('/members')}
className="text-primary text-sm flex items-center gap-1"
>
전체보기 <ChevronRight size={16} />
</button>
</div>
<div className="grid grid-cols-5 gap-2">
{members.map((member, index) => (
<motion.div
key={member.id}
className="text-center"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.4 + index * 0.05, duration: 0.3 }}
whileTap={{ scale: 0.95 }}
>
<div className="aspect-square rounded-full overflow-hidden bg-gray-200 mb-1">
{member.image_url && (
<img
src={member.image_url}
alt={member.name}
className="w-full h-full object-cover"
/>
)}
</div>
<p className="text-xs font-medium truncate">{member.name}</p>
</motion.div>
))}
</div>
</motion.section>
{/* 앨범 섹션 */}
<motion.section
className="px-4 py-6 bg-gray-50"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5, duration: 0.5 }}
>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold">앨범</h2>
<button
onClick={() => navigate('/album')}
className="text-primary text-sm flex items-center gap-1"
>
전체보기 <ChevronRight size={16} />
</button>
</div>
<div className="grid grid-cols-2 gap-3">
{albums.map((album, index) => (
<motion.div
key={album.id}
onClick={() => navigate(`/album/${album.folder_name}`)}
className="bg-white rounded-xl overflow-hidden shadow-sm"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.6 + index * 0.1, duration: 0.3 }}
whileTap={{ scale: 0.98 }}
>
<div className="aspect-square bg-gray-200">
{album.cover_thumb_url && (
<img
src={album.cover_thumb_url}
alt={album.title}
className="w-full h-full object-cover"
/>
)}
</div>
<div className="p-3">
<p className="font-medium text-sm truncate">{album.title}</p>
<p className="text-xs text-gray-400">{album.release_date?.slice(0, 4)}</p>
</div>
</motion.div>
))}
</div>
</motion.section>
{/* 일정 섹션 */}
<motion.section
className="px-4 py-4"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.7, duration: 0.5 }}
>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold">다가오는 일정</h2>
<button
onClick={() => navigate('/schedule')}
className="text-primary text-sm flex items-center gap-1"
>
전체보기 <ChevronRight size={16} />
</button>
</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;
// 멤버 처리 (5명 이상이면 프로미스나인)
const memberList = schedule.member_names ? schedule.member_names.split(',').map(n => n.trim()).filter(Boolean) : [];
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 }}
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>
{/* 시간 + 카테고리 (PC버전 스타일) */}
<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}
</span>
)}
</div>
{/* 멤버 */}
{memberList.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{(memberList.length >= 5 ? ['프로미스나인'] : 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>
);
})}
</div>
) : (
<div className="text-center py-8 text-gray-400">
<p>다가오는 일정이 없습니다</p>
</div>
)}
</motion.section>
</div>
);
}
export default MobileHome;