feat: 관리자 페이지 마이그레이션 완료 (Phase 4-5)
- 관리자 페이지 폴더 구조 재구성 (pages/pc/admin/) - login/, dashboard/, members/, albums/, schedules/ - 앨범 관리 페이지 마이그레이션 (Albums, AlbumForm, AlbumPhotos, AlbumTeasers) - 일정 관리 페이지 마이그레이션 (Schedules, ScheduleForm, ScheduleCategory, ScheduleDict, ScheduleBots) - DatePicker 컴포넌트 버그 수정 (월 이동 및 연도 선택) - 일정 관리 라우트 경로 수정 (/admin/schedule) - 마이그레이션 문서 업데이트 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
dc5ac97717
commit
7dc3ec692e
17 changed files with 6773 additions and 99 deletions
|
|
@ -492,10 +492,10 @@ frontend-temp/src/
|
||||||
- [x] AdminMemberEdit 마이그레이션
|
- [x] AdminMemberEdit 마이그레이션
|
||||||
- [x] useToast 훅 추가
|
- [x] useToast 훅 추가
|
||||||
|
|
||||||
### 4단계: 앨범 관리
|
### 4단계: 앨범 관리 ✅
|
||||||
- [ ] AdminAlbums 마이그레이션
|
- [x] AdminAlbums 마이그레이션
|
||||||
- [ ] AdminAlbumForm 마이그레이션
|
- [x] AdminAlbumForm 마이그레이션
|
||||||
- [ ] AdminAlbumPhotos 마이그레이션 (SSE 처리 포함)
|
- [x] AdminAlbumPhotos 마이그레이션 (SSE 처리 포함)
|
||||||
|
|
||||||
### 5단계: 일정 관리
|
### 5단계: 일정 관리
|
||||||
- [ ] AdminSchedule 마이그레이션
|
- [ ] AdminSchedule 마이그레이션
|
||||||
|
|
|
||||||
|
|
@ -219,14 +219,14 @@ function App() {
|
||||||
- [x] schedules.js
|
- [x] schedules.js
|
||||||
- [x] auth.js
|
- [x] auth.js
|
||||||
|
|
||||||
#### 관리자 API (`api/admin/`)
|
#### 관리자 API (`api/pc/admin/`)
|
||||||
- [ ] albums.js
|
- [x] albums.js
|
||||||
- [ ] members.js
|
- [x] members.js
|
||||||
- [ ] schedules.js
|
- [x] schedules.js
|
||||||
- [ ] categories.js
|
- [x] categories.js
|
||||||
- [ ] stats.js
|
- [x] stats.js
|
||||||
- [ ] bots.js
|
- [x] bots.js
|
||||||
- [ ] suggestions.js
|
- [x] suggestions.js
|
||||||
|
|
||||||
### 훅 (hooks/)
|
### 훅 (hooks/)
|
||||||
- [x] useAlbumData.js
|
- [x] useAlbumData.js
|
||||||
|
|
@ -238,8 +238,9 @@ function App() {
|
||||||
- [x] useMediaQuery.js
|
- [x] useMediaQuery.js
|
||||||
- [x] useAdminAuth.js
|
- [x] useAdminAuth.js
|
||||||
|
|
||||||
### 관리자 훅 (hooks/) - 관리자 영역 마이그레이션 시 진행
|
### 관리자 훅 (hooks/pc/admin/, hooks/common/)
|
||||||
- [ ] useToast.js (관리자 페이지 전용)
|
- [x] useAdminAuth.js (hooks/pc/admin/)
|
||||||
|
- [x] useToast.js (hooks/common/)
|
||||||
|
|
||||||
### 스토어 (stores/)
|
### 스토어 (stores/)
|
||||||
- [x] useScheduleStore.js
|
- [x] useScheduleStore.js
|
||||||
|
|
@ -289,13 +290,13 @@ function App() {
|
||||||
- [x] confetti.js (fireBirthdayConfetti)
|
- [x] confetti.js (fireBirthdayConfetti)
|
||||||
- [x] AdminScheduleCard.jsx
|
- [x] AdminScheduleCard.jsx
|
||||||
|
|
||||||
### 관리자 컴포넌트 (components/admin/)
|
### 관리자 컴포넌트 (components/pc/admin/)
|
||||||
- [ ] AdminLayout.jsx
|
- [x] Layout.jsx
|
||||||
- [ ] AdminHeader.jsx
|
- [x] Header.jsx
|
||||||
- [ ] ConfirmDialog.jsx
|
- [x] ConfirmDialog.jsx
|
||||||
- [ ] CustomDatePicker.jsx
|
- [x] CustomDatePicker.jsx
|
||||||
- [ ] CustomTimePicker.jsx
|
- [x] CustomTimePicker.jsx
|
||||||
- [ ] NumberPicker.jsx
|
- [x] NumberPicker.jsx
|
||||||
|
|
||||||
### 페이지 - Home (pages/home/)
|
### 페이지 - Home (pages/home/)
|
||||||
- [x] pc/Home.jsx
|
- [x] pc/Home.jsx
|
||||||
|
|
@ -332,22 +333,19 @@ function App() {
|
||||||
- [x] pc/NotFound.jsx
|
- [x] pc/NotFound.jsx
|
||||||
- [x] mobile/NotFound.jsx
|
- [x] mobile/NotFound.jsx
|
||||||
|
|
||||||
### 페이지 - Admin (pages/admin/) - PC 전용
|
### 페이지 - Admin (pages/pc/admin/) - PC 전용
|
||||||
- [ ] Login.jsx
|
- [x] login/Login.jsx
|
||||||
- [ ] Dashboard.jsx
|
- [x] dashboard/Dashboard.jsx
|
||||||
- [ ] members/List.jsx
|
- [x] members/Members.jsx
|
||||||
- [ ] members/Edit.jsx
|
- [x] albums/Albums.jsx
|
||||||
- [ ] albums/List.jsx
|
- [x] albums/AlbumForm.jsx
|
||||||
- [ ] albums/Form.jsx
|
- [x] albums/AlbumPhotos.jsx
|
||||||
- [ ] albums/Photos.jsx
|
- [x] albums/AlbumTeasers.jsx
|
||||||
- [ ] schedules/List.jsx
|
- [x] schedules/Schedules.jsx
|
||||||
- [ ] schedules/Form.jsx
|
- [x] schedules/ScheduleForm.jsx
|
||||||
- [ ] schedules/YouTubeForm.jsx
|
- [x] schedules/ScheduleCategory.jsx
|
||||||
- [ ] schedules/XForm.jsx
|
- [x] schedules/ScheduleDict.jsx
|
||||||
- [ ] schedules/YouTubeEditForm.jsx
|
- [x] schedules/ScheduleBots.jsx
|
||||||
- [ ] categories/List.jsx
|
|
||||||
- [ ] bots/Manager.jsx
|
|
||||||
- [ ] dict/Manager.jsx
|
|
||||||
|
|
||||||
### 기타
|
### 기타
|
||||||
- [x] App.jsx (BrowserView/MobileView 라우팅)
|
- [x] App.jsx (BrowserView/MobileView 라우팅)
|
||||||
|
|
@ -443,11 +441,12 @@ import 'swiper/css';
|
||||||
#### 공개 영역
|
#### 공개 영역
|
||||||
- ✅ 모두 완료
|
- ✅ 모두 완료
|
||||||
|
|
||||||
#### 관리자 영역 (별도 요청 시 진행)
|
#### 관리자 영역
|
||||||
- [ ] 관리자 API 전체 (api/admin/)
|
- [x] 관리자 API 전체 (api/pc/admin/)
|
||||||
- [ ] 관리자 컴포넌트 전체 (components/admin/)
|
- [x] 관리자 컴포넌트 전체 (components/pc/admin/)
|
||||||
- [ ] 관리자 페이지 전체 (pages/admin/)
|
- [x] 관리자 페이지 전체 (pages/pc/admin/)
|
||||||
- [ ] useToast 훅 (관리자 전용)
|
- [x] useToast 훅 (hooks/common/)
|
||||||
|
- [x] useAdminAuth 훅 (hooks/pc/admin/)
|
||||||
|
|
||||||
### 최종 검증
|
### 최종 검증
|
||||||
- [ ] 모든 라우트 동작 확인
|
- [ ] 모든 라우트 동작 확인
|
||||||
|
|
|
||||||
|
|
@ -24,10 +24,18 @@ import PCAlbumGallery from '@/pages/pc/public/album/AlbumGallery';
|
||||||
import PCNotFound from '@/pages/pc/public/common/NotFound';
|
import PCNotFound from '@/pages/pc/public/common/NotFound';
|
||||||
|
|
||||||
// PC 관리자 페이지
|
// PC 관리자 페이지
|
||||||
import AdminLogin from '@/pages/pc/admin/Login';
|
import AdminLogin from '@/pages/pc/admin/login/Login';
|
||||||
import AdminDashboard from '@/pages/pc/admin/Dashboard';
|
import AdminDashboard from '@/pages/pc/admin/dashboard/Dashboard';
|
||||||
import AdminMembers from '@/pages/pc/admin/Members';
|
import AdminMembers from '@/pages/pc/admin/members/Members';
|
||||||
import AdminMemberEdit from '@/pages/pc/admin/MemberEdit';
|
import AdminMemberEdit from '@/pages/pc/admin/members/MemberEdit';
|
||||||
|
import AdminAlbums from '@/pages/pc/admin/albums/Albums';
|
||||||
|
import AdminAlbumForm from '@/pages/pc/admin/albums/AlbumForm';
|
||||||
|
import AdminAlbumPhotos from '@/pages/pc/admin/albums/AlbumPhotos';
|
||||||
|
import AdminSchedules from '@/pages/pc/admin/schedules/Schedules';
|
||||||
|
import AdminScheduleForm from '@/pages/pc/admin/schedules/ScheduleForm';
|
||||||
|
import AdminScheduleCategory from '@/pages/pc/admin/schedules/ScheduleCategory';
|
||||||
|
import AdminScheduleDict from '@/pages/pc/admin/schedules/ScheduleDict';
|
||||||
|
import AdminScheduleBots from '@/pages/pc/admin/schedules/ScheduleBots';
|
||||||
|
|
||||||
// Mobile 페이지
|
// Mobile 페이지
|
||||||
import MobileHome from '@/pages/mobile/home/Home';
|
import MobileHome from '@/pages/mobile/home/Home';
|
||||||
|
|
@ -70,6 +78,16 @@ function App() {
|
||||||
<Route path="/admin/dashboard" element={<AdminDashboard />} />
|
<Route path="/admin/dashboard" element={<AdminDashboard />} />
|
||||||
<Route path="/admin/members" element={<AdminMembers />} />
|
<Route path="/admin/members" element={<AdminMembers />} />
|
||||||
<Route path="/admin/members/:name/edit" element={<AdminMemberEdit />} />
|
<Route path="/admin/members/:name/edit" element={<AdminMemberEdit />} />
|
||||||
|
<Route path="/admin/albums" element={<AdminAlbums />} />
|
||||||
|
<Route path="/admin/albums/new" element={<AdminAlbumForm />} />
|
||||||
|
<Route path="/admin/albums/:id/edit" element={<AdminAlbumForm />} />
|
||||||
|
<Route path="/admin/albums/:albumId/photos" element={<AdminAlbumPhotos />} />
|
||||||
|
<Route path="/admin/schedule" element={<AdminSchedules />} />
|
||||||
|
<Route path="/admin/schedule/new" element={<AdminScheduleForm />} />
|
||||||
|
<Route path="/admin/schedule/:id/edit" element={<AdminScheduleForm />} />
|
||||||
|
<Route path="/admin/schedule/categories" element={<AdminScheduleCategory />} />
|
||||||
|
<Route path="/admin/schedule/dict" element={<AdminScheduleDict />} />
|
||||||
|
<Route path="/admin/schedule/bots" element={<AdminScheduleBots />} />
|
||||||
|
|
||||||
{/* 일반 페이지 (레이아웃 포함) */}
|
{/* 일반 페이지 (레이아웃 포함) */}
|
||||||
<Route
|
<Route
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,24 @@
|
||||||
/**
|
/**
|
||||||
* DatePicker 컴포넌트
|
* DatePicker 컴포넌트
|
||||||
* 연/월/일 선택이 가능한 드롭다운 형태의 날짜 선택기
|
* 연/월/일 선택이 가능한 드롭다운 형태의 날짜 선택기
|
||||||
|
*
|
||||||
|
* @param {string} value - 선택된 날짜 (YYYY-MM-DD 형식)
|
||||||
|
* @param {function} onChange - 날짜 변경 콜백
|
||||||
|
* @param {string} placeholder - 플레이스홀더 텍스트
|
||||||
|
* @param {boolean} showDayOfWeek - 요일 표시 여부
|
||||||
|
* @param {number} minYear - 선택 가능한 최소 연도 (기본값: 2000)
|
||||||
*/
|
*/
|
||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { Calendar, ChevronLeft, ChevronRight, ChevronDown } from 'lucide-react';
|
import { Calendar, ChevronLeft, ChevronRight, ChevronDown } from 'lucide-react';
|
||||||
|
|
||||||
function DatePicker({ value, onChange, placeholder = '날짜 선택', showDayOfWeek = false }) {
|
function DatePicker({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = '날짜 선택',
|
||||||
|
showDayOfWeek = false,
|
||||||
|
minYear = 2000,
|
||||||
|
}) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [viewMode, setViewMode] = useState('days');
|
const [viewMode, setViewMode] = useState('days');
|
||||||
const [viewDate, setViewDate] = useState(() => {
|
const [viewDate, setViewDate] = useState(() => {
|
||||||
|
|
@ -26,6 +38,13 @@ function DatePicker({ value, onChange, placeholder = '날짜 선택', showDayOfW
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// value가 변경되면 viewDate도 업데이트
|
||||||
|
useEffect(() => {
|
||||||
|
if (value) {
|
||||||
|
setViewDate(new Date(value));
|
||||||
|
}
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
const year = viewDate.getFullYear();
|
const year = viewDate.getFullYear();
|
||||||
const month = viewDate.getMonth();
|
const month = viewDate.getMonth();
|
||||||
|
|
||||||
|
|
@ -40,16 +59,38 @@ function DatePicker({ value, onChange, placeholder = '날짜 선택', showDayOfW
|
||||||
days.push(i);
|
days.push(i);
|
||||||
}
|
}
|
||||||
|
|
||||||
const MIN_YEAR = 2025;
|
// 연도 범위 계산 (minYear 기준으로 12개씩 그룹)
|
||||||
const startYear = Math.max(MIN_YEAR, Math.floor(year / 12) * 12 - 1);
|
const groupIndex = Math.floor((year - minYear) / 12);
|
||||||
|
const startYear = minYear + groupIndex * 12;
|
||||||
const years = Array.from({ length: 12 }, (_, i) => startYear + i);
|
const years = Array.from({ length: 12 }, (_, i) => startYear + i);
|
||||||
const canGoPrevYearRange = startYear > MIN_YEAR;
|
const canGoPrevYearRange = startYear > minYear;
|
||||||
|
|
||||||
const prevMonth = () => setViewDate(new Date(year, month - 1, 1));
|
const handleButtonClick = (e, callback) => {
|
||||||
const nextMonth = () => setViewDate(new Date(year, month + 1, 1));
|
e.preventDefault();
|
||||||
const prevYearRange = () =>
|
e.stopPropagation();
|
||||||
canGoPrevYearRange && setViewDate(new Date(Math.max(MIN_YEAR, year - 12), month, 1));
|
callback();
|
||||||
const nextYearRange = () => setViewDate(new Date(year + 12, month, 1));
|
};
|
||||||
|
|
||||||
|
const prevMonth = () => {
|
||||||
|
const newDate = new Date(year, month - 1, 1);
|
||||||
|
if (newDate.getFullYear() >= minYear) {
|
||||||
|
setViewDate(newDate);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextMonth = () => {
|
||||||
|
setViewDate(new Date(year, month + 1, 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const prevYearRange = () => {
|
||||||
|
if (canGoPrevYearRange) {
|
||||||
|
setViewDate(new Date(startYear - 12, month, 1));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const nextYearRange = () => {
|
||||||
|
setViewDate(new Date(startYear + 12, month, 1));
|
||||||
|
};
|
||||||
|
|
||||||
const selectDate = (day) => {
|
const selectDate = (day) => {
|
||||||
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||||
|
|
@ -67,7 +108,6 @@ function DatePicker({ value, onChange, placeholder = '날짜 선택', showDayOfW
|
||||||
setViewMode('days');
|
setViewMode('days');
|
||||||
};
|
};
|
||||||
|
|
||||||
// 날짜 표시 포맷 (요일 포함 옵션)
|
|
||||||
const formatDisplayDate = (dateStr) => {
|
const formatDisplayDate = (dateStr) => {
|
||||||
if (!dateStr) return '';
|
if (!dateStr) return '';
|
||||||
const [y, m, d] = dateStr.split('-');
|
const [y, m, d] = dateStr.split('-');
|
||||||
|
|
@ -92,11 +132,10 @@ function DatePicker({ value, onChange, placeholder = '날짜 선택', showDayOfW
|
||||||
return today.getFullYear() === year && today.getMonth() === month && today.getDate() === day;
|
return today.getFullYear() === year && today.getMonth() === month && today.getDate() === day;
|
||||||
};
|
};
|
||||||
|
|
||||||
const isCurrentYear = (y) => new Date().getFullYear() === y;
|
const currentYear = new Date().getFullYear();
|
||||||
const isCurrentMonth = (m) => {
|
const currentMonth = new Date().getMonth();
|
||||||
const today = new Date();
|
const isCurrentYear = (y) => currentYear === y;
|
||||||
return today.getFullYear() === year && today.getMonth() === m;
|
const isCurrentMonth = (m) => currentYear === year && currentMonth === m;
|
||||||
};
|
|
||||||
|
|
||||||
const monthNames = [
|
const monthNames = [
|
||||||
'1월',
|
'1월',
|
||||||
|
|
@ -113,11 +152,33 @@ function DatePicker({ value, onChange, placeholder = '날짜 선택', showDayOfW
|
||||||
'12월',
|
'12월',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 연도 버튼 클래스
|
||||||
|
const getYearButtonClass = (y) => {
|
||||||
|
if (year === y) {
|
||||||
|
return 'bg-primary text-white';
|
||||||
|
}
|
||||||
|
if (isCurrentYear(y)) {
|
||||||
|
return 'text-primary font-medium hover:bg-gray-100';
|
||||||
|
}
|
||||||
|
return 'text-gray-700 hover:bg-gray-100';
|
||||||
|
};
|
||||||
|
|
||||||
|
// 월 버튼 클래스
|
||||||
|
const getMonthButtonClass = (m) => {
|
||||||
|
if (month === m) {
|
||||||
|
return 'bg-primary text-white';
|
||||||
|
}
|
||||||
|
if (isCurrentMonth(m)) {
|
||||||
|
return 'text-primary font-medium hover:bg-gray-100';
|
||||||
|
}
|
||||||
|
return 'text-gray-700 hover:bg-gray-100';
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={ref} className="relative">
|
<div ref={ref} className="relative">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={(e) => handleButtonClick(e, () => setIsOpen(!isOpen))}
|
||||||
className="w-full px-4 py-3 border border-gray-200 rounded-xl bg-white flex items-center justify-between hover:border-gray-300 transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
className="w-full px-4 py-3 border border-gray-200 rounded-xl bg-white flex items-center justify-between hover:border-gray-300 transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||||
>
|
>
|
||||||
<span className={value ? 'text-gray-900' : 'text-gray-400'}>
|
<span className={value ? 'text-gray-900' : 'text-gray-400'}>
|
||||||
|
|
@ -138,15 +199,28 @@ function DatePicker({ value, onChange, placeholder = '날짜 선택', showDayOfW
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={viewMode === 'years' ? prevYearRange : prevMonth}
|
onClick={(e) =>
|
||||||
disabled={viewMode === 'years' && !canGoPrevYearRange}
|
handleButtonClick(e, viewMode === 'years' ? prevYearRange : prevMonth)
|
||||||
className={`p-1.5 rounded-lg transition-colors ${viewMode === 'years' && !canGoPrevYearRange ? 'opacity-30' : 'hover:bg-gray-100'}`}
|
}
|
||||||
|
disabled={
|
||||||
|
viewMode === 'years'
|
||||||
|
? !canGoPrevYearRange
|
||||||
|
: year === minYear && month === 0
|
||||||
|
}
|
||||||
|
className={`p-1.5 rounded-lg transition-colors ${
|
||||||
|
(viewMode === 'years' && !canGoPrevYearRange) ||
|
||||||
|
(viewMode !== 'years' && year === minYear && month === 0)
|
||||||
|
? 'opacity-30 cursor-not-allowed'
|
||||||
|
: 'hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<ChevronLeft size={20} className="text-gray-600" />
|
<ChevronLeft size={20} className="text-gray-600" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setViewMode(viewMode === 'days' ? 'years' : 'days')}
|
onClick={(e) =>
|
||||||
|
handleButtonClick(e, () => setViewMode(viewMode === 'days' ? 'years' : 'days'))
|
||||||
|
}
|
||||||
className="font-medium text-gray-900 hover:text-primary transition-colors flex items-center gap-1"
|
className="font-medium text-gray-900 hover:text-primary transition-colors flex items-center gap-1"
|
||||||
>
|
>
|
||||||
{viewMode === 'years'
|
{viewMode === 'years'
|
||||||
|
|
@ -159,7 +233,9 @@ function DatePicker({ value, onChange, placeholder = '날짜 선택', showDayOfW
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={viewMode === 'years' ? nextYearRange : nextMonth}
|
onClick={(e) =>
|
||||||
|
handleButtonClick(e, viewMode === 'years' ? nextYearRange : nextMonth)
|
||||||
|
}
|
||||||
className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors"
|
className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
>
|
>
|
||||||
<ChevronRight size={20} className="text-gray-600" />
|
<ChevronRight size={20} className="text-gray-600" />
|
||||||
|
|
@ -181,8 +257,8 @@ function DatePicker({ value, onChange, placeholder = '날짜 선택', showDayOfW
|
||||||
<button
|
<button
|
||||||
key={y}
|
key={y}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => selectYear(y)}
|
onClick={(e) => handleButtonClick(e, () => selectYear(y))}
|
||||||
className={`py-2 rounded-lg text-sm transition-colors ${year === y ? 'bg-primary text-white' : 'hover:bg-gray-100 text-gray-700'} ${isCurrentYear(y) && year !== y ? 'text-primary font-medium' : ''}`}
|
className={`py-2 rounded-lg text-sm transition-colors ${getYearButtonClass(y)}`}
|
||||||
>
|
>
|
||||||
{y}
|
{y}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -194,32 +270,8 @@ function DatePicker({ value, onChange, placeholder = '날짜 선택', showDayOfW
|
||||||
<button
|
<button
|
||||||
key={m}
|
key={m}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => selectMonth(i)}
|
onClick={(e) => handleButtonClick(e, () => selectMonth(i))}
|
||||||
className={`py-2 rounded-lg text-sm transition-colors ${month === i ? 'bg-primary text-white' : 'hover:bg-gray-100 text-gray-700'} ${isCurrentMonth(i) && month !== i ? 'text-primary font-medium' : ''}`}
|
className={`py-2 rounded-lg text-sm transition-colors ${getMonthButtonClass(i)}`}
|
||||||
>
|
|
||||||
{m}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{viewMode === 'months' && (
|
|
||||||
<motion.div
|
|
||||||
key="months"
|
|
||||||
initial={{ opacity: 0 }}
|
|
||||||
animate={{ opacity: 1 }}
|
|
||||||
exit={{ opacity: 0 }}
|
|
||||||
transition={{ duration: 0.15 }}
|
|
||||||
>
|
|
||||||
<div className="text-center text-sm text-gray-500 mb-3">월 선택</div>
|
|
||||||
<div className="grid grid-cols-4 gap-2">
|
|
||||||
{monthNames.map((m, i) => (
|
|
||||||
<button
|
|
||||||
key={m}
|
|
||||||
type="button"
|
|
||||||
onClick={() => selectMonth(i)}
|
|
||||||
className={`py-2.5 rounded-lg text-sm transition-colors ${month === i ? 'bg-primary text-white' : 'hover:bg-gray-100 text-gray-700'} ${isCurrentMonth(i) && month !== i ? 'text-primary font-medium' : ''}`}
|
|
||||||
>
|
>
|
||||||
{m}
|
{m}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -240,7 +292,9 @@ function DatePicker({ value, onChange, placeholder = '날짜 선택', showDayOfW
|
||||||
{['일', '월', '화', '수', '목', '금', '토'].map((d, i) => (
|
{['일', '월', '화', '수', '목', '금', '토'].map((d, i) => (
|
||||||
<div
|
<div
|
||||||
key={d}
|
key={d}
|
||||||
className={`text-center text-xs font-medium py-1 ${i === 0 ? 'text-red-400' : i === 6 ? 'text-blue-400' : 'text-gray-400'}`}
|
className={`text-center text-xs font-medium py-1 ${
|
||||||
|
i === 0 ? 'text-red-400' : i === 6 ? 'text-blue-400' : 'text-gray-400'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{d}
|
{d}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -254,7 +308,7 @@ function DatePicker({ value, onChange, placeholder = '날짜 선택', showDayOfW
|
||||||
key={i}
|
key={i}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!day}
|
disabled={!day}
|
||||||
onClick={() => day && selectDate(day)}
|
onClick={(e) => day && handleButtonClick(e, () => selectDate(day))}
|
||||||
className={`aspect-square rounded-full text-sm font-medium flex items-center justify-center transition-all
|
className={`aspect-square rounded-full text-sm font-medium flex items-center justify-center transition-all
|
||||||
${!day ? '' : 'hover:bg-gray-100'}
|
${!day ? '' : 'hover:bg-gray-100'}
|
||||||
${isSelected(day) ? 'bg-primary text-white hover:bg-primary' : ''}
|
${isSelected(day) ? 'bg-primary text-white hover:bg-primary' : ''}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ function AdminLayout({ user, children }) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
// 일정 관리 페이지는 내부 스크롤 처리
|
// 일정 관리 페이지는 내부 스크롤 처리
|
||||||
const isSchedulePage = location.pathname.includes('/admin/schedules');
|
const isSchedulePage = location.pathname.includes('/admin/schedule');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen overflow-hidden flex flex-col bg-gray-50">
|
<div className="h-screen overflow-hidden flex flex-col bg-gray-50">
|
||||||
|
|
|
||||||
631
frontend-temp/src/pages/pc/admin/albums/AlbumForm.jsx
Normal file
631
frontend-temp/src/pages/pc/admin/albums/AlbumForm.jsx
Normal file
|
|
@ -0,0 +1,631 @@
|
||||||
|
/**
|
||||||
|
* 관리자 앨범 추가/수정 페이지
|
||||||
|
*/
|
||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import { useNavigate, useParams, Link } from 'react-router-dom';
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { Save, Home, ChevronRight, Music, Trash2, Plus, Image, Star, ChevronDown } from 'lucide-react';
|
||||||
|
import { Toast } from '@/components/common';
|
||||||
|
import { AdminLayout, DatePicker } from '@/components/pc/admin';
|
||||||
|
import { useAdminAuth } from '@/hooks/pc/admin';
|
||||||
|
import { useToast } from '@/hooks/common';
|
||||||
|
import { adminAlbumApi } from '@/api/pc/admin';
|
||||||
|
import { fetchFormData } from '@/api/common/client';
|
||||||
|
|
||||||
|
// 커스텀 드롭다운 컴포넌트
|
||||||
|
function CustomSelect({ value, onChange, options, placeholder }) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const ref = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (e) => {
|
||||||
|
if (ref.current && !ref.current.contains(e.target)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className="w-full px-4 py-2.5 border border-gray-200 rounded-lg bg-white flex items-center justify-between hover:border-gray-300 transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||||
|
>
|
||||||
|
<span className={value ? 'text-gray-900' : 'text-gray-400'}>{value || placeholder}</span>
|
||||||
|
<ChevronDown
|
||||||
|
size={18}
|
||||||
|
className={`text-gray-400 transition-transform ${isOpen ? 'rotate-180' : ''}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{isOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -10 }}
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
className="absolute z-50 w-full mt-2 bg-white border border-gray-200 rounded-xl shadow-lg overflow-hidden"
|
||||||
|
>
|
||||||
|
{options.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onChange(option);
|
||||||
|
setIsOpen(false);
|
||||||
|
}}
|
||||||
|
className={`w-full px-4 py-2.5 text-left hover:bg-gray-50 transition-colors ${
|
||||||
|
value === option ? 'bg-primary/10 text-primary font-medium' : 'text-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{option}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AdminAlbumForm() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { id } = useParams();
|
||||||
|
const isEditMode = !!id;
|
||||||
|
const coverInputRef = useRef(null);
|
||||||
|
const { user, isAuthenticated } = useAdminAuth();
|
||||||
|
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [coverPreview, setCoverPreview] = useState(null);
|
||||||
|
const [coverFile, setCoverFile] = useState(null);
|
||||||
|
const { toast, setToast } = useToast();
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
title: '',
|
||||||
|
album_type: '',
|
||||||
|
album_type_short: '',
|
||||||
|
release_date: '',
|
||||||
|
cover_original_url: '',
|
||||||
|
cover_medium_url: '',
|
||||||
|
cover_thumb_url: '',
|
||||||
|
folder_name: '',
|
||||||
|
description: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const [tracks, setTracks] = useState([]);
|
||||||
|
|
||||||
|
// 수정 모드일 때 앨범 데이터 로드
|
||||||
|
const {
|
||||||
|
data: albumData,
|
||||||
|
isLoading: loading,
|
||||||
|
error: albumError,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['admin', 'album', id],
|
||||||
|
queryFn: () => adminAlbumApi.getAlbum(id),
|
||||||
|
enabled: isAuthenticated && isEditMode && !!id,
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 앨범 데이터 로드 시 폼에 반영
|
||||||
|
useEffect(() => {
|
||||||
|
if (albumData) {
|
||||||
|
setFormData({
|
||||||
|
title: albumData.title || '',
|
||||||
|
album_type: albumData.album_type || '',
|
||||||
|
album_type_short: albumData.album_type_short || '',
|
||||||
|
release_date: albumData.release_date ? albumData.release_date.split('T')[0] : '',
|
||||||
|
cover_original_url: albumData.cover_original_url || '',
|
||||||
|
cover_medium_url: albumData.cover_medium_url || '',
|
||||||
|
cover_thumb_url: albumData.cover_thumb_url || '',
|
||||||
|
folder_name: albumData.folder_name || '',
|
||||||
|
description: albumData.description || '',
|
||||||
|
});
|
||||||
|
if (albumData.cover_medium_url || albumData.cover_original_url) {
|
||||||
|
setCoverPreview(albumData.cover_medium_url || albumData.cover_original_url);
|
||||||
|
}
|
||||||
|
setTracks(albumData.tracks || []);
|
||||||
|
}
|
||||||
|
}, [albumData]);
|
||||||
|
|
||||||
|
// 에러 처리
|
||||||
|
useEffect(() => {
|
||||||
|
if (albumError) {
|
||||||
|
console.error('앨범 로드 오류:', albumError);
|
||||||
|
setToast({ message: '앨범 로드 중 오류가 발생했습니다.', type: 'error' });
|
||||||
|
}
|
||||||
|
}, [albumError, setToast]);
|
||||||
|
|
||||||
|
const handleInputChange = (e) => {
|
||||||
|
const { name, value } = e.target;
|
||||||
|
|
||||||
|
// 앨범명 변경 시 RustFS 폴더명 자동 생성
|
||||||
|
if (name === 'title') {
|
||||||
|
const folderName = value
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[\s.]+/g, '-')
|
||||||
|
.replace(/[^a-z0-9가-힣-]/g, '')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '');
|
||||||
|
setFormData((prev) => ({ ...prev, title: value, folder_name: folderName }));
|
||||||
|
} else {
|
||||||
|
setFormData((prev) => ({ ...prev, [name]: value }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCoverChange = (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (file) {
|
||||||
|
setCoverFile(file);
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = () => {
|
||||||
|
setCoverPreview(reader.result);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addTrack = () => {
|
||||||
|
setTracks((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
track_number: prev.length + 1,
|
||||||
|
title: '',
|
||||||
|
is_title_track: false,
|
||||||
|
duration: '',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeTrack = (index) => {
|
||||||
|
setTracks((prev) =>
|
||||||
|
prev
|
||||||
|
.filter((_, i) => i !== index)
|
||||||
|
.map((track, i) => ({
|
||||||
|
...track,
|
||||||
|
track_number: i + 1,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateTrack = (index, field, value) => {
|
||||||
|
// 작사/작곡/편곡 필드에서 '|' (전각 세로 막대)를 ', '로 자동 변환
|
||||||
|
let processedValue = value;
|
||||||
|
if (['lyricist', 'composer', 'arranger'].includes(field)) {
|
||||||
|
processedValue = value.replace(/[||]/g, ', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
setTracks((prev) =>
|
||||||
|
prev.map((track, i) => (i === index ? { ...track, [field]: processedValue } : track))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// 커스텀 검증
|
||||||
|
if (!formData.title.trim()) {
|
||||||
|
setToast({ message: '앨범명을 입력해주세요.', type: 'warning' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!formData.folder_name.trim()) {
|
||||||
|
setToast({ message: 'RustFS 폴더명을 입력해주세요.', type: 'warning' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!formData.album_type_short) {
|
||||||
|
setToast({ message: '앨범 타입을 선택해주세요.', type: 'warning' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!formData.release_date) {
|
||||||
|
setToast({ message: '발매일을 선택해주세요.', type: 'warning' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!formData.album_type.trim()) {
|
||||||
|
setToast({ message: '앨범 유형을 입력해주세요.', type: 'warning' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('data', JSON.stringify({ ...formData, tracks }));
|
||||||
|
if (coverFile) {
|
||||||
|
form.append('cover', coverFile);
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = isEditMode ? `/albums/${id}` : '/albums';
|
||||||
|
const method = isEditMode ? 'PUT' : 'POST';
|
||||||
|
|
||||||
|
await fetchFormData(url, form, method);
|
||||||
|
|
||||||
|
// 앨범 목록 캐시 무효화
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'albums'] });
|
||||||
|
navigate('/admin/albums');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('저장 오류:', error);
|
||||||
|
setToast({ message: '저장 중 오류가 발생했습니다.', type: 'error' });
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const albumTypes = ['정규', '미니', '싱글'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminLayout user={user}>
|
||||||
|
<Toast toast={toast} onClose={() => setToast(null)} />
|
||||||
|
|
||||||
|
<div className="max-w-4xl mx-auto px-6 py-8">
|
||||||
|
{/* 브레드크럼 */}
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-400 mb-8">
|
||||||
|
<Link to="/admin/dashboard" className="hover:text-primary transition-colors">
|
||||||
|
<Home size={16} />
|
||||||
|
</Link>
|
||||||
|
<ChevronRight size={14} />
|
||||||
|
<Link to="/admin/albums" className="hover:text-primary transition-colors">
|
||||||
|
앨범 관리
|
||||||
|
</Link>
|
||||||
|
<ChevronRight size={14} />
|
||||||
|
<span className="text-gray-700">{isEditMode ? '앨범 수정' : '새 앨범 추가'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 타이틀 */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 mb-2">
|
||||||
|
{isEditMode ? '앨범 수정' : '새 앨범 추가'}
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-500">앨범 정보와 트랙을 입력하세요</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="min-h-[600px]" />
|
||||||
|
) : (
|
||||||
|
<motion.form
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{
|
||||||
|
duration: 0.4,
|
||||||
|
ease: [0.25, 0.1, 0.25, 1],
|
||||||
|
delay: 0.15,
|
||||||
|
}}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="space-y-8"
|
||||||
|
>
|
||||||
|
{/* 앨범 기본 정보 */}
|
||||||
|
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
||||||
|
<h2 className="text-lg font-bold text-gray-900 mb-6">앨범 정보</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-6">
|
||||||
|
{/* 커버 이미지 */}
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
커버 이미지
|
||||||
|
</label>
|
||||||
|
<div className="flex items-start gap-6">
|
||||||
|
<div
|
||||||
|
onClick={() => coverInputRef.current?.click()}
|
||||||
|
className="w-40 h-40 rounded-xl border-2 border-dashed border-gray-200 flex items-center justify-center cursor-pointer hover:border-primary hover:bg-primary/5 transition-colors overflow-hidden"
|
||||||
|
>
|
||||||
|
{coverPreview ? (
|
||||||
|
<img
|
||||||
|
src={coverPreview}
|
||||||
|
alt="커버 미리보기"
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="text-center text-gray-400">
|
||||||
|
<Image size={32} className="mx-auto mb-2" />
|
||||||
|
<p className="text-xs">클릭하여 업로드</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={coverInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={handleCoverChange}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm text-gray-500 mb-2">권장 크기: 1000x1000px</p>
|
||||||
|
<p className="text-sm text-gray-500">지원 형식: JPG, PNG, WebP</p>
|
||||||
|
{coverPreview && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setCoverPreview(null);
|
||||||
|
setCoverFile(null);
|
||||||
|
}}
|
||||||
|
className="mt-3 text-sm text-red-500 hover:text-red-600"
|
||||||
|
>
|
||||||
|
이미지 제거
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 앨범명 */}
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">앨범명 *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="title"
|
||||||
|
value={formData.title}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="w-full px-4 py-2.5 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||||
|
placeholder="예: 하얀 그리움"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 폴더명 */}
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
RustFS 폴더명 *
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-gray-400 text-sm">fromis-9/album/</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="folder_name"
|
||||||
|
value={formData.folder_name}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="flex-1 px-4 py-2.5 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||||
|
placeholder="예: white-memories"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">영문 소문자, 숫자, 하이픈만 사용</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 앨범 타입 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">앨범 타입 *</label>
|
||||||
|
<CustomSelect
|
||||||
|
value={formData.album_type_short}
|
||||||
|
onChange={(val) => setFormData((prev) => ({ ...prev, album_type_short: val }))}
|
||||||
|
options={albumTypes}
|
||||||
|
placeholder="타입 선택"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 앨범 유형 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">앨범 유형 *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
name="album_type"
|
||||||
|
value={formData.album_type}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
className="w-full px-4 py-2.5 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||||
|
placeholder="예: 미니 6집"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 발매일 */}
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">발매일 *</label>
|
||||||
|
<DatePicker
|
||||||
|
value={formData.release_date}
|
||||||
|
onChange={(val) => setFormData((prev) => ({ ...prev, release_date: val }))}
|
||||||
|
minYear={2017}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 설명 */}
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">앨범 설명</label>
|
||||||
|
<textarea
|
||||||
|
name="description"
|
||||||
|
value={formData.description}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
rows={8}
|
||||||
|
className="w-full px-4 py-2.5 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent resize-none"
|
||||||
|
placeholder="앨범에 대한 설명을 입력하세요..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 트랙 목록 */}
|
||||||
|
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h2 className="text-lg font-bold text-gray-900">트랙 목록</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addTrack}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 text-sm bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus size={16} />
|
||||||
|
트랙 추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tracks.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-gray-500">
|
||||||
|
<Music size={40} className="mx-auto mb-3 text-gray-300" />
|
||||||
|
<p>트랙을 추가하세요</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{tracks.map((track, index) => (
|
||||||
|
<div key={index} className="border border-gray-200 rounded-xl p-4">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="w-8 h-8 bg-gray-100 rounded-lg flex items-center justify-center text-sm font-medium text-gray-600">
|
||||||
|
{String(track.track_number).padStart(2, '0')}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => updateTrack(index, 'is_title_track', !track.is_title_track)}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium transition-colors ${
|
||||||
|
track.is_title_track
|
||||||
|
? 'bg-primary text-white'
|
||||||
|
: 'bg-gray-100 text-gray-500 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Star size={12} fill={track.is_title_track ? 'currentColor' : 'none'} />
|
||||||
|
타이틀
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeTrack(index)}
|
||||||
|
className="p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 gap-4">
|
||||||
|
<div className="col-span-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={track.title}
|
||||||
|
onChange={(e) => updateTrack(index, 'title', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||||
|
placeholder="곡 제목"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={track.duration || ''}
|
||||||
|
onChange={(e) => updateTrack(index, 'duration', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent text-center"
|
||||||
|
placeholder="0:00"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 상세 정보 토글 */}
|
||||||
|
<div className="mt-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => updateTrack(index, 'showDetails', !track.showDetails)}
|
||||||
|
className="text-sm text-gray-500 hover:text-gray-700 flex items-center gap-1 transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronDown
|
||||||
|
size={14}
|
||||||
|
className={`transition-transform ${track.showDetails ? 'rotate-180' : ''}`}
|
||||||
|
/>
|
||||||
|
상세 정보 {track.showDetails ? '접기' : '펼치기'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{track.showDetails && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
>
|
||||||
|
{/* 작사/작곡/편곡 */}
|
||||||
|
<div className="space-y-3 mt-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">작사</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={track.lyricist || ''}
|
||||||
|
onChange={(e) => updateTrack(index, 'lyricist', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||||
|
placeholder="여러 명일 경우 쉼표로 구분"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">작곡</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={track.composer || ''}
|
||||||
|
onChange={(e) => updateTrack(index, 'composer', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||||
|
placeholder="여러 명일 경우 쉼표로 구분"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">편곡</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={track.arranger || ''}
|
||||||
|
onChange={(e) => updateTrack(index, 'arranger', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||||
|
placeholder="여러 명일 경우 쉼표로 구분"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* MV URL */}
|
||||||
|
<div className="mt-3">
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">
|
||||||
|
뮤직비디오 URL
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={track.music_video_url || ''}
|
||||||
|
onChange={(e) => updateTrack(index, 'music_video_url', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||||
|
placeholder="https://youtube.com/watch?v=..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 가사 */}
|
||||||
|
<div className="mt-3">
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">가사</label>
|
||||||
|
<textarea
|
||||||
|
value={track.lyrics || ''}
|
||||||
|
onChange={(e) => updateTrack(index, 'lyrics', e.target.value)}
|
||||||
|
rows={12}
|
||||||
|
className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent resize-none min-h-[200px]"
|
||||||
|
placeholder="가사를 입력하세요..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 버튼 */}
|
||||||
|
<div className="flex items-center justify-end gap-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate('/admin/albums')}
|
||||||
|
className="px-6 py-2.5 text-gray-600 hover:text-gray-900 transition-colors"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving}
|
||||||
|
className="flex items-center gap-2 px-6 py-2.5 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||||
|
저장 중...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save size={18} />
|
||||||
|
저장
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AdminLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AdminAlbumForm;
|
||||||
1536
frontend-temp/src/pages/pc/admin/albums/AlbumPhotos.jsx
Normal file
1536
frontend-temp/src/pages/pc/admin/albums/AlbumPhotos.jsx
Normal file
File diff suppressed because it is too large
Load diff
231
frontend-temp/src/pages/pc/admin/albums/Albums.jsx
Normal file
231
frontend-temp/src/pages/pc/admin/albums/Albums.jsx
Normal file
|
|
@ -0,0 +1,231 @@
|
||||||
|
/**
|
||||||
|
* 관리자 앨범 목록 페이지
|
||||||
|
*/
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate, Link } from 'react-router-dom';
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import { Plus, Search, Edit2, Trash2, Image, Music, Home, ChevronRight, Calendar } from 'lucide-react';
|
||||||
|
import { Toast, Tooltip } from '@/components/common';
|
||||||
|
import { AdminLayout, ConfirmDialog } from '@/components/pc/admin';
|
||||||
|
import { useAdminAuth } from '@/hooks/pc/admin';
|
||||||
|
import { useToast } from '@/hooks/common';
|
||||||
|
import { adminAlbumApi } from '@/api/pc/admin';
|
||||||
|
|
||||||
|
function AdminAlbums() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { user, isAuthenticated } = useAdminAuth();
|
||||||
|
const { toast, setToast } = useToast();
|
||||||
|
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [deleteDialog, setDeleteDialog] = useState({ show: false, album: null });
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
|
// 앨범 목록 조회
|
||||||
|
const { data: albums = [], isLoading: loading } = useQuery({
|
||||||
|
queryKey: ['admin', 'albums'],
|
||||||
|
queryFn: adminAlbumApi.getAlbums,
|
||||||
|
enabled: isAuthenticated,
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deleteDialog.album) return;
|
||||||
|
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
await adminAlbumApi.deleteAlbum(deleteDialog.album.id);
|
||||||
|
setToast({ message: `"${deleteDialog.album.title}" 앨범이 삭제되었습니다.`, type: 'success' });
|
||||||
|
setDeleteDialog({ show: false, album: null });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'albums'] });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('삭제 오류:', error);
|
||||||
|
setToast({ message: '앨범 삭제 중 오류가 발생했습니다.', type: 'error' });
|
||||||
|
} finally {
|
||||||
|
setDeleting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 날짜 포맷팅
|
||||||
|
const formatDate = (dateStr) => {
|
||||||
|
if (!dateStr) return '';
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return `${date.getFullYear()}.${String(date.getMonth() + 1).padStart(2, '0')}.${String(date.getDate()).padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 검색 필터링
|
||||||
|
const filteredAlbums = albums.filter((album) =>
|
||||||
|
album.title.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminLayout user={user}>
|
||||||
|
<Toast toast={toast} onClose={() => setToast(null)} />
|
||||||
|
|
||||||
|
{/* 삭제 확인 다이얼로그 */}
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={deleteDialog.show}
|
||||||
|
onClose={() => setDeleteDialog({ show: false, album: null })}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
title="앨범 삭제"
|
||||||
|
message={
|
||||||
|
<>
|
||||||
|
<span className="font-medium text-gray-900">"{deleteDialog.album?.title}"</span> 앨범을
|
||||||
|
삭제하시겠습니까?
|
||||||
|
<br />
|
||||||
|
<span className="text-sm text-red-500">
|
||||||
|
이 작업은 되돌릴 수 없으며, 모든 트랙과 커버 이미지가 함께 삭제됩니다.
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
loading={deleting}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="max-w-7xl mx-auto px-6 py-8">
|
||||||
|
{/* 브레드크럼 */}
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-400 mb-8">
|
||||||
|
<Link to="/admin/dashboard" className="hover:text-primary transition-colors">
|
||||||
|
<Home size={16} />
|
||||||
|
</Link>
|
||||||
|
<ChevronRight size={14} />
|
||||||
|
<span className="text-gray-700">앨범 관리</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 타이틀 & 액션 */}
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 mb-2">앨범 관리</h1>
|
||||||
|
<p className="text-gray-500">앨범, 트랙, 사진을 관리합니다</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/admin/albums/new')}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors"
|
||||||
|
>
|
||||||
|
<Plus size={18} />
|
||||||
|
<span>새 앨범 추가</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 검색 */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="relative max-w-md">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" size={18} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="앨범 검색..."
|
||||||
|
className="w-full pl-10 pr-4 py-2 border border-gray-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 앨범 목록 */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="min-h-[400px]" />
|
||||||
|
) : (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{
|
||||||
|
duration: 0.4,
|
||||||
|
ease: [0.25, 0.1, 0.25, 1],
|
||||||
|
delay: 0.15,
|
||||||
|
}}
|
||||||
|
className="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden"
|
||||||
|
>
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50 border-b border-gray-100">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-6 py-4 text-sm font-medium text-gray-500">앨범</th>
|
||||||
|
<th className="text-left px-6 py-4 text-sm font-medium text-gray-500">타입</th>
|
||||||
|
<th className="text-left px-6 py-4 text-sm font-medium text-gray-500">발매일</th>
|
||||||
|
<th className="text-left px-6 py-4 text-sm font-medium text-gray-500">트랙</th>
|
||||||
|
<th className="text-right px-6 py-4 text-sm font-medium text-gray-500">관리</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{filteredAlbums.map((album, index) => (
|
||||||
|
<motion.tr
|
||||||
|
key={album.id}
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: index * 0.05 }}
|
||||||
|
className="hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<img
|
||||||
|
src={album.cover_thumb_url || album.cover_original_url}
|
||||||
|
alt={album.title}
|
||||||
|
className="w-12 h-12 rounded-lg object-cover"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900">{album.title}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span className="px-2 py-1 bg-gray-100 text-gray-600 text-xs font-medium rounded-full">
|
||||||
|
{album.album_type}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-gray-500 text-sm">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Calendar size={14} />
|
||||||
|
{formatDate(album.release_date)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-gray-500 text-sm">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Music size={14} />
|
||||||
|
{album.tracks?.length || 0}곡
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Tooltip text="사진 관리" position="top">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/admin/albums/${album.id}/photos`)}
|
||||||
|
className="p-2 text-gray-400 hover:text-purple-500 hover:bg-purple-50 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Image size={18} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip text="수정" position="top">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/admin/albums/${album.id}/edit`)}
|
||||||
|
className="p-2 text-gray-400 hover:text-primary hover:bg-primary/10 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Edit2 size={18} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip text="삭제" position="top">
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteDialog({ show: true, album })}
|
||||||
|
className="p-2 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={18} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</motion.tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{filteredAlbums.length === 0 && (
|
||||||
|
<div className="text-center py-12 text-gray-500">
|
||||||
|
{searchQuery ? '검색 결과가 없습니다.' : '등록된 앨범이 없습니다.'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AdminLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AdminAlbums;
|
||||||
|
|
@ -85,7 +85,7 @@ function AdminDashboard() {
|
||||||
icon: Calendar,
|
icon: Calendar,
|
||||||
label: '일정 관리',
|
label: '일정 관리',
|
||||||
description: '일정 추가 및 관리',
|
description: '일정 추가 및 관리',
|
||||||
path: '/admin/schedules',
|
path: '/admin/schedule',
|
||||||
color: 'bg-blue-500',
|
color: 'bg-blue-500',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
@ -253,6 +253,7 @@ function AdminMemberEdit() {
|
||||||
<DatePicker
|
<DatePicker
|
||||||
value={formData.birth_date}
|
value={formData.birth_date}
|
||||||
onChange={(date) => setFormData({ ...formData, birth_date: date })}
|
onChange={(date) => setFormData({ ...formData, birth_date: date })}
|
||||||
|
minYear={1995}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
507
frontend-temp/src/pages/pc/admin/schedules/ScheduleBots.jsx
Normal file
507
frontend-temp/src/pages/pc/admin/schedules/ScheduleBots.jsx
Normal file
|
|
@ -0,0 +1,507 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { motion } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
Home,
|
||||||
|
ChevronRight,
|
||||||
|
Bot,
|
||||||
|
Play,
|
||||||
|
Square,
|
||||||
|
Youtube,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
RefreshCw,
|
||||||
|
Download,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Toast, Tooltip } from '@/components/common';
|
||||||
|
import { AdminLayout } from '@/components/pc/admin';
|
||||||
|
import { useAdminAuth } from '@/hooks/pc/admin';
|
||||||
|
import { useToast } from '@/hooks/common';
|
||||||
|
import * as botsApi from '@/api/pc/admin/bots';
|
||||||
|
|
||||||
|
// X 아이콘 컴포넌트
|
||||||
|
const XIcon = ({ size = 20, fill = 'currentColor' }) => (
|
||||||
|
<svg width={size} height={size} viewBox="0 0 24 24" fill={fill}>
|
||||||
|
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Meilisearch 아이콘 컴포넌트
|
||||||
|
const MeilisearchIcon = ({ size = 20 }) => (
|
||||||
|
<svg width={size} height={size} viewBox="0 108.4 512 295.2">
|
||||||
|
<defs>
|
||||||
|
<linearGradient
|
||||||
|
id="meili-a"
|
||||||
|
x1="488.157"
|
||||||
|
x2="-21.055"
|
||||||
|
y1="469.917"
|
||||||
|
y2="179.001"
|
||||||
|
gradientTransform="matrix(1 0 0 -1 0 514)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
>
|
||||||
|
<stop offset="0" stopColor="#ff5caa" />
|
||||||
|
<stop offset="1" stopColor="#ff4e62" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient
|
||||||
|
id="meili-b"
|
||||||
|
x1="522.305"
|
||||||
|
x2="13.094"
|
||||||
|
y1="410.144"
|
||||||
|
y2="119.228"
|
||||||
|
gradientTransform="matrix(1 0 0 -1 0 514)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
>
|
||||||
|
<stop offset="0" stopColor="#ff5caa" />
|
||||||
|
<stop offset="1" stopColor="#ff4e62" />
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient
|
||||||
|
id="meili-c"
|
||||||
|
x1="556.456"
|
||||||
|
x2="47.244"
|
||||||
|
y1="350.368"
|
||||||
|
y2="59.452"
|
||||||
|
gradientTransform="matrix(1 0 0 -1 0 514)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
>
|
||||||
|
<stop offset="0" stopColor="#ff5caa" />
|
||||||
|
<stop offset="1" stopColor="#ff4e62" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path
|
||||||
|
d="m0 403.6 94.6-239.3c13.3-33.7 46.2-55.9 82.8-55.9h57l-94.6 239.3c-13.3 33.7-46.2 55.9-82.8 55.9z"
|
||||||
|
fill="url(#meili-a)"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="m138.8 403.6 94.6-239.3c13.3-33.7 46.2-55.9 82.8-55.9h57l-94.6 239.3c-13.3 33.7-46.2 55.9-82.8 55.9z"
|
||||||
|
fill="url(#meili-b)"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="m277.6 403.6 94.6-239.3c13.3-33.7 46.2-55.9 82.8-55.9h57l-94.6 239.3c-13.3 33.7-46.2 55.9-82.8 55.9z"
|
||||||
|
fill="url(#meili-c)"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
function ScheduleBots() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { user, isAuthenticated } = useAdminAuth();
|
||||||
|
const { toast, setToast } = useToast();
|
||||||
|
const [isInitialLoad, setIsInitialLoad] = useState(true); // 첫 로드 여부 (애니메이션용)
|
||||||
|
const [syncing, setSyncing] = useState(null); // 동기화 중인 봇 ID
|
||||||
|
const [quotaWarning, setQuotaWarning] = useState(null); // 할당량 경고 상태
|
||||||
|
|
||||||
|
// 봇 목록 조회
|
||||||
|
const {
|
||||||
|
data: bots = [],
|
||||||
|
isLoading: loading,
|
||||||
|
isError,
|
||||||
|
refetch: fetchBots,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['admin', 'bots'],
|
||||||
|
queryFn: botsApi.getBots,
|
||||||
|
enabled: isAuthenticated,
|
||||||
|
staleTime: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 할당량 경고 상태 조회
|
||||||
|
const { data: quotaData } = useQuery({
|
||||||
|
queryKey: ['admin', 'bots', 'quota'],
|
||||||
|
queryFn: botsApi.getQuotaWarning,
|
||||||
|
enabled: isAuthenticated,
|
||||||
|
staleTime: 60000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 에러 처리
|
||||||
|
useEffect(() => {
|
||||||
|
if (isError) {
|
||||||
|
setToast({ type: 'error', message: '봇 목록을 불러올 수 없습니다.' });
|
||||||
|
}
|
||||||
|
}, [isError, setToast]);
|
||||||
|
|
||||||
|
// 할당량 경고 상태 업데이트
|
||||||
|
useEffect(() => {
|
||||||
|
if (quotaData?.active) {
|
||||||
|
setQuotaWarning(quotaData);
|
||||||
|
}
|
||||||
|
}, [quotaData]);
|
||||||
|
|
||||||
|
// 할당량 경고 해제
|
||||||
|
const handleDismissQuotaWarning = async () => {
|
||||||
|
try {
|
||||||
|
await botsApi.dismissQuotaWarning();
|
||||||
|
setQuotaWarning(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('할당량 경고 해제 오류:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 봇 시작/정지 토글
|
||||||
|
const toggleBot = async (botId, currentStatus, botName) => {
|
||||||
|
try {
|
||||||
|
const action = currentStatus === 'running' ? 'stop' : 'start';
|
||||||
|
|
||||||
|
if (action === 'start') {
|
||||||
|
await botsApi.startBot(botId);
|
||||||
|
} else {
|
||||||
|
await botsApi.stopBot(botId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 캐시 업데이트 (전체 목록 새로고침 대신)
|
||||||
|
queryClient.setQueryData(['admin', 'bots'], (prev) =>
|
||||||
|
prev?.map((bot) =>
|
||||||
|
bot.id === botId ? { ...bot, status: action === 'start' ? 'running' : 'stopped' } : bot
|
||||||
|
)
|
||||||
|
);
|
||||||
|
setToast({
|
||||||
|
type: 'success',
|
||||||
|
message:
|
||||||
|
action === 'start' ? `${botName} 봇이 시작되었습니다.` : `${botName} 봇이 정지되었습니다.`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('봇 토글 오류:', error);
|
||||||
|
setToast({ type: 'error', message: error.message || '작업 중 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 전체 동기화
|
||||||
|
const handleSyncAllVideos = async (botId) => {
|
||||||
|
setSyncing(botId);
|
||||||
|
try {
|
||||||
|
const data = await botsApi.syncAllVideos(botId);
|
||||||
|
setToast({
|
||||||
|
type: 'success',
|
||||||
|
message: `${data.addedCount}개 일정이 추가되었습니다. (전체 ${data.total}개)`,
|
||||||
|
});
|
||||||
|
fetchBots();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('전체 동기화 오류:', error);
|
||||||
|
setToast({ type: 'error', message: error.message || '동기화 중 오류가 발생했습니다.' });
|
||||||
|
fetchBots();
|
||||||
|
} finally {
|
||||||
|
setSyncing(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 상태 아이콘 및 색상
|
||||||
|
const getStatusInfo = (status) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'running':
|
||||||
|
return {
|
||||||
|
icon: <CheckCircle size={16} />,
|
||||||
|
text: '실행 중',
|
||||||
|
color: 'text-green-500',
|
||||||
|
bg: 'bg-green-50',
|
||||||
|
dot: 'bg-green-500',
|
||||||
|
};
|
||||||
|
case 'stopped':
|
||||||
|
return {
|
||||||
|
icon: <XCircle size={16} />,
|
||||||
|
text: '정지됨',
|
||||||
|
color: 'text-gray-400',
|
||||||
|
bg: 'bg-gray-50',
|
||||||
|
dot: 'bg-gray-400',
|
||||||
|
};
|
||||||
|
case 'error':
|
||||||
|
return {
|
||||||
|
icon: <XCircle size={16} />,
|
||||||
|
text: '오류',
|
||||||
|
color: 'text-red-500',
|
||||||
|
bg: 'bg-red-50',
|
||||||
|
dot: 'bg-red-500',
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
icon: null,
|
||||||
|
text: '알 수 없음',
|
||||||
|
color: 'text-gray-400',
|
||||||
|
bg: 'bg-gray-50',
|
||||||
|
dot: 'bg-gray-400',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 시간 포맷 (UTC → KST 변환)
|
||||||
|
const formatTime = (dateString) => {
|
||||||
|
if (!dateString) return '-';
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleString('ko-KR', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 간격 포맷 (분 → 분/시간/일)
|
||||||
|
const formatInterval = (minutes) => {
|
||||||
|
if (!minutes) return '-';
|
||||||
|
if (minutes >= 1440) {
|
||||||
|
const days = Math.floor(minutes / 1440);
|
||||||
|
return `${days}일`;
|
||||||
|
} else if (minutes >= 60) {
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
return `${hours}시간`;
|
||||||
|
}
|
||||||
|
return `${minutes}분`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminLayout user={user}>
|
||||||
|
<Toast toast={toast} onClose={() => setToast(null)} />
|
||||||
|
|
||||||
|
{/* 메인 콘텐츠 */}
|
||||||
|
<div className="max-w-7xl mx-auto px-6 py-8">
|
||||||
|
{/* 브레드크럼 */}
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-400 mb-8">
|
||||||
|
<Link to="/admin/dashboard" className="hover:text-primary transition-colors">
|
||||||
|
<Home size={16} />
|
||||||
|
</Link>
|
||||||
|
<ChevronRight size={14} />
|
||||||
|
<Link to="/admin/schedule" className="hover:text-primary transition-colors">
|
||||||
|
일정 관리
|
||||||
|
</Link>
|
||||||
|
<ChevronRight size={14} />
|
||||||
|
<span className="text-gray-700">봇 관리</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 타이틀 */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 mb-2">봇 관리</h1>
|
||||||
|
<p className="text-gray-500">일정 자동화 봇을 관리합니다</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 봇 통계 */}
|
||||||
|
<div className="grid grid-cols-4 gap-4 mb-8">
|
||||||
|
<div className="bg-white rounded-xl p-5 border border-gray-100">
|
||||||
|
<div className="text-sm text-gray-500 mb-1">전체 봇</div>
|
||||||
|
<div className="text-2xl font-bold text-gray-900">{bots.length}</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-xl p-5 border border-gray-100">
|
||||||
|
<div className="text-sm text-gray-500 mb-1">실행 중</div>
|
||||||
|
<div className="text-2xl font-bold text-green-500">
|
||||||
|
{bots.filter((b) => b.status === 'running').length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-xl p-5 border border-gray-100">
|
||||||
|
<div className="text-sm text-gray-500 mb-1">정지됨</div>
|
||||||
|
<div className="text-2xl font-bold text-gray-400">
|
||||||
|
{bots.filter((b) => b.status === 'stopped').length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-xl p-5 border border-gray-100">
|
||||||
|
<div className="text-sm text-gray-500 mb-1">오류</div>
|
||||||
|
<div className="text-2xl font-bold text-red-500">
|
||||||
|
{bots.filter((b) => b.status === 'error').length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* API 할당량 경고 배너 */}
|
||||||
|
{quotaWarning && (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-xl p-4 mb-8 flex items-start justify-between">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-red-100 flex items-center justify-center flex-shrink-0">
|
||||||
|
<XCircle size={18} className="text-red-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-red-700">YouTube API 할당량 경고</h3>
|
||||||
|
<p className="text-sm text-red-600 mt-0.5">{quotaWarning.message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleDismissQuotaWarning}
|
||||||
|
className="text-red-400 hover:text-red-600 transition-colors text-sm px-2 py-1"
|
||||||
|
>
|
||||||
|
닫기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 봇 목록 */}
|
||||||
|
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden">
|
||||||
|
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between">
|
||||||
|
<h2 className="font-bold text-gray-900">봇 목록</h2>
|
||||||
|
<Tooltip text="새로고침">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setIsInitialLoad(true);
|
||||||
|
fetchBots();
|
||||||
|
}}
|
||||||
|
disabled={loading}
|
||||||
|
className="p-2 hover:bg-gray-100 rounded-lg transition-colors text-gray-500 hover:text-gray-700 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw size={18} className={loading ? 'animate-spin' : ''} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex justify-center items-center py-20">
|
||||||
|
<div className="animate-spin rounded-full h-10 w-10 border-4 border-primary border-t-transparent"></div>
|
||||||
|
</div>
|
||||||
|
) : bots.length === 0 ? (
|
||||||
|
<div className="text-center py-20 text-gray-400">
|
||||||
|
<Bot size={48} className="mx-auto mb-4 opacity-30" />
|
||||||
|
<p>등록된 봇이 없습니다</p>
|
||||||
|
<p className="text-sm mt-1">위의 버튼을 클릭하여 봇을 추가하세요</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-6 grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
{bots.map((bot, index) => {
|
||||||
|
const statusInfo = getStatusInfo(bot.status);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
key={bot.id}
|
||||||
|
initial={isInitialLoad ? { opacity: 0, scale: 0.95 } : false}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
transition={isInitialLoad ? { delay: index * 0.05 } : { duration: 0.15 }}
|
||||||
|
onAnimationComplete={() =>
|
||||||
|
isInitialLoad && index === bots.length - 1 && setIsInitialLoad(false)
|
||||||
|
}
|
||||||
|
className="relative bg-gradient-to-br from-gray-50 to-white rounded-xl border border-gray-200 overflow-hidden hover:shadow-md transition-all"
|
||||||
|
>
|
||||||
|
{/* 상단 헤더 */}
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-gray-100">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div
|
||||||
|
className={`w-10 h-10 rounded-lg flex items-center justify-center ${
|
||||||
|
bot.type === 'x'
|
||||||
|
? 'bg-black'
|
||||||
|
: bot.type === 'meilisearch'
|
||||||
|
? 'bg-[#ddf1fd]'
|
||||||
|
: 'bg-red-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{bot.type === 'x' ? (
|
||||||
|
<XIcon size={20} fill="white" />
|
||||||
|
) : bot.type === 'meilisearch' ? (
|
||||||
|
<MeilisearchIcon size={20} />
|
||||||
|
) : (
|
||||||
|
<Youtube size={20} className="text-red-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-bold text-gray-900">{bot.name}</h3>
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
{bot.last_check_at
|
||||||
|
? `${formatTime(bot.last_check_at)}에 업데이트됨`
|
||||||
|
: '아직 업데이트 없음'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium rounded-full ${statusInfo.bg} ${statusInfo.color}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`w-1.5 h-1.5 rounded-full ${statusInfo.dot} ${bot.status === 'running' ? 'animate-pulse' : ''}`}
|
||||||
|
></span>
|
||||||
|
{statusInfo.text}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 통계 정보 */}
|
||||||
|
<div className="grid grid-cols-3 divide-x divide-gray-100 bg-gray-50/50">
|
||||||
|
{bot.type === 'meilisearch' ? (
|
||||||
|
<>
|
||||||
|
<div className="p-3 text-center">
|
||||||
|
<div className="text-lg font-bold text-gray-900">
|
||||||
|
{bot.schedules_added || 0}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400">동기화 수</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 text-center">
|
||||||
|
<div className="text-lg font-bold text-gray-900">
|
||||||
|
{bot.last_added_count
|
||||||
|
? `${(bot.last_added_count / 1000 || 0).toFixed(1)}초`
|
||||||
|
: '-'}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400">소요 시간</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="p-3 text-center">
|
||||||
|
<div className="text-lg font-bold text-gray-900">{bot.schedules_added}</div>
|
||||||
|
<div className="text-xs text-gray-400">총 추가</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 text-center">
|
||||||
|
<div
|
||||||
|
className={`text-lg font-bold ${bot.last_added_count > 0 ? 'text-green-500' : 'text-gray-400'}`}
|
||||||
|
>
|
||||||
|
+{bot.last_added_count || 0}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400">마지막</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="p-3 text-center">
|
||||||
|
<div className="text-lg font-bold text-gray-900">
|
||||||
|
{formatInterval(bot.check_interval)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-400">업데이트 간격</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 오류 메시지 */}
|
||||||
|
{bot.status === 'error' && bot.error_message && (
|
||||||
|
<div className="px-4 py-2 bg-red-50 text-red-600 text-xs border-t border-red-100">
|
||||||
|
{bot.error_message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 액션 버튼 */}
|
||||||
|
<div className="p-4 border-t border-gray-100">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleSyncAllVideos(bot.id)}
|
||||||
|
disabled={syncing === bot.id}
|
||||||
|
className="flex-1 flex items-center justify-center gap-2 px-4 py-2.5 bg-blue-500 text-white rounded-lg font-medium transition-colors hover:bg-blue-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{syncing === bot.id ? (
|
||||||
|
<>
|
||||||
|
<RefreshCw size={16} className="animate-spin" />
|
||||||
|
<span>동기화 중...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Download size={16} />
|
||||||
|
<span>전체 동기화</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => toggleBot(bot.id, bot.status, bot.name)}
|
||||||
|
className={`flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium transition-colors ${
|
||||||
|
bot.status === 'running'
|
||||||
|
? 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||||
|
: 'bg-green-500 text-white hover:bg-green-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{bot.status === 'running' ? (
|
||||||
|
<>
|
||||||
|
<Square size={16} />
|
||||||
|
<span>정지</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Play size={16} />
|
||||||
|
<span>시작</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AdminLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ScheduleBots;
|
||||||
466
frontend-temp/src/pages/pc/admin/schedules/ScheduleCategory.jsx
Normal file
466
frontend-temp/src/pages/pc/admin/schedules/ScheduleCategory.jsx
Normal file
|
|
@ -0,0 +1,466 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { motion, AnimatePresence, Reorder } from 'framer-motion';
|
||||||
|
import { Home, ChevronRight, Plus, Edit3, Trash2, GripVertical } from 'lucide-react';
|
||||||
|
import { HexColorPicker } from 'react-colorful';
|
||||||
|
import { Toast } from '@/components/common';
|
||||||
|
import { AdminLayout, ConfirmDialog } from '@/components/pc/admin';
|
||||||
|
import { useAdminAuth } from '@/hooks/pc/admin';
|
||||||
|
import { useToast } from '@/hooks/common';
|
||||||
|
import * as categoriesApi from '@/api/pc/admin/categories';
|
||||||
|
|
||||||
|
// 기본 색상 (8개)
|
||||||
|
const colorOptions = [
|
||||||
|
{ id: 'blue', name: '파란색', bg: 'bg-blue-500', hex: '#3b82f6' },
|
||||||
|
{ id: 'green', name: '초록색', bg: 'bg-green-500', hex: '#22c55e' },
|
||||||
|
{ id: 'purple', name: '보라색', bg: 'bg-purple-500', hex: '#a855f7' },
|
||||||
|
{ id: 'red', name: '빨간색', bg: 'bg-red-500', hex: '#ef4444' },
|
||||||
|
{ id: 'pink', name: '분홍색', bg: 'bg-pink-500', hex: '#ec4899' },
|
||||||
|
{ id: 'yellow', name: '노란색', bg: 'bg-yellow-500', hex: '#eab308' },
|
||||||
|
{ id: 'orange', name: '주황색', bg: 'bg-orange-500', hex: '#f97316' },
|
||||||
|
{ id: 'gray', name: '회색', bg: 'bg-gray-500', hex: '#6b7280' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 색상 헬퍼 (커스텀 HEX 지원)
|
||||||
|
const getColorStyle = (colorValue) => {
|
||||||
|
// 기본 색상인지 확인
|
||||||
|
const preset = colorOptions.find((c) => c.id === colorValue);
|
||||||
|
if (preset) {
|
||||||
|
return { className: preset.bg };
|
||||||
|
}
|
||||||
|
// HEX 색상인 경우
|
||||||
|
if (colorValue?.startsWith('#')) {
|
||||||
|
return { style: { backgroundColor: colorValue } };
|
||||||
|
}
|
||||||
|
return { className: 'bg-gray-500' };
|
||||||
|
};
|
||||||
|
|
||||||
|
function ScheduleCategory() {
|
||||||
|
const { user, isAuthenticated } = useAdminAuth();
|
||||||
|
const [categories, setCategories] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const { toast, setToast, showSuccess, showError } = useToast();
|
||||||
|
|
||||||
|
// 모달 상태
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
const [editingCategory, setEditingCategory] = useState(null);
|
||||||
|
const [formData, setFormData] = useState({ name: '', color: 'blue' });
|
||||||
|
|
||||||
|
// 삭제 다이얼로그
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||||
|
|
||||||
|
// 카스텀 컴러 피커 팝업
|
||||||
|
const [colorPickerOpen, setColorPickerOpen] = useState(false);
|
||||||
|
|
||||||
|
// 카테고리 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
fetchCategories();
|
||||||
|
}
|
||||||
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
|
// 카테고리 목록 조회
|
||||||
|
const fetchCategories = async () => {
|
||||||
|
try {
|
||||||
|
const data = await categoriesApi.getCategories();
|
||||||
|
setCategories(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('카테고리 조회 오류:', error);
|
||||||
|
showError('카테고리를 불러오는데 실패했습니다.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 모달 열기 (추가/수정)
|
||||||
|
const openModal = (category = null) => {
|
||||||
|
if (category) {
|
||||||
|
setEditingCategory(category);
|
||||||
|
setFormData({ name: category.name, color: category.color });
|
||||||
|
} else {
|
||||||
|
setEditingCategory(null);
|
||||||
|
setFormData({ name: '', color: 'blue' });
|
||||||
|
}
|
||||||
|
setColorPickerOpen(false); // 컬러 피커는 닫힌 상태로
|
||||||
|
setModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 카테고리 저장
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!formData.name.trim()) {
|
||||||
|
showError('카테고리 이름을 입력해주세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 중복 체크 (수정시 자기 자신 제외)
|
||||||
|
const isDuplicate = categories.some(
|
||||||
|
(cat) =>
|
||||||
|
cat.name.toLowerCase() === formData.name.trim().toLowerCase() && cat.id !== editingCategory?.id
|
||||||
|
);
|
||||||
|
if (isDuplicate) {
|
||||||
|
showError('이미 존재하는 카테고리입니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (editingCategory) {
|
||||||
|
await categoriesApi.updateCategory(editingCategory.id, formData);
|
||||||
|
} else {
|
||||||
|
await categoriesApi.createCategory(formData);
|
||||||
|
}
|
||||||
|
showSuccess(editingCategory ? '카테고리가 수정되었습니다.' : '카테고리가 추가되었습니다.');
|
||||||
|
setModalOpen(false);
|
||||||
|
fetchCategories();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('저장 오류:', error);
|
||||||
|
showError(error.message || '저장에 실패했습니다.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 삭제 다이얼로그 열기
|
||||||
|
const openDeleteDialog = (category) => {
|
||||||
|
setDeleteTarget(category);
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 카테고리 삭제
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await categoriesApi.deleteCategory(deleteTarget.id);
|
||||||
|
showSuccess('카테고리가 삭제되었습니다.');
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
setDeleteTarget(null);
|
||||||
|
fetchCategories();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('삭제 오류:', error);
|
||||||
|
showError(error.message || '삭제에 실패했습니다.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reorder 핸들러 (부드러운 애니메이션)
|
||||||
|
const handleReorder = async (newOrder) => {
|
||||||
|
setCategories(newOrder);
|
||||||
|
|
||||||
|
// 순서 업데이트 API 호출
|
||||||
|
const orders = newOrder.map((cat, idx) => ({
|
||||||
|
id: cat.id,
|
||||||
|
sort_order: idx + 1,
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await categoriesApi.reorderCategories(orders);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('순서 업데이트 오류:', error);
|
||||||
|
fetchCategories(); // 실패시 원래 데이터 다시 불러오기
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<AdminLayout user={user}>
|
||||||
|
<div className="flex items-center justify-center" style={{ minHeight: 'calc(100vh - 80px)' }}>
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-4 border-primary border-t-transparent"></div>
|
||||||
|
</div>
|
||||||
|
</AdminLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminLayout user={user}>
|
||||||
|
<Toast toast={toast} onClose={() => setToast(null)} />
|
||||||
|
|
||||||
|
{/* 메인 콘텐츠 */}
|
||||||
|
<div className="max-w-4xl mx-auto px-6 py-8">
|
||||||
|
{/* 브레드크럼 */}
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-500 mb-6">
|
||||||
|
<Link to="/admin/dashboard" className="hover:text-primary flex items-center gap-1">
|
||||||
|
<Home size={14} />
|
||||||
|
대시보드
|
||||||
|
</Link>
|
||||||
|
<ChevronRight size={14} />
|
||||||
|
<Link to="/admin/schedule" className="hover:text-primary">
|
||||||
|
일정 관리
|
||||||
|
</Link>
|
||||||
|
<ChevronRight size={14} />
|
||||||
|
<span className="text-gray-900">카테고리 관리</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 타이틀 */}
|
||||||
|
<div className="flex items-center justify-between mb-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">카테고리 관리</h1>
|
||||||
|
<p className="text-gray-500 mt-1">일정 카테고리를 추가, 수정, 삭제할 수 있습니다.</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => openModal()}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-primary text-white rounded-xl hover:bg-primary-dark transition-colors"
|
||||||
|
>
|
||||||
|
<Plus size={18} />
|
||||||
|
카테고리 추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 카테고리 목록 */}
|
||||||
|
<div className="bg-white rounded-2xl shadow-sm overflow-hidden">
|
||||||
|
{categories.length === 0 ? (
|
||||||
|
<div className="p-12 text-center text-gray-500">등록된 카테고리가 없습니다.</div>
|
||||||
|
) : (
|
||||||
|
<Reorder.Group
|
||||||
|
axis="y"
|
||||||
|
values={categories}
|
||||||
|
onReorder={handleReorder}
|
||||||
|
className="divide-y divide-gray-100"
|
||||||
|
>
|
||||||
|
{categories.map((category) => (
|
||||||
|
<Reorder.Item
|
||||||
|
key={category.id}
|
||||||
|
value={category}
|
||||||
|
className="flex items-center gap-4 p-4 bg-white cursor-grab active:cursor-grabbing"
|
||||||
|
whileDrag={{
|
||||||
|
scale: 1.02,
|
||||||
|
boxShadow: '0 10px 30px rgba(0,0,0,0.15)',
|
||||||
|
zIndex: 50,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 드래그 핸들 */}
|
||||||
|
<div className="text-gray-400 hover:text-gray-600">
|
||||||
|
<GripVertical size={20} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 색상 표시 */}
|
||||||
|
{(() => {
|
||||||
|
const colorStyle = getColorStyle(category.color);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`w-4 h-4 rounded-full ${colorStyle.className || ''}`}
|
||||||
|
style={colorStyle.style}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* 이름 */}
|
||||||
|
<span className="flex-1 font-medium text-gray-900">{category.name}</span>
|
||||||
|
|
||||||
|
{/* 액션 버튼 */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openModal(category);
|
||||||
|
}}
|
||||||
|
className="p-2 text-gray-400 hover:text-primary hover:bg-primary/10 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Edit3 size={18} />
|
||||||
|
</button>
|
||||||
|
{category.is_default ? (
|
||||||
|
<span
|
||||||
|
className="p-2 text-gray-300 cursor-not-allowed"
|
||||||
|
title="기본 카테고리는 삭제할 수 없습니다"
|
||||||
|
>
|
||||||
|
<Trash2 size={18} />
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openDeleteDialog(category);
|
||||||
|
}}
|
||||||
|
className="p-2 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={18} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Reorder.Item>
|
||||||
|
))}
|
||||||
|
</Reorder.Group>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 추가/수정 모달 */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{modalOpen && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||||
|
onClick={() => setModalOpen(false)}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.9, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
exit={{ scale: 0.9, opacity: 0 }}
|
||||||
|
className="bg-white rounded-2xl p-6 w-full mx-4 shadow-xl"
|
||||||
|
style={{ maxWidth: '452px', minWidth: '452px' }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 className="text-xl font-bold text-gray-900 mb-6">
|
||||||
|
{editingCategory ? '카테고리 수정' : '카테고리 추가'}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* 카테고리 이름 */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">카테고리 이름 *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
placeholder="예: 방송, 이벤트"
|
||||||
|
className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 색상 선택 */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-3">색상 선택 *</label>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{colorOptions.map((color) => (
|
||||||
|
<button
|
||||||
|
key={color.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFormData({ ...formData, color: color.id })}
|
||||||
|
className={`w-10 h-10 rounded-full ${color.bg} transition-all ${
|
||||||
|
formData.color === color.id
|
||||||
|
? 'ring-2 ring-offset-2 ring-gray-400 scale-110'
|
||||||
|
: 'hover:scale-105'
|
||||||
|
}`}
|
||||||
|
title={color.name}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{/* 커스텀 색상 - 무지개 그라디언트 버튼 */}
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setColorPickerOpen(!colorPickerOpen);
|
||||||
|
}}
|
||||||
|
className={`w-10 h-10 rounded-full transition-all ${
|
||||||
|
formData.color?.startsWith('#')
|
||||||
|
? 'ring-2 ring-offset-2 ring-gray-400 scale-110'
|
||||||
|
: 'hover:scale-105'
|
||||||
|
}`}
|
||||||
|
style={{
|
||||||
|
background: formData.color?.startsWith('#')
|
||||||
|
? formData.color
|
||||||
|
: 'conic-gradient(from 0deg, #ff0000, #ff8000, #ffff00, #00ff00, #00ffff, #0000ff, #8000ff, #ff0080, #ff0000)',
|
||||||
|
}}
|
||||||
|
title="커스텀 색상"
|
||||||
|
/>
|
||||||
|
{/* 색상 선택 팝업 */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{colorPickerOpen && (
|
||||||
|
<>
|
||||||
|
{/* 바깥 영역 클릭시 컬러피커만 닫기 */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-40"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setColorPickerOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.9, y: -10 }}
|
||||||
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.9, y: -10 }}
|
||||||
|
className="absolute top-12 left-0 z-50 p-4 bg-white rounded-2xl shadow-xl border border-gray-100"
|
||||||
|
style={{ width: '240px' }}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<HexColorPicker
|
||||||
|
color={formData.color?.startsWith('#') ? formData.color : '#6b7280'}
|
||||||
|
onChange={(color) => setFormData({ ...formData, color })}
|
||||||
|
style={{ width: '100%', height: '180px' }}
|
||||||
|
/>
|
||||||
|
<div className="mt-4 flex items-center">
|
||||||
|
<span className="px-3 py-2 text-sm bg-gray-100 border border-r-0 border-gray-200 rounded-l-lg text-gray-500">
|
||||||
|
#
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.color?.startsWith('#') ? formData.color.slice(1) : ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value.replace(/[^0-9A-Fa-f]/g, '').slice(0, 6);
|
||||||
|
if (val) {
|
||||||
|
setFormData({ ...formData, color: '#' + val });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="FFFFFF"
|
||||||
|
className="w-full px-3 py-2 text-sm border border-gray-200 rounded-r-lg focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
style={{ minWidth: 0 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setFormData({ ...formData, color: 'blue' });
|
||||||
|
setColorPickerOpen(false);
|
||||||
|
}}
|
||||||
|
className="px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setColorPickerOpen(false)}
|
||||||
|
className="px-3 py-1.5 text-sm bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors"
|
||||||
|
>
|
||||||
|
확인
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 버튼 */}
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setModalOpen(false)}
|
||||||
|
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
className="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors"
|
||||||
|
>
|
||||||
|
{editingCategory ? '수정' : '추가'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* 삭제 확인 다이얼로그 */}
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={deleteDialogOpen}
|
||||||
|
onClose={() => setDeleteDialogOpen(false)}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
title="카테고리 삭제"
|
||||||
|
message={
|
||||||
|
<>
|
||||||
|
<span className="font-medium text-gray-900">"{deleteTarget?.name}"</span> 카테고리를
|
||||||
|
삭제하시겠습니까?
|
||||||
|
<br />
|
||||||
|
<span className="text-sm text-red-500">이 작업은 되돌릴 수 없습니다.</span>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</AdminLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ScheduleCategory;
|
||||||
714
frontend-temp/src/pages/pc/admin/schedules/ScheduleDict.jsx
Normal file
714
frontend-temp/src/pages/pc/admin/schedules/ScheduleDict.jsx
Normal file
|
|
@ -0,0 +1,714 @@
|
||||||
|
import { useState, useEffect, useMemo, useRef, useCallback } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Home, ChevronRight, Book, Plus, Trash2, Search, ChevronDown } from 'lucide-react';
|
||||||
|
import { Toast } from '@/components/common';
|
||||||
|
import { AdminLayout, ConfirmDialog } from '@/components/pc/admin';
|
||||||
|
import { useAdminAuth } from '@/hooks/pc/admin';
|
||||||
|
import { useToast } from '@/hooks/common';
|
||||||
|
import * as suggestionsApi from '@/api/pc/admin/suggestions';
|
||||||
|
|
||||||
|
// 애니메이션 variants
|
||||||
|
const containerVariants = {
|
||||||
|
hidden: { opacity: 0 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
transition: {
|
||||||
|
staggerChildren: 0.08,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const itemVariants = {
|
||||||
|
hidden: { opacity: 0, y: 20 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
transition: { duration: 0.4, ease: 'easeOut' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const cardVariants = {
|
||||||
|
hidden: { opacity: 0, scale: 0.95 },
|
||||||
|
visible: {
|
||||||
|
opacity: 1,
|
||||||
|
scale: 1,
|
||||||
|
transition: { duration: 0.3, ease: 'easeOut' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 품사 태그 옵션
|
||||||
|
const POS_TAGS = [
|
||||||
|
{
|
||||||
|
value: 'NNP',
|
||||||
|
label: '고유명사 (NNP)',
|
||||||
|
description: '사람, 그룹, 프로그램 이름 등',
|
||||||
|
examples: '프로미스나인, 송하영, 뮤직뱅크',
|
||||||
|
},
|
||||||
|
{ value: 'NNG', label: '일반명사 (NNG)', description: '일반적인 명사', examples: '직캠, 팬미팅, 콘서트' },
|
||||||
|
{
|
||||||
|
value: 'SL',
|
||||||
|
label: '외국어 (SL)',
|
||||||
|
description: '영어 등 외국어 단어',
|
||||||
|
examples: 'fromis_9, YouTube, fromm',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 단어 항목 컴포넌트
|
||||||
|
function WordItem({ id, word, pos, index, onUpdate, onDelete }) {
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [editWord, setEditWord] = useState(word);
|
||||||
|
const [editPos, setEditPos] = useState(pos);
|
||||||
|
const [showPosDropdown, setShowPosDropdown] = useState(false);
|
||||||
|
const dropdownRef = useRef(null);
|
||||||
|
|
||||||
|
// 외부 클릭 시 드롭다운 닫기
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event) => {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
|
||||||
|
setShowPosDropdown(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (showPosDropdown) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, [showPosDropdown]);
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (editWord.trim() && (editWord.trim() !== word || editPos !== pos)) {
|
||||||
|
onUpdate(id, editWord.trim(), editPos);
|
||||||
|
}
|
||||||
|
setIsEditing(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleSave();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setEditWord(word);
|
||||||
|
setEditPos(pos);
|
||||||
|
setIsEditing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.tr
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, x: -20 }}
|
||||||
|
className="group hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3 text-sm text-gray-400 w-16">{index + 1}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{isEditing ? (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editWord}
|
||||||
|
onChange={(e) => setEditWord(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onBlur={handleSave}
|
||||||
|
autoFocus
|
||||||
|
className="w-full px-3 py-1.5 border border-primary rounded-lg focus:outline-none focus:ring-2 focus:ring-primary/20"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
onClick={() => setIsEditing(true)}
|
||||||
|
className="cursor-pointer hover:text-primary transition-colors font-medium"
|
||||||
|
>
|
||||||
|
{word}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 w-48">
|
||||||
|
<div className="relative" ref={dropdownRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPosDropdown(!showPosDropdown)}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 bg-gray-100 hover:bg-gray-200 rounded-lg text-sm transition-colors w-full justify-between"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{POS_TAGS.find((t) => t.value === (isEditing ? editPos : pos))?.label.split(' ')[0] || pos}
|
||||||
|
</span>
|
||||||
|
<ChevronDown
|
||||||
|
size={14}
|
||||||
|
className={`transition-transform ${showPosDropdown ? 'rotate-180' : ''}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<AnimatePresence>
|
||||||
|
{showPosDropdown && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -5 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -5 }}
|
||||||
|
className="absolute top-full left-0 mt-1 w-64 bg-white rounded-xl shadow-lg border border-gray-200 py-1 z-20"
|
||||||
|
>
|
||||||
|
{POS_TAGS.map((tag) => (
|
||||||
|
<button
|
||||||
|
key={tag.value}
|
||||||
|
onClick={() => {
|
||||||
|
if (isEditing) {
|
||||||
|
setEditPos(tag.value);
|
||||||
|
} else {
|
||||||
|
onUpdate(id, word, tag.value);
|
||||||
|
}
|
||||||
|
setShowPosDropdown(false);
|
||||||
|
}}
|
||||||
|
className={`w-full px-4 py-2.5 text-left hover:bg-gray-50 transition-colors ${
|
||||||
|
(isEditing ? editPos : pos) === tag.value ? 'bg-primary/5 text-primary' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="font-medium text-sm">{tag.label}</div>
|
||||||
|
<div className="text-xs text-gray-400">{tag.description}</div>
|
||||||
|
<div className="text-xs text-gray-300 mt-0.5">예: {tag.examples}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 w-20">
|
||||||
|
<button
|
||||||
|
onClick={() => onDelete(index)}
|
||||||
|
className="p-2 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors opacity-0 group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</motion.tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScheduleDict() {
|
||||||
|
const { user, isAuthenticated } = useAdminAuth();
|
||||||
|
const { toast, setToast } = useToast();
|
||||||
|
const [entries, setEntries] = useState([]); // [{word, pos, isComment, id}]
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [filterPos, setFilterPos] = useState('all');
|
||||||
|
const [showFilterDropdown, setShowFilterDropdown] = useState(false);
|
||||||
|
|
||||||
|
// 새 단어 입력
|
||||||
|
const [newWord, setNewWord] = useState('');
|
||||||
|
const [newPos, setNewPos] = useState('NNP');
|
||||||
|
const [showNewPosDropdown, setShowNewPosDropdown] = useState(false);
|
||||||
|
|
||||||
|
// 드롭다운 refs
|
||||||
|
const newPosDropdownRef = useRef(null);
|
||||||
|
const filterDropdownRef = useRef(null);
|
||||||
|
|
||||||
|
// 다이얼로그 상태
|
||||||
|
const [addDialogOpen, setAddDialogOpen] = useState(false);
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [wordToDelete, setWordToDelete] = useState(null); // { index, word, id }
|
||||||
|
|
||||||
|
// 외부 클릭 시 드롭다운 닫기
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event) => {
|
||||||
|
if (newPosDropdownRef.current && !newPosDropdownRef.current.contains(event.target)) {
|
||||||
|
setShowNewPosDropdown(false);
|
||||||
|
}
|
||||||
|
if (filterDropdownRef.current && !filterDropdownRef.current.contains(event.target)) {
|
||||||
|
setShowFilterDropdown(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (showNewPosDropdown || showFilterDropdown) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, [showNewPosDropdown, showFilterDropdown]);
|
||||||
|
|
||||||
|
// 필터링된 항목
|
||||||
|
const filteredEntries = useMemo(() => {
|
||||||
|
return entries.filter((entry) => {
|
||||||
|
if (entry.isComment) return true; // 주석은 항상 포함 (but 표시 안함)
|
||||||
|
|
||||||
|
const matchesSearch =
|
||||||
|
!searchQuery || entry.word.toLowerCase().includes(searchQuery.toLowerCase());
|
||||||
|
const matchesPos = filterPos === 'all' || entry.pos === filterPos;
|
||||||
|
|
||||||
|
return matchesSearch && matchesPos;
|
||||||
|
});
|
||||||
|
}, [entries, searchQuery, filterPos]);
|
||||||
|
|
||||||
|
// 실제 단어 항목만 (주석 제외)
|
||||||
|
const wordEntries = useMemo(() => {
|
||||||
|
return filteredEntries.filter((e) => !e.isComment);
|
||||||
|
}, [filteredEntries]);
|
||||||
|
|
||||||
|
// 품사별 통계
|
||||||
|
const posStats = useMemo(() => {
|
||||||
|
const stats = { total: 0 };
|
||||||
|
entries.forEach((e) => {
|
||||||
|
if (!e.isComment) {
|
||||||
|
stats.total++;
|
||||||
|
stats[e.pos] = (stats[e.pos] || 0) + 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return stats;
|
||||||
|
}, [entries]);
|
||||||
|
|
||||||
|
// 고유 ID 생성
|
||||||
|
const generateId = useCallback(
|
||||||
|
() => `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 사전 파일 파싱
|
||||||
|
const parseDict = useCallback((content) => {
|
||||||
|
const lines = content.split('\n');
|
||||||
|
return lines
|
||||||
|
.map((line) => {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || trimmed.startsWith('#')) {
|
||||||
|
return {
|
||||||
|
isComment: true,
|
||||||
|
raw: line,
|
||||||
|
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const parts = trimmed.split('\t');
|
||||||
|
return {
|
||||||
|
word: parts[0] || '',
|
||||||
|
pos: parts[1] || 'NNP',
|
||||||
|
isComment: false,
|
||||||
|
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((e) => e.isComment || e.word); // 빈 줄 제거하되 주석은 유지
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 사전 파일 생성
|
||||||
|
const serializeDict = useCallback((entries) => {
|
||||||
|
return entries
|
||||||
|
.map((e) => {
|
||||||
|
if (e.isComment) return e.raw;
|
||||||
|
return `${e.word}\t${e.pos}`;
|
||||||
|
})
|
||||||
|
.join('\n');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 사전 내용 조회 (useQuery)
|
||||||
|
const {
|
||||||
|
data: dictContent,
|
||||||
|
isLoading: loading,
|
||||||
|
isError,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['admin', 'dict'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const data = await suggestionsApi.getDict();
|
||||||
|
return data.content || '';
|
||||||
|
},
|
||||||
|
enabled: isAuthenticated,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 사전 데이터 로드 후 파싱
|
||||||
|
useEffect(() => {
|
||||||
|
if (dictContent !== undefined) {
|
||||||
|
const parsed = parseDict(dictContent);
|
||||||
|
setEntries(parsed);
|
||||||
|
}
|
||||||
|
}, [dictContent, parseDict]);
|
||||||
|
|
||||||
|
// 에러 처리
|
||||||
|
useEffect(() => {
|
||||||
|
if (isError) {
|
||||||
|
setToast({ type: 'error', message: '사전을 불러올 수 없습니다.' });
|
||||||
|
}
|
||||||
|
}, [isError, setToast]);
|
||||||
|
|
||||||
|
// 사전 저장 (entries 배열을 받아서 저장)
|
||||||
|
const saveDict = async (newEntries) => {
|
||||||
|
try {
|
||||||
|
const content = serializeDict(newEntries);
|
||||||
|
await suggestionsApi.saveDict(content);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('사전 저장 오류:', error);
|
||||||
|
setToast({ type: 'error', message: error.message || '저장 중 오류가 발생했습니다.' });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 단어 추가 다이얼로그 열기
|
||||||
|
const openAddDialog = () => {
|
||||||
|
if (!newWord.trim()) return;
|
||||||
|
|
||||||
|
// 중복 확인
|
||||||
|
const isDuplicate = entries.some(
|
||||||
|
(e) => !e.isComment && e.word.toLowerCase() === newWord.trim().toLowerCase()
|
||||||
|
);
|
||||||
|
if (isDuplicate) {
|
||||||
|
setToast({ type: 'error', message: '이미 존재하는 단어입니다.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAddDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 단어 추가 확인
|
||||||
|
const handleAddWord = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
const wordToAdd = newWord.trim();
|
||||||
|
const newEntry = { word: wordToAdd, pos: newPos, isComment: false, id: generateId() };
|
||||||
|
const newEntries = [...entries, newEntry];
|
||||||
|
|
||||||
|
const success = await saveDict(newEntries);
|
||||||
|
if (success) {
|
||||||
|
setEntries(newEntries);
|
||||||
|
setNewWord('');
|
||||||
|
setToast({ type: 'success', message: `"${wordToAdd}" 단어가 추가되었습니다.` });
|
||||||
|
}
|
||||||
|
setAddDialogOpen(false);
|
||||||
|
setSaving(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 단어 수정 (id 기반)
|
||||||
|
const handleUpdateWord = async (id, word, pos) => {
|
||||||
|
const entryIndex = entries.findIndex((e) => e.id === id);
|
||||||
|
if (entryIndex === -1) return;
|
||||||
|
|
||||||
|
const newEntries = [...entries];
|
||||||
|
newEntries[entryIndex] = { ...newEntries[entryIndex], word, pos };
|
||||||
|
|
||||||
|
const success = await saveDict(newEntries);
|
||||||
|
if (success) {
|
||||||
|
setEntries(newEntries);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 단어 삭제 다이얼로그 열기
|
||||||
|
const openDeleteDialog = (id, word) => {
|
||||||
|
setWordToDelete({ id, word });
|
||||||
|
setDeleteDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 단어 삭제 확인
|
||||||
|
const handleDeleteWord = async () => {
|
||||||
|
if (!wordToDelete) return;
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
const deletedWord = wordToDelete.word;
|
||||||
|
const newEntries = entries.filter((e) => e.id !== wordToDelete.id);
|
||||||
|
|
||||||
|
const success = await saveDict(newEntries);
|
||||||
|
if (success) {
|
||||||
|
setEntries(newEntries);
|
||||||
|
setToast({ type: 'success', message: `"${deletedWord}" 단어가 삭제되었습니다.` });
|
||||||
|
}
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
setWordToDelete(null);
|
||||||
|
setSaving(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 엔터키로 추가 다이얼로그 열기
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
openAddDialog();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminLayout user={user}>
|
||||||
|
<Toast toast={toast} onClose={() => setToast(null)} />
|
||||||
|
|
||||||
|
{/* 단어 추가 확인 다이얼로그 */}
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={addDialogOpen}
|
||||||
|
onClose={() => !saving && setAddDialogOpen(false)}
|
||||||
|
onConfirm={handleAddWord}
|
||||||
|
title="단어 추가"
|
||||||
|
message={
|
||||||
|
<>
|
||||||
|
<p className="text-gray-600 mb-2">다음 단어를 추가하시겠습니까?</p>
|
||||||
|
<div className="p-3 bg-gray-50 rounded-lg">
|
||||||
|
<p className="font-medium text-gray-900">{newWord}</p>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
{POS_TAGS.find((t) => t.value === newPos)?.label}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
confirmText="추가"
|
||||||
|
loadingText="추가 중..."
|
||||||
|
loading={saving}
|
||||||
|
variant="primary"
|
||||||
|
icon={Plus}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 단어 삭제 확인 다이얼로그 */}
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={deleteDialogOpen}
|
||||||
|
onClose={() => {
|
||||||
|
if (!saving) {
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
setWordToDelete(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onConfirm={handleDeleteWord}
|
||||||
|
title="단어 삭제"
|
||||||
|
message={
|
||||||
|
<>
|
||||||
|
<p className="text-gray-600 mb-2">다음 단어를 삭제하시겠습니까?</p>
|
||||||
|
<p className="font-medium text-gray-900 p-3 bg-gray-50 rounded-lg">{wordToDelete?.word}</p>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
confirmText="삭제"
|
||||||
|
loadingText="삭제 중..."
|
||||||
|
loading={saving}
|
||||||
|
variant="danger"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 메인 콘텐츠 */}
|
||||||
|
<motion.div
|
||||||
|
className="max-w-5xl mx-auto px-6 py-8"
|
||||||
|
variants={containerVariants}
|
||||||
|
initial="hidden"
|
||||||
|
animate="visible"
|
||||||
|
>
|
||||||
|
{/* 브레드크럼 */}
|
||||||
|
<motion.div variants={itemVariants} className="flex items-center gap-2 text-sm text-gray-400 mb-8">
|
||||||
|
<Link to="/admin/dashboard" className="hover:text-primary transition-colors">
|
||||||
|
<Home size={16} />
|
||||||
|
</Link>
|
||||||
|
<ChevronRight size={14} />
|
||||||
|
<Link to="/admin/schedule" className="hover:text-primary transition-colors">
|
||||||
|
일정 관리
|
||||||
|
</Link>
|
||||||
|
<ChevronRight size={14} />
|
||||||
|
<span className="text-gray-700">사전 관리</span>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* 타이틀 */}
|
||||||
|
<motion.div variants={itemVariants} className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 mb-2">사전 관리</h1>
|
||||||
|
<p className="text-gray-500">형태소 분석기 사용자 사전을 관리합니다</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* 통계 카드 */}
|
||||||
|
<motion.div variants={itemVariants} className="grid grid-cols-4 gap-4 mb-6">
|
||||||
|
<motion.div variants={cardVariants} className="bg-white rounded-xl p-4 border border-gray-100">
|
||||||
|
<div className="text-2xl font-bold text-gray-900">{posStats.total || 0}</div>
|
||||||
|
<div className="text-sm text-gray-500">전체 단어</div>
|
||||||
|
</motion.div>
|
||||||
|
<motion.div variants={cardVariants} className="bg-white rounded-xl p-4 border border-gray-100">
|
||||||
|
<div className="text-2xl font-bold text-blue-500">{posStats.NNP || 0}</div>
|
||||||
|
<div className="text-sm text-gray-500">고유명사</div>
|
||||||
|
</motion.div>
|
||||||
|
<motion.div variants={cardVariants} className="bg-white rounded-xl p-4 border border-gray-100">
|
||||||
|
<div className="text-2xl font-bold text-green-500">{posStats.NNG || 0}</div>
|
||||||
|
<div className="text-sm text-gray-500">일반명사</div>
|
||||||
|
</motion.div>
|
||||||
|
<motion.div variants={cardVariants} className="bg-white rounded-xl p-4 border border-gray-100">
|
||||||
|
<div className="text-2xl font-bold text-purple-500">{posStats.SL || 0}</div>
|
||||||
|
<div className="text-sm text-gray-500">외국어</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* 단어 추가 영역 */}
|
||||||
|
<motion.div
|
||||||
|
variants={itemVariants}
|
||||||
|
className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6 mb-6"
|
||||||
|
>
|
||||||
|
<h3 className="font-bold text-gray-900 mb-4">단어 추가</h3>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newWord}
|
||||||
|
onChange={(e) => setNewWord(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="추가할 단어 입력..."
|
||||||
|
className="w-full px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="relative w-48" ref={newPosDropdownRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowNewPosDropdown(!showNewPosDropdown)}
|
||||||
|
className="flex items-center gap-2 px-4 py-3 bg-gray-100 hover:bg-gray-200 rounded-xl text-sm transition-colors w-full justify-between"
|
||||||
|
>
|
||||||
|
<span>{POS_TAGS.find((t) => t.value === newPos)?.label.split(' ')[0]}</span>
|
||||||
|
<ChevronDown
|
||||||
|
size={14}
|
||||||
|
className={`transition-transform ${showNewPosDropdown ? 'rotate-180' : ''}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<AnimatePresence>
|
||||||
|
{showNewPosDropdown && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -5 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -5 }}
|
||||||
|
className="absolute top-full left-0 mt-1 w-64 bg-white rounded-xl shadow-lg border border-gray-200 py-1 z-20"
|
||||||
|
>
|
||||||
|
{POS_TAGS.map((tag) => (
|
||||||
|
<button
|
||||||
|
key={tag.value}
|
||||||
|
onClick={() => {
|
||||||
|
setNewPos(tag.value);
|
||||||
|
setShowNewPosDropdown(false);
|
||||||
|
}}
|
||||||
|
className={`w-full px-4 py-2.5 text-left hover:bg-gray-50 transition-colors ${
|
||||||
|
newPos === tag.value ? 'bg-primary/5 text-primary' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="font-medium text-sm">{tag.label}</div>
|
||||||
|
<div className="text-xs text-gray-400">{tag.description}</div>
|
||||||
|
<div className="text-xs text-gray-300 mt-0.5">예: {tag.examples}</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={openAddDialog}
|
||||||
|
disabled={!newWord.trim()}
|
||||||
|
className="flex items-center gap-2 px-6 py-3 bg-primary text-white rounded-xl hover:bg-primary-dark transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<Plus size={20} />
|
||||||
|
추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* 단어 목록 */}
|
||||||
|
<motion.div
|
||||||
|
variants={itemVariants}
|
||||||
|
className="bg-white rounded-2xl shadow-sm border border-gray-100 overflow-hidden"
|
||||||
|
>
|
||||||
|
{/* 검색 및 필터 */}
|
||||||
|
<div className="p-4 border-b border-gray-100 flex items-center gap-4">
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<Search size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="단어 검색..."
|
||||||
|
className="w-full pl-11 pr-4 py-2.5 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="relative" ref={filterDropdownRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowFilterDropdown(!showFilterDropdown)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2.5 bg-gray-100 hover:bg-gray-200 rounded-xl text-sm transition-colors"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
{filterPos === 'all'
|
||||||
|
? '전체 품사'
|
||||||
|
: POS_TAGS.find((t) => t.value === filterPos)?.label.split(' ')[0]}
|
||||||
|
</span>
|
||||||
|
<ChevronDown
|
||||||
|
size={14}
|
||||||
|
className={`transition-transform ${showFilterDropdown ? 'rotate-180' : ''}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<AnimatePresence>
|
||||||
|
{showFilterDropdown && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -5 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -5 }}
|
||||||
|
className="absolute top-full right-0 mt-1 w-48 bg-white rounded-xl shadow-lg border border-gray-200 py-1 z-20"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setFilterPos('all');
|
||||||
|
setShowFilterDropdown(false);
|
||||||
|
}}
|
||||||
|
className={`w-full px-4 py-2 text-left hover:bg-gray-50 transition-colors text-sm ${
|
||||||
|
filterPos === 'all' ? 'bg-primary/5 text-primary' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
전체 품사
|
||||||
|
</button>
|
||||||
|
{POS_TAGS.map((tag) => (
|
||||||
|
<button
|
||||||
|
key={tag.value}
|
||||||
|
onClick={() => {
|
||||||
|
setFilterPos(tag.value);
|
||||||
|
setShowFilterDropdown(false);
|
||||||
|
}}
|
||||||
|
className={`w-full px-4 py-2 text-left hover:bg-gray-50 transition-colors text-sm ${
|
||||||
|
filterPos === tag.value ? 'bg-primary/5 text-primary' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tag.label.split(' ')[0]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 테이블 */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex justify-center items-center py-20">
|
||||||
|
<div className="animate-spin rounded-full h-10 w-10 border-4 border-primary border-t-transparent"></div>
|
||||||
|
</div>
|
||||||
|
) : wordEntries.length === 0 ? (
|
||||||
|
<div className="text-center py-20 text-gray-400">
|
||||||
|
<Book size={48} className="mx-auto mb-4 opacity-30" />
|
||||||
|
<p>{searchQuery || filterPos !== 'all' ? '검색 결과가 없습니다' : '등록된 단어가 없습니다'}</p>
|
||||||
|
<p className="text-sm mt-1">위의 입력창에서 단어를 추가하세요</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto max-h-[500px] overflow-y-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50 sticky top-0 z-30">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-16">
|
||||||
|
#
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
단어
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-48">
|
||||||
|
품사
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-20"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
<AnimatePresence>
|
||||||
|
{wordEntries.map((entry, index) => (
|
||||||
|
<WordItem
|
||||||
|
key={entry.id}
|
||||||
|
id={entry.id}
|
||||||
|
word={entry.word}
|
||||||
|
pos={entry.pos}
|
||||||
|
index={index}
|
||||||
|
onUpdate={handleUpdateWord}
|
||||||
|
onDelete={() => openDeleteDialog(entry.id, entry.word)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 푸터 */}
|
||||||
|
{wordEntries.length > 0 && (
|
||||||
|
<div className="px-4 py-3 bg-gray-50 border-t border-gray-100 text-sm text-gray-500">
|
||||||
|
{searchQuery || filterPos !== 'all' ? (
|
||||||
|
<span>
|
||||||
|
{wordEntries.length}개 검색됨 (전체 {posStats.total}개)
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>총 {posStats.total}개 단어</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
</AdminLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ScheduleDict;
|
||||||
1046
frontend-temp/src/pages/pc/admin/schedules/ScheduleForm.jsx
Normal file
1046
frontend-temp/src/pages/pc/admin/schedules/ScheduleForm.jsx
Normal file
File diff suppressed because it is too large
Load diff
1471
frontend-temp/src/pages/pc/admin/schedules/Schedules.jsx
Normal file
1471
frontend-temp/src/pages/pc/admin/schedules/Schedules.jsx
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue