refactor: 관리자 기반 설정 마이그레이션 (Phase 2)

- 관리자 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 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-01-22 18:44:15 +09:00
parent 67a41d78ea
commit 4a26369dff
19 changed files with 1388 additions and 19 deletions

View file

@ -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 마이그레이션

View file

@ -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() {
<BrowserView>
<PCWrapper>
<Routes>
{/* 관리자 페이지 (레이아웃 없음) - 추후 추가 */}
{/* <Route path="/admin" element={<AdminLogin />} /> */}
{/* 관리자 페이지 (레이아웃 없음) */}
<Route path="/admin" element={<AdminLogin />} />
{/* 일반 페이지 (레이아웃 포함) */}
<Route

View file

@ -0,0 +1,97 @@
/**
* 관리자 앨범 API
*/
import { fetchAuthApi, fetchFormData } from '@/api/common/client';
/**
* 앨범 목록 조회
* @returns {Promise<Array>}
*/
export async function getAlbums() {
return fetchAuthApi('/albums');
}
/**
* 앨범 상세 조회
* @param {number} id - 앨범 ID
* @returns {Promise<object>}
*/
export async function getAlbum(id) {
return fetchAuthApi(`/albums/${id}`);
}
/**
* 앨범 생성
* @param {FormData} formData - 앨범 데이터
* @returns {Promise<object>}
*/
export async function createAlbum(formData) {
return fetchFormData('/albums', formData, 'POST');
}
/**
* 앨범 수정
* @param {number} id - 앨범 ID
* @param {FormData} formData - 앨범 데이터
* @returns {Promise<object>}
*/
export async function updateAlbum(id, formData) {
return fetchFormData(`/albums/${id}`, formData, 'PUT');
}
/**
* 앨범 삭제
* @param {number} id - 앨범 ID
* @returns {Promise<void>}
*/
export async function deleteAlbum(id) {
return fetchAuthApi(`/albums/${id}`, { method: 'DELETE' });
}
/**
* 앨범 사진 목록 조회
* @param {number} albumId - 앨범 ID
* @returns {Promise<Array>}
*/
export async function getAlbumPhotos(albumId) {
return fetchAuthApi(`/albums/${albumId}/photos`);
}
/**
* 앨범 사진 업로드
* @param {number} albumId - 앨범 ID
* @param {FormData} formData - 사진 데이터
* @returns {Promise<object>}
*/
export async function uploadAlbumPhotos(albumId, formData) {
return fetchFormData(`/albums/${albumId}/photos`, formData, 'POST');
}
/**
* 앨범 사진 삭제
* @param {number} albumId - 앨범 ID
* @param {number} photoId - 사진 ID
* @returns {Promise<void>}
*/
export async function deleteAlbumPhoto(albumId, photoId) {
return fetchAuthApi(`/albums/${albumId}/photos/${photoId}`, { method: 'DELETE' });
}
/**
* 앨범 티저 목록 조회
* @param {number} albumId - 앨범 ID
* @returns {Promise<Array>}
*/
export async function getAlbumTeasers(albumId) {
return fetchAuthApi(`/albums/${albumId}/teasers`);
}
/**
* 앨범 티저 삭제
* @param {number} albumId - 앨범 ID
* @param {number} teaserId - 티저 ID
* @returns {Promise<void>}
*/
export async function deleteAlbumTeaser(albumId, teaserId) {
return fetchAuthApi(`/albums/${albumId}/teasers/${teaserId}`, { method: 'DELETE' });
}

View file

@ -0,0 +1,55 @@
/**
* 관리자 관리 API
*/
import { fetchAuthApi } from '@/api/common/client';
/**
* 목록 조회
* @returns {Promise<Array>}
*/
export async function getBots() {
return fetchAuthApi('/admin/bots');
}
/**
* 시작
* @param {string} id - ID
* @returns {Promise<object>}
*/
export async function startBot(id) {
return fetchAuthApi(`/admin/bots/${id}/start`, { method: 'POST' });
}
/**
* 정지
* @param {string} id - ID
* @returns {Promise<object>}
*/
export async function stopBot(id) {
return fetchAuthApi(`/admin/bots/${id}/stop`, { method: 'POST' });
}
/**
* 전체 동기화
* @param {string} id - ID
* @returns {Promise<object>}
*/
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<void>}
*/
export async function dismissQuotaWarning() {
return fetchAuthApi('/admin/bots/quota-warning', { method: 'DELETE' });
}

View file

@ -0,0 +1,60 @@
/**
* 관리자 카테고리 API
*/
import { fetchAuthApi } from '@/api/common/client';
/**
* 카테고리 목록 조회
* @returns {Promise<Array>}
*/
export async function getCategories() {
return fetchAuthApi('/schedules/categories');
}
/**
* 카테고리 생성
* @param {object} data - 카테고리 데이터
* @param {string} data.name - 카테고리 이름
* @param {string} data.color - 색상 코드
* @returns {Promise<object>}
*/
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<object>}
*/
export async function updateCategory(id, data) {
return fetchAuthApi(`/admin/schedule-categories/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
});
}
/**
* 카테고리 삭제
* @param {number} id - 카테고리 ID
* @returns {Promise<void>}
*/
export async function deleteCategory(id) {
return fetchAuthApi(`/admin/schedule-categories/${id}`, { method: 'DELETE' });
}
/**
* 카테고리 순서 변경
* @param {Array<{id: number, sort_order: number}>} orders - 순서 데이터
* @returns {Promise<void>}
*/
export async function reorderCategories(orders) {
return fetchAuthApi('/admin/schedule-categories-order', {
method: 'PUT',
body: JSON.stringify({ orders }),
});
}

View file

@ -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';

View file

@ -0,0 +1,31 @@
/**
* 관리자 멤버 API
*/
import { fetchAuthApi, fetchFormData } from '@/api/common/client';
/**
* 멤버 목록 조회
* @returns {Promise<Array>}
*/
export async function getMembers() {
return fetchAuthApi('/members');
}
/**
* 멤버 상세 조회
* @param {number} id - 멤버 ID
* @returns {Promise<object>}
*/
export async function getMember(id) {
return fetchAuthApi(`/members/${id}`);
}
/**
* 멤버 수정
* @param {number} id - 멤버 ID
* @param {FormData} formData - 멤버 데이터
* @returns {Promise<object>}
*/
export async function updateMember(id, formData) {
return fetchFormData(`/members/${id}`, formData, 'PUT');
}

View file

@ -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<Array>}
*/
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<object>}
*/
export async function getSchedule(id) {
return fetchAuthApi(`/admin/schedules/${id}`);
}
/**
* 일정 생성
* @param {FormData} formData - 일정 데이터
* @returns {Promise<object>}
*/
export async function createSchedule(formData) {
return fetchFormData('/admin/schedules', formData, 'POST');
}
/**
* 일정 수정
* @param {number} id - 일정 ID
* @param {FormData} formData - 일정 데이터
* @returns {Promise<object>}
*/
export async function updateSchedule(id, formData) {
return fetchFormData(`/admin/schedules/${id}`, formData, 'PUT');
}
/**
* 일정 삭제
* @param {number} id - 일정 ID
* @returns {Promise<void>}
*/
export async function deleteSchedule(id) {
return fetchAuthApi(`/schedules/${id}`, { method: 'DELETE' });
}

View file

@ -0,0 +1,12 @@
/**
* 관리자 통계 API
*/
import { fetchAuthApi } from '@/api/common/client';
/**
* 대시보드 통계 조회
* @returns {Promise<object>}
*/
export async function getStats() {
return fetchAuthApi('/stats');
}

View file

@ -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<void>}
*/
export async function saveDict(content) {
return fetchAuthApi('/schedules/suggestions/dict', {
method: 'PUT',
body: JSON.stringify({ content }),
});
}

View file

@ -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 (
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
onClick={() => !loading && onClose()}
>
<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 max-w-md w-full mx-4 shadow-xl"
onClick={(e) => e.stopPropagation()}
>
{/* 헤더 */}
<div className="flex items-center gap-3 mb-4">
<div
className={`w-10 h-10 rounded-full ${iconBgColors[variant]} flex items-center justify-center`}
>
<Icon className={iconColors[variant]} size={20} />
</div>
<h3 className="text-lg font-bold text-gray-900">{title}</h3>
</div>
{/* 메시지 */}
<div className="text-gray-600 mb-6">{message}</div>
{/* 버튼 */}
<div className="flex justify-end gap-3">
<button
type="button"
onClick={onClose}
disabled={loading}
className="px-4 py-2 text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors disabled:opacity-50"
>
{cancelText}
</button>
<button
type="button"
onClick={onConfirm}
disabled={loading}
className={`px-4 py-2 ${buttonColors[variant]} text-white rounded-lg transition-colors flex items-center gap-2 disabled:opacity-50`}
>
{loading ? (
<>
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
{loadingText}
</>
) : (
<>
<Trash2 size={16} />
{confirmText}
</>
)}
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
export default ConfirmDialog;

View file

@ -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 (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => 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"
>
<span className={value ? 'text-gray-900' : 'text-gray-400'}>
{value ? formatDisplayDate(value) : placeholder}
</span>
<Calendar size={18} className="text-gray-400" />
</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 mt-2 bg-white border border-gray-200 rounded-xl shadow-lg p-4 w-80"
>
<div className="flex items-center justify-between mb-4">
<button
type="button"
onClick={viewMode === 'years' ? prevYearRange : prevMonth}
disabled={viewMode === 'years' && !canGoPrevYearRange}
className={`p-1.5 rounded-lg transition-colors ${viewMode === 'years' && !canGoPrevYearRange ? 'opacity-30' : 'hover:bg-gray-100'}`}
>
<ChevronLeft size={20} className="text-gray-600" />
</button>
<button
type="button"
onClick={() => setViewMode(viewMode === 'days' ? 'years' : 'days')}
className="font-medium text-gray-900 hover:text-primary transition-colors flex items-center gap-1"
>
{viewMode === 'years'
? `${years[0]} - ${years[years.length - 1]}`
: `${year}${month + 1}`}
<ChevronDown
size={16}
className={`transition-transform ${viewMode !== 'days' ? 'rotate-180' : ''}`}
/>
</button>
<button
type="button"
onClick={viewMode === 'years' ? nextYearRange : nextMonth}
className="p-1.5 hover:bg-gray-100 rounded-lg transition-colors"
>
<ChevronRight size={20} className="text-gray-600" />
</button>
</div>
<AnimatePresence mode="wait">
{viewMode === 'years' && (
<motion.div
key="years"
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 mb-4">
{years.map((y) => (
<button
key={y}
type="button"
onClick={() => 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' : ''}`}
>
{y}
</button>
))}
</div>
<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 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}
</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}
</button>
))}
</div>
</motion.div>
)}
{viewMode === 'days' && (
<motion.div
key="days"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<div className="grid grid-cols-7 gap-1 mb-2">
{['일', '월', '화', '수', '목', '금', '토'].map((d, i) => (
<div
key={d}
className={`text-center text-xs font-medium py-1 ${i === 0 ? 'text-red-400' : i === 6 ? 'text-blue-400' : 'text-gray-400'}`}
>
{d}
</div>
))}
</div>
<div className="grid grid-cols-7 gap-1">
{days.map((day, i) => {
const dayOfWeek = i % 7;
return (
<button
key={i}
type="button"
disabled={!day}
onClick={() => day && selectDate(day)}
className={`aspect-square rounded-full text-sm font-medium flex items-center justify-center transition-all
${!day ? '' : 'hover:bg-gray-100'}
${isSelected(day) ? 'bg-primary text-white hover:bg-primary' : ''}
${isToday(day) && !isSelected(day) ? 'text-primary font-bold' : ''}
${day && !isSelected(day) && !isToday(day) && dayOfWeek === 0 ? 'text-red-500' : ''}
${day && !isSelected(day) && !isToday(day) && dayOfWeek === 6 ? 'text-blue-500' : ''}
${day && !isSelected(day) && !isToday(day) && dayOfWeek > 0 && dayOfWeek < 6 ? 'text-gray-700' : ''}
`}
>
{day}
</button>
);
})}
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
export default DatePicker;

View file

@ -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 (
<header className="bg-white shadow-sm border-b border-gray-100">
<div className="max-w-7xl mx-auto px-6 py-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<Link
to="/admin/dashboard"
className="text-2xl font-bold text-primary hover:opacity-80 transition-opacity"
>
fromis_9
</Link>
<span className="px-3 py-1 bg-primary/10 text-primary text-sm font-medium rounded-full">
Admin
</span>
</div>
<div className="flex items-center gap-4">
<span className="text-gray-500 text-sm">
안녕하세요, <span className="text-gray-900 font-medium">{user?.username}</span>
</span>
<button
onClick={handleLogout}
className="flex items-center gap-2 px-4 py-2 text-gray-500 hover:text-gray-900 hover:bg-gray-100 rounded-lg transition-colors"
>
<LogOut size={18} />
<span>로그아웃</span>
</button>
</div>
</div>
</header>
);
}
export default AdminHeader;

View file

@ -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 (
<div className="h-screen overflow-hidden flex flex-col bg-gray-50">
<Header user={user} />
<main className={`flex-1 min-h-0 ${isSchedulePage ? 'overflow-hidden' : 'overflow-y-auto'}`}>
{children}
</main>
</div>
);
}
export default AdminLayout;

View file

@ -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 (
<div
ref={containerRef}
className="relative w-16 h-[120px] overflow-hidden touch-none select-none cursor-grab active:cursor-grabbing"
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onMouseDown={handleMouseDown}
>
{/* 중앙 선택 영역 */}
<div className="absolute top-1/2 left-1 right-1 h-10 -translate-y-1/2 bg-primary/10 rounded-lg z-0" />
{/* 피커 내부 */}
<div
className="relative transition-transform duration-150 ease-out"
style={{ transform: `translateY(${offset + centerOffset}px)` }}
>
{items.map((item) => (
<div
key={item}
className={`h-10 leading-10 text-center select-none transition-all duration-150 ${
isItemInCenter(item) ? 'text-primary text-lg font-bold' : 'text-gray-300 text-base'
}`}
>
{item}
</div>
))}
</div>
</div>
);
}
export default NumberPicker;

View file

@ -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 (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => 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"
>
<span className={value ? 'text-gray-900' : 'text-gray-400'}>{displayValue()}</span>
<Clock size={18} className="text-gray-400" />
</button>
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="absolute top-full left-0 mt-2 bg-white rounded-2xl shadow-xl border border-gray-200 z-50 overflow-hidden"
>
{/* 피커 영역 */}
<div className="flex items-center justify-center px-4 py-4">
{/* 오전/오후 (맨 앞) */}
<NumberPicker items={periods} value={selectedPeriod} onChange={setSelectedPeriod} />
{/* 시간 */}
<NumberPicker items={hours} value={selectedHour} onChange={setSelectedHour} />
<span className="text-xl text-gray-300 font-medium mx-0.5">:</span>
{/* 분 */}
<NumberPicker items={minutes} value={selectedMinute} onChange={setSelectedMinute} />
</div>
{/* 푸터 버튼 */}
<div className="flex items-center justify-between px-4 py-3 bg-gray-50">
<button
type="button"
onClick={handleClear}
className="px-3 py-1.5 text-sm text-gray-400 hover:text-gray-600 transition-colors"
>
초기화
</button>
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleCancel}
className="px-4 py-1.5 text-sm text-gray-600 hover:bg-gray-200 rounded-lg transition-colors"
>
취소
</button>
<button
type="button"
onClick={handleSave}
className="px-4 py-1.5 text-sm bg-primary text-white font-medium rounded-lg hover:bg-primary-dark transition-colors"
>
저장
</button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
export default TimePicker;

View file

@ -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';

View file

@ -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 (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-4 border-primary border-t-transparent" />
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
className="w-full max-w-md"
>
{/* 로고 */}
<div className="text-center mb-8">
<h1 className="text-3xl font-bold text-primary mb-2">fromis_9</h1>
<p className="text-gray-500">관리자 페이지</p>
</div>
{/* 로그인 카드 */}
<div className="bg-white rounded-2xl p-8 shadow-lg border border-gray-100">
<h2 className="text-xl font-bold text-gray-900 text-center mb-6">로그인</h2>
{/* 에러 메시지 */}
{loginMutation.isError && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="flex items-center gap-2 bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded-lg mb-6"
>
<AlertCircle size={18} />
<span className="text-sm">{loginMutation.error?.message || '로그인 실패'}</span>
</motion.div>
)}
<form onSubmit={handleSubmit} className="space-y-5">
{/* 아이디 입력 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">아이디</label>
<div className="relative">
<User
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"
size={18}
/>
<input
type="text"
value={username}
onChange={(e) => 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
/>
</div>
</div>
{/* 비밀번호 입력 */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">비밀번호</label>
<div className="relative">
<Lock
className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400"
size={18}
/>
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => 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
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 transition-colors"
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
</div>
{/* 로그인 버튼 */}
<button
type="submit"
disabled={loginMutation.isPending}
className="w-full bg-primary hover:bg-primary-dark text-white font-medium py-3 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{loginMutation.isPending ? (
<span className="flex items-center justify-center gap-2">
<span className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
로그인 ...
</span>
) : (
'로그인'
)}
</button>
</form>
</div>
{/* 하단 링크 */}
<div className="text-center mt-6">
<Link to="/" className="text-gray-500 hover:text-primary text-sm transition-colors">
&larr; 메인 사이트로 돌아가기
</Link>
</div>
</motion.div>
</div>
);
}
export default AdminLogin;

View file

@ -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 일정 상세 페이지