diff --git a/backend/src/routes/admin/youtube.js b/backend/src/routes/admin/youtube.js new file mode 100644 index 0000000..34b065b --- /dev/null +++ b/backend/src/routes/admin/youtube.js @@ -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; +} diff --git a/backend/src/routes/index.js b/backend/src/routes/index.js index b3c51f8..a986f71 100644 --- a/backend/src/routes/index.js +++ b/backend/src/routes/index.js @@ -4,6 +4,7 @@ import albumsRoutes from './albums/index.js'; import schedulesRoutes from './schedules/index.js'; import statsRoutes from './stats/index.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' }); + + // 관리자 - YouTube 라우트 + fastify.register(youtubeAdminRoutes, { prefix: '/admin/youtube' }); } diff --git a/backend/src/routes/schedules/index.js b/backend/src/routes/schedules/index.js index f55eb85..7af35e1 100644 --- a/backend/src/routes/schedules/index.js +++ b/backend/src/routes/schedules/index.js @@ -11,6 +11,22 @@ export default async function schedulesRoutes(fastify) { // 추천 검색어 라우트 등록 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 * 검색 모드: search 파라미터가 있으면 Meilisearch 검색 @@ -128,6 +144,46 @@ export default async function schedulesRoutes(fastify) { 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 }; + }); } /** diff --git a/docs/api.md b/docs/api.md index f5c910b..193056d 100644 --- a/docs/api.md +++ b/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 일정 상세 조회 +### DELETE /schedules/:id +일정 삭제 (인증 필요) + ### POST /schedules/sync-search 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 diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 75bfd13..8714204 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -38,6 +38,7 @@ import AdminAlbumForm from './pages/pc/admin/AdminAlbumForm'; import AdminAlbumPhotos from './pages/pc/admin/AdminAlbumPhotos'; import AdminSchedule from './pages/pc/admin/AdminSchedule'; import AdminScheduleForm from './pages/pc/admin/AdminScheduleForm'; +import ScheduleFormPage from './pages/pc/admin/schedule/form'; import AdminScheduleCategory from './pages/pc/admin/AdminScheduleCategory'; import AdminScheduleBots from './pages/pc/admin/AdminScheduleBots'; import AdminScheduleDict from './pages/pc/admin/AdminScheduleDict'; @@ -72,7 +73,8 @@ function App() { } /> } /> } /> - } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/api/admin/schedules.js b/frontend/src/api/admin/schedules.js index 15418dd..4a689bc 100644 --- a/frontend/src/api/admin/schedules.js +++ b/frontend/src/api/admin/schedules.js @@ -48,5 +48,5 @@ export async function updateSchedule(id, formData) { // 일정 삭제 export async function deleteSchedule(id) { - return fetchAdminApi(`/api/admin/schedules/${id}`, { method: "DELETE" }); + return fetchAdminApi(`/api/schedules/${id}`, { method: "DELETE" }); } diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index 7af8c48..661c0b4 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -5,12 +5,16 @@ // 기본 fetch 래퍼 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, { ...options, - headers: { - "Content-Type": "application/json", - ...options.headers, - }, + headers, }); if (!response.ok) { diff --git a/frontend/src/pages/pc/admin/schedule/form/YouTubeForm.jsx b/frontend/src/pages/pc/admin/schedule/form/YouTubeForm.jsx new file mode 100644 index 0000000..ce31090 --- /dev/null +++ b/frontend/src/pages/pc/admin/schedule/form/YouTubeForm.jsx @@ -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 ( + <> + setToast(null)} /> + + + {/* YouTube URL 입력 */} + + + + YouTube 영상 + + + + {/* URL 입력 필드 */} + + + YouTube URL * + + + + + 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} + /> + + {!videoInfo ? ( + + {loading ? ( + <> + + 조회 중... + > + ) : ( + "조회" + )} + + ) : ( + + 다시 입력 + + )} + + + + {/* 에러 메시지 */} + {error && ( + + + {error} + + )} + + {/* 영상 정보 미리보기 */} + {videoInfo && ( + + + {/* 썸네일 */} + + + + + {/* 정보 */} + + + + + 영상 정보를 가져왔습니다 + + + + + {videoInfo.title} + + + + + 채널:{" "} + {videoInfo.channelName} + + + 업로드:{" "} + {videoInfo.date} {videoInfo.time} + + + 유형:{" "} + + {videoInfo.videoType === "shorts" ? "Shorts" : "Video"} + + + + + + + )} + + + + {/* 버튼 */} + + navigate("/admin/schedule")} + className="px-6 py-3 text-gray-700 hover:bg-gray-100 rounded-xl transition-colors font-medium" + > + 취소 + + + {saving ? ( + <> + + 저장 중... + > + ) : ( + <> + + 추가하기 + > + )} + + + + > + ); +} + +export default YouTubeForm; diff --git a/frontend/src/pages/pc/admin/schedule/form/components/CategorySelector.jsx b/frontend/src/pages/pc/admin/schedule/form/components/CategorySelector.jsx new file mode 100644 index 0000000..c049bf1 --- /dev/null +++ b/frontend/src/pages/pc/admin/schedule/form/components/CategorySelector.jsx @@ -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 ( + + + 카테고리 선택 + + + 카테고리 관리 + + + + + {categories.map((category) => { + const colorStyle = getColorStyle(category.color); + const isSelected = selectedId === category.id; + + return ( + 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" + }`} + > + + + {category.name} + + + ); + })} + + + ); +} + +export default CategorySelector; diff --git a/frontend/src/pages/pc/admin/schedule/form/index.jsx b/frontend/src/pages/pc/admin/schedule/form/index.jsx new file mode 100644 index 0000000..f0bf5ce --- /dev/null +++ b/frontend/src/pages/pc/admin/schedule/form/index.jsx @@ -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 ; + + // 다른 카테고리는 기존 폼으로 리다이렉트 (추후 구현) + default: + return ( + + + 이 카테고리는 아직 전용 폼이 없습니다. + + navigate("/admin/schedule/new-legacy")} + className="px-6 py-3 bg-primary text-white rounded-xl hover:bg-primary-dark transition-colors" + > + 기존 폼으로 추가하기 + + + ); + } + }; + + if (loading) { + return ( + + + + + + ); + } + + return ( + + + {/* 브레드크럼 */} + + + + + + + 일정 관리 + + + 일정 추가 + + + {/* 타이틀 */} + + 일정 추가 + 카테고리를 선택하고 일정을 등록하세요 + + + {/* 카테고리 선택 */} + + + + + {/* 카테고리별 폼 */} + {renderForm()} + + + ); +} + +export default ScheduleFormPage;
+ 채널:{" "} + {videoInfo.channelName} +
+ 업로드:{" "} + {videoInfo.date} {videoInfo.time} +
+ 유형:{" "} + + {videoInfo.videoType === "shorts" ? "Shorts" : "Video"} + +
+ 이 카테고리는 아직 전용 폼이 없습니다. +
카테고리를 선택하고 일정을 등록하세요