feat: 일정 추가 페이지 카테고리별 폼 분리 (YouTube)
- 카테고리 선택 UI를 최상단으로 이동 - YouTube 카테고리 전용 폼 추가 (URL 입력 → 자동 정보 조회) - 폴더 구조 분리: pages/pc/admin/schedule/form/ - API 추가: - GET /schedules/categories (카테고리 목록) - DELETE /schedules/:id (일정 삭제) - GET /admin/youtube/video-info (영상 정보 조회) - POST /admin/youtube/schedule (YouTube 일정 저장) - fetchApi에서 body 없는 요청 시 Content-Type 미설정 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
c7b0a51924
commit
0a73149849
10 changed files with 796 additions and 6 deletions
164
backend/src/routes/admin/youtube.js
Normal file
164
backend/src/routes/admin/youtube.js
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
import { fetchVideoInfo } from '../../services/youtube/api.js';
|
||||||
|
import { addOrUpdateSchedule } from '../../services/meilisearch/index.js';
|
||||||
|
|
||||||
|
const YOUTUBE_CATEGORY_ID = 2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* YouTube 관련 관리자 라우트
|
||||||
|
*/
|
||||||
|
export default async function youtubeRoutes(fastify) {
|
||||||
|
const { db, meilisearch } = fastify;
|
||||||
|
/**
|
||||||
|
* GET /api/admin/youtube/video-info
|
||||||
|
* YouTube 영상 정보 조회
|
||||||
|
*/
|
||||||
|
fastify.get('/video-info', {
|
||||||
|
schema: {
|
||||||
|
tags: ['admin/youtube'],
|
||||||
|
summary: 'YouTube 영상 정보 조회',
|
||||||
|
security: [{ bearerAuth: [] }],
|
||||||
|
querystring: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
url: { type: 'string', description: 'YouTube URL' },
|
||||||
|
},
|
||||||
|
required: ['url'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const { url } = request.query;
|
||||||
|
|
||||||
|
// YouTube URL에서 video ID 추출
|
||||||
|
const videoId = extractVideoId(url);
|
||||||
|
if (!videoId) {
|
||||||
|
return reply.code(400).send({ error: '유효하지 않은 YouTube URL입니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const video = await fetchVideoInfo(videoId);
|
||||||
|
if (!video) {
|
||||||
|
return reply.code(404).send({ error: '영상을 찾을 수 없습니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
videoId: video.videoId,
|
||||||
|
title: video.title,
|
||||||
|
channelId: video.channelId,
|
||||||
|
channelName: video.channelTitle,
|
||||||
|
publishedAt: video.publishedAt,
|
||||||
|
date: video.date,
|
||||||
|
time: video.time,
|
||||||
|
videoType: video.videoType,
|
||||||
|
videoUrl: video.videoUrl,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
fastify.log.error(`YouTube 영상 조회 오류: ${err.message}`);
|
||||||
|
return reply.code(500).send({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/youtube/schedule
|
||||||
|
* YouTube 일정 저장
|
||||||
|
*/
|
||||||
|
fastify.post('/schedule', {
|
||||||
|
schema: {
|
||||||
|
tags: ['admin/youtube'],
|
||||||
|
summary: 'YouTube 일정 저장',
|
||||||
|
security: [{ bearerAuth: [] }],
|
||||||
|
body: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
videoId: { type: 'string' },
|
||||||
|
title: { type: 'string' },
|
||||||
|
channelId: { type: 'string' },
|
||||||
|
channelName: { type: 'string' },
|
||||||
|
date: { type: 'string' },
|
||||||
|
time: { type: 'string' },
|
||||||
|
videoType: { type: 'string' },
|
||||||
|
},
|
||||||
|
required: ['videoId', 'title', 'date'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const { videoId, title, channelId, channelName, date, time, videoType } = request.body;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 중복 체크
|
||||||
|
const [existing] = await db.query(
|
||||||
|
'SELECT id FROM schedule_youtube WHERE video_id = ?',
|
||||||
|
[videoId]
|
||||||
|
);
|
||||||
|
if (existing.length > 0) {
|
||||||
|
return reply.code(409).send({ error: '이미 등록된 영상입니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// schedules 테이블에 저장
|
||||||
|
const [result] = await db.query(
|
||||||
|
'INSERT INTO schedules (category_id, title, date, time) VALUES (?, ?, ?, ?)',
|
||||||
|
[YOUTUBE_CATEGORY_ID, title, date, time || null]
|
||||||
|
);
|
||||||
|
const scheduleId = result.insertId;
|
||||||
|
|
||||||
|
// schedule_youtube 테이블에 저장
|
||||||
|
await db.query(
|
||||||
|
'INSERT INTO schedule_youtube (schedule_id, video_id, video_type, channel_id, channel_name) VALUES (?, ?, ?, ?, ?)',
|
||||||
|
[scheduleId, videoId, videoType || 'video', channelId, channelName]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Meilisearch 동기화
|
||||||
|
const [categoryRows] = await db.query(
|
||||||
|
'SELECT name, color FROM schedule_categories WHERE id = ?',
|
||||||
|
[YOUTUBE_CATEGORY_ID]
|
||||||
|
);
|
||||||
|
const category = categoryRows[0] || {};
|
||||||
|
|
||||||
|
await addOrUpdateSchedule(meilisearch, {
|
||||||
|
id: scheduleId,
|
||||||
|
title,
|
||||||
|
date,
|
||||||
|
time: time || '',
|
||||||
|
category_id: YOUTUBE_CATEGORY_ID,
|
||||||
|
category_name: category.name || '',
|
||||||
|
category_color: category.color || '',
|
||||||
|
source_name: channelName || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, scheduleId };
|
||||||
|
} catch (err) {
|
||||||
|
fastify.log.error(`YouTube 일정 저장 오류: ${err.message}`);
|
||||||
|
return reply.code(500).send({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* YouTube URL에서 video ID 추출
|
||||||
|
*/
|
||||||
|
function extractVideoId(url) {
|
||||||
|
if (!url) return null;
|
||||||
|
|
||||||
|
const patterns = [
|
||||||
|
// https://www.youtube.com/watch?v=VIDEO_ID
|
||||||
|
/(?:youtube\.com\/watch\?v=)([a-zA-Z0-9_-]{11})/,
|
||||||
|
// https://youtu.be/VIDEO_ID
|
||||||
|
/(?:youtu\.be\/)([a-zA-Z0-9_-]{11})/,
|
||||||
|
// https://www.youtube.com/shorts/VIDEO_ID
|
||||||
|
/(?:youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/,
|
||||||
|
// https://www.youtube.com/embed/VIDEO_ID
|
||||||
|
/(?:youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/,
|
||||||
|
// https://www.youtube.com/v/VIDEO_ID
|
||||||
|
/(?:youtube\.com\/v\/)([a-zA-Z0-9_-]{11})/,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const match = url.match(pattern);
|
||||||
|
if (match) {
|
||||||
|
return match[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import albumsRoutes from './albums/index.js';
|
||||||
import schedulesRoutes from './schedules/index.js';
|
import schedulesRoutes from './schedules/index.js';
|
||||||
import statsRoutes from './stats/index.js';
|
import statsRoutes from './stats/index.js';
|
||||||
import botsRoutes from './admin/bots.js';
|
import botsRoutes from './admin/bots.js';
|
||||||
|
import youtubeAdminRoutes from './admin/youtube.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 라우트 통합
|
* 라우트 통합
|
||||||
|
|
@ -27,4 +28,7 @@ export default async function routes(fastify) {
|
||||||
|
|
||||||
// 관리자 - 봇 라우트
|
// 관리자 - 봇 라우트
|
||||||
fastify.register(botsRoutes, { prefix: '/admin/bots' });
|
fastify.register(botsRoutes, { prefix: '/admin/bots' });
|
||||||
|
|
||||||
|
// 관리자 - YouTube 라우트
|
||||||
|
fastify.register(youtubeAdminRoutes, { prefix: '/admin/youtube' });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,22 @@ export default async function schedulesRoutes(fastify) {
|
||||||
// 추천 검색어 라우트 등록
|
// 추천 검색어 라우트 등록
|
||||||
fastify.register(suggestionsRoutes, { prefix: '/suggestions' });
|
fastify.register(suggestionsRoutes, { prefix: '/suggestions' });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/schedules/categories
|
||||||
|
* 카테고리 목록 조회
|
||||||
|
*/
|
||||||
|
fastify.get('/categories', {
|
||||||
|
schema: {
|
||||||
|
tags: ['schedules'],
|
||||||
|
summary: '카테고리 목록 조회',
|
||||||
|
},
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const [categories] = await db.query(
|
||||||
|
'SELECT id, name, color, sort_order FROM schedule_categories ORDER BY sort_order ASC, id ASC'
|
||||||
|
);
|
||||||
|
return categories;
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/schedules
|
* GET /api/schedules
|
||||||
* 검색 모드: search 파라미터가 있으면 Meilisearch 검색
|
* 검색 모드: search 파라미터가 있으면 Meilisearch 검색
|
||||||
|
|
@ -128,6 +144,46 @@ export default async function schedulesRoutes(fastify) {
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE /api/schedules/:id
|
||||||
|
* 일정 삭제 (인증 필요)
|
||||||
|
*/
|
||||||
|
fastify.delete('/:id', {
|
||||||
|
schema: {
|
||||||
|
tags: ['schedules'],
|
||||||
|
summary: '일정 삭제',
|
||||||
|
security: [{ bearerAuth: [] }],
|
||||||
|
},
|
||||||
|
preHandler: [fastify.authenticate],
|
||||||
|
}, async (request, reply) => {
|
||||||
|
const { id } = request.params;
|
||||||
|
|
||||||
|
// 일정 존재 확인
|
||||||
|
const [existing] = await db.query('SELECT id FROM schedules WHERE id = ?', [id]);
|
||||||
|
if (existing.length === 0) {
|
||||||
|
return reply.code(404).send({ error: '일정을 찾을 수 없습니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 관련 테이블 삭제 (외래 키)
|
||||||
|
await db.query('DELETE FROM schedule_youtube WHERE schedule_id = ?', [id]);
|
||||||
|
await db.query('DELETE FROM schedule_x WHERE schedule_id = ?', [id]);
|
||||||
|
await db.query('DELETE FROM schedule_members WHERE schedule_id = ?', [id]);
|
||||||
|
await db.query('DELETE FROM schedule_images WHERE schedule_id = ?', [id]);
|
||||||
|
|
||||||
|
// 메인 테이블 삭제
|
||||||
|
await db.query('DELETE FROM schedules WHERE id = ?', [id]);
|
||||||
|
|
||||||
|
// Meilisearch에서도 삭제
|
||||||
|
try {
|
||||||
|
const { deleteSchedule } = await import('../../services/meilisearch/index.js');
|
||||||
|
await deleteSchedule(meilisearch, id);
|
||||||
|
} catch (err) {
|
||||||
|
fastify.log.error(`Meilisearch 삭제 오류: ${err.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
54
docs/api.md
54
docs/api.md
|
|
@ -91,9 +91,23 @@ Base URL: `/api`
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### GET /schedules/categories
|
||||||
|
카테고리 목록 조회
|
||||||
|
|
||||||
|
**응답:**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "id": 1, "name": "기타", "color": "#gray", "sort_order": 0 },
|
||||||
|
{ "id": 2, "name": "유튜브", "color": "#ff0033", "sort_order": 1 }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
### GET /schedules/:id
|
### GET /schedules/:id
|
||||||
일정 상세 조회
|
일정 상세 조회
|
||||||
|
|
||||||
|
### DELETE /schedules/:id
|
||||||
|
일정 삭제 (인증 필요)
|
||||||
|
|
||||||
### POST /schedules/sync-search
|
### POST /schedules/sync-search
|
||||||
Meilisearch 전체 동기화 (인증 필요)
|
Meilisearch 전체 동기화 (인증 필요)
|
||||||
|
|
||||||
|
|
@ -175,6 +189,46 @@ YouTube API 할당량 경고 조회
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 관리자 - YouTube (인증 필요)
|
||||||
|
|
||||||
|
### GET /admin/youtube/video-info
|
||||||
|
YouTube 영상 정보 조회
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
- `url` - YouTube URL (watch, shorts, youtu.be 모두 지원)
|
||||||
|
|
||||||
|
**응답:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"videoId": "abc123",
|
||||||
|
"title": "영상 제목",
|
||||||
|
"channelId": "UCxxx",
|
||||||
|
"channelName": "채널명",
|
||||||
|
"date": "2026-01-19",
|
||||||
|
"time": "15:00:00",
|
||||||
|
"videoType": "video",
|
||||||
|
"videoUrl": "https://www.youtube.com/watch?v=abc123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /admin/youtube/schedule
|
||||||
|
YouTube 일정 저장
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"videoId": "abc123",
|
||||||
|
"title": "영상 제목",
|
||||||
|
"channelId": "UCxxx",
|
||||||
|
"channelName": "채널명",
|
||||||
|
"date": "2026-01-19",
|
||||||
|
"time": "15:00:00",
|
||||||
|
"videoType": "video"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 헬스 체크
|
## 헬스 체크
|
||||||
|
|
||||||
### GET /health
|
### GET /health
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ import AdminAlbumForm from './pages/pc/admin/AdminAlbumForm';
|
||||||
import AdminAlbumPhotos from './pages/pc/admin/AdminAlbumPhotos';
|
import AdminAlbumPhotos from './pages/pc/admin/AdminAlbumPhotos';
|
||||||
import AdminSchedule from './pages/pc/admin/AdminSchedule';
|
import AdminSchedule from './pages/pc/admin/AdminSchedule';
|
||||||
import AdminScheduleForm from './pages/pc/admin/AdminScheduleForm';
|
import AdminScheduleForm from './pages/pc/admin/AdminScheduleForm';
|
||||||
|
import ScheduleFormPage from './pages/pc/admin/schedule/form';
|
||||||
import AdminScheduleCategory from './pages/pc/admin/AdminScheduleCategory';
|
import AdminScheduleCategory from './pages/pc/admin/AdminScheduleCategory';
|
||||||
import AdminScheduleBots from './pages/pc/admin/AdminScheduleBots';
|
import AdminScheduleBots from './pages/pc/admin/AdminScheduleBots';
|
||||||
import AdminScheduleDict from './pages/pc/admin/AdminScheduleDict';
|
import AdminScheduleDict from './pages/pc/admin/AdminScheduleDict';
|
||||||
|
|
@ -72,7 +73,8 @@ function App() {
|
||||||
<Route path="/admin/albums/:id/edit" element={<AdminAlbumForm />} />
|
<Route path="/admin/albums/:id/edit" element={<AdminAlbumForm />} />
|
||||||
<Route path="/admin/albums/:albumId/photos" element={<AdminAlbumPhotos />} />
|
<Route path="/admin/albums/:albumId/photos" element={<AdminAlbumPhotos />} />
|
||||||
<Route path="/admin/schedule" element={<AdminSchedule />} />
|
<Route path="/admin/schedule" element={<AdminSchedule />} />
|
||||||
<Route path="/admin/schedule/new" element={<AdminScheduleForm />} />
|
<Route path="/admin/schedule/new" element={<ScheduleFormPage />} />
|
||||||
|
<Route path="/admin/schedule/new-legacy" element={<AdminScheduleForm />} />
|
||||||
<Route path="/admin/schedule/:id/edit" element={<AdminScheduleForm />} />
|
<Route path="/admin/schedule/:id/edit" element={<AdminScheduleForm />} />
|
||||||
<Route path="/admin/schedule/categories" element={<AdminScheduleCategory />} />
|
<Route path="/admin/schedule/categories" element={<AdminScheduleCategory />} />
|
||||||
<Route path="/admin/schedule/bots" element={<AdminScheduleBots />} />
|
<Route path="/admin/schedule/bots" element={<AdminScheduleBots />} />
|
||||||
|
|
|
||||||
|
|
@ -48,5 +48,5 @@ export async function updateSchedule(id, formData) {
|
||||||
|
|
||||||
// 일정 삭제
|
// 일정 삭제
|
||||||
export async function deleteSchedule(id) {
|
export async function deleteSchedule(id) {
|
||||||
return fetchAdminApi(`/api/admin/schedules/${id}`, { method: "DELETE" });
|
return fetchAdminApi(`/api/schedules/${id}`, { method: "DELETE" });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,16 @@
|
||||||
|
|
||||||
// 기본 fetch 래퍼
|
// 기본 fetch 래퍼
|
||||||
export async function fetchApi(url, options = {}) {
|
export async function fetchApi(url, options = {}) {
|
||||||
|
const headers = { ...options.headers };
|
||||||
|
|
||||||
|
// body가 있을 때만 Content-Type 설정 (DELETE 등 body 없는 요청 대응)
|
||||||
|
if (options.body) {
|
||||||
|
headers["Content-Type"] = "application/json";
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
...options,
|
...options,
|
||||||
headers: {
|
headers,
|
||||||
"Content-Type": "application/json",
|
|
||||||
...options.headers,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
|
||||||
301
frontend/src/pages/pc/admin/schedule/form/YouTubeForm.jsx
Normal file
301
frontend/src/pages/pc/admin/schedule/form/YouTubeForm.jsx
Normal file
|
|
@ -0,0 +1,301 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import {
|
||||||
|
Youtube,
|
||||||
|
Link as LinkIcon,
|
||||||
|
Loader2,
|
||||||
|
Check,
|
||||||
|
AlertCircle,
|
||||||
|
Save,
|
||||||
|
} from "lucide-react";
|
||||||
|
import Toast from "../../../../../components/Toast";
|
||||||
|
import useToast from "../../../../../hooks/useToast";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* YouTube 일정 추가 폼
|
||||||
|
* - URL 입력 시 자동으로 영상 정보 조회
|
||||||
|
* - 조회된 정보로 일정 저장
|
||||||
|
*/
|
||||||
|
function YouTubeForm() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { toast, setToast } = useToast();
|
||||||
|
|
||||||
|
const [url, setUrl] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [videoInfo, setVideoInfo] = useState(null);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
// YouTube URL에서 영상 정보 조회
|
||||||
|
const fetchVideoInfo = async () => {
|
||||||
|
if (!url.trim()) {
|
||||||
|
setError("YouTube URL을 입력해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setVideoInfo(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem("adminToken");
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/admin/youtube/video-info?url=${encodeURIComponent(url)}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
throw new Error(data.error || "영상 정보를 가져올 수 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
setVideoInfo(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// URL 입력 후 엔터 키
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
fetchVideoInfo();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 초기화
|
||||||
|
const handleReset = () => {
|
||||||
|
setUrl("");
|
||||||
|
setVideoInfo(null);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 폼 제출
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!videoInfo) {
|
||||||
|
setError("먼저 YouTube URL을 입력하고 조회해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem("adminToken");
|
||||||
|
|
||||||
|
const response = await fetch("/api/admin/youtube/schedule", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
videoId: videoInfo.videoId,
|
||||||
|
title: videoInfo.title,
|
||||||
|
channelId: videoInfo.channelId,
|
||||||
|
channelName: videoInfo.channelName,
|
||||||
|
date: videoInfo.date,
|
||||||
|
time: videoInfo.time,
|
||||||
|
videoType: videoInfo.videoType,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
throw new Error(data.error || "일정 저장에 실패했습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 성공 메시지를 sessionStorage에 저장하고 목록 페이지로 이동
|
||||||
|
sessionStorage.setItem(
|
||||||
|
"scheduleToast",
|
||||||
|
JSON.stringify({
|
||||||
|
type: "success",
|
||||||
|
message: "YouTube 일정이 추가되었습니다.",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
navigate("/admin/schedule");
|
||||||
|
} catch (err) {
|
||||||
|
setToast({
|
||||||
|
type: "error",
|
||||||
|
message: err.message,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Toast toast={toast} onClose={() => setToast(null)} />
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{/* YouTube URL 입력 */}
|
||||||
|
<div className="bg-white rounded-2xl shadow-sm p-8">
|
||||||
|
<div className="flex items-center gap-2 mb-6">
|
||||||
|
<Youtube size={24} className="text-red-500" />
|
||||||
|
<h2 className="text-lg font-bold text-gray-900">YouTube 영상</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* URL 입력 필드 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
YouTube URL *
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<LinkIcon
|
||||||
|
size={18}
|
||||||
|
className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="https://www.youtube.com/watch?v=... 또는 https://youtu.be/..."
|
||||||
|
className="w-full pl-12 pr-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-transparent"
|
||||||
|
disabled={loading || videoInfo}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!videoInfo ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={fetchVideoInfo}
|
||||||
|
disabled={loading || !url.trim()}
|
||||||
|
className="px-6 py-3 bg-red-500 text-white rounded-xl hover:bg-red-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={18} className="animate-spin" />
|
||||||
|
조회 중...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"조회"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleReset}
|
||||||
|
className="px-6 py-3 bg-gray-100 text-gray-600 rounded-xl hover:bg-gray-200 transition-colors whitespace-nowrap"
|
||||||
|
>
|
||||||
|
다시 입력
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 에러 메시지 */}
|
||||||
|
{error && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="flex items-center gap-2 p-4 bg-red-50 text-red-600 rounded-xl"
|
||||||
|
>
|
||||||
|
<AlertCircle size={18} />
|
||||||
|
<span>{error}</span>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 영상 정보 미리보기 */}
|
||||||
|
{videoInfo && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="border border-green-200 bg-green-50 rounded-xl p-6"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
{/* 썸네일 */}
|
||||||
|
<div className="flex-shrink-0 w-48 aspect-video bg-gray-200 rounded-lg overflow-hidden">
|
||||||
|
<img
|
||||||
|
src={`https://img.youtube.com/vi/${videoInfo.videoId}/mqdefault.jpg`}
|
||||||
|
alt={videoInfo.title}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 정보 */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Check size={18} className="text-green-500" />
|
||||||
|
<span className="text-sm font-medium text-green-600">
|
||||||
|
영상 정보를 가져왔습니다
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="text-lg font-bold text-gray-900 mb-2 line-clamp-2">
|
||||||
|
{videoInfo.title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-1 text-sm text-gray-600">
|
||||||
|
<p>
|
||||||
|
<span className="text-gray-400">채널:</span>{" "}
|
||||||
|
{videoInfo.channelName}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className="text-gray-400">업로드:</span>{" "}
|
||||||
|
{videoInfo.date} {videoInfo.time}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className="text-gray-400">유형:</span>{" "}
|
||||||
|
<span
|
||||||
|
className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||||
|
videoInfo.videoType === "shorts"
|
||||||
|
? "bg-pink-100 text-pink-600"
|
||||||
|
: "bg-blue-100 text-blue-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{videoInfo.videoType === "shorts" ? "Shorts" : "Video"}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 버튼 */}
|
||||||
|
<div className="flex items-center justify-end gap-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate("/admin/schedule")}
|
||||||
|
className="px-6 py-3 text-gray-700 hover:bg-gray-100 rounded-xl transition-colors font-medium"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!videoInfo || saving}
|
||||||
|
className="flex items-center gap-2 px-6 py-3 bg-primary text-white rounded-xl hover:bg-primary-dark transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={18} className="animate-spin" />
|
||||||
|
저장 중...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save size={18} />
|
||||||
|
추가하기
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default YouTubeForm;
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { Settings } from "lucide-react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리 선택 컴포넌트
|
||||||
|
*/
|
||||||
|
function CategorySelector({ categories, selectedId, onChange }) {
|
||||||
|
// 색상 스타일 (기본 색상 또는 커스텀 HEX)
|
||||||
|
const getColorStyle = (color) => {
|
||||||
|
const colorMap = {
|
||||||
|
blue: "bg-blue-500",
|
||||||
|
green: "bg-green-500",
|
||||||
|
purple: "bg-purple-500",
|
||||||
|
red: "bg-red-500",
|
||||||
|
pink: "bg-pink-500",
|
||||||
|
yellow: "bg-yellow-500",
|
||||||
|
orange: "bg-orange-500",
|
||||||
|
gray: "bg-gray-500",
|
||||||
|
cyan: "bg-cyan-500",
|
||||||
|
indigo: "bg-indigo-500",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!color) return { className: "bg-gray-500" };
|
||||||
|
if (color.startsWith("#")) {
|
||||||
|
return { style: { backgroundColor: color } };
|
||||||
|
}
|
||||||
|
return { className: colorMap[color] || "bg-gray-500" };
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-2xl shadow-sm p-8">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h2 className="text-lg font-bold text-gray-900">카테고리 선택</h2>
|
||||||
|
<Link
|
||||||
|
to="/admin/schedule/categories"
|
||||||
|
className="flex items-center gap-1 text-xs text-gray-400 hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
<Settings size={12} />
|
||||||
|
카테고리 관리
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
{categories.map((category) => {
|
||||||
|
const colorStyle = getColorStyle(category.color);
|
||||||
|
const isSelected = selectedId === category.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={category.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(category.id)}
|
||||||
|
className={`flex items-center justify-center gap-2 px-4 py-4 rounded-xl border-2 transition-all ${
|
||||||
|
isSelected
|
||||||
|
? "border-primary bg-primary/5 shadow-sm"
|
||||||
|
: "border-gray-200 hover:border-gray-300 hover:bg-gray-50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`w-3 h-3 rounded-full ${colorStyle.className || ""}`}
|
||||||
|
style={colorStyle.style}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={`text-sm font-medium ${
|
||||||
|
isSelected ? "text-primary" : "text-gray-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{category.name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CategorySelector;
|
||||||
127
frontend/src/pages/pc/admin/schedule/form/index.jsx
Normal file
127
frontend/src/pages/pc/admin/schedule/form/index.jsx
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useNavigate, Link } from "react-router-dom";
|
||||||
|
import { Home, ChevronRight } from "lucide-react";
|
||||||
|
import AdminLayout from "../../../../../components/admin/AdminLayout";
|
||||||
|
import useAdminAuth from "../../../../../hooks/useAdminAuth";
|
||||||
|
import * as categoriesApi from "../../../../../api/admin/categories";
|
||||||
|
import CategorySelector from "./components/CategorySelector";
|
||||||
|
import YouTubeForm from "./YouTubeForm";
|
||||||
|
|
||||||
|
// 카테고리 ID 상수
|
||||||
|
const CATEGORY_IDS = {
|
||||||
|
YOUTUBE: 2,
|
||||||
|
X: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일정 추가 페이지 (카테고리별 폼 분기)
|
||||||
|
*/
|
||||||
|
function ScheduleFormPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user, isAuthenticated } = useAdminAuth();
|
||||||
|
|
||||||
|
const [categories, setCategories] = useState([]);
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// 카테고리 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated) return;
|
||||||
|
|
||||||
|
const fetchCategories = async () => {
|
||||||
|
try {
|
||||||
|
const data = await categoriesApi.getCategories();
|
||||||
|
setCategories(data);
|
||||||
|
// 첫 번째 카테고리를 기본값으로
|
||||||
|
if (data.length > 0) {
|
||||||
|
setSelectedCategory(data[0].id);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("카테고리 로드 오류:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchCategories();
|
||||||
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
|
// 카테고리에 따른 폼 렌더링
|
||||||
|
const renderForm = () => {
|
||||||
|
switch (selectedCategory) {
|
||||||
|
case CATEGORY_IDS.YOUTUBE:
|
||||||
|
return <YouTubeForm />;
|
||||||
|
|
||||||
|
// 다른 카테고리는 기존 폼으로 리다이렉트 (추후 구현)
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-2xl shadow-sm p-8 text-center">
|
||||||
|
<p className="text-gray-500 mb-4">
|
||||||
|
이 카테고리는 아직 전용 폼이 없습니다.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate("/admin/schedule/new-legacy")}
|
||||||
|
className="px-6 py-3 bg-primary text-white rounded-xl hover:bg-primary-dark transition-colors"
|
||||||
|
>
|
||||||
|
기존 폼으로 추가하기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<AdminLayout user={user}>
|
||||||
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
|
<div className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
</AdminLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminLayout user={user}>
|
||||||
|
<div className="max-w-4xl mx-auto px-6 py-8">
|
||||||
|
{/* 브레드크럼 */}
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-400 mb-8">
|
||||||
|
<Link
|
||||||
|
to="/admin/dashboard"
|
||||||
|
className="hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
<Home size={16} />
|
||||||
|
</Link>
|
||||||
|
<ChevronRight size={14} />
|
||||||
|
<Link
|
||||||
|
to="/admin/schedule"
|
||||||
|
className="hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
일정 관리
|
||||||
|
</Link>
|
||||||
|
<ChevronRight size={14} />
|
||||||
|
<span className="text-gray-700">일정 추가</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 타이틀 */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 mb-2">일정 추가</h1>
|
||||||
|
<p className="text-gray-500">카테고리를 선택하고 일정을 등록하세요</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 카테고리 선택 */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<CategorySelector
|
||||||
|
categories={categories}
|
||||||
|
selectedId={selectedCategory}
|
||||||
|
onChange={setSelectedCategory}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 카테고리별 폼 */}
|
||||||
|
{renderForm()}
|
||||||
|
</div>
|
||||||
|
</AdminLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ScheduleFormPage;
|
||||||
Loading…
Add table
Reference in a new issue