2026-01-12 15:46:34 +09:00
|
|
|
import { useState } from "react";
|
|
|
|
|
import { useQuery } from "@tanstack/react-query";
|
|
|
|
|
import { motion } from "framer-motion";
|
|
|
|
|
import { Link } from "react-router-dom";
|
2026-01-12 15:57:36 +09:00
|
|
|
import { Calendar, ArrowRight, Clock, Link2, Tag, Music } from "lucide-react";
|
2026-01-12 15:46:34 +09:00
|
|
|
import { getTodayKST } from "../../../utils/date";
|
|
|
|
|
import { getMembers } from "../../../api/public/members";
|
2026-01-12 15:57:36 +09:00
|
|
|
import { getAlbums } from "../../../api/public/albums";
|
2026-01-12 15:46:34 +09:00
|
|
|
import { getUpcomingSchedules } from "../../../api/public/schedules";
|
2025-12-31 21:51:23 +09:00
|
|
|
|
|
|
|
|
function Home() {
|
2026-01-12 15:46:34 +09:00
|
|
|
// useQuery로 멤버 데이터 로드
|
|
|
|
|
const { data: members = [] } = useQuery({
|
|
|
|
|
queryKey: ["members"],
|
|
|
|
|
queryFn: getMembers,
|
|
|
|
|
});
|
2026-01-01 00:26:04 +09:00
|
|
|
|
2026-01-12 15:57:36 +09:00
|
|
|
// useQuery로 앨범 로드 (최신 4개)
|
|
|
|
|
const { data: albums = [] } = useQuery({
|
|
|
|
|
queryKey: ["albums"],
|
|
|
|
|
queryFn: getAlbums,
|
|
|
|
|
select: (data) => data.slice(0, 4),
|
|
|
|
|
});
|
|
|
|
|
|
2026-01-12 15:46:34 +09:00
|
|
|
// useQuery로 다가오는 일정 로드 (오늘 이후 3개)
|
|
|
|
|
const { data: upcomingSchedules = [] } = useQuery({
|
|
|
|
|
queryKey: ["upcomingSchedules", 3],
|
|
|
|
|
queryFn: () => getUpcomingSchedules(3),
|
|
|
|
|
});
|
2026-01-01 00:26:04 +09:00
|
|
|
|
2026-01-12 15:46:34 +09:00
|
|
|
return (
|
|
|
|
|
<div>
|
|
|
|
|
{/* 히어로 섹션 */}
|
|
|
|
|
<section className="relative h-[600px] bg-gradient-to-br from-primary to-primary-dark overflow-hidden">
|
|
|
|
|
<div className="absolute inset-0 bg-black/20" />
|
|
|
|
|
<div className="relative max-w-7xl mx-auto px-6 h-full flex items-center">
|
|
|
|
|
<motion.div
|
|
|
|
|
initial={{ opacity: 0, y: 30 }}
|
|
|
|
|
animate={{ opacity: 1, y: 0 }}
|
|
|
|
|
transition={{ duration: 0.8 }}
|
|
|
|
|
className="text-white"
|
|
|
|
|
>
|
|
|
|
|
<h1 className="text-6xl font-bold mb-4">fromis_9</h1>
|
|
|
|
|
<p className="text-2xl font-light mb-2">프로미스나인</p>
|
2026-01-12 15:57:36 +09:00
|
|
|
<p className="text-lg opacity-80 leading-relaxed">
|
2026-01-12 15:46:34 +09:00
|
|
|
인사드리겠습니다. 둘, 셋!
|
|
|
|
|
<br />
|
|
|
|
|
이제는 약속해 소중히 간직해,
|
|
|
|
|
<br />
|
|
|
|
|
당신의 아이돌로 성장하겠습니다!
|
|
|
|
|
</p>
|
|
|
|
|
</motion.div>
|
|
|
|
|
</div>
|
2025-12-31 21:51:23 +09:00
|
|
|
|
2026-01-12 15:46:34 +09:00
|
|
|
{/* 장식 */}
|
|
|
|
|
<div className="absolute right-0 bottom-0 w-1/2 h-full opacity-10">
|
|
|
|
|
<div className="absolute right-10 top-20 w-64 h-64 rounded-full bg-white/30" />
|
|
|
|
|
<div className="absolute right-40 bottom-20 w-48 h-48 rounded-full bg-white/20" />
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
2025-12-31 21:51:23 +09:00
|
|
|
|
2026-01-12 15:46:34 +09:00
|
|
|
{/* 그룹 통계 섹션 */}
|
|
|
|
|
<section className="py-16 bg-gray-50">
|
|
|
|
|
<div className="max-w-7xl mx-auto px-6">
|
|
|
|
|
<motion.div
|
|
|
|
|
className="grid grid-cols-4 gap-6"
|
|
|
|
|
initial="hidden"
|
|
|
|
|
whileInView="visible"
|
|
|
|
|
viewport={{ once: true, amount: 0.1 }}
|
|
|
|
|
variants={{
|
|
|
|
|
hidden: { opacity: 1 },
|
|
|
|
|
visible: { opacity: 1, transition: { staggerChildren: 0.1 } },
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{[
|
|
|
|
|
{ value: "2018.01.24", label: "데뷔일" },
|
|
|
|
|
{
|
|
|
|
|
value: `D+${(
|
|
|
|
|
Math.floor(
|
|
|
|
|
(new Date() - new Date("2018-01-24")) /
|
|
|
|
|
(1000 * 60 * 60 * 24)
|
|
|
|
|
) + 1
|
|
|
|
|
).toLocaleString()}`,
|
|
|
|
|
label: "D+Day",
|
|
|
|
|
},
|
|
|
|
|
{ value: "5", label: "멤버 수" },
|
|
|
|
|
{ value: "flover", label: "팬덤명" },
|
|
|
|
|
].map((stat, index) => (
|
|
|
|
|
<motion.div
|
|
|
|
|
key={index}
|
|
|
|
|
variants={{
|
|
|
|
|
hidden: { opacity: 0, y: 20 },
|
|
|
|
|
visible: {
|
|
|
|
|
opacity: 1,
|
|
|
|
|
y: 0,
|
|
|
|
|
transition: { duration: 0.4, ease: "easeOut" },
|
|
|
|
|
},
|
|
|
|
|
}}
|
|
|
|
|
className="bg-gradient-to-br from-primary to-primary-dark rounded-2xl p-6 text-white text-center"
|
|
|
|
|
>
|
|
|
|
|
<p className="text-3xl font-bold mb-1">{stat.value}</p>
|
|
|
|
|
<p className="text-white/70 text-sm">{stat.label}</p>
|
|
|
|
|
</motion.div>
|
|
|
|
|
))}
|
|
|
|
|
</motion.div>
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
2025-12-31 21:51:23 +09:00
|
|
|
|
2026-01-12 15:46:34 +09:00
|
|
|
{/* 멤버 미리보기 */}
|
|
|
|
|
<section className="py-16 bg-gray-50">
|
|
|
|
|
<div className="max-w-7xl mx-auto px-6">
|
|
|
|
|
<div className="flex justify-between items-center mb-8">
|
|
|
|
|
<h2 className="text-3xl font-bold">멤버</h2>
|
|
|
|
|
<Link
|
|
|
|
|
to="/members"
|
|
|
|
|
className="text-primary hover:underline flex items-center gap-1"
|
|
|
|
|
>
|
|
|
|
|
전체보기 <ArrowRight size={16} />
|
|
|
|
|
</Link>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="grid grid-cols-5 gap-6">
|
|
|
|
|
{members
|
|
|
|
|
.filter((m) => !m.is_former)
|
|
|
|
|
.map((member, index) => (
|
|
|
|
|
<motion.div
|
|
|
|
|
key={member.id}
|
|
|
|
|
initial={{ opacity: 0, y: 30 }}
|
|
|
|
|
animate={{ opacity: 1, y: 0 }}
|
|
|
|
|
transition={{
|
|
|
|
|
delay: 0.3 + index * 0.1,
|
|
|
|
|
duration: 0.5,
|
|
|
|
|
ease: "easeOut",
|
|
|
|
|
}}
|
|
|
|
|
className="group relative rounded-2xl overflow-hidden shadow-sm hover:shadow-xl transition-all duration-300"
|
|
|
|
|
>
|
|
|
|
|
{/* 이미지 컨테이너 */}
|
|
|
|
|
<div className="aspect-[3/4] overflow-hidden">
|
|
|
|
|
<img
|
|
|
|
|
src={member.image_url}
|
|
|
|
|
alt={member.name}
|
|
|
|
|
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* 그라데이션 오버레이 */}
|
|
|
|
|
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent opacity-60 group-hover:opacity-90 transition-opacity duration-300" />
|
|
|
|
|
|
|
|
|
|
{/* 멤버 정보 */}
|
|
|
|
|
<div className="absolute bottom-0 left-0 right-0 p-5 text-white">
|
|
|
|
|
<h3 className="font-bold text-xl drop-shadow-lg">
|
|
|
|
|
{member.name}
|
|
|
|
|
</h3>
|
|
|
|
|
</div>
|
|
|
|
|
</motion.div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
2025-12-31 21:51:23 +09:00
|
|
|
|
2026-01-12 15:57:36 +09:00
|
|
|
{/* 앨범 미리보기 */}
|
|
|
|
|
<section className="py-16 bg-gray-50">
|
|
|
|
|
<div className="max-w-7xl mx-auto px-6">
|
|
|
|
|
<div className="flex justify-between items-center mb-8">
|
|
|
|
|
<h2 className="text-3xl font-bold">앨범</h2>
|
|
|
|
|
<Link
|
|
|
|
|
to="/album"
|
|
|
|
|
className="text-primary hover:underline flex items-center gap-1"
|
|
|
|
|
>
|
|
|
|
|
전체보기 <ArrowRight size={16} />
|
|
|
|
|
</Link>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="grid grid-cols-4 gap-6">
|
|
|
|
|
{albums.map((album, index) => (
|
|
|
|
|
<motion.div
|
|
|
|
|
key={album.id}
|
|
|
|
|
initial={{ opacity: 0, y: 30 }}
|
|
|
|
|
animate={{ opacity: 1, y: 0 }}
|
|
|
|
|
transition={{
|
|
|
|
|
delay: 0.3 + index * 0.1,
|
|
|
|
|
duration: 0.5,
|
|
|
|
|
ease: "easeOut",
|
|
|
|
|
}}
|
|
|
|
|
className="group cursor-pointer"
|
|
|
|
|
onClick={() => window.location.href = `/album/${encodeURIComponent(album.title)}`}
|
|
|
|
|
>
|
|
|
|
|
<div className="relative aspect-square rounded-2xl overflow-hidden shadow-md hover:shadow-xl transition-all duration-300">
|
|
|
|
|
<img
|
|
|
|
|
src={album.cover_medium_url || album.cover_original_url}
|
|
|
|
|
alt={album.title}
|
|
|
|
|
className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-105"
|
|
|
|
|
/>
|
|
|
|
|
{/* 호버 오버레이 */}
|
|
|
|
|
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center">
|
|
|
|
|
<div className="text-center text-white">
|
|
|
|
|
<Music size={32} className="mx-auto mb-2" />
|
|
|
|
|
<p className="text-sm">{album.tracks?.length || 0}곡 수록</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="mt-3">
|
|
|
|
|
<h3 className="font-bold text-lg truncate">{album.title}</h3>
|
|
|
|
|
<p className="text-gray-500 text-sm">{album.release_date?.slice(0, 4)}</p>
|
|
|
|
|
</div>
|
|
|
|
|
</motion.div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
|
2026-01-12 15:46:34 +09:00
|
|
|
{/* 일정 미리보기 */}
|
|
|
|
|
<section className="py-16 bg-gray-50">
|
|
|
|
|
<div className="max-w-7xl mx-auto px-6">
|
|
|
|
|
<div className="flex justify-between items-center mb-8">
|
|
|
|
|
<h2 className="text-3xl font-bold">다가오는 일정</h2>
|
|
|
|
|
<Link
|
|
|
|
|
to="/schedule"
|
|
|
|
|
className="text-primary hover:underline flex items-center gap-1"
|
|
|
|
|
>
|
|
|
|
|
전체보기 <ArrowRight size={16} />
|
|
|
|
|
</Link>
|
|
|
|
|
</div>
|
|
|
|
|
{upcomingSchedules.length === 0 ? (
|
|
|
|
|
<div className="text-center py-12 text-gray-400">
|
|
|
|
|
<Calendar size={48} className="mx-auto mb-4 opacity-30" />
|
|
|
|
|
<p>예정된 일정이 없습니다</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<motion.div
|
|
|
|
|
className="space-y-4"
|
|
|
|
|
initial="hidden"
|
|
|
|
|
whileInView="visible"
|
|
|
|
|
viewport={{ once: true, amount: 0.1 }}
|
|
|
|
|
variants={{
|
|
|
|
|
hidden: { opacity: 1 },
|
|
|
|
|
visible: { opacity: 1, transition: { staggerChildren: 0.1 } },
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{upcomingSchedules.map((schedule) => {
|
|
|
|
|
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(",")
|
2026-01-20 17:27:31 +09:00
|
|
|
: schedule.members?.map(m => m.name) || [];
|
|
|
|
|
const displayMembers = memberList;
|
|
|
|
|
|
|
|
|
|
const categoryColor = schedule.category_color || '#6366f1';
|
2026-01-12 15:46:34 +09:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<motion.div
|
|
|
|
|
key={schedule.id}
|
|
|
|
|
variants={{
|
|
|
|
|
hidden: { opacity: 0, x: -30 },
|
|
|
|
|
visible: {
|
|
|
|
|
opacity: 1,
|
|
|
|
|
x: 0,
|
|
|
|
|
transition: { duration: 0.4, ease: "easeOut" },
|
|
|
|
|
},
|
|
|
|
|
}}
|
2026-01-20 17:27:31 +09:00
|
|
|
className="flex items-stretch bg-white rounded-2xl shadow-sm hover:shadow-md transition-shadow overflow-hidden cursor-pointer"
|
2026-01-12 15:46:34 +09:00
|
|
|
>
|
2026-01-20 17:27:31 +09:00
|
|
|
{/* 날짜 영역 - 카테고리 색상 */}
|
|
|
|
|
<div
|
|
|
|
|
className="w-24 flex flex-col items-center justify-center text-white py-6"
|
|
|
|
|
style={{ backgroundColor: categoryColor }}
|
|
|
|
|
>
|
2026-01-12 15:46:34 +09:00
|
|
|
{!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>
|
2026-01-20 17:27:31 +09:00
|
|
|
<span className="text-sm font-medium opacity-80">{weekday}</span>
|
2025-12-31 21:51:23 +09:00
|
|
|
</div>
|
2026-01-06 12:04:27 +09:00
|
|
|
|
2026-01-12 15:46:34 +09:00
|
|
|
{/* 내용 영역 */}
|
2026-01-20 17:27:31 +09:00
|
|
|
<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">
|
2026-01-12 15:46:34 +09:00
|
|
|
{schedule.time && (
|
2026-01-20 17:27:31 +09:00
|
|
|
<span className="flex items-center gap-1">
|
|
|
|
|
<Clock size={16} className="opacity-60" />
|
|
|
|
|
{schedule.time.slice(0, 5)}
|
|
|
|
|
</span>
|
2026-01-12 15:46:34 +09:00
|
|
|
)}
|
2026-01-20 17:27:31 +09:00
|
|
|
<span className="flex items-center gap-1">
|
|
|
|
|
<Tag size={16} className="opacity-60" />
|
|
|
|
|
{schedule.category_name}
|
|
|
|
|
</span>
|
2026-01-12 15:46:34 +09:00
|
|
|
</div>
|
|
|
|
|
{displayMembers.length > 0 && (
|
2026-01-20 17:27:31 +09:00
|
|
|
<div className="flex flex-wrap gap-1.5 mt-2">
|
2026-01-12 15:46:34 +09:00
|
|
|
{displayMembers.map((name, i) => (
|
|
|
|
|
<span
|
|
|
|
|
key={i}
|
2026-01-20 17:27:31 +09:00
|
|
|
className="px-2 py-0.5 bg-primary/10 text-primary text-sm font-medium rounded-full"
|
2026-01-12 15:46:34 +09:00
|
|
|
>
|
|
|
|
|
{name.trim()}
|
|
|
|
|
</span>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</motion.div>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</motion.div>
|
|
|
|
|
)}
|
2025-12-31 21:51:23 +09:00
|
|
|
</div>
|
2026-01-12 15:46:34 +09:00
|
|
|
</section>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
2025-12-31 21:51:23 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default Home;
|