feat(mobile): 모바일 페이지 기본 구현
- 모바일 레이아웃 및 하단 네비게이션 추가 - 홈 페이지: 히어로, 멤버, 앨범(2개), 일정 섹션 - 멤버 페이지: 현재/전 멤버 분리, 상세 모달 - 앨범 페이지: 목록, 상세, 갤러리 - 일정 페이지: 캘린더 피커, 세로 스크롤 목록 - ScrollToTop 컴포넌트로 페이지 이동 시 스크롤 초기화 - PC 홈 일정 API startDate+limit 방식으로 변경
This commit is contained in:
parent
c54de2ba82
commit
f958e2b5e2
14 changed files with 1586 additions and 28 deletions
|
|
@ -2,7 +2,10 @@
|
|||
<html lang="ko">
|
||||
<head>
|
||||
<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" />
|
||||
<title>fromis_9 - 프로미스나인</title>
|
||||
<link
|
||||
|
|
|
|||
20
frontend/package-lock.json
generated
20
frontend/package-lock.json
generated
|
|
@ -21,6 +21,7 @@
|
|||
"react-photo-album": "^3.4.0",
|
||||
"react-router-dom": "^6.22.3",
|
||||
"react-window": "^2.2.3",
|
||||
"swiper": "^12.0.3",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -2618,6 +2619,25 @@
|
|||
"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": {
|
||||
"version": "3.4.19",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@
|
|||
"react-photo-album": "^3.4.0",
|
||||
"react-router-dom": "^6.22.3",
|
||||
"react-window": "^2.2.3",
|
||||
"swiper": "^12.0.3",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||
import { BrowserView, MobileView } from 'react-device-detect';
|
||||
|
||||
// 공통 컴포넌트
|
||||
import ScrollToTop from './components/ScrollToTop';
|
||||
|
||||
// PC 페이지
|
||||
import PCHome from './pages/pc/Home';
|
||||
import PCMembers from './pages/pc/Members';
|
||||
|
|
@ -9,6 +12,14 @@ import PCAlbumDetail from './pages/pc/AlbumDetail';
|
|||
import PCAlbumGallery from './pages/pc/AlbumGallery';
|
||||
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 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 AdminScheduleBots from './pages/pc/admin/AdminScheduleBots';
|
||||
|
||||
// PC 레이아웃
|
||||
// 레이아웃
|
||||
import PCLayout from './components/pc/Layout';
|
||||
import MobileLayout from './components/mobile/Layout';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||
<ScrollToTop />
|
||||
<BrowserView>
|
||||
<Routes>
|
||||
{/* 관리자 페이지 (레이아웃 없음) */}
|
||||
|
|
@ -61,16 +74,18 @@ function App() {
|
|||
</Routes>
|
||||
</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>
|
||||
<Routes>
|
||||
<Route path="/" element={<MobileLayout><MobileHome /></MobileLayout>} />
|
||||
<Route path="/members" element={<MobileLayout pageTitle="멤버"><MobileMembers /></MobileLayout>} />
|
||||
<Route path="/album" element={<MobileLayout pageTitle="앨범"><MobileAlbum /></MobileLayout>} />
|
||||
<Route path="/album/:name" element={<MobileLayout pageTitle="앨범"><MobileAlbumDetail /></MobileLayout>} />
|
||||
<Route path="/album/:name/gallery" element={<MobileLayout pageTitle="앨범"><MobileAlbumGallery /></MobileLayout>} />
|
||||
<Route path="/schedule" element={<MobileLayout hideHeader><MobileSchedule /></MobileLayout>} />
|
||||
</Routes>
|
||||
</MobileView>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
|
|
|
|||
15
frontend/src/components/ScrollToTop.jsx
Normal file
15
frontend/src/components/ScrollToTop.jsx
Normal 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;
|
||||
72
frontend/src/components/mobile/Layout.jsx
Normal file
72
frontend/src/components/mobile/Layout.jsx
Normal 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;
|
||||
|
||||
|
|
@ -12,9 +12,20 @@ body {
|
|||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
/* 최소 너비 설정 - 화면 축소시 깨짐 방지 */
|
||||
#root {
|
||||
min-width: 1440px;
|
||||
/* 최소 너비 설정 - PC에서만 적용 */
|
||||
@media (min-width: 1024px) {
|
||||
#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);
|
||||
}
|
||||
|
||||
/* 스크롤바 스타일 */
|
||||
|
|
|
|||
63
frontend/src/pages/mobile/Album.jsx
Normal file
63
frontend/src/pages/mobile/Album.jsx
Normal 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;
|
||||
142
frontend/src/pages/mobile/AlbumDetail.jsx
Normal file
142
frontend/src/pages/mobile/AlbumDetail.jsx
Normal 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;
|
||||
132
frontend/src/pages/mobile/AlbumGallery.jsx
Normal file
132
frontend/src/pages/mobile/AlbumGallery.jsx
Normal 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;
|
||||
231
frontend/src/pages/mobile/Home.jsx
Normal file
231
frontend/src/pages/mobile/Home.jsx
Normal 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;
|
||||
124
frontend/src/pages/mobile/Members.jsx
Normal file
124
frontend/src/pages/mobile/Members.jsx
Normal 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;
|
||||
733
frontend/src/pages/mobile/Schedule.jsx
Normal file
733
frontend/src/pages/mobile/Schedule.jsx
Normal 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;
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
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() {
|
||||
const [members, setMembers] = useState([]);
|
||||
|
|
@ -157,9 +157,6 @@ function Home() {
|
|||
const memberList = schedule.member_names ? schedule.member_names.split(',') : [];
|
||||
const displayMembers = memberList.length >= 5 ? ['프로미스나인'] : memberList;
|
||||
|
||||
// 카테고리 색상 (기본값)
|
||||
const categoryColor = schedule.category_color || '#6B8E6B';
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={schedule.id}
|
||||
|
|
@ -168,13 +165,12 @@ function Home() {
|
|||
transition={{ delay: index * 0.05 }}
|
||||
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">
|
||||
<span className="text-3xl font-bold">{day}</span>
|
||||
<span className="text-sm font-medium opacity-80">{weekday}</span>
|
||||
</div>
|
||||
|
||||
|
||||
{/* 내용 영역 */}
|
||||
<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>
|
||||
|
|
@ -182,20 +178,20 @@ function Home() {
|
|||
<div className="flex flex-wrap items-center gap-3 text-sm text-gray-500">
|
||||
{schedule.time && (
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
{schedule.category_name && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: categoryColor }}
|
||||
/>
|
||||
<span>
|
||||
{schedule.category_name}
|
||||
{schedule.source_name && ` · ${schedule.source_name}`}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Tag size={14} className="text-primary opacity-60" />
|
||||
<span>{schedule.category_name}</span>
|
||||
</div>
|
||||
)}
|
||||
{schedule.source_name && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Link2 size={14} className="text-primary opacity-60" />
|
||||
<span>{schedule.source_name}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue