From 4a26369dff38e2b34740f7227b21907ea52de9f1 Mon Sep 17 00:00:00 2001 From: caadiq Date: Thu, 22 Jan 2026 18:44:15 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EC=84=A4=EC=A0=95=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20(Phase=202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 관리자 API 추가 (albums, members, schedules, categories, stats, suggestions, bots) - AdminLayout/Header 컴포넌트 추가 - 공통 컴포넌트 추가 (ConfirmDialog, DatePicker, TimePicker, NumberPicker) - AdminLogin 페이지 마이그레이션 - App.jsx에 관리자 라우트 추가 (/admin) - ScheduleDetail.jsx import 경로 수정 Co-Authored-By: Claude Opus 4.5 --- docs/admin-migration.md | 29 +- frontend-temp/src/App.jsx | 9 +- frontend-temp/src/api/pc/admin/albums.js | 97 ++++++ frontend-temp/src/api/pc/admin/bots.js | 55 ++++ frontend-temp/src/api/pc/admin/categories.js | 60 ++++ frontend-temp/src/api/pc/admin/index.js | 10 + frontend-temp/src/api/pc/admin/members.js | 31 ++ frontend-temp/src/api/pc/admin/schedules.js | 102 +++++++ frontend-temp/src/api/pc/admin/stats.js | 12 + frontend-temp/src/api/pc/admin/suggestions.js | 24 ++ .../src/components/pc/admin/ConfirmDialog.jsx | 115 +++++++ .../src/components/pc/admin/DatePicker.jsx | 282 ++++++++++++++++++ .../src/components/pc/admin/Header.jsx | 48 +++ .../src/components/pc/admin/Layout.jsx | 25 ++ .../src/components/pc/admin/NumberPicker.jsx | 188 ++++++++++++ .../src/components/pc/admin/TimePicker.jsx | 157 ++++++++++ .../src/components/pc/admin/index.js | 10 +- frontend-temp/src/pages/pc/admin/Login.jsx | 151 ++++++++++ .../pc/public/schedule/ScheduleDetail.jsx | 2 +- 19 files changed, 1388 insertions(+), 19 deletions(-) create mode 100644 frontend-temp/src/api/pc/admin/albums.js create mode 100644 frontend-temp/src/api/pc/admin/bots.js create mode 100644 frontend-temp/src/api/pc/admin/categories.js create mode 100644 frontend-temp/src/api/pc/admin/members.js create mode 100644 frontend-temp/src/api/pc/admin/schedules.js create mode 100644 frontend-temp/src/api/pc/admin/stats.js create mode 100644 frontend-temp/src/api/pc/admin/suggestions.js create mode 100644 frontend-temp/src/components/pc/admin/ConfirmDialog.jsx create mode 100644 frontend-temp/src/components/pc/admin/DatePicker.jsx create mode 100644 frontend-temp/src/components/pc/admin/Header.jsx create mode 100644 frontend-temp/src/components/pc/admin/Layout.jsx create mode 100644 frontend-temp/src/components/pc/admin/NumberPicker.jsx create mode 100644 frontend-temp/src/components/pc/admin/TimePicker.jsx create mode 100644 frontend-temp/src/pages/pc/admin/Login.jsx diff --git a/docs/admin-migration.md b/docs/admin-migration.md index f5a680f..62d5126 100644 --- a/docs/admin-migration.md +++ b/docs/admin-migration.md @@ -468,21 +468,22 @@ frontend-temp/src/ ## 작업 체크리스트 -### 1단계: 폴더 구조 재편 -- [ ] api/ 구조 변경 (common/, pc/common/, pc/public/, pc/admin/) -- [ ] components/ 구조 변경 (common/, pc/public/, pc/admin/, mobile/) -- [ ] hooks/ 구조 변경 (common/, pc/admin/) -- [ ] pages/ 구조 변경 (pc/public/, pc/admin/, mobile/) -- [ ] 모든 import 경로 업데이트 -- [ ] 각 폴더에 index.js 생성 (re-export) -- [ ] 빌드 및 동작 확인 +### 1단계: 폴더 구조 재편 ✅ +- [x] api/ 구조 변경 (common/, pc/common/, pc/public/, pc/admin/) +- [x] components/ 구조 변경 (common/, pc/public/, pc/admin/, mobile/) +- [x] hooks/ 구조 변경 (common/, pc/admin/) +- [x] pages/ 구조 변경 (pc/public/, mobile/) +- [x] 모든 import 경로 업데이트 +- [x] 각 폴더에 index.js 생성 (re-export) +- [x] 개발 서버 동작 확인 -### 2단계: 관리자 기반 설정 -- [ ] 관리자 API 마이그레이션 (api/pc/admin/) -- [ ] AdminLayout 마이그레이션 (components/pc/admin/) -- [ ] AdminHeader 마이그레이션 (components/pc/admin/) -- [ ] useAdminAuth 훅 이동 (hooks/pc/admin/) -- [ ] 관리자 라우트 설정 (App.jsx) +### 2단계: 관리자 기반 설정 ✅ +- [x] 관리자 API 마이그레이션 (api/pc/admin/) +- [x] AdminLayout 마이그레이션 (components/pc/admin/) +- [x] AdminHeader 마이그레이션 (components/pc/admin/) +- [x] useAdminAuth 훅 이동 (hooks/pc/admin/) +- [x] 관리자 라우트 설정 (App.jsx) +- [x] AdminLogin 페이지 마이그레이션 ### 3단계: 간단한 페이지 - [ ] AdminLogin 마이그레이션 diff --git a/frontend-temp/src/App.jsx b/frontend-temp/src/App.jsx index 9dfaf41..f88a681 100644 --- a/frontend-temp/src/App.jsx +++ b/frontend-temp/src/App.jsx @@ -11,7 +11,7 @@ import { Layout as PCLayout } from '@/components/pc/public'; // Mobile 레이아웃 import { Layout as MobileLayout } from '@/components/mobile'; -// PC 페이지 +// PC 공개 페이지 import PCHome from '@/pages/pc/public/home/Home'; import PCMembers from '@/pages/pc/public/members/Members'; import PCSchedule from '@/pages/pc/public/schedule/Schedule'; @@ -23,6 +23,9 @@ import PCTrackDetail from '@/pages/pc/public/album/TrackDetail'; import PCAlbumGallery from '@/pages/pc/public/album/AlbumGallery'; import PCNotFound from '@/pages/pc/public/common/NotFound'; +// PC 관리자 페이지 +import AdminLogin from '@/pages/pc/admin/Login'; + // Mobile 페이지 import MobileHome from '@/pages/mobile/home/Home'; import MobileMembers from '@/pages/mobile/members/Members'; @@ -59,8 +62,8 @@ function App() { - {/* 관리자 페이지 (레이아웃 없음) - 추후 추가 */} - {/* } /> */} + {/* 관리자 페이지 (레이아웃 없음) */} + } /> {/* 일반 페이지 (레이아웃 포함) */} } + */ +export async function getAlbums() { + return fetchAuthApi('/albums'); +} + +/** + * 앨범 상세 조회 + * @param {number} id - 앨범 ID + * @returns {Promise} + */ +export async function getAlbum(id) { + return fetchAuthApi(`/albums/${id}`); +} + +/** + * 앨범 생성 + * @param {FormData} formData - 앨범 데이터 + * @returns {Promise} + */ +export async function createAlbum(formData) { + return fetchFormData('/albums', formData, 'POST'); +} + +/** + * 앨범 수정 + * @param {number} id - 앨범 ID + * @param {FormData} formData - 앨범 데이터 + * @returns {Promise} + */ +export async function updateAlbum(id, formData) { + return fetchFormData(`/albums/${id}`, formData, 'PUT'); +} + +/** + * 앨범 삭제 + * @param {number} id - 앨범 ID + * @returns {Promise} + */ +export async function deleteAlbum(id) { + return fetchAuthApi(`/albums/${id}`, { method: 'DELETE' }); +} + +/** + * 앨범 사진 목록 조회 + * @param {number} albumId - 앨범 ID + * @returns {Promise} + */ +export async function getAlbumPhotos(albumId) { + return fetchAuthApi(`/albums/${albumId}/photos`); +} + +/** + * 앨범 사진 업로드 + * @param {number} albumId - 앨범 ID + * @param {FormData} formData - 사진 데이터 + * @returns {Promise} + */ +export async function uploadAlbumPhotos(albumId, formData) { + return fetchFormData(`/albums/${albumId}/photos`, formData, 'POST'); +} + +/** + * 앨범 사진 삭제 + * @param {number} albumId - 앨범 ID + * @param {number} photoId - 사진 ID + * @returns {Promise} + */ +export async function deleteAlbumPhoto(albumId, photoId) { + return fetchAuthApi(`/albums/${albumId}/photos/${photoId}`, { method: 'DELETE' }); +} + +/** + * 앨범 티저 목록 조회 + * @param {number} albumId - 앨범 ID + * @returns {Promise} + */ +export async function getAlbumTeasers(albumId) { + return fetchAuthApi(`/albums/${albumId}/teasers`); +} + +/** + * 앨범 티저 삭제 + * @param {number} albumId - 앨범 ID + * @param {number} teaserId - 티저 ID + * @returns {Promise} + */ +export async function deleteAlbumTeaser(albumId, teaserId) { + return fetchAuthApi(`/albums/${albumId}/teasers/${teaserId}`, { method: 'DELETE' }); +} diff --git a/frontend-temp/src/api/pc/admin/bots.js b/frontend-temp/src/api/pc/admin/bots.js new file mode 100644 index 0000000..a7fa4f0 --- /dev/null +++ b/frontend-temp/src/api/pc/admin/bots.js @@ -0,0 +1,55 @@ +/** + * 관리자 봇 관리 API + */ +import { fetchAuthApi } from '@/api/common/client'; + +/** + * 봇 목록 조회 + * @returns {Promise} + */ +export async function getBots() { + return fetchAuthApi('/admin/bots'); +} + +/** + * 봇 시작 + * @param {string} id - 봇 ID + * @returns {Promise} + */ +export async function startBot(id) { + return fetchAuthApi(`/admin/bots/${id}/start`, { method: 'POST' }); +} + +/** + * 봇 정지 + * @param {string} id - 봇 ID + * @returns {Promise} + */ +export async function stopBot(id) { + return fetchAuthApi(`/admin/bots/${id}/stop`, { method: 'POST' }); +} + +/** + * 봇 전체 동기화 + * @param {string} id - 봇 ID + * @returns {Promise} + */ +export async function syncAllVideos(id) { + return fetchAuthApi(`/admin/bots/${id}/sync-all`, { method: 'POST' }); +} + +/** + * 할당량 경고 조회 + * @returns {Promise<{warning: boolean, message: string}>} + */ +export async function getQuotaWarning() { + return fetchAuthApi('/admin/bots/quota-warning'); +} + +/** + * 할당량 경고 해제 + * @returns {Promise} + */ +export async function dismissQuotaWarning() { + return fetchAuthApi('/admin/bots/quota-warning', { method: 'DELETE' }); +} diff --git a/frontend-temp/src/api/pc/admin/categories.js b/frontend-temp/src/api/pc/admin/categories.js new file mode 100644 index 0000000..fd2e9d0 --- /dev/null +++ b/frontend-temp/src/api/pc/admin/categories.js @@ -0,0 +1,60 @@ +/** + * 관리자 카테고리 API + */ +import { fetchAuthApi } from '@/api/common/client'; + +/** + * 카테고리 목록 조회 + * @returns {Promise} + */ +export async function getCategories() { + return fetchAuthApi('/schedules/categories'); +} + +/** + * 카테고리 생성 + * @param {object} data - 카테고리 데이터 + * @param {string} data.name - 카테고리 이름 + * @param {string} data.color - 색상 코드 + * @returns {Promise} + */ +export async function createCategory(data) { + return fetchAuthApi('/admin/schedule-categories', { + method: 'POST', + body: JSON.stringify(data), + }); +} + +/** + * 카테고리 수정 + * @param {number} id - 카테고리 ID + * @param {object} data - 카테고리 데이터 + * @returns {Promise} + */ +export async function updateCategory(id, data) { + return fetchAuthApi(`/admin/schedule-categories/${id}`, { + method: 'PUT', + body: JSON.stringify(data), + }); +} + +/** + * 카테고리 삭제 + * @param {number} id - 카테고리 ID + * @returns {Promise} + */ +export async function deleteCategory(id) { + return fetchAuthApi(`/admin/schedule-categories/${id}`, { method: 'DELETE' }); +} + +/** + * 카테고리 순서 변경 + * @param {Array<{id: number, sort_order: number}>} orders - 순서 데이터 + * @returns {Promise} + */ +export async function reorderCategories(orders) { + return fetchAuthApi('/admin/schedule-categories-order', { + method: 'PUT', + body: JSON.stringify({ orders }), + }); +} diff --git a/frontend-temp/src/api/pc/admin/index.js b/frontend-temp/src/api/pc/admin/index.js index 269586e..2d69918 100644 --- a/frontend-temp/src/api/pc/admin/index.js +++ b/frontend-temp/src/api/pc/admin/index.js @@ -1 +1,11 @@ +// 인증 export * from './auth'; + +// 관리자 API +export * as adminAlbumApi from './albums'; +export * as adminMemberApi from './members'; +export * as adminScheduleApi from './schedules'; +export * as adminCategoryApi from './categories'; +export * as adminStatsApi from './stats'; +export * as adminSuggestionApi from './suggestions'; +export * as adminBotApi from './bots'; diff --git a/frontend-temp/src/api/pc/admin/members.js b/frontend-temp/src/api/pc/admin/members.js new file mode 100644 index 0000000..f2bd13d --- /dev/null +++ b/frontend-temp/src/api/pc/admin/members.js @@ -0,0 +1,31 @@ +/** + * 관리자 멤버 API + */ +import { fetchAuthApi, fetchFormData } from '@/api/common/client'; + +/** + * 멤버 목록 조회 + * @returns {Promise} + */ +export async function getMembers() { + return fetchAuthApi('/members'); +} + +/** + * 멤버 상세 조회 + * @param {number} id - 멤버 ID + * @returns {Promise} + */ +export async function getMember(id) { + return fetchAuthApi(`/members/${id}`); +} + +/** + * 멤버 수정 + * @param {number} id - 멤버 ID + * @param {FormData} formData - 멤버 데이터 + * @returns {Promise} + */ +export async function updateMember(id, formData) { + return fetchFormData(`/members/${id}`, formData, 'PUT'); +} diff --git a/frontend-temp/src/api/pc/admin/schedules.js b/frontend-temp/src/api/pc/admin/schedules.js new file mode 100644 index 0000000..7c163d4 --- /dev/null +++ b/frontend-temp/src/api/pc/admin/schedules.js @@ -0,0 +1,102 @@ +/** + * 관리자 일정 API + */ +import { fetchAuthApi, fetchFormData } from '@/api/common/client'; + +/** + * API 응답을 프론트엔드 형식으로 변환 + * - datetime → date, time 분리 + * - category 객체 → category_id, category_name, category_color 플랫화 + * - members 배열 → member_names 문자열 + */ +function transformSchedule(schedule) { + const category = schedule.category || {}; + + // datetime에서 date와 time 분리 + let date = ''; + let time = null; + if (schedule.datetime) { + const parts = schedule.datetime.split('T'); + date = parts[0]; + time = parts[1] || null; + } + + // members 배열을 문자열로 (기존 코드 호환성) + const memberNames = Array.isArray(schedule.members) ? schedule.members.join(',') : ''; + + return { + ...schedule, + date, + time, + category_id: category.id, + category_name: category.name, + category_color: category.color, + member_names: memberNames, + }; +} + +/** + * 일정 목록 조회 (월별) + * @param {number} year - 년도 + * @param {number} month - 월 + * @returns {Promise} + */ +export async function getSchedules(year, month) { + const data = await fetchAuthApi(`/schedules?year=${year}&month=${month}`); + return (data.schedules || []).map(transformSchedule); +} + +/** + * 일정 검색 (Meilisearch) + * @param {string} query - 검색어 + * @param {object} options - 페이지네이션 옵션 + * @param {number} options.offset - 시작 위치 + * @param {number} options.limit - 조회 개수 + * @returns {Promise<{schedules: Array, total: number}>} + */ +export async function searchSchedules(query, { offset = 0, limit = 20 } = {}) { + const data = await fetchAuthApi( + `/schedules?search=${encodeURIComponent(query)}&offset=${offset}&limit=${limit}` + ); + return { + ...data, + schedules: (data.schedules || []).map(transformSchedule), + }; +} + +/** + * 일정 상세 조회 + * @param {number} id - 일정 ID + * @returns {Promise} + */ +export async function getSchedule(id) { + return fetchAuthApi(`/admin/schedules/${id}`); +} + +/** + * 일정 생성 + * @param {FormData} formData - 일정 데이터 + * @returns {Promise} + */ +export async function createSchedule(formData) { + return fetchFormData('/admin/schedules', formData, 'POST'); +} + +/** + * 일정 수정 + * @param {number} id - 일정 ID + * @param {FormData} formData - 일정 데이터 + * @returns {Promise} + */ +export async function updateSchedule(id, formData) { + return fetchFormData(`/admin/schedules/${id}`, formData, 'PUT'); +} + +/** + * 일정 삭제 + * @param {number} id - 일정 ID + * @returns {Promise} + */ +export async function deleteSchedule(id) { + return fetchAuthApi(`/schedules/${id}`, { method: 'DELETE' }); +} diff --git a/frontend-temp/src/api/pc/admin/stats.js b/frontend-temp/src/api/pc/admin/stats.js new file mode 100644 index 0000000..4b29c89 --- /dev/null +++ b/frontend-temp/src/api/pc/admin/stats.js @@ -0,0 +1,12 @@ +/** + * 관리자 통계 API + */ +import { fetchAuthApi } from '@/api/common/client'; + +/** + * 대시보드 통계 조회 + * @returns {Promise} + */ +export async function getStats() { + return fetchAuthApi('/stats'); +} diff --git a/frontend-temp/src/api/pc/admin/suggestions.js b/frontend-temp/src/api/pc/admin/suggestions.js new file mode 100644 index 0000000..b92affb --- /dev/null +++ b/frontend-temp/src/api/pc/admin/suggestions.js @@ -0,0 +1,24 @@ +/** + * 관리자 추천 검색어 API + */ +import { fetchAuthApi } from '@/api/common/client'; + +/** + * 사전 내용 조회 + * @returns {Promise<{content: string}>} + */ +export async function getDict() { + return fetchAuthApi('/schedules/suggestions/dict'); +} + +/** + * 사전 저장 + * @param {string} content - 사전 내용 + * @returns {Promise} + */ +export async function saveDict(content) { + return fetchAuthApi('/schedules/suggestions/dict', { + method: 'PUT', + body: JSON.stringify({ content }), + }); +} diff --git a/frontend-temp/src/components/pc/admin/ConfirmDialog.jsx b/frontend-temp/src/components/pc/admin/ConfirmDialog.jsx new file mode 100644 index 0000000..1b4627a --- /dev/null +++ b/frontend-temp/src/components/pc/admin/ConfirmDialog.jsx @@ -0,0 +1,115 @@ +/** + * ConfirmDialog 컴포넌트 + * 삭제 등 위험한 작업의 확인을 위한 공통 다이얼로그 + * + * Props: + * - isOpen: 다이얼로그 표시 여부 + * - onClose: 닫기 콜백 + * - onConfirm: 확인 콜백 + * - title: 제목 (예: "앨범 삭제") + * - message: 메시지 내용 (ReactNode 가능) + * - confirmText: 확인 버튼 텍스트 (기본: "삭제") + * - cancelText: 취소 버튼 텍스트 (기본: "취소") + * - loading: 로딩 상태 + * - loadingText: 로딩 중 텍스트 (기본: "삭제 중...") + * - variant: 버튼 색상 (기본: "danger", "primary" 가능) + */ +import { motion, AnimatePresence } from 'framer-motion'; +import { AlertTriangle, Trash2 } from 'lucide-react'; + +function ConfirmDialog({ + isOpen, + onClose, + onConfirm, + title, + message, + confirmText = '삭제', + cancelText = '취소', + loading = false, + loadingText = '삭제 중...', + variant = 'danger', + icon: Icon = AlertTriangle, +}) { + // 버튼 색상 설정 + const buttonColors = { + danger: 'bg-red-500 hover:bg-red-600', + primary: 'bg-primary hover:bg-primary-dark', + }; + + const iconBgColors = { + danger: 'bg-red-100', + primary: 'bg-primary/10', + }; + + const iconColors = { + danger: 'text-red-500', + primary: 'text-primary', + }; + + return ( + + {isOpen && ( + !loading && onClose()} + > + e.stopPropagation()} + > + {/* 헤더 */} +
+
+ +
+

{title}

+
+ + {/* 메시지 */} +
{message}
+ + {/* 버튼 */} +
+ + +
+
+
+ )} +
+ ); +} + +export default ConfirmDialog; diff --git a/frontend-temp/src/components/pc/admin/DatePicker.jsx b/frontend-temp/src/components/pc/admin/DatePicker.jsx new file mode 100644 index 0000000..1822ff7 --- /dev/null +++ b/frontend-temp/src/components/pc/admin/DatePicker.jsx @@ -0,0 +1,282 @@ +/** + * DatePicker 컴포넌트 + * 연/월/일 선택이 가능한 드롭다운 형태의 날짜 선택기 + */ +import { useState, useEffect, useRef } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { Calendar, ChevronLeft, ChevronRight, ChevronDown } from 'lucide-react'; + +function DatePicker({ value, onChange, placeholder = '날짜 선택', showDayOfWeek = false }) { + const [isOpen, setIsOpen] = useState(false); + const [viewMode, setViewMode] = useState('days'); + const [viewDate, setViewDate] = useState(() => { + if (value) return new Date(value); + return new Date(); + }); + const ref = useRef(null); + + useEffect(() => { + const handleClickOutside = (e) => { + if (ref.current && !ref.current.contains(e.target)) { + setIsOpen(false); + setViewMode('days'); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const year = viewDate.getFullYear(); + const month = viewDate.getMonth(); + + const firstDay = new Date(year, month, 1).getDay(); + const daysInMonth = new Date(year, month + 1, 0).getDate(); + + const days = []; + for (let i = 0; i < firstDay; i++) { + days.push(null); + } + for (let i = 1; i <= daysInMonth; i++) { + days.push(i); + } + + const MIN_YEAR = 2025; + const startYear = Math.max(MIN_YEAR, Math.floor(year / 12) * 12 - 1); + const years = Array.from({ length: 12 }, (_, i) => startYear + i); + const canGoPrevYearRange = startYear > MIN_YEAR; + + const prevMonth = () => setViewDate(new Date(year, month - 1, 1)); + const nextMonth = () => setViewDate(new Date(year, month + 1, 1)); + const prevYearRange = () => + canGoPrevYearRange && setViewDate(new Date(Math.max(MIN_YEAR, year - 12), month, 1)); + const nextYearRange = () => setViewDate(new Date(year + 12, month, 1)); + + const selectDate = (day) => { + const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; + onChange(dateStr); + setIsOpen(false); + setViewMode('days'); + }; + + const selectYear = (y) => { + setViewDate(new Date(y, month, 1)); + }; + + const selectMonth = (m) => { + setViewDate(new Date(year, m, 1)); + setViewMode('days'); + }; + + // 날짜 표시 포맷 (요일 포함 옵션) + const formatDisplayDate = (dateStr) => { + if (!dateStr) return ''; + const [y, m, d] = dateStr.split('-'); + if (showDayOfWeek) { + const dayNames = ['일', '월', '화', '수', '목', '금', '토']; + const date = new Date(parseInt(y), parseInt(m) - 1, parseInt(d)); + const dayOfWeek = dayNames[date.getDay()]; + return `${y}년 ${parseInt(m)}월 ${parseInt(d)}일 (${dayOfWeek})`; + } + return `${y}년 ${parseInt(m)}월 ${parseInt(d)}일`; + }; + + const isSelected = (day) => { + if (!value || !day) return false; + const [y, m, d] = value.split('-'); + return parseInt(y) === year && parseInt(m) === month + 1 && parseInt(d) === day; + }; + + const isToday = (day) => { + if (!day) return false; + const today = new Date(); + return today.getFullYear() === year && today.getMonth() === month && today.getDate() === day; + }; + + const isCurrentYear = (y) => new Date().getFullYear() === y; + const isCurrentMonth = (m) => { + const today = new Date(); + return today.getFullYear() === year && today.getMonth() === m; + }; + + const monthNames = [ + '1월', + '2월', + '3월', + '4월', + '5월', + '6월', + '7월', + '8월', + '9월', + '10월', + '11월', + '12월', + ]; + + return ( +
+ + + + {isOpen && ( + +
+ + + +
+ + + {viewMode === 'years' && ( + +
년도
+
+ {years.map((y) => ( + + ))} +
+
+
+ {monthNames.map((m, i) => ( + + ))} +
+
+ )} + + {viewMode === 'months' && ( + +
월 선택
+
+ {monthNames.map((m, i) => ( + + ))} +
+
+ )} + + {viewMode === 'days' && ( + +
+ {['일', '월', '화', '수', '목', '금', '토'].map((d, i) => ( +
+ {d} +
+ ))} +
+
+ {days.map((day, i) => { + const dayOfWeek = i % 7; + return ( + + ); + })} +
+
+ )} +
+
+ )} +
+
+ ); +} + +export default DatePicker; diff --git a/frontend-temp/src/components/pc/admin/Header.jsx b/frontend-temp/src/components/pc/admin/Header.jsx new file mode 100644 index 0000000..acdbfec --- /dev/null +++ b/frontend-temp/src/components/pc/admin/Header.jsx @@ -0,0 +1,48 @@ +/** + * AdminHeader 컴포넌트 + * 모든 Admin 페이지에서 공통으로 사용하는 헤더 + * 로고, Admin 배지, 사용자 정보, 로그아웃 버튼 포함 + */ +import { Link } from 'react-router-dom'; +import { LogOut } from 'lucide-react'; +import { useAuthStore } from '@/stores'; + +function AdminHeader({ user }) { + const logout = useAuthStore((state) => state.logout); + + const handleLogout = () => { + logout(); + }; + + return ( +
+
+
+ + fromis_9 + + + Admin + +
+
+ + 안녕하세요, {user?.username}님 + + +
+
+
+ ); +} + +export default AdminHeader; diff --git a/frontend-temp/src/components/pc/admin/Layout.jsx b/frontend-temp/src/components/pc/admin/Layout.jsx new file mode 100644 index 0000000..99aebba --- /dev/null +++ b/frontend-temp/src/components/pc/admin/Layout.jsx @@ -0,0 +1,25 @@ +/** + * AdminLayout 컴포넌트 + * 모든 Admin 페이지에서 공통으로 사용하는 레이아웃 + * 헤더 고정 + 본문 스크롤 구조 + */ +import { useLocation } from 'react-router-dom'; +import Header from './Header'; + +function AdminLayout({ user, children }) { + const location = useLocation(); + + // 일정 관리 페이지는 내부 스크롤 처리 + const isSchedulePage = location.pathname.includes('/admin/schedules'); + + return ( +
+
+
+ {children} +
+
+ ); +} + +export default AdminLayout; diff --git a/frontend-temp/src/components/pc/admin/NumberPicker.jsx b/frontend-temp/src/components/pc/admin/NumberPicker.jsx new file mode 100644 index 0000000..0d153f2 --- /dev/null +++ b/frontend-temp/src/components/pc/admin/NumberPicker.jsx @@ -0,0 +1,188 @@ +/** + * NumberPicker 컴포넌트 + * 스크롤 가능한 숫자/값 선택 피커 + * AdminScheduleForm의 시간 선택에서 사용 + */ +import { useState, useEffect, useRef } from 'react'; + +function NumberPicker({ items, value, onChange }) { + const ITEM_HEIGHT = 40; + const containerRef = useRef(null); + const [offset, setOffset] = useState(0); + const offsetRef = useRef(0); // 드래그용 ref + const touchStartY = useRef(0); + const startOffset = useRef(0); + const isScrolling = useRef(false); + + // offset 변경시 ref도 업데이트 + useEffect(() => { + offsetRef.current = offset; + }, [offset]); + + // 초기 위치 설정 + useEffect(() => { + if (value !== null && value !== undefined) { + const index = items.indexOf(value); + if (index !== -1) { + const newOffset = -index * ITEM_HEIGHT; + setOffset(newOffset); + offsetRef.current = newOffset; + } + } + }, []); + + // 값 변경시 위치 업데이트 + useEffect(() => { + const index = items.indexOf(value); + if (index !== -1) { + const targetOffset = -index * ITEM_HEIGHT; + if (Math.abs(offset - targetOffset) > 1) { + setOffset(targetOffset); + offsetRef.current = targetOffset; + } + } + }, [value, items]); + + const centerOffset = ITEM_HEIGHT; // 중앙 위치 오프셋 + + // 아이템이 중앙에 있는지 확인 + const isItemInCenter = (item) => { + const itemIndex = items.indexOf(item); + const itemPosition = -itemIndex * ITEM_HEIGHT; + const tolerance = ITEM_HEIGHT / 2; + return Math.abs(offset - itemPosition) < tolerance; + }; + + // 오프셋 업데이트 (경계 제한) + const updateOffset = (newOffset) => { + const maxOffset = 0; + const minOffset = -(items.length - 1) * ITEM_HEIGHT; + return Math.min(maxOffset, Math.max(minOffset, newOffset)); + }; + + // 중앙 아이템 업데이트 + const updateCenterItem = (currentOffset) => { + const centerIndex = Math.round(-currentOffset / ITEM_HEIGHT); + if (centerIndex >= 0 && centerIndex < items.length) { + const centerItem = items[centerIndex]; + if (value !== centerItem) { + onChange(centerItem); + } + } + }; + + // 가장 가까운 아이템에 스냅 + const snapToClosestItem = (currentOffset) => { + const targetOffset = Math.round(currentOffset / ITEM_HEIGHT) * ITEM_HEIGHT; + setOffset(targetOffset); + offsetRef.current = targetOffset; + updateCenterItem(targetOffset); + }; + + // 터치 시작 + const handleTouchStart = (e) => { + e.stopPropagation(); + touchStartY.current = e.touches[0].clientY; + startOffset.current = offsetRef.current; + }; + + // 터치 이동 + const handleTouchMove = (e) => { + e.stopPropagation(); + const touchY = e.touches[0].clientY; + const deltaY = touchY - touchStartY.current; + const newOffset = updateOffset(startOffset.current + deltaY); + setOffset(newOffset); + offsetRef.current = newOffset; + }; + + // 터치 종료 + const handleTouchEnd = (e) => { + e.stopPropagation(); + snapToClosestItem(offsetRef.current); + }; + + // 마우스 휠 - 바깥 스크롤 방지 + const handleWheel = (e) => { + e.preventDefault(); + e.stopPropagation(); + if (isScrolling.current) return; + isScrolling.current = true; + + const newOffset = updateOffset(offsetRef.current - Math.sign(e.deltaY) * ITEM_HEIGHT); + setOffset(newOffset); + offsetRef.current = newOffset; + snapToClosestItem(newOffset); + + setTimeout(() => { + isScrolling.current = false; + }, 50); + }; + + // 마우스 드래그 + const handleMouseDown = (e) => { + e.preventDefault(); + e.stopPropagation(); + touchStartY.current = e.clientY; + startOffset.current = offsetRef.current; + + const handleMouseMove = (moveEvent) => { + moveEvent.preventDefault(); + const deltaY = moveEvent.clientY - touchStartY.current; + const newOffset = updateOffset(startOffset.current + deltaY); + setOffset(newOffset); + offsetRef.current = newOffset; + }; + + const handleMouseUp = () => { + snapToClosestItem(offsetRef.current); + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }; + + // wheel 이벤트 passive false로 등록 + useEffect(() => { + const container = containerRef.current; + if (container) { + container.addEventListener('wheel', handleWheel, { passive: false }); + return () => container.removeEventListener('wheel', handleWheel); + } + }, []); + + return ( +
+ {/* 중앙 선택 영역 */} +
+ + {/* 피커 내부 */} +
+ {items.map((item) => ( +
+ {item} +
+ ))} +
+
+ ); +} + +export default NumberPicker; diff --git a/frontend-temp/src/components/pc/admin/TimePicker.jsx b/frontend-temp/src/components/pc/admin/TimePicker.jsx new file mode 100644 index 0000000..305fc81 --- /dev/null +++ b/frontend-temp/src/components/pc/admin/TimePicker.jsx @@ -0,0 +1,157 @@ +/** + * TimePicker 컴포넌트 + * 오전/오후, 시간, 분을 선택할 수 있는 시간 피커 + * NumberPicker를 사용하여 스크롤 방식 선택 제공 + */ +import { useState, useEffect, useRef } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { Clock } from 'lucide-react'; +import NumberPicker from './NumberPicker'; + +function TimePicker({ value, onChange, placeholder = '시간 선택' }) { + const [isOpen, setIsOpen] = useState(false); + const ref = useRef(null); + + // 현재 값 파싱 + const parseValue = () => { + if (!value) return { hour: '12', minute: '00', period: '오후' }; + const [h, m] = value.split(':'); + const hour = parseInt(h); + const isPM = hour >= 12; + const hour12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour; + return { + hour: String(hour12).padStart(2, '0'), + minute: m, + period: isPM ? '오후' : '오전', + }; + }; + + const parsed = parseValue(); + const [selectedHour, setSelectedHour] = useState(parsed.hour); + const [selectedMinute, setSelectedMinute] = useState(parsed.minute); + const [selectedPeriod, setSelectedPeriod] = useState(parsed.period); + + // 외부 클릭 시 닫기 + useEffect(() => { + const handleClickOutside = (e) => { + if (ref.current && !ref.current.contains(e.target)) { + setIsOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + // 피커 열릴 때 현재 값으로 초기화 + useEffect(() => { + if (isOpen) { + const parsed = parseValue(); + setSelectedHour(parsed.hour); + setSelectedMinute(parsed.minute); + setSelectedPeriod(parsed.period); + } + }, [isOpen, value]); + + // 시간 확정 + const handleSave = () => { + let hour = parseInt(selectedHour); + if (selectedPeriod === '오후' && hour !== 12) hour += 12; + if (selectedPeriod === '오전' && hour === 12) hour = 0; + const timeStr = `${String(hour).padStart(2, '0')}:${selectedMinute}`; + onChange(timeStr); + setIsOpen(false); + }; + + // 취소 + const handleCancel = () => { + setIsOpen(false); + }; + + // 초기화 + const handleClear = () => { + onChange(''); + setIsOpen(false); + }; + + // 표시용 포맷 + const displayValue = () => { + if (!value) return placeholder; + const [h, m] = value.split(':'); + const hour = parseInt(h); + const isPM = hour >= 12; + const hour12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour; + return `${isPM ? '오후' : '오전'} ${hour12}:${m}`; + }; + + // 피커 아이템 데이터 + const periods = ['오전', '오후']; + const hours = ['01', '02', '03', '04', '05', '06', '07', '08', '09', '10', '11', '12']; + const minutes = Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0')); + + return ( +
+ + + + {isOpen && ( + + {/* 피커 영역 */} +
+ {/* 오전/오후 (맨 앞) */} + + + {/* 시간 */} + + + : + + {/* 분 */} + +
+ + {/* 푸터 버튼 */} +
+ +
+ + +
+
+
+ )} +
+
+ ); +} + +export default TimePicker; diff --git a/frontend-temp/src/components/pc/admin/index.js b/frontend-temp/src/components/pc/admin/index.js index f07e79b..aebc090 100644 --- a/frontend-temp/src/components/pc/admin/index.js +++ b/frontend-temp/src/components/pc/admin/index.js @@ -1,2 +1,10 @@ -// 관리자 컴포넌트 +// 레이아웃 +export { default as AdminLayout } from './Layout'; +export { default as AdminHeader } from './Header'; + +// 공통 컴포넌트 export { default as AdminScheduleCard } from './AdminScheduleCard'; +export { default as ConfirmDialog } from './ConfirmDialog'; +export { default as NumberPicker } from './NumberPicker'; +export { default as DatePicker } from './DatePicker'; +export { default as TimePicker } from './TimePicker'; diff --git a/frontend-temp/src/pages/pc/admin/Login.jsx b/frontend-temp/src/pages/pc/admin/Login.jsx new file mode 100644 index 0000000..93a60cd --- /dev/null +++ b/frontend-temp/src/pages/pc/admin/Login.jsx @@ -0,0 +1,151 @@ +/** + * 관리자 로그인 페이지 + */ +import { useState } from 'react'; +import { useNavigate, Link } from 'react-router-dom'; +import { useMutation } from '@tanstack/react-query'; +import { motion } from 'framer-motion'; +import { Lock, User, AlertCircle, Eye, EyeOff } from 'lucide-react'; +import { useAuthStore } from '@/stores'; +import { useRedirectIfAuthenticated } from '@/hooks/pc/admin'; +import * as authApi from '@/api/pc/admin/auth'; + +function AdminLogin() { + const navigate = useNavigate(); + const loginStore = useAuthStore((state) => state.login); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); + + // 이미 로그인된 경우 리다이렉트 + const { isLoading: checkingAuth } = useRedirectIfAuthenticated(); + + // 로그인 mutation + const loginMutation = useMutation({ + mutationFn: () => authApi.login(username, password), + onSuccess: (data) => { + loginStore(data.token, data.user); + navigate('/admin/dashboard'); + }, + }); + + const handleSubmit = (e) => { + e.preventDefault(); + loginMutation.mutate(); + }; + + // 인증 확인 중 로딩 화면 + if (checkingAuth) { + return ( +
+
+
+ ); + } + + return ( +
+ + {/* 로고 */} +
+

fromis_9

+

관리자 페이지

+
+ + {/* 로그인 카드 */} +
+

로그인

+ + {/* 에러 메시지 */} + {loginMutation.isError && ( + + + {loginMutation.error?.message || '로그인 실패'} + + )} + +
+ {/* 아이디 입력 */} +
+ +
+ + setUsername(e.target.value)} + className="w-full bg-gray-50 border border-gray-200 rounded-lg pl-10 pr-4 py-3 text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all" + placeholder="아이디를 입력하세요" + required + /> +
+
+ + {/* 비밀번호 입력 */} +
+ +
+ + setPassword(e.target.value)} + className="w-full bg-gray-50 border border-gray-200 rounded-lg pl-10 pr-12 py-3 text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent transition-all" + placeholder="비밀번호를 입력하세요" + required + /> + +
+
+ + {/* 로그인 버튼 */} + +
+
+ + {/* 하단 링크 */} +
+ + ← 메인 사이트로 돌아가기 + +
+
+
+ ); +} + +export default AdminLogin; diff --git a/frontend-temp/src/pages/pc/public/schedule/ScheduleDetail.jsx b/frontend-temp/src/pages/pc/public/schedule/ScheduleDetail.jsx index 34ccec1..61a81bd 100644 --- a/frontend-temp/src/pages/pc/public/schedule/ScheduleDetail.jsx +++ b/frontend-temp/src/pages/pc/public/schedule/ScheduleDetail.jsx @@ -5,7 +5,7 @@ import { Calendar, ChevronRight } from 'lucide-react'; import { getSchedule } from '@/api'; // 섹션 컴포넌트들 -import { YoutubeSection, XSection, DefaultSection, CATEGORY_ID, decodeHtmlEntities } from '../sections'; +import { YoutubeSection, XSection, DefaultSection, CATEGORY_ID, decodeHtmlEntities } from './sections'; /** * PC 일정 상세 페이지