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:
parent
67a41d78ea
commit
4a26369dff
19 changed files with 1388 additions and 19 deletions
|
|
@ -468,21 +468,22 @@ frontend-temp/src/
|
||||||
|
|
||||||
## 작업 체크리스트
|
## 작업 체크리스트
|
||||||
|
|
||||||
### 1단계: 폴더 구조 재편
|
### 1단계: 폴더 구조 재편 ✅
|
||||||
- [ ] api/ 구조 변경 (common/, pc/common/, pc/public/, pc/admin/)
|
- [x] api/ 구조 변경 (common/, pc/common/, pc/public/, pc/admin/)
|
||||||
- [ ] components/ 구조 변경 (common/, pc/public/, pc/admin/, mobile/)
|
- [x] components/ 구조 변경 (common/, pc/public/, pc/admin/, mobile/)
|
||||||
- [ ] hooks/ 구조 변경 (common/, pc/admin/)
|
- [x] hooks/ 구조 변경 (common/, pc/admin/)
|
||||||
- [ ] pages/ 구조 변경 (pc/public/, pc/admin/, mobile/)
|
- [x] pages/ 구조 변경 (pc/public/, mobile/)
|
||||||
- [ ] 모든 import 경로 업데이트
|
- [x] 모든 import 경로 업데이트
|
||||||
- [ ] 각 폴더에 index.js 생성 (re-export)
|
- [x] 각 폴더에 index.js 생성 (re-export)
|
||||||
- [ ] 빌드 및 동작 확인
|
- [x] 개발 서버 동작 확인
|
||||||
|
|
||||||
### 2단계: 관리자 기반 설정
|
### 2단계: 관리자 기반 설정 ✅
|
||||||
- [ ] 관리자 API 마이그레이션 (api/pc/admin/)
|
- [x] 관리자 API 마이그레이션 (api/pc/admin/)
|
||||||
- [ ] AdminLayout 마이그레이션 (components/pc/admin/)
|
- [x] AdminLayout 마이그레이션 (components/pc/admin/)
|
||||||
- [ ] AdminHeader 마이그레이션 (components/pc/admin/)
|
- [x] AdminHeader 마이그레이션 (components/pc/admin/)
|
||||||
- [ ] useAdminAuth 훅 이동 (hooks/pc/admin/)
|
- [x] useAdminAuth 훅 이동 (hooks/pc/admin/)
|
||||||
- [ ] 관리자 라우트 설정 (App.jsx)
|
- [x] 관리자 라우트 설정 (App.jsx)
|
||||||
|
- [x] AdminLogin 페이지 마이그레이션
|
||||||
|
|
||||||
### 3단계: 간단한 페이지
|
### 3단계: 간단한 페이지
|
||||||
- [ ] AdminLogin 마이그레이션
|
- [ ] AdminLogin 마이그레이션
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import { Layout as PCLayout } from '@/components/pc/public';
|
||||||
// Mobile 레이아웃
|
// Mobile 레이아웃
|
||||||
import { Layout as MobileLayout } from '@/components/mobile';
|
import { Layout as MobileLayout } from '@/components/mobile';
|
||||||
|
|
||||||
// PC 페이지
|
// PC 공개 페이지
|
||||||
import PCHome from '@/pages/pc/public/home/Home';
|
import PCHome from '@/pages/pc/public/home/Home';
|
||||||
import PCMembers from '@/pages/pc/public/members/Members';
|
import PCMembers from '@/pages/pc/public/members/Members';
|
||||||
import PCSchedule from '@/pages/pc/public/schedule/Schedule';
|
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 PCAlbumGallery from '@/pages/pc/public/album/AlbumGallery';
|
||||||
import PCNotFound from '@/pages/pc/public/common/NotFound';
|
import PCNotFound from '@/pages/pc/public/common/NotFound';
|
||||||
|
|
||||||
|
// PC 관리자 페이지
|
||||||
|
import AdminLogin from '@/pages/pc/admin/Login';
|
||||||
|
|
||||||
// Mobile 페이지
|
// Mobile 페이지
|
||||||
import MobileHome from '@/pages/mobile/home/Home';
|
import MobileHome from '@/pages/mobile/home/Home';
|
||||||
import MobileMembers from '@/pages/mobile/members/Members';
|
import MobileMembers from '@/pages/mobile/members/Members';
|
||||||
|
|
@ -59,8 +62,8 @@ function App() {
|
||||||
<BrowserView>
|
<BrowserView>
|
||||||
<PCWrapper>
|
<PCWrapper>
|
||||||
<Routes>
|
<Routes>
|
||||||
{/* 관리자 페이지 (레이아웃 없음) - 추후 추가 */}
|
{/* 관리자 페이지 (레이아웃 없음) */}
|
||||||
{/* <Route path="/admin" element={<AdminLogin />} /> */}
|
<Route path="/admin" element={<AdminLogin />} />
|
||||||
|
|
||||||
{/* 일반 페이지 (레이아웃 포함) */}
|
{/* 일반 페이지 (레이아웃 포함) */}
|
||||||
<Route
|
<Route
|
||||||
|
|
|
||||||
97
frontend-temp/src/api/pc/admin/albums.js
Normal file
97
frontend-temp/src/api/pc/admin/albums.js
Normal 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' });
|
||||||
|
}
|
||||||
55
frontend-temp/src/api/pc/admin/bots.js
Normal file
55
frontend-temp/src/api/pc/admin/bots.js
Normal 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' });
|
||||||
|
}
|
||||||
60
frontend-temp/src/api/pc/admin/categories.js
Normal file
60
frontend-temp/src/api/pc/admin/categories.js
Normal 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 }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1 +1,11 @@
|
||||||
|
// 인증
|
||||||
export * from './auth';
|
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';
|
||||||
|
|
|
||||||
31
frontend-temp/src/api/pc/admin/members.js
Normal file
31
frontend-temp/src/api/pc/admin/members.js
Normal 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');
|
||||||
|
}
|
||||||
102
frontend-temp/src/api/pc/admin/schedules.js
Normal file
102
frontend-temp/src/api/pc/admin/schedules.js
Normal 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' });
|
||||||
|
}
|
||||||
12
frontend-temp/src/api/pc/admin/stats.js
Normal file
12
frontend-temp/src/api/pc/admin/stats.js
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
/**
|
||||||
|
* 관리자 통계 API
|
||||||
|
*/
|
||||||
|
import { fetchAuthApi } from '@/api/common/client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대시보드 통계 조회
|
||||||
|
* @returns {Promise<object>}
|
||||||
|
*/
|
||||||
|
export async function getStats() {
|
||||||
|
return fetchAuthApi('/stats');
|
||||||
|
}
|
||||||
24
frontend-temp/src/api/pc/admin/suggestions.js
Normal file
24
frontend-temp/src/api/pc/admin/suggestions.js
Normal 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 }),
|
||||||
|
});
|
||||||
|
}
|
||||||
115
frontend-temp/src/components/pc/admin/ConfirmDialog.jsx
Normal file
115
frontend-temp/src/components/pc/admin/ConfirmDialog.jsx
Normal 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;
|
||||||
282
frontend-temp/src/components/pc/admin/DatePicker.jsx
Normal file
282
frontend-temp/src/components/pc/admin/DatePicker.jsx
Normal 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;
|
||||||
48
frontend-temp/src/components/pc/admin/Header.jsx
Normal file
48
frontend-temp/src/components/pc/admin/Header.jsx
Normal 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;
|
||||||
25
frontend-temp/src/components/pc/admin/Layout.jsx
Normal file
25
frontend-temp/src/components/pc/admin/Layout.jsx
Normal 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;
|
||||||
188
frontend-temp/src/components/pc/admin/NumberPicker.jsx
Normal file
188
frontend-temp/src/components/pc/admin/NumberPicker.jsx
Normal 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;
|
||||||
157
frontend-temp/src/components/pc/admin/TimePicker.jsx
Normal file
157
frontend-temp/src/components/pc/admin/TimePicker.jsx
Normal 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;
|
||||||
|
|
@ -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 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';
|
||||||
|
|
|
||||||
151
frontend-temp/src/pages/pc/admin/Login.jsx
Normal file
151
frontend-temp/src/pages/pc/admin/Login.jsx
Normal 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">
|
||||||
|
← 메인 사이트로 돌아가기
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AdminLogin;
|
||||||
|
|
@ -5,7 +5,7 @@ import { Calendar, ChevronRight } from 'lucide-react';
|
||||||
import { getSchedule } from '@/api';
|
import { getSchedule } from '@/api';
|
||||||
|
|
||||||
// 섹션 컴포넌트들
|
// 섹션 컴포넌트들
|
||||||
import { YoutubeSection, XSection, DefaultSection, CATEGORY_ID, decodeHtmlEntities } from '../sections';
|
import { YoutubeSection, XSection, DefaultSection, CATEGORY_ID, decodeHtmlEntities } from './sections';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PC 일정 상세 페이지
|
* PC 일정 상세 페이지
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue