PC 버전 프론트엔드 UI 구현 - 홈, 멤버, 디스코그래피, 스케줄 페이지 및 더미 데이터

This commit is contained in:
caadiq 2025-12-31 21:51:23 +09:00
parent 05b77140d6
commit e2c1a6a774
16 changed files with 933 additions and 0 deletions

29
backend/server.js Normal file
View file

@ -0,0 +1,29 @@
import express from "express";
import path from "path";
import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const app = express();
const PORT = process.env.PORT || 80;
// JSON 파싱
app.use(express.json());
// 정적 파일 서빙 (프론트엔드 빌드 결과물)
app.use(express.static(path.join(__dirname, "dist")));
// API 라우트 (추후 구현)
app.get("/api/health", (req, res) => {
res.json({ status: "ok", timestamp: new Date().toISOString() });
});
// SPA 폴백 - 모든 요청을 index.html로
app.get("*", (req, res) => {
res.sendFile(path.join(__dirname, "dist", "index.html"));
});
app.listen(PORT, () => {
console.log(`🌸 fromis_9 서버가 포트 ${PORT}에서 실행 중입니다`);
});

18
frontend/index.html Normal file
View file

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>fromis_9 - 프로미스나인</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;600;700&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

39
frontend/src/App.jsx Normal file
View file

@ -0,0 +1,39 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { BrowserView, MobileView } from 'react-device-detect';
// PC
import PCHome from './pages/pc/Home';
import PCMembers from './pages/pc/Members';
import PCDiscography from './pages/pc/Discography';
import PCSchedule from './pages/pc/Schedule';
// PC
import PCLayout from './components/pc/Layout';
function App() {
return (
<BrowserRouter>
<BrowserView>
<PCLayout>
<Routes>
<Route path="/" element={<PCHome />} />
<Route path="/members" element={<PCMembers />} />
<Route path="/discography" element={<PCDiscography />} />
<Route path="/schedule" element={<PCSchedule />} />
</Routes>
</PCLayout>
</BrowserView>
<MobileView>
{/* 모바일 버전은 추후 구현 */}
<div className="flex items-center justify-center h-screen bg-gray-100 p-4">
<div className="text-center">
<p className="text-xl font-bold text-primary mb-2">fromis_9</p>
<p className="text-gray-500">모바일 버전은 준비 중입니다.</p>
</div>
</div>
</MobileView>
</BrowserRouter>
);
}
export default App;

View file

@ -0,0 +1,72 @@
import { Instagram, Youtube, Twitter } from 'lucide-react';
import { socialLinks } from '../../data/dummy';
function Footer() {
return (
<footer className="bg-gray-900 text-white py-12">
<div className="max-w-7xl mx-auto px-6">
<div className="grid grid-cols-3 gap-8">
{/* 로고 및 설명 */}
<div>
<h3 className="text-2xl font-bold text-primary-light mb-4">fromis_9</h3>
<p className="text-gray-400 text-sm leading-relaxed">
인사드리겠습니다. , !<br />
이제는 약속해 소중히 간직해,<br />
당신의 아이돌로 성장하겠습니다!<br />
안녕하세요, 프로미스나인입니다.
</p>
</div>
{/* 링크 */}
<div>
<h4 className="font-semibold mb-4">Quick Links</h4>
<ul className="space-y-2 text-sm text-gray-400">
<li><a href="/members" className="hover:text-primary-light transition-colors">멤버</a></li>
<li><a href="/discography" className="hover:text-primary-light transition-colors">디스코그래피</a></li>
<li><a href="/schedule" className="hover:text-primary-light transition-colors">스케줄</a></li>
</ul>
</div>
{/* SNS */}
<div>
<h4 className="font-semibold mb-4">Follow Us</h4>
<div className="flex gap-4">
<a
href={socialLinks.youtube}
target="_blank"
rel="noopener noreferrer"
className="text-gray-400 hover:text-red-500 transition-colors"
>
<Youtube size={24} />
</a>
<a
href={socialLinks.instagram}
target="_blank"
rel="noopener noreferrer"
className="text-gray-400 hover:text-pink-500 transition-colors"
>
<Instagram size={24} />
</a>
<a
href={socialLinks.twitter}
target="_blank"
rel="noopener noreferrer"
className="text-gray-400 hover:text-blue-400 transition-colors"
>
<Twitter size={24} />
</a>
</div>
</div>
</div>
{/* 저작권 */}
<div className="border-t border-gray-800 mt-8 pt-8 text-center text-sm text-gray-500">
<p>© 2025 fromis_9 Fan Site. This is an unofficial fan-made website.</p>
<p className="mt-1">All rights belong to PLEDIS Entertainment / HYBE.</p>
</div>
</div>
</footer>
);
}
export default Footer;

View file

@ -0,0 +1,72 @@
import { NavLink } from 'react-router-dom';
import { Instagram, Youtube, Twitter } from 'lucide-react';
import { socialLinks } from '../../data/dummy';
function Header() {
const navItems = [
{ path: '/', label: '홈' },
{ path: '/members', label: '멤버' },
{ path: '/discography', label: '디스코그래피' },
{ path: '/schedule', label: '스케줄' },
];
return (
<header className="bg-white shadow-sm sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-6">
<div className="flex items-center justify-between h-16">
{/* 로고 */}
<NavLink to="/" className="flex items-center gap-2">
<span className="text-2xl font-bold text-primary">fromis_9</span>
</NavLink>
{/* 네비게이션 */}
<nav className="flex items-center gap-8">
{navItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) =>
`text-sm font-medium transition-colors hover:text-primary ${
isActive ? 'text-primary' : 'text-gray-600'
}`
}
>
{item.label}
</NavLink>
))}
</nav>
{/* SNS 링크 */}
<div className="flex items-center gap-4">
<a
href={socialLinks.youtube}
target="_blank"
rel="noopener noreferrer"
className="text-gray-500 hover:text-red-600 transition-colors"
>
<Youtube size={20} />
</a>
<a
href={socialLinks.instagram}
target="_blank"
rel="noopener noreferrer"
className="text-gray-500 hover:text-pink-600 transition-colors"
>
<Instagram size={20} />
</a>
<a
href={socialLinks.twitter}
target="_blank"
rel="noopener noreferrer"
className="text-gray-500 hover:text-blue-500 transition-colors"
>
<Twitter size={20} />
</a>
</div>
</div>
</div>
</header>
);
}
export default Header;

View file

@ -0,0 +1,14 @@
import Header from './Header';
import Footer from './Footer';
function Layout({ children }) {
return (
<div className="min-h-screen flex flex-col">
<Header />
<main className="flex-1">{children}</main>
<Footer />
</div>
);
}
export default Layout;

125
frontend/src/data/dummy.js Normal file
View file

@ -0,0 +1,125 @@
// 더미 멤버 데이터
export const members = [
{
id: 1,
name: "송하영",
birthDate: "1997.03.25",
position: "리더, 메인보컬, 메인댄서",
imageUrl:
"https://i.namu.wiki/i/M9XsM0yLkPOEwQJBAnVn2rMqo4kXRfv-AkvWNvgAX1m2IUWX1IECPIZ1sqH6tDMAmpqYQefaP1ydJz7hUZ6Zxw.webp",
instagram: "https://www.instagram.com/hayoung_0325/",
},
{
id: 2,
name: "박지원",
birthDate: "1998.10.20",
position: "메인보컬",
imageUrl:
"https://i.namu.wiki/i/M9XsM0yLkPOEwQJBAnVn2rMqo4kXRfv-AkvWNvgAX1lE7WfQKMuTmQCCJcXhPLv65cQCTMHWFhzhjJZIEbAKbQ.webp",
instagram: "https://www.instagram.com/jiwon_1020/",
},
{
id: 3,
name: "이채영",
birthDate: "2000.06.14",
position: "래퍼",
imageUrl:
"https://i.namu.wiki/i/M9XsM0yLkPOEwQJBAnVn2rMqo4kXRfv-AkvWNvgAX1lqvGHXpz1iuLHTnVmAEdbgahPaQEZA7VOxLLdB-pK_hQ.webp",
instagram: "https://www.instagram.com/chaeyoung_0614/",
},
{
id: 4,
name: "이나경",
birthDate: "2000.05.01",
position: "리드보컬",
imageUrl:
"https://i.namu.wiki/i/M9XsM0yLkPOEwQJBAnVn2rMqo4kXRfv-AkvWNvgAX1kGZHhKV9nNDi9mSwBCKKl5QGcHNT8_EZxLKSv6QmtVmg.webp",
instagram: "https://www.instagram.com/nakyung_0501/",
},
{
id: 5,
name: "백지헌",
birthDate: "2003.04.17",
position: "막내",
imageUrl:
"https://i.namu.wiki/i/M9XsM0yLkPOEwQJBAnVn2rMqo4kXRfv-AkvWNvgAX1n2pqk4sWJWC_FGvQpEmLxSJC1tuhIwq8cWnqt-HMv_gw.webp",
instagram: "https://www.instagram.com/jiheon_0417/",
},
];
// 더미 앨범 데이터
export const albums = [
{
id: 1,
title: "Unlock My World",
albumType: "정규",
releaseDate: "2023.06.05",
titleTrack: "Unlock My World",
coverUrl:
"https://i.namu.wiki/i/KDu-5K16r75g3eCGiYiOqRBNJLKfmimubzXR1bSY2C39dxGbAG1nZqKYn_uqxjShKmN0HRjqJjM1DQfbHwXhsQ.webp",
},
{
id: 2,
title: "from our Memento Box",
albumType: "미니",
releaseDate: "2022.06.27",
titleTrack: "Stay This Way",
coverUrl:
"https://i.namu.wiki/i/RJ-v7VHvnbV2-M7S1YBn35LLQ1PV4rrLM-QE5qE0-C_p5xYx6vb0fB0O5oXPZsMo0kOWPLyE_45Cpt1kkQa5xg.webp",
},
{
id: 3,
title: "Midnight Guest",
albumType: "미니",
releaseDate: "2022.01.17",
titleTrack: "DM",
coverUrl:
"https://i.namu.wiki/i/yPAOeX6xBp_Bxs0wNLX-4IWLvqoTJoiZPAcDFdFHiUXTM3YLKtfSbEOqr3ofAZGdPNnBxNBKXQ0bZKBJjWG1hg.webp",
},
{
id: 4,
title: "Talk & Talk",
albumType: "싱글",
releaseDate: "2024.09.05",
titleTrack: "Supersonic",
coverUrl:
"https://i.namu.wiki/i/jYC5xE6SyC-eDlQWMHrx2OIKHKkJGkgj-_zCeMIgw8CbvU-d6c5kGE4Zy3cwkiZ7kpRG5Hmo5WfCN0uqUWpyiw.webp",
},
];
// 더미 스케줄 데이터
export const schedules = [
{
id: 1,
date: "2025-01-02",
time: "19:00",
title: "유튜브 [스프 : 스튜디오 프로미스나인 30화]",
platform: "YouTube",
members: ["전원"],
},
{
id: 2,
date: "2025-01-05",
time: "18:00",
title: "SBS 인기가요 출연",
platform: "SBS",
members: ["전원"],
},
{
id: 3,
date: "2025-01-10",
time: "20:00",
title: '팬미팅 "FLOVER DAY"',
platform: "올림픽공원",
members: ["전원"],
},
];
// 공식 SNS 링크
export const socialLinks = {
youtube: "https://www.youtube.com/c/officialfromis9",
instagram: "https://www.instagram.com/officialfromis9/",
twitter: "https://twitter.com/realfromis_9",
tiktok: "https://www.tiktok.com/@officialfromis_9",
fancafe: "https://cafe.daum.net/officialfromis9",
};

28
frontend/src/index.css Normal file
View file

@ -0,0 +1,28 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* 기본 스타일 */
body {
font-family: "Noto Sans KR", sans-serif;
background-color: #fafafa;
color: #1a1a1a;
}
/* 스크롤바 스타일 */
::-webkit-scrollbar {
width: 8px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
::-webkit-scrollbar-thumb {
background: #548360;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #456e50;
}

10
frontend/src/main.jsx Normal file
View file

@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View file

@ -0,0 +1,104 @@
import { motion } from 'framer-motion';
import { Calendar, Music } from 'lucide-react';
import { albums } from '../../data/dummy';
function Discography() {
return (
<div className="py-16">
<div className="max-w-7xl mx-auto px-6">
{/* 헤더 */}
<div className="text-center mb-12">
<motion.h1
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="text-4xl font-bold mb-4"
>
디스코그래피
</motion.h1>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
className="text-gray-500"
>
프로미스나인의 음악을 만나보세요
</motion.p>
</div>
{/* 앨범 그리드 */}
<div className="grid grid-cols-4 gap-8">
{albums.map((album, index) => (
<motion.div
key={album.id}
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
className="group bg-white rounded-2xl overflow-hidden shadow-lg hover:shadow-2xl transition-all duration-300"
>
{/* 앨범 커버 */}
<div className="relative aspect-square bg-gray-100 overflow-hidden">
<img
src={album.coverUrl}
alt={album.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
/>
{/* 앨범 타입 배지 */}
<span className="absolute top-4 left-4 px-3 py-1 bg-primary text-white text-xs font-medium rounded-full">
{album.albumType}
</span>
{/* 호버 오버레이 */}
<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={40} className="mx-auto mb-2" />
<p className="text-sm">앨범 상세보기</p>
</div>
</div>
</div>
{/* 앨범 정보 */}
<div className="p-6">
<h3 className="font-bold text-lg mb-1 truncate">{album.title}</h3>
<p className="text-primary text-sm font-medium mb-3">
{album.titleTrack}
</p>
<div className="flex items-center gap-2 text-sm text-gray-500">
<Calendar size={14} />
<span>{album.releaseDate}</span>
</div>
</div>
</motion.div>
))}
</div>
{/* 통계 */}
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
className="mt-16 grid grid-cols-4 gap-6"
>
<div className="bg-gray-50 rounded-2xl p-6 text-center">
<p className="text-3xl font-bold text-primary mb-1">1</p>
<p className="text-gray-500 text-sm">정규 앨범</p>
</div>
<div className="bg-gray-50 rounded-2xl p-6 text-center">
<p className="text-3xl font-bold text-primary mb-1">2</p>
<p className="text-gray-500 text-sm">미니 앨범</p>
</div>
<div className="bg-gray-50 rounded-2xl p-6 text-center">
<p className="text-3xl font-bold text-primary mb-1">1</p>
<p className="text-gray-500 text-sm">싱글 앨범</p>
</div>
<div className="bg-gray-50 rounded-2xl p-6 text-center">
<p className="text-3xl font-bold text-primary mb-1">4+</p>
<p className="text-gray-500 text-sm"> 앨범</p>
</div>
</motion.div>
</div>
</div>
);
}
export default Discography;

View file

@ -0,0 +1,149 @@
import { motion } from 'framer-motion';
import { Link } from 'react-router-dom';
import { Calendar, Users, Disc3, ArrowRight } from 'lucide-react';
import { members, schedules, albums } from '../../data/dummy';
function Home() {
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>
<p className="text-lg opacity-80 mb-8 leading-relaxed">
인사드리겠습니다. , !<br />
이제는 약속해 소중히 간직해,<br />
당신의 아이돌로 성장하겠습니다!
</p>
<Link
to="/members"
className="inline-flex items-center gap-2 bg-white text-primary px-6 py-3 rounded-full font-medium hover:bg-gray-100 transition-colors"
>
멤버 보기
<ArrowRight size={18} />
</Link>
</motion.div>
</div>
{/* 장식 */}
<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>
{/* 퀵 링크 섹션 */}
<section className="py-16 bg-white">
<div className="max-w-7xl mx-auto px-6">
<div className="grid grid-cols-3 gap-8">
<Link
to="/members"
className="group p-8 bg-gray-50 rounded-2xl hover:bg-primary hover:text-white transition-all duration-300"
>
<Users size={40} className="mb-4 text-primary group-hover:text-white" />
<h3 className="text-xl font-bold mb-2">멤버</h3>
<p className="text-gray-500 group-hover:text-white/80">5명의 멤버를 만나보세요</p>
</Link>
<Link
to="/discography"
className="group p-8 bg-gray-50 rounded-2xl hover:bg-primary hover:text-white transition-all duration-300"
>
<Disc3 size={40} className="mb-4 text-primary group-hover:text-white" />
<h3 className="text-xl font-bold mb-2">디스코그래피</h3>
<p className="text-gray-500 group-hover:text-white/80">앨범과 음악을 확인하세요</p>
</Link>
<Link
to="/schedule"
className="group p-8 bg-gray-50 rounded-2xl hover:bg-primary hover:text-white transition-all duration-300"
>
<Calendar size={40} className="mb-4 text-primary group-hover:text-white" />
<h3 className="text-xl font-bold mb-2">스케줄</h3>
<p className="text-gray-500 group-hover:text-white/80">다가오는 일정을 확인하세요</p>
</Link>
</div>
</div>
</section>
{/* 멤버 미리보기 */}
<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.map((member, index) => (
<motion.div
key={member.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
className="bg-white rounded-2xl overflow-hidden shadow-sm hover:shadow-lg transition-shadow"
>
<div className="aspect-square bg-gray-100">
<img
src={member.imageUrl}
alt={member.name}
className="w-full h-full object-cover"
/>
</div>
<div className="p-4 text-center">
<h3 className="font-bold text-lg">{member.name}</h3>
<p className="text-sm text-gray-500">{member.position.split(',')[0]}</p>
</div>
</motion.div>
))}
</div>
</div>
</section>
{/* 스케줄 미리보기 */}
<section className="py-16 bg-white">
<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>
<div className="space-y-4">
{schedules.slice(0, 3).map((schedule) => (
<div
key={schedule.id}
className="flex items-center gap-6 p-6 bg-gray-50 rounded-xl hover:bg-gray-100 transition-colors"
>
<div className="text-center min-w-[80px]">
<p className="text-2xl font-bold text-primary">
{schedule.date.split('-')[2]}
</p>
<p className="text-sm text-gray-500">
{schedule.date.split('-')[1]}
</p>
</div>
<div className="flex-1">
<h3 className="font-bold text-lg">{schedule.title}</h3>
<p className="text-gray-500">{schedule.platform} · {schedule.time}</p>
</div>
<div className="text-sm text-primary font-medium">
{schedule.members.join(', ')}
</div>
</div>
))}
</div>
</div>
</section>
</div>
);
}
export default Home;

View file

@ -0,0 +1,108 @@
import { motion } from 'framer-motion';
import { Instagram, Calendar } from 'lucide-react';
import { members } from '../../data/dummy';
function Members() {
return (
<div className="py-16">
<div className="max-w-7xl mx-auto px-6">
{/* 헤더 */}
<div className="text-center mb-12">
<motion.h1
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="text-4xl font-bold mb-4"
>
멤버
</motion.h1>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
className="text-gray-500"
>
프로미스나인의 5명의 멤버를 소개합니다
</motion.p>
</div>
{/* 멤버 그리드 */}
<div className="grid grid-cols-5 gap-8">
{members.map((member, index) => (
<motion.div
key={member.id}
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.1 }}
className="group"
>
<div className="relative bg-white rounded-3xl overflow-hidden shadow-lg hover:shadow-2xl transition-all duration-300">
{/* 이미지 */}
<div className="aspect-[3/4] bg-gray-100 overflow-hidden">
<img
src={member.imageUrl}
alt={member.name}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
/>
</div>
{/* 정보 */}
<div className="p-6">
<h3 className="text-xl font-bold mb-1">{member.name}</h3>
<p className="text-primary text-sm font-medium mb-3">{member.position}</p>
<div className="flex items-center gap-2 text-sm text-gray-500 mb-4">
<Calendar size={14} />
<span>{member.birthDate}</span>
</div>
{/* 인스타그램 링크 */}
<a
href={member.instagram}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-sm text-gray-500 hover:text-pink-500 transition-colors"
>
<Instagram size={16} />
<span>Instagram</span>
</a>
</div>
{/* 호버 효과 - 컬러 바 */}
<div className="absolute bottom-0 left-0 right-0 h-1 bg-primary transform scale-x-0 group-hover:scale-x-100 transition-transform duration-300" />
</div>
</motion.div>
))}
</div>
{/* 그룹 정보 */}
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.6 }}
className="mt-16 bg-gradient-to-r from-primary to-primary-dark rounded-3xl p-10 text-white"
>
<div className="grid grid-cols-4 gap-8 text-center">
<div>
<p className="text-4xl font-bold mb-2">2018</p>
<p className="text-white/70">데뷔 연도</p>
</div>
<div>
<p className="text-4xl font-bold mb-2">5</p>
<p className="text-white/70">멤버 </p>
</div>
<div>
<p className="text-4xl font-bold mb-2">7+</p>
<p className="text-white/70">앨범 </p>
</div>
<div>
<p className="text-4xl font-bold mb-2">flover</p>
<p className="text-white/70">팬덤명</p>
</div>
</div>
</motion.div>
</div>
</div>
);
}
export default Members;

View file

@ -0,0 +1,126 @@
import { motion } from 'framer-motion';
import { Calendar, Clock, MapPin, Users } from 'lucide-react';
import { schedules } from '../../data/dummy';
function Schedule() {
//
const groupedSchedules = schedules.reduce((acc, schedule) => {
const date = schedule.date;
if (!acc[date]) {
acc[date] = [];
}
acc[date].push(schedule);
return acc;
}, {});
const formatDate = (dateStr) => {
const date = new Date(dateStr);
const days = ['일', '월', '화', '수', '목', '금', '토'];
return {
month: date.getMonth() + 1,
day: date.getDate(),
weekday: days[date.getDay()],
};
};
return (
<div className="py-16">
<div className="max-w-7xl mx-auto px-6">
{/* 헤더 */}
<div className="text-center mb-12">
<motion.h1
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
className="text-4xl font-bold mb-4"
>
스케줄
</motion.h1>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
className="text-gray-500"
>
프로미스나인의 다가오는 일정을 확인하세요
</motion.p>
</div>
{/* 스케줄 타임라인 */}
<div className="max-w-4xl mx-auto">
{Object.entries(groupedSchedules).map(([date, daySchedules], groupIndex) => {
const formatted = formatDate(date);
return (
<motion.div
key={date}
initial={{ opacity: 0, x: -30 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: groupIndex * 0.1 }}
className="relative pl-24 pb-12 last:pb-0"
>
{/* 타임라인 라인 */}
<div className="absolute left-[44px] top-0 bottom-0 w-0.5 bg-gray-200" />
{/* 날짜 원 */}
<div className="absolute left-0 top-0 w-[88px] flex items-start">
<div className="w-12 h-12 rounded-full bg-primary text-white flex flex-col items-center justify-center text-sm font-bold">
<span className="text-xs">{formatted.month}</span>
<span>{formatted.day}</span>
</div>
<span className="ml-2 mt-3 text-sm text-gray-400">{formatted.weekday}</span>
</div>
{/* 스케줄 카드들 */}
<div className="space-y-4">
{daySchedules.map((schedule, index) => (
<div
key={schedule.id}
className="bg-white rounded-2xl p-6 shadow-md hover:shadow-lg transition-shadow"
>
<h3 className="font-bold text-lg mb-3">{schedule.title}</h3>
<div className="flex flex-wrap gap-4 text-sm text-gray-500">
<div className="flex items-center gap-1">
<Clock size={14} className="text-primary" />
<span>{schedule.time}</span>
</div>
<div className="flex items-center gap-1">
<MapPin size={14} className="text-primary" />
<span>{schedule.platform}</span>
</div>
<div className="flex items-center gap-1">
<Users size={14} className="text-primary" />
<span>{schedule.members.join(', ')}</span>
</div>
</div>
</div>
))}
</div>
</motion.div>
);
})}
</div>
{/* 빈 스케줄 메시지 (스케줄이 없을 때) */}
{Object.keys(groupedSchedules).length === 0 && (
<div className="text-center py-20">
<Calendar size={64} className="mx-auto text-gray-300 mb-4" />
<p className="text-gray-500">예정된 스케줄이 없습니다.</p>
</div>
)}
{/* 안내 */}
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
className="mt-12 bg-gray-50 rounded-2xl p-6 text-center text-sm text-gray-500"
>
<p>스케줄은 DC Inside 갤러리에서 자동으로 수집됩니다.</p>
<p className="mt-1">일정은 변경될 있으니 공식 채널을 확인해 주세요.</p>
</motion.div>
</div>
</div>
);
}
export default Schedule;

View file

@ -0,0 +1,23 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {
colors: {
// 프로미스나인 팬덤 컬러
primary: {
DEFAULT: "#548360",
dark: "#456E50",
light: "#6A9A75",
},
// 보조 컬러
secondary: "#F5F5F5",
accent: "#FFD700",
},
fontFamily: {
sans: ['"Noto Sans KR"', "sans-serif"],
},
},
},
plugins: [],
};

10
frontend/vite.config.js Normal file
View file

@ -0,0 +1,10 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
host: true,
port: 5173,
},
});