feat(mobile): 모바일 페이지 기본 구현

- 모바일 레이아웃 및 하단 네비게이션 추가
- 홈 페이지: 히어로, 멤버, 앨범(2개), 일정 섹션
- 멤버 페이지: 현재/전 멤버 분리, 상세 모달
- 앨범 페이지: 목록, 상세, 갤러리
- 일정 페이지: 캘린더 피커, 세로 스크롤 목록
- ScrollToTop 컴포넌트로 페이지 이동 시 스크롤 초기화
- PC 홈 일정 API startDate+limit 방식으로 변경
This commit is contained in:
caadiq 2026-01-07 10:10:12 +09:00
parent c54de2ba82
commit f958e2b5e2
14 changed files with 1586 additions and 28 deletions

View file

@ -2,7 +2,10 @@
<html lang="ko"> <html lang="ko">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
/>
<link rel="icon" type="image/x-icon" href="/favicon.ico" /> <link rel="icon" type="image/x-icon" href="/favicon.ico" />
<title>fromis_9 - 프로미스나인</title> <title>fromis_9 - 프로미스나인</title>
<link <link

View file

@ -21,6 +21,7 @@
"react-photo-album": "^3.4.0", "react-photo-album": "^3.4.0",
"react-router-dom": "^6.22.3", "react-router-dom": "^6.22.3",
"react-window": "^2.2.3", "react-window": "^2.2.3",
"swiper": "^12.0.3",
"zustand": "^5.0.9" "zustand": "^5.0.9"
}, },
"devDependencies": { "devDependencies": {
@ -2618,6 +2619,25 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/swiper": {
"version": "12.0.3",
"resolved": "https://registry.npmjs.org/swiper/-/swiper-12.0.3.tgz",
"integrity": "sha512-BHd6U1VPEIksrXlyXjMmRWO0onmdNPaTAFduzqR3pgjvi7KfmUCAm/0cj49u2D7B0zNjMw02TSeXfinC1hDCXg==",
"funding": [
{
"type": "patreon",
"url": "https://www.patreon.com/swiperjs"
},
{
"type": "open_collective",
"url": "http://opencollective.com/swiper"
}
],
"license": "MIT",
"engines": {
"node": ">= 4.7.0"
}
},
"node_modules/tailwindcss": { "node_modules/tailwindcss": {
"version": "3.4.19", "version": "3.4.19",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",

View file

@ -22,6 +22,7 @@
"react-photo-album": "^3.4.0", "react-photo-album": "^3.4.0",
"react-router-dom": "^6.22.3", "react-router-dom": "^6.22.3",
"react-window": "^2.2.3", "react-window": "^2.2.3",
"swiper": "^12.0.3",
"zustand": "^5.0.9" "zustand": "^5.0.9"
}, },
"devDependencies": { "devDependencies": {

View file

@ -1,6 +1,9 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom'; import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { BrowserView, MobileView } from 'react-device-detect'; import { BrowserView, MobileView } from 'react-device-detect';
//
import ScrollToTop from './components/ScrollToTop';
// PC // PC
import PCHome from './pages/pc/Home'; import PCHome from './pages/pc/Home';
import PCMembers from './pages/pc/Members'; import PCMembers from './pages/pc/Members';
@ -9,6 +12,14 @@ import PCAlbumDetail from './pages/pc/AlbumDetail';
import PCAlbumGallery from './pages/pc/AlbumGallery'; import PCAlbumGallery from './pages/pc/AlbumGallery';
import PCSchedule from './pages/pc/Schedule'; import PCSchedule from './pages/pc/Schedule';
//
import MobileHome from './pages/mobile/Home';
import MobileMembers from './pages/mobile/Members';
import MobileAlbum from './pages/mobile/Album';
import MobileAlbumDetail from './pages/mobile/AlbumDetail';
import MobileAlbumGallery from './pages/mobile/AlbumGallery';
import MobileSchedule from './pages/mobile/Schedule';
// //
import AdminLogin from './pages/pc/admin/AdminLogin'; import AdminLogin from './pages/pc/admin/AdminLogin';
import AdminDashboard from './pages/pc/admin/AdminDashboard'; import AdminDashboard from './pages/pc/admin/AdminDashboard';
@ -22,12 +33,14 @@ import AdminScheduleForm from './pages/pc/admin/AdminScheduleForm';
import AdminScheduleCategory from './pages/pc/admin/AdminScheduleCategory'; import AdminScheduleCategory from './pages/pc/admin/AdminScheduleCategory';
import AdminScheduleBots from './pages/pc/admin/AdminScheduleBots'; import AdminScheduleBots from './pages/pc/admin/AdminScheduleBots';
// PC //
import PCLayout from './components/pc/Layout'; import PCLayout from './components/pc/Layout';
import MobileLayout from './components/mobile/Layout';
function App() { function App() {
return ( return (
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}> <BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
<ScrollToTop />
<BrowserView> <BrowserView>
<Routes> <Routes>
{/* 관리자 페이지 (레이아웃 없음) */} {/* 관리자 페이지 (레이아웃 없음) */}
@ -61,16 +74,18 @@ function App() {
</Routes> </Routes>
</BrowserView> </BrowserView>
<MobileView> <MobileView>
{/* 모바일 버전은 추후 구현 */} <Routes>
<div className="flex items-center justify-center h-screen bg-gray-100 p-4"> <Route path="/" element={<MobileLayout><MobileHome /></MobileLayout>} />
<div className="text-center"> <Route path="/members" element={<MobileLayout pageTitle="멤버"><MobileMembers /></MobileLayout>} />
<p className="text-xl font-bold text-primary mb-2">fromis_9</p> <Route path="/album" element={<MobileLayout pageTitle="앨범"><MobileAlbum /></MobileLayout>} />
<p className="text-gray-500">모바일 버전은 준비 중입니다.</p> <Route path="/album/:name" element={<MobileLayout pageTitle="앨범"><MobileAlbumDetail /></MobileLayout>} />
</div> <Route path="/album/:name/gallery" element={<MobileLayout pageTitle="앨범"><MobileAlbumGallery /></MobileLayout>} />
</div> <Route path="/schedule" element={<MobileLayout hideHeader><MobileSchedule /></MobileLayout>} />
</Routes>
</MobileView> </MobileView>
</BrowserRouter> </BrowserRouter>
); );
} }
export default App; export default App;

View file

@ -0,0 +1,15 @@
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
//
function ScrollToTop() {
const { pathname } = useLocation();
useEffect(() => {
window.scrollTo(0, 0);
}, [pathname]);
return null;
}
export default ScrollToTop;

View file

@ -0,0 +1,72 @@
import { NavLink, useLocation } from 'react-router-dom';
import { Home, Users, Disc3, Calendar } from 'lucide-react';
//
function MobileHeader({ title }) {
return (
<header className="bg-white shadow-sm sticky top-0 z-50">
<div className="flex items-center justify-center h-14 px-4">
{title ? (
<span className="text-xl font-bold text-primary">{title}</span>
) : (
<NavLink to="/" className="text-xl font-bold text-primary">
fromis_9
</NavLink>
)}
</div>
</header>
);
}
//
function MobileBottomNav() {
const location = useLocation();
const navItems = [
{ path: '/', label: '홈', icon: Home },
{ path: '/members', label: '멤버', icon: Users },
{ path: '/album', label: '앨범', icon: Disc3 },
{ path: '/schedule', label: '일정', icon: Calendar },
];
return (
<nav className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 z-50 safe-area-bottom">
<div className="flex items-center justify-around h-16">
{navItems.map((item) => {
const Icon = item.icon;
const isActive = location.pathname === item.path ||
(item.path !== '/' && location.pathname.startsWith(item.path));
return (
<NavLink
key={item.path}
to={item.path}
className={`flex flex-col items-center justify-center gap-1 w-full h-full transition-colors ${
isActive ? 'text-primary' : 'text-gray-400'
}`}
>
<Icon size={22} strokeWidth={isActive ? 2.5 : 2} />
<span className="text-xs font-medium">{item.label}</span>
</NavLink>
);
})}
</div>
</nav>
);
}
//
// pageTitle: ( fromis_9)
// hideHeader: true ( )
function MobileLayout({ children, pageTitle, hideHeader = false }) {
return (
<div className="min-h-screen flex flex-col bg-gray-50">
{!hideHeader && <MobileHeader title={pageTitle} />}
<main className={`flex-1 pb-20 ${hideHeader ? 'pt-0' : ''}`}>{children}</main>
<MobileBottomNav />
</div>
);
}
export default MobileLayout;

View file

@ -12,9 +12,20 @@ body {
color: #1a1a1a; color: #1a1a1a;
} }
/* 최소 너비 설정 - 화면 축소시 깨짐 방지 */ /* 최소 너비 설정 - PC에서만 적용 */
#root { @media (min-width: 1024px) {
min-width: 1440px; #root {
min-width: 1440px;
}
}
/* 모바일 safe-area 지원 (노치, 홈 인디케이터) */
.safe-area-bottom {
padding-bottom: env(safe-area-inset-bottom, 0);
}
.safe-area-top {
padding-top: env(safe-area-inset-top, 0);
} }
/* 스크롤바 스타일 */ /* 스크롤바 스타일 */

View file

@ -0,0 +1,63 @@
import { motion } from 'framer-motion';
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
//
function MobileAlbum() {
const navigate = useNavigate();
const [albums, setAlbums] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch('/api/albums')
.then(res => res.json())
.then(data => {
setAlbums(data);
setLoading(false);
})
.catch(console.error);
}, []);
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div>
);
}
return (
<div className="px-4 py-6">
<div className="grid grid-cols-2 gap-4">
{albums.map((album, index) => (
<motion.div
key={album.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: index * 0.05 }}
onClick={() => navigate(`/album/${album.folder_name}`)}
className="bg-white rounded-2xl overflow-hidden shadow-sm"
>
<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-semibold text-sm truncate">{album.title}</p>
<p className="text-xs text-gray-400 mt-0.5">
{album.album_type} · {album.release_date?.slice(0, 4)}
</p>
</div>
</motion.div>
))}
</div>
</div>
);
}
export default MobileAlbum;

View file

@ -0,0 +1,142 @@
import { motion } from 'framer-motion';
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { ArrowLeft, Play } from 'lucide-react';
//
function MobileAlbumDetail() {
const { name } = useParams();
const navigate = useNavigate();
const [album, setAlbum] = useState(null);
const [tracks, setTracks] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
//
fetch('/api/albums')
.then(res => res.json())
.then(data => {
const found = data.find(a => a.folder_name === name);
if (found) {
setAlbum(found);
//
fetch(`/api/albums/${found.id}/tracks`)
.then(res => res.json())
.then(setTracks)
.catch(console.error);
}
setLoading(false);
})
.catch(console.error);
}, [name]);
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div>
);
}
if (!album) {
return (
<div className="text-center py-12">
<p className="text-gray-500">앨범을 찾을 없습니다</p>
</div>
);
}
return (
<div className="pb-6">
{/* 헤더 */}
<div className="sticky top-14 z-40 bg-white/80 backdrop-blur-sm px-4 py-3 flex items-center gap-3 border-b">
<button onClick={() => navigate(-1)} className="p-1">
<ArrowLeft size={24} />
</button>
<span className="font-semibold truncate">{album.title}</span>
</div>
{/* 앨범 정보 */}
<div className="px-4 py-6">
<div className="flex gap-4">
<div className="w-32 h-32 rounded-2xl overflow-hidden bg-gray-200 shadow-lg flex-shrink-0">
{album.cover_medium_url && (
<img
src={album.cover_medium_url}
alt={album.title}
className="w-full h-full object-cover"
/>
)}
</div>
<div className="flex-1">
<h1 className="text-xl font-bold">{album.title}</h1>
<p className="text-gray-500 text-sm mt-1">{album.album_type}</p>
<p className="text-gray-400 text-sm">{album.release_date}</p>
<button
onClick={() => navigate(`/album/${name}/gallery`)}
className="mt-4 px-4 py-2 bg-primary text-white rounded-full text-sm font-medium"
>
갤러리 보기
</button>
</div>
</div>
</div>
{/* 트랙 리스트 */}
{tracks.length > 0 && (
<div className="px-4">
<h2 className="text-lg font-bold mb-3">수록곡</h2>
<div className="bg-white rounded-2xl overflow-hidden shadow-sm">
{tracks.map((track, index) => (
<motion.div
key={track.id}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: index * 0.05 }}
className="flex items-center gap-3 p-4 border-b border-gray-100 last:border-none"
>
<span className="w-6 text-center text-gray-400 text-sm">
{track.track_number}
</span>
<div className="flex-1 min-w-0">
<p className={`font-medium text-sm truncate ${track.is_title_track ? 'text-primary' : ''}`}>
{track.title}
{track.is_title_track && (
<span className="ml-2 text-xs bg-primary/10 text-primary px-2 py-0.5 rounded-full">
타이틀
</span>
)}
</p>
<p className="text-xs text-gray-400 truncate">{track.duration}</p>
</div>
{track.music_video_url && (
<a
href={track.music_video_url}
target="_blank"
rel="noopener noreferrer"
className="p-2 text-red-500"
>
<Play size={18} fill="currentColor" />
</a>
)}
</motion.div>
))}
</div>
</div>
)}
{/* 설명 */}
{album.description && (
<div className="px-4 mt-6">
<h2 className="text-lg font-bold mb-3">소개</h2>
<p className="text-gray-600 text-sm leading-relaxed bg-white p-4 rounded-2xl">
{album.description}
</p>
</div>
)}
</div>
);
}
export default MobileAlbumDetail;

View file

@ -0,0 +1,132 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { ArrowLeft, X, ChevronLeft, ChevronRight } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
//
function MobileAlbumGallery() {
const { name } = useParams();
const navigate = useNavigate();
const [album, setAlbum] = useState(null);
const [photos, setPhotos] = useState([]);
const [loading, setLoading] = useState(true);
const [selectedIndex, setSelectedIndex] = useState(null);
useEffect(() => {
fetch('/api/albums')
.then(res => res.json())
.then(data => {
const found = data.find(a => a.folder_name === name);
if (found) {
setAlbum(found);
fetch(`/api/albums/${found.id}/photos`)
.then(res => res.json())
.then(setPhotos)
.catch(console.error);
}
setLoading(false);
})
.catch(console.error);
}, [name]);
//
const goToImage = (delta) => {
const newIndex = selectedIndex + delta;
if (newIndex >= 0 && newIndex < photos.length) {
setSelectedIndex(newIndex);
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div>
);
}
return (
<div className="pb-4">
{/* 헤더 */}
<div className="sticky top-14 z-40 bg-white/80 backdrop-blur-sm px-4 py-3 flex items-center gap-3 border-b">
<button onClick={() => navigate(-1)} className="p-1">
<ArrowLeft size={24} />
</button>
<span className="font-semibold truncate">{album?.title} 갤러리</span>
</div>
{/* 갤러리 그리드 */}
<div className="grid grid-cols-3 gap-0.5 p-0.5">
{photos.map((photo, index) => (
<motion.div
key={photo.id}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: index * 0.02 }}
onClick={() => setSelectedIndex(index)}
className="aspect-square bg-gray-200 cursor-pointer"
>
<img
src={photo.thumb_url}
alt=""
className="w-full h-full object-cover"
loading="lazy"
/>
</motion.div>
))}
</div>
{/* 풀스크린 뷰어 */}
<AnimatePresence>
{selectedIndex !== null && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black z-50 flex flex-col"
>
{/* 뷰어 헤더 */}
<div className="flex items-center justify-between p-4 text-white">
<button onClick={() => setSelectedIndex(null)}>
<X size={24} />
</button>
<span className="text-sm">
{selectedIndex + 1} / {photos.length}
</span>
<div className="w-6" />
</div>
{/* 이미지 */}
<div className="flex-1 flex items-center justify-center relative">
<img
src={photos[selectedIndex]?.medium_url || photos[selectedIndex]?.original_url}
alt=""
className="max-w-full max-h-full object-contain"
/>
{/* 좌우 네비게이션 */}
{selectedIndex > 0 && (
<button
onClick={() => goToImage(-1)}
className="absolute left-2 p-2 text-white/80"
>
<ChevronLeft size={32} />
</button>
)}
{selectedIndex < photos.length - 1 && (
<button
onClick={() => goToImage(1)}
className="absolute right-2 p-2 text-white/80"
>
<ChevronRight size={32} />
</button>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
export default MobileAlbumGallery;

View file

@ -0,0 +1,231 @@
import { motion } from 'framer-motion';
import { ChevronRight, Clock, Tag } from 'lucide-react';
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
//
function MobileHome() {
const navigate = useNavigate();
const [members, setMembers] = useState([]);
const [albums, setAlbums] = useState([]);
const [schedules, setSchedules] = useState([]);
//
useEffect(() => {
//
fetch('/api/members')
.then(res => res.json())
.then(data => setMembers(data.filter(m => !m.is_former)))
.catch(console.error);
// ( 4)
fetch('/api/albums')
.then(res => res.json())
.then(data => setAlbums(data.slice(0, 2)))
.catch(console.error);
// (startDate + limit )
const today = new Date().toISOString().split('T')[0];
fetch(`/api/schedules?startDate=${today}&limit=3`)
.then(res => res.json())
.then(data => setSchedules(data))
.catch(console.error);
}, []);
return (
<div className="pb-4">
{/* 히어로 섹션 */}
<section className="relative bg-gradient-to-br from-primary to-primary-dark py-12 px-4 overflow-hidden">
<div className="absolute inset-0 bg-black/10" />
<div className="relative text-center text-white">
<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>
</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" />
</section>
{/* 멤버 섹션 */}
<section className="px-4 py-6">
<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) => (
<motion.div
key={member.id}
className="text-center"
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>
</section>
{/* 앨범 섹션 */}
<section className="px-4 py-6 bg-gray-50">
<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) => (
<motion.div
key={album.id}
onClick={() => navigate(`/album/${album.folder_name}`)}
className="bg-white rounded-xl overflow-hidden shadow-sm"
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>
</section>
{/* 일정 섹션 */}
<section className="px-4 py-6">
<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) => {
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"
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>
)}
</section>
</div>
);
}
export default MobileHome;

View file

@ -0,0 +1,124 @@
import { motion, AnimatePresence } from 'framer-motion';
import { useState, useEffect } from 'react';
import { Instagram } from 'lucide-react';
//
function MobileMembers() {
const [members, setMembers] = useState([]);
const [formerMembers, setFormerMembers] = useState([]);
const [selectedMember, setSelectedMember] = useState(null);
useEffect(() => {
fetch('/api/members')
.then(res => res.json())
.then(data => {
setMembers(data.filter(m => !m.is_former));
setFormerMembers(data.filter(m => m.is_former));
})
.catch(console.error);
}, []);
//
const renderMemberCard = (member, isFormer = false) => (
<motion.div
key={member.id}
onClick={() => setSelectedMember(member)}
className="text-center cursor-pointer"
whileTap={{ scale: 0.95 }}
>
<div className={`aspect-square rounded-2xl overflow-hidden bg-gray-200 mb-2 shadow-sm ${isFormer ? 'grayscale' : ''}`}>
{member.image_url && (
<img
src={member.image_url}
alt={member.name}
className="w-full h-full object-cover"
/>
)}
</div>
<p className={`font-medium text-sm ${isFormer ? 'text-gray-400' : ''}`}>{member.name}</p>
<p className="text-xs text-gray-400">{member.position || ''}</p>
</motion.div>
);
return (
<div className="px-4 py-6">
{/* 현재 멤버 */}
<div className="grid grid-cols-3 gap-3">
{members.map((member) => renderMemberCard(member))}
</div>
{/* 전 멤버 */}
{formerMembers.length > 0 && (
<>
<div className="mt-8 mb-4">
<h2 className="text-lg font-bold text-gray-400"> 멤버</h2>
</div>
<div className="grid grid-cols-3 gap-3">
{formerMembers.map((member) => renderMemberCard(member, true))}
</div>
</>
)}
{/* 멤버 상세 모달 */}
<AnimatePresence>
{selectedMember && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/50 z-50 flex items-end"
onClick={() => setSelectedMember(null)}
>
<motion.div
initial={{ y: '100%' }}
animate={{ y: 0 }}
exit={{ y: '100%' }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
className="bg-white w-full rounded-t-3xl p-6 pb-24"
onClick={e => e.stopPropagation()}
>
<div className="flex gap-4">
<div className={`w-24 h-24 rounded-2xl overflow-hidden bg-gray-200 flex-shrink-0 ${selectedMember.is_former ? 'grayscale' : ''}`}>
{selectedMember.image_url && (
<img
src={selectedMember.image_url}
alt={selectedMember.name}
className="w-full h-full object-cover"
/>
)}
</div>
<div>
<h2 className="text-xl font-bold">{selectedMember.name}</h2>
<p className="text-gray-500 text-sm">{selectedMember.position}</p>
<p className="text-gray-400 text-sm mt-1">
{selectedMember.birth_date?.slice(0, 10).replaceAll('-', '.')}
</p>
{/* 전 멤버가 아닌 경우에만 인스타그램 표시 */}
{!selectedMember.is_former && selectedMember.instagram && (
<a
href={selectedMember.instagram}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 mt-2 text-pink-500"
>
<Instagram size={16} />
<span className="text-sm">Instagram</span>
</a>
)}
</div>
</div>
<button
onClick={() => setSelectedMember(null)}
className="w-full mt-6 py-3 bg-gray-100 rounded-xl font-medium"
>
닫기
</button>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
export default MobileMembers;

View file

@ -0,0 +1,733 @@
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { Clock, Tag, Link2, ChevronLeft, ChevronRight, ChevronDown, Search, X, Calendar } from 'lucide-react';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useInView } from 'react-intersection-observer';
import { Swiper, SwiperSlide } from 'swiper/react';
import 'swiper/css';
//
function MobileSchedule() {
const [selectedDate, setSelectedDate] = useState(new Date());
const [schedules, setSchedules] = useState([]);
const [categories, setCategories] = useState([]);
const [loading, setLoading] = useState(true);
const [isSearchMode, setIsSearchMode] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [showCalendar, setShowCalendar] = useState(false);
const SEARCH_LIMIT = 10;
const { ref: loadMoreRef, inView } = useInView({ threshold: 0, rootMargin: '100px' });
//
const {
data: searchData,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading: searchLoading,
} = useInfiniteQuery({
queryKey: ['mobileScheduleSearch', searchTerm],
queryFn: async ({ pageParam = 0 }) => {
const response = await fetch(
`/api/schedules?search=${encodeURIComponent(searchTerm)}&offset=${pageParam}&limit=${SEARCH_LIMIT}`
);
if (!response.ok) throw new Error('Search failed');
return response.json();
},
getNextPageParam: (lastPage) => {
if (lastPage.hasMore) {
return lastPage.offset + lastPage.schedules.length;
}
return undefined;
},
enabled: !!searchTerm && isSearchMode,
});
const searchResults = useMemo(() => {
if (!searchData?.pages) return [];
return searchData.pages.flatMap(page => page.schedules);
}, [searchData]);
useEffect(() => {
if (inView && hasNextPage && !isFetchingNextPage && isSearchMode && searchTerm) {
fetchNextPage();
}
}, [inView, hasNextPage, isFetchingNextPage, fetchNextPage, isSearchMode, searchTerm]);
//
useEffect(() => {
const year = selectedDate.getFullYear();
const month = selectedDate.getMonth() + 1;
Promise.all([
fetch(`/api/schedules?year=${year}&month=${month}`).then(res => res.json()),
fetch('/api/schedules/categories').then(res => res.json())
]).then(([schedulesData, categoriesData]) => {
setSchedules(schedulesData);
setCategories(categoriesData);
setLoading(false);
}).catch(console.error);
}, [selectedDate]);
//
const changeMonth = (delta) => {
const newDate = new Date(selectedDate);
newDate.setMonth(newDate.getMonth() + delta);
setSelectedDate(newDate);
};
//
const getCategoryColor = (categoryId) => {
const category = categories.find(c => c.id === categoryId);
return category?.color || '#6b7280';
};
//
const groupedSchedules = useMemo(() => {
const groups = {};
schedules.forEach(schedule => {
const date = schedule.date;
if (!groups[date]) groups[date] = [];
groups[date].push(schedule);
});
return Object.entries(groups).sort((a, b) => a[0].localeCompare(b[0]));
}, [schedules]);
return (
<div className="pb-4">
{/* 헤더 */}
<div className="sticky top-0 z-50 bg-white shadow-sm">
{isSearchMode ? (
<div className="flex items-center gap-2 px-4 py-3">
<div className="flex-1 flex items-center gap-2 bg-gray-100 rounded-full px-4 py-2">
<Search size={18} className="text-gray-400" />
<input
type="text"
placeholder="일정 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="flex-1 bg-transparent outline-none text-sm"
autoFocus
/>
{searchTerm && (
<button onClick={() => setSearchTerm('')}>
<X size={18} className="text-gray-400" />
</button>
)}
</div>
<button
onClick={() => { setIsSearchMode(false); setSearchTerm(''); }}
className="text-sm text-gray-500 flex-shrink-0 whitespace-nowrap"
>
취소
</button>
</div>
) : (
<div className="flex items-center justify-between px-4 py-3">
<div className="flex items-center gap-1">
<button
onClick={() => setShowCalendar(!showCalendar)}
className="p-2 rounded-lg hover:bg-gray-100"
>
<Calendar size={20} className="text-primary" />
</button>
<button onClick={() => changeMonth(-1)} className="p-2">
<ChevronLeft size={20} />
</button>
</div>
<span className="font-bold">
{selectedDate.getFullYear()} {selectedDate.getMonth() + 1}
</span>
<div className="flex items-center gap-1">
<button onClick={() => changeMonth(1)} className="p-2">
<ChevronRight size={20} />
</button>
<button onClick={() => setIsSearchMode(true)} className="p-2">
<Search size={20} />
</button>
</div>
</div>
)}
{/* 달력 팝업 */}
<AnimatePresence>
{showCalendar && !isSearchMode && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden border-t bg-white"
>
<CalendarPicker
selectedDate={selectedDate}
schedules={schedules}
categories={categories}
onSelectDate={(date) => {
setSelectedDate(date);
setShowCalendar(false);
}}
/>
</motion.div>
)}
</AnimatePresence>
</div>
{/* 컨텐츠 */}
<div className="px-4 py-4">
{isSearchMode && searchTerm ? (
//
<div className="space-y-3">
{searchLoading ? (
<div className="flex justify-center py-8">
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div>
) : searchResults.length === 0 ? (
<div className="text-center py-8 text-gray-400">
검색 결과가 없습니다
</div>
) : (
<>
{searchResults.map((schedule, index) => (
<ScheduleCard
key={`${schedule.id}-search-${index}`}
schedule={schedule}
categoryColor={getCategoryColor(schedule.category_id)}
categories={categories}
/>
))}
<div ref={loadMoreRef} className="py-4">
{isFetchingNextPage && (
<div className="flex justify-center">
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div>
)}
</div>
</>
)}
</div>
) : loading ? (
<div className="flex justify-center py-8">
<div className="w-8 h-8 border-2 border-primary border-t-transparent rounded-full animate-spin" />
</div>
) : groupedSchedules.length === 0 ? (
<div className="text-center py-8 text-gray-400">
이번 일정이 없습니다
</div>
) : (
//
<div className="space-y-6">
{groupedSchedules.map(([date, daySchedules], groupIndex) => {
const dateObj = new Date(date);
const month = dateObj.getMonth() + 1;
const day = dateObj.getDate();
const weekday = ['일', '월', '화', '수', '목', '금', '토'][dateObj.getDay()];
const isWeekend = dateObj.getDay() === 0 || dateObj.getDay() === 6;
return (
<div key={date}>
{/* 날짜 헤더 - 심플 스타일 */}
<div className="flex items-center gap-3 mb-3">
<div className={`flex items-center gap-1.5 px-3 py-1.5 rounded-xl font-bold ${
isWeekend
? 'bg-red-50 text-red-500'
: 'bg-primary/10 text-primary'
}`}>
<span className="text-lg">{day}</span>
<span className="text-xs opacity-70">{weekday}</span>
</div>
<div className="h-px flex-1 bg-gray-200" />
</div>
{/* 일정 카드들 */}
<div className="space-y-3">
{daySchedules.map((schedule, index) => (
<TimelineScheduleCard
key={schedule.id}
schedule={schedule}
categoryColor={getCategoryColor(schedule.category_id)}
categories={categories}
delay={groupIndex * 0.05 + index * 0.02}
/>
))}
</div>
</div>
);
})}
</div>
)}
</div>
</div>
);
}
// ()
function ScheduleCard({ schedule, categoryColor, categories, delay = 0 }) {
const categoryName = categories.find(c => c.id === schedule.category_id)?.name || '미분류';
const memberNames = schedule.member_names || schedule.members?.map(m => m.name).join(',') || '';
const memberList = memberNames.split(',').filter(name => name.trim());
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay }}
className="bg-white rounded-xl p-4 shadow-sm"
>
<div className="flex gap-3">
<div
className="w-1 rounded-full flex-shrink-0"
style={{ backgroundColor: categoryColor }}
/>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-sm">{schedule.title}</h3>
<div className="flex flex-wrap items-center gap-2 mt-1 text-xs text-gray-500">
{schedule.time && (
<span className="flex items-center gap-1">
<Clock size={12} />
{schedule.time.slice(0, 5)}
</span>
)}
<span className="flex items-center gap-1">
<Tag size={12} />
{categoryName}
</span>
{schedule.source_name && (
<span className="flex items-center gap-1">
<Link2 size={12} />
{schedule.source_name}
</span>
)}
</div>
{memberList.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{memberList.length >= 5 ? (
<span className="px-2 py-0.5 bg-gradient-to-r from-primary to-primary-dark text-white text-xs rounded-full shadow-sm">
프로미스나인
</span>
) : (
memberList.map((name, i) => (
<span key={i} className="px-2 py-0.5 bg-primary/10 text-primary text-xs rounded-full">
{name.trim()}
</span>
))
)}
</div>
)}
</div>
</div>
</motion.div>
);
}
// -
function TimelineScheduleCard({ schedule, categoryColor, categories, delay = 0 }) {
const categoryName = categories.find(c => c.id === schedule.category_id)?.name || '미분류';
const memberNames = schedule.member_names || schedule.members?.map(m => m.name).join(',') || '';
const memberList = memberNames.split(',').filter(name => name.trim());
return (
<motion.div
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay, type: "spring", stiffness: 300, damping: 30 }}
>
{/* 카드 본체 */}
<div className="relative bg-white rounded-md shadow-[0_2px_12px_rgba(0,0,0,0.06)] border border-gray-100/50 overflow-hidden">
<div className="p-4">
{/* 시간 뱃지 */}
{schedule.time && (
<div className="flex items-center gap-1.5 mb-2">
<div
className="flex items-center gap-1 px-2 py-0.5 rounded-full text-white text-xs font-medium"
style={{ backgroundColor: categoryColor }}
>
<Clock size={10} />
{schedule.time.slice(0, 5)}
</div>
<span
className="px-2 py-0.5 rounded-full text-xs font-medium"
style={{
backgroundColor: `${categoryColor}15`,
color: categoryColor
}}
>
{categoryName}
</span>
</div>
)}
{/* 제목 */}
<h3 className="font-bold text-[15px] text-gray-800 leading-snug">
{schedule.title}
</h3>
{/* 출처 */}
{schedule.source_name && (
<div className="flex items-center gap-1 mt-1.5 text-xs text-gray-400">
<Link2 size={11} />
<span>{schedule.source_name}</span>
</div>
)}
{/* 멤버 */}
{memberList.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-3 pt-3 border-t border-gray-100">
{memberList.length >= 5 ? (
<span className="px-2.5 py-1 bg-gradient-to-r from-primary to-primary-dark text-white text-xs rounded-lg font-semibold shadow-sm">
프로미스나인
</span>
) : (
memberList.map((name, i) => (
<span
key={i}
className="px-2 py-0.5 bg-gray-100 text-gray-600 text-xs rounded-lg font-medium"
>
{name.trim()}
</span>
))
)}
</div>
)}
</div>
</div>
</motion.div>
);
}
//
function CalendarPicker({ selectedDate, schedules = [], categories = [], onSelectDate }) {
const [viewDate, setViewDate] = useState(new Date(selectedDate));
const swiperRef = useRef(null);
//
const scheduleDates = useMemo(() => {
const dateMap = {};
schedules.forEach(schedule => {
const date = schedule.date;
if (!dateMap[date]) {
dateMap[date] = [];
}
const category = categories.find(c => c.id === schedule.category_id);
dateMap[date].push(category?.color || '#6b7280');
});
return dateMap;
}, [schedules, categories]);
const getScheduleColors = (date) => {
const dateStr = date.toISOString().split('T')[0];
const colors = scheduleDates[dateStr] || [];
// 3
return [...new Set(colors)].slice(0, 3);
};
const year = viewDate.getFullYear();
const month = viewDate.getMonth();
//
const getCalendarDays = useCallback((y, m) => {
const firstDay = new Date(y, m, 1);
const lastDay = new Date(y, m + 1, 0);
const startDay = firstDay.getDay();
const daysInMonth = lastDay.getDate();
const days = [];
//
const prevMonth = new Date(y, m, 0);
for (let i = startDay - 1; i >= 0; i--) {
days.push({
day: prevMonth.getDate() - i,
isCurrentMonth: false,
date: new Date(y, m - 1, prevMonth.getDate() - i)
});
}
//
for (let i = 1; i <= daysInMonth; i++) {
days.push({
day: i,
isCurrentMonth: true,
date: new Date(y, m, i)
});
}
// ( )
const remaining = (7 - (days.length % 7)) % 7;
for (let i = 1; i <= remaining; i++) {
days.push({
day: i,
isCurrentMonth: false,
date: new Date(y, m + 1, i)
});
}
return days;
}, []);
const changeMonth = useCallback((delta) => {
const newDate = new Date(viewDate);
newDate.setMonth(newDate.getMonth() + delta);
setViewDate(newDate);
}, [viewDate]);
const isToday = (date) => {
const today = new Date();
return date.getDate() === today.getDate() &&
date.getMonth() === today.getMonth() &&
date.getFullYear() === today.getFullYear();
};
//
const [showYearMonth, setShowYearMonth] = useState(false);
const [yearRangeStart, setYearRangeStart] = useState(Math.floor(year / 12) * 12);
const yearRange = Array.from({ length: 12 }, (_, i) => yearRangeStart + i);
//
useEffect(() => {
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = ''; };
}, []);
// 3 (, , )
const slides = useMemo(() => {
return [-1, 0, 1].map(offset => {
const d = new Date(year, month + offset, 1);
return {
year: d.getFullYear(),
month: d.getMonth(),
days: getCalendarDays(d.getFullYear(), d.getMonth())
};
});
}, [year, month, getCalendarDays]);
//
const handleSlideChange = (swiper) => {
const direction = swiper.activeIndex - 1; // -1, 0, 1
if (direction !== 0) {
changeMonth(direction);
// ( )
setTimeout(() => {
swiper.slideTo(1, 0);
}, 0);
}
};
//
const renderMonth = (slideData) => (
<div>
{/* 요일 헤더 */}
<div className="grid grid-cols-7 gap-1 mb-2">
{['일', '월', '화', '수', '목', '금', '토'].map((day, i) => (
<div
key={day}
className={`text-center text-xs font-medium py-1 ${
i === 0 ? 'text-red-400' : i === 6 ? 'text-blue-400' : 'text-gray-500'
}`}
>
{day}
</div>
))}
</div>
{/* 날짜 그리드 */}
<div className="grid grid-cols-7 gap-1">
{slideData.days.map((item, index) => {
const dayOfWeek = index % 7;
const isSunday = dayOfWeek === 0;
const isSaturday = dayOfWeek === 6;
const scheduleColors = item.isCurrentMonth ? getScheduleColors(item.date) : [];
return (
<button
key={index}
onClick={() => onSelectDate(item.date)}
className="flex flex-col items-center py-1"
>
<span className={`w-7 h-7 flex items-center justify-center text-xs rounded-full transition-colors ${
!item.isCurrentMonth
? 'text-gray-300'
: isToday(item.date)
? 'bg-primary text-white font-bold'
: isSunday
? 'text-red-500 hover:bg-red-50'
: isSaturday
? 'text-blue-500 hover:bg-blue-50'
: 'text-gray-700 hover:bg-gray-100'
}`}>
{item.day}
</span>
{/* 일정 점 */}
<div className="flex gap-0.5 mt-0.5 h-1.5">
{scheduleColors.map((color, i) => (
<div
key={i}
className="w-1 h-1 rounded-full"
style={{ backgroundColor: color }}
/>
))}
</div>
</button>
);
})}
</div>
</div>
);
return (
<div className="p-4">
<AnimatePresence mode="wait">
{showYearMonth ? (
// UI
<motion.div
key="yearMonth"
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.15 }}
>
{/* 년도 범위 헤더 */}
<div className="flex items-center justify-between mb-3">
<button
onClick={() => setYearRangeStart(yearRangeStart - 12)}
className="p-1"
>
<ChevronLeft size={18} />
</button>
<span className="font-semibold text-sm">
{yearRangeStart} - {yearRangeStart + 11}
</span>
<button
onClick={() => setYearRangeStart(yearRangeStart + 12)}
className="p-1"
>
<ChevronRight size={18} />
</button>
</div>
{/* 년도 선택 */}
<div className="text-center text-xs text-gray-400 mb-2">년도</div>
<div className="grid grid-cols-4 gap-2 mb-4">
{yearRange.map(y => (
<button
key={y}
onClick={() => {
const newDate = new Date(viewDate);
newDate.setFullYear(y);
setViewDate(newDate);
}}
className={`py-2 text-sm rounded-lg transition-colors ${
y === year
? 'bg-primary text-white'
: 'text-gray-600 hover:bg-gray-100'
}`}
>
{y}
</button>
))}
</div>
{/* 월 선택 */}
<div className="text-center text-xs text-gray-400 mb-2"></div>
<div className="grid grid-cols-4 gap-2">
{Array.from({ length: 12 }, (_, i) => i + 1).map(m => (
<button
key={m}
onClick={() => {
const newDate = new Date(year, m - 1, 1);
setViewDate(newDate);
setShowYearMonth(false);
}}
className={`py-2 text-sm rounded-lg transition-colors ${
m === month + 1
? 'bg-primary text-white'
: 'text-gray-600 hover:bg-gray-100'
}`}
>
{m}
</button>
))}
</div>
{/* 취소 버튼 */}
<div className="mt-4 flex justify-center">
<button
onClick={() => setShowYearMonth(false)}
className="text-xs text-gray-500 px-4 py-1.5"
>
취소
</button>
</div>
</motion.div>
) : (
<motion.div
key="calendar"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{ duration: 0.15 }}
>
{/* 달력 헤더 */}
<div className="flex items-center justify-between mb-4">
<button
onClick={() => {
swiperRef.current?.slidePrev();
}}
className="p-1"
>
<ChevronLeft size={18} />
</button>
<button
onClick={() => setShowYearMonth(true)}
className="flex items-center gap-1 font-semibold text-sm hover:text-primary transition-colors"
>
{year} {month + 1}
<ChevronDown size={16} />
</button>
<button
onClick={() => {
swiperRef.current?.slideNext();
}}
className="p-1"
>
<ChevronRight size={18} />
</button>
</div>
{/* Swiper 달력 */}
<Swiper
onSwiper={(swiper) => { swiperRef.current = swiper; }}
initialSlide={1}
onSlideChangeTransitionEnd={handleSlideChange}
spaceBetween={20}
slidesPerView={1}
speed={200}
touchRatio={1}
resistance={true}
resistanceRatio={0.5}
>
{slides.map((slide, idx) => (
<SwiperSlide key={`${slide.year}-${slide.month}-${idx}`}>
{renderMonth(slide)}
</SwiperSlide>
))}
</Swiper>
{/* 오늘 버튼 */}
<div className="mt-3 flex justify-center">
<button
onClick={() => onSelectDate(new Date())}
className="text-xs text-primary font-medium px-4 py-1.5 bg-primary/10 rounded-full"
>
오늘
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
export default MobileSchedule;

View file

@ -1,7 +1,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Calendar, Users, Disc3, ArrowRight, Clock } from 'lucide-react'; import { Calendar, Users, Disc3, ArrowRight, Clock, Link2, Tag } from 'lucide-react';
function Home() { function Home() {
const [members, setMembers] = useState([]); const [members, setMembers] = useState([]);
@ -157,9 +157,6 @@ function Home() {
const memberList = schedule.member_names ? schedule.member_names.split(',') : []; const memberList = schedule.member_names ? schedule.member_names.split(',') : [];
const displayMembers = memberList.length >= 5 ? ['프로미스나인'] : memberList; const displayMembers = memberList.length >= 5 ? ['프로미스나인'] : memberList;
// ()
const categoryColor = schedule.category_color || '#6B8E6B';
return ( return (
<motion.div <motion.div
key={schedule.id} key={schedule.id}
@ -168,13 +165,12 @@ function Home() {
transition={{ delay: index * 0.05 }} transition={{ delay: index * 0.05 }}
className="flex items-stretch bg-white rounded-2xl shadow-sm hover:shadow-md transition-shadow overflow-hidden" className="flex items-stretch bg-white rounded-2xl shadow-sm hover:shadow-md transition-shadow overflow-hidden"
> >
{/* 날짜 영역 */} {/* 날짜 영역 - primary 색상 고정 */}
<div className="w-20 flex flex-col items-center justify-center text-white py-5 bg-primary"> <div className="w-20 flex flex-col items-center justify-center text-white py-5 bg-primary">
<span className="text-3xl font-bold">{day}</span> <span className="text-3xl font-bold">{day}</span>
<span className="text-sm font-medium opacity-80">{weekday}</span> <span className="text-sm font-medium opacity-80">{weekday}</span>
</div> </div>
{/* 내용 영역 */} {/* 내용 영역 */}
<div className="flex-1 p-5 flex flex-col justify-center"> <div className="flex-1 p-5 flex flex-col justify-center">
<h3 className="font-bold text-lg text-gray-900 mb-2">{schedule.title}</h3> <h3 className="font-bold text-lg text-gray-900 mb-2">{schedule.title}</h3>
@ -182,20 +178,20 @@ function Home() {
<div className="flex flex-wrap items-center gap-3 text-sm text-gray-500"> <div className="flex flex-wrap items-center gap-3 text-sm text-gray-500">
{schedule.time && ( {schedule.time && (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Clock size={14} className="text-primary" /> <Clock size={14} className="text-primary opacity-60" />
<span>{schedule.time.slice(0, 5)}</span> <span>{schedule.time.slice(0, 5)}</span>
</div> </div>
)} )}
{schedule.category_name && ( {schedule.category_name && (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1">
<span <Tag size={14} className="text-primary opacity-60" />
className="w-2 h-2 rounded-full" <span>{schedule.category_name}</span>
style={{ backgroundColor: categoryColor }} </div>
/> )}
<span> {schedule.source_name && (
{schedule.category_name} <div className="flex items-center gap-1">
{schedule.source_name && ` · ${schedule.source_name}`} <Link2 size={14} className="text-primary opacity-60" />
</span> <span>{schedule.source_name}</span>
</div> </div>
)} )}
</div> </div>