From 73f84fd7acf1498ee1fda9464bfa7cd3e7633478 Mon Sep 17 00:00:00 2001 From: caadiq Date: Sat, 4 Apr 2026 18:16:16 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=98=88=EB=8A=A5=20=EC=B9=B4=ED=85=8C?= =?UTF-8?q?=EA=B3=A0=EB=A6=AC=20=EA=B4=80=EB=A6=AC=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 백엔드: POST/PUT/GET /admin/variety/schedule API - 백엔드: 일정 상세 응답에 broadcaster, replayUrl, thumbnailUrl 포함 - 프론트엔드: VarietyForm (추가), VarietyEditForm (수정) 페이지 - 방송사 프리셋 버튼 (KBS, MBC, SBS, tvN, 유튜브, 티빙 등) - 출연 멤버 선택, 다시보기 링크, 썸네일 URL 지원 - 라우트 등록 및 일정 목록 편집 링크 연결 Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/src/config/index.js | 1 + backend/src/routes/admin/variety.js | 220 ++++++++++++ backend/src/routes/index.js | 4 + backend/src/services/schedule.js | 10 +- frontend/src/api/admin/variety.js | 31 ++ .../pc/admin/schedule/ScheduleItem.jsx | 2 + .../admin/schedules/edit/VarietyEditForm.jsx | 196 +++++++++++ .../pc/admin/schedules/form/VarietyForm.jsx | 320 ++++++++++++++++++ .../pages/pc/admin/schedules/form/index.jsx | 4 + frontend/src/routes/pc/admin/index.jsx | 2 + 10 files changed, 789 insertions(+), 1 deletion(-) create mode 100644 backend/src/routes/admin/variety.js create mode 100644 frontend/src/api/admin/variety.js create mode 100644 frontend/src/pages/pc/admin/schedules/edit/VarietyEditForm.jsx create mode 100644 frontend/src/pages/pc/admin/schedules/form/VarietyForm.jsx diff --git a/backend/src/config/index.js b/backend/src/config/index.js index d5f811e..76523a3 100644 --- a/backend/src/config/index.js +++ b/backend/src/config/index.js @@ -3,6 +3,7 @@ export const CATEGORY_IDS = { YOUTUBE: 2, X: 3, CONCERT: 6, + VARIETY: 10, BIRTHDAY: 8, DEBUT: 9, }; diff --git a/backend/src/routes/admin/variety.js b/backend/src/routes/admin/variety.js new file mode 100644 index 0000000..fdeaac5 --- /dev/null +++ b/backend/src/routes/admin/variety.js @@ -0,0 +1,220 @@ +import { CATEGORY_IDS } from '../../config/index.js'; +import { addOrUpdateSchedule, syncScheduleById } from '../../services/meilisearch/index.js'; +import { badRequest, notFound, serverError } from '../../utils/error.js'; +import { logActivity } from '../../utils/log.js'; + +const VARIETY_CATEGORY_ID = CATEGORY_IDS.VARIETY; + +/** + * 예능 관련 관리자 라우트 + */ +export default async function varietyRoutes(fastify) { + const { db, meilisearch } = fastify; + + /** + * POST /api/admin/variety/schedule + * 예능 일정 저장 + */ + fastify.post('/schedule', { + schema: { + tags: ['admin/variety'], + summary: '예능 일정 저장', + security: [{ bearerAuth: [] }], + }, + preHandler: [fastify.authenticate], + }, async (request, reply) => { + const { title, date, time, broadcaster, replayUrl, thumbnailUrl, memberIds } = request.body; + + if (!title?.trim()) { + return badRequest(reply, '프로그램명은 필수입니다.'); + } + if (!date) { + return badRequest(reply, '날짜는 필수입니다.'); + } + if (!broadcaster?.trim()) { + return badRequest(reply, '방송사/플랫폼은 필수입니다.'); + } + + try { + // schedules 테이블 + const [scheduleResult] = await db.query( + 'INSERT INTO schedules (category_id, title, date, time) VALUES (?, ?, ?, ?)', + [VARIETY_CATEGORY_ID, title.trim(), date, time || null] + ); + const scheduleId = scheduleResult.insertId; + + // schedule_variety 테이블 + await db.query( + 'INSERT INTO schedule_variety (schedule_id, broadcaster, replay_url, thumbnail_url) VALUES (?, ?, ?, ?)', + [scheduleId, broadcaster.trim(), replayUrl?.trim() || null, thumbnailUrl?.trim() || null] + ); + + // schedule_members 테이블 + if (memberIds && memberIds.length > 0) { + const values = memberIds.map(memberId => [scheduleId, memberId]); + await db.query('INSERT INTO schedule_members (schedule_id, member_id) VALUES ?', [values]); + } + + // Meilisearch 동기화 + const [categoryRows] = await db.query( + 'SELECT name, color FROM schedule_categories WHERE id = ?', + [VARIETY_CATEGORY_ID] + ); + const category = categoryRows[0] || {}; + + let memberNames = ''; + if (memberIds && memberIds.length > 0) { + const [members] = await db.query('SELECT name FROM members WHERE id IN (?) ORDER BY id', [memberIds]); + memberNames = members.map(m => m.name).join(','); + } + + await addOrUpdateSchedule(meilisearch, { + id: scheduleId, + title: title.trim(), + date, + time: time || '', + category_id: VARIETY_CATEGORY_ID, + category_name: category.name || '', + category_color: category.color || '', + member_names: memberNames, + }); + + logActivity(db, { + actor: 'admin', + action: 'create', + category: 'schedule', + targetType: 'variety_schedule', + targetId: scheduleId, + summary: `예능 일정 생성: ${title.trim()}`, + }); + + return { success: true, scheduleId }; + } catch (err) { + fastify.log.error(`예능 일정 저장 오류: ${err.message}`); + return serverError(reply, err.message); + } + }); + + /** + * PUT /api/admin/variety/schedule/:id + * 예능 일정 수정 + */ + fastify.put('/schedule/:id', { + schema: { + tags: ['admin/variety'], + summary: '예능 일정 수정', + security: [{ bearerAuth: [] }], + }, + preHandler: [fastify.authenticate], + }, async (request, reply) => { + const { id } = request.params; + const { title, date, time, broadcaster, replayUrl, thumbnailUrl, memberIds } = request.body; + + if (!title?.trim()) { + return badRequest(reply, '프로그램명은 필수입니다.'); + } + + try { + // 존재 확인 + const [existing] = await db.query('SELECT id FROM schedules WHERE id = ?', [id]); + if (existing.length === 0) { + return notFound(reply, '일정을 찾을 수 없습니다.'); + } + + // schedules 업데이트 + await db.query( + 'UPDATE schedules SET title = ?, date = ?, time = ? WHERE id = ?', + [title.trim(), date, time || null, id] + ); + + // schedule_variety 업데이트 (upsert) + const [varietyExisting] = await db.query('SELECT schedule_id FROM schedule_variety WHERE schedule_id = ?', [id]); + if (varietyExisting.length > 0) { + await db.query( + 'UPDATE schedule_variety SET broadcaster = ?, replay_url = ?, thumbnail_url = ? WHERE schedule_id = ?', + [broadcaster?.trim() || '', replayUrl?.trim() || null, thumbnailUrl?.trim() || null, id] + ); + } else { + await db.query( + 'INSERT INTO schedule_variety (schedule_id, broadcaster, replay_url, thumbnail_url) VALUES (?, ?, ?, ?)', + [id, broadcaster?.trim() || '', replayUrl?.trim() || null, thumbnailUrl?.trim() || null] + ); + } + + // 멤버 업데이트 + await db.query('DELETE FROM schedule_members WHERE schedule_id = ?', [id]); + if (memberIds && memberIds.length > 0) { + const values = memberIds.map(memberId => [id, memberId]); + await db.query('INSERT INTO schedule_members (schedule_id, member_id) VALUES ?', [values]); + } + + // Meilisearch 동기화 + await syncScheduleById(meilisearch, db, parseInt(id)); + + logActivity(db, { + actor: 'admin', + action: 'update', + category: 'schedule', + targetType: 'variety_schedule', + targetId: parseInt(id), + summary: `예능 일정 수정: ${title.trim()}`, + }); + + return { success: true }; + } catch (err) { + fastify.log.error(`예능 일정 수정 오류: ${err.message}`); + return serverError(reply, err.message); + } + }); + + /** + * GET /api/admin/variety/schedule/:id + * 예능 일정 상세 조회 (수정 폼용) + */ + fastify.get('/schedule/:id', { + schema: { + tags: ['admin/variety'], + summary: '예능 일정 상세 조회', + security: [{ bearerAuth: [] }], + }, + preHandler: [fastify.authenticate], + }, async (request, reply) => { + const { id } = request.params; + + try { + const [rows] = await db.query(` + SELECT s.id, s.title, s.date, s.time, + sv.broadcaster, sv.replay_url, sv.thumbnail_url + FROM schedules s + LEFT JOIN schedule_variety sv ON s.id = sv.schedule_id + WHERE s.id = ? + `, [id]); + + if (rows.length === 0) { + return notFound(reply, '일정을 찾을 수 없습니다.'); + } + + const schedule = rows[0]; + + // 멤버 조회 + const [memberRows] = await db.query( + 'SELECT member_id FROM schedule_members WHERE schedule_id = ?', + [id] + ); + + return { + id: schedule.id, + title: schedule.title, + date: schedule.date instanceof Date ? schedule.date.toISOString().split('T')[0] : schedule.date?.split('T')[0] || '', + time: schedule.time ? schedule.time.substring(0, 5) : '', + broadcaster: schedule.broadcaster || '', + replayUrl: schedule.replay_url || '', + thumbnailUrl: schedule.thumbnail_url || '', + memberIds: memberRows.map(r => r.member_id), + }; + } catch (err) { + fastify.log.error(`예능 일정 조회 오류: ${err.message}`); + return serverError(reply, err.message); + } + }); +} diff --git a/backend/src/routes/index.js b/backend/src/routes/index.js index 3f8dbf7..a214e61 100644 --- a/backend/src/routes/index.js +++ b/backend/src/routes/index.js @@ -9,6 +9,7 @@ import xBotsRoutes from './admin/x-bots.js'; import youtubeAdminRoutes from './admin/youtube.js'; import xAdminRoutes from './admin/x.js'; import concertAdminRoutes from './admin/concert.js'; +import varietyAdminRoutes from './admin/variety.js'; import placesAdminRoutes from './admin/places.js'; import logsAdminRoutes from './admin/logs.js'; @@ -50,6 +51,9 @@ export default async function routes(fastify) { // 관리자 - 콘서트 라우트 fastify.register(concertAdminRoutes, { prefix: '/admin/concert' }); + // 관리자 - 예능 라우트 + fastify.register(varietyAdminRoutes, { prefix: '/admin/variety' }); + // 관리자 - 장소 검색 라우트 fastify.register(placesAdminRoutes, { prefix: '/admin' }); diff --git a/backend/src/services/schedule.js b/backend/src/services/schedule.js index 2784702..c4af345 100644 --- a/backend/src/services/schedule.js +++ b/backend/src/services/schedule.js @@ -200,11 +200,15 @@ export async function getScheduleDetail(db, id, getXProfile = null) { sx.post_id as x_post_id, sx.username as x_username, sx.content as x_content, - sx.image_urls as x_image_urls + sx.image_urls as x_image_urls, + sv.broadcaster as variety_broadcaster, + sv.replay_url as variety_replay_url, + sv.thumbnail_url as variety_thumbnail_url FROM schedules s LEFT JOIN schedule_categories c ON s.category_id = c.id LEFT JOIN schedule_youtube sy ON s.id = sy.schedule_id LEFT JOIN schedule_x sx ON s.id = sx.schedule_id + LEFT JOIN schedule_variety sv ON s.id = sv.schedule_id WHERE s.id = ? `, [id]); @@ -279,6 +283,10 @@ export async function getScheduleDetail(db, id, getXProfile = null) { }; } } + } else if (s.category_id === CATEGORY_IDS.VARIETY && s.variety_broadcaster) { + result.broadcaster = s.variety_broadcaster; + result.replayUrl = s.variety_replay_url || null; + result.thumbnailUrl = s.variety_thumbnail_url || null; } return result; diff --git a/frontend/src/api/admin/variety.js b/frontend/src/api/admin/variety.js new file mode 100644 index 0000000..92cab4f --- /dev/null +++ b/frontend/src/api/admin/variety.js @@ -0,0 +1,31 @@ +/** + * 예능 관리자 API + */ +import { fetchAuthApi } from '@/api/client'; + +/** + * 예능 일정 생성 + */ +export async function createVarietySchedule(data) { + return fetchAuthApi('/admin/variety/schedule', { + method: 'POST', + body: JSON.stringify(data), + }); +} + +/** + * 예능 일정 상세 조회 + */ +export async function getVarietySchedule(id) { + return fetchAuthApi(`/admin/variety/schedule/${id}`); +} + +/** + * 예능 일정 수정 + */ +export async function updateVarietySchedule(id, data) { + return fetchAuthApi(`/admin/variety/schedule/${id}`, { + method: 'PUT', + body: JSON.stringify(data), + }); +} diff --git a/frontend/src/components/pc/admin/schedule/ScheduleItem.jsx b/frontend/src/components/pc/admin/schedule/ScheduleItem.jsx index 761f7a8..920ca7f 100644 --- a/frontend/src/components/pc/admin/schedule/ScheduleItem.jsx +++ b/frontend/src/components/pc/admin/schedule/ScheduleItem.jsx @@ -28,6 +28,8 @@ export const getEditPath = (scheduleId, categoryName, schedule) => { return `/admin/schedule/concert/${schedule.concertSeriesId}/edit`; } return `/admin/schedule/${scheduleId}/edit`; + case '예능': + return `/admin/schedule/${scheduleId}/edit/variety`; default: return `/admin/schedule/${scheduleId}/edit`; } diff --git a/frontend/src/pages/pc/admin/schedules/edit/VarietyEditForm.jsx b/frontend/src/pages/pc/admin/schedules/edit/VarietyEditForm.jsx new file mode 100644 index 0000000..fd26390 --- /dev/null +++ b/frontend/src/pages/pc/admin/schedules/edit/VarietyEditForm.jsx @@ -0,0 +1,196 @@ +import { useState, useEffect } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { motion } from "framer-motion"; +import { Save, Loader2, Tv, Link2, Image, Calendar, Clock, Users } from "lucide-react"; + +import AdminLayout from "@/components/pc/admin/layout/Layout"; +import Toast from "@/components/common/Toast"; +import { useToast } from "@/hooks/common"; +import { useAdminAuth } from "@/hooks/pc/admin"; +import { getMembers } from "@/api/public/members"; +import { getVarietySchedule, updateVarietySchedule } from "@/api/admin/variety"; + +const broadcasterPresets = [ + "KBS", "MBC", "SBS", "tvN", "JTBC", + "Mnet", "유튜브", "티빙", "웨이브", "쿠팡플레이", +]; + +/** + * 예능 일정 수정 폼 + */ +function VarietyEditForm() { + const { id } = useParams(); + const navigate = useNavigate(); + const { toast, setToast } = useToast(); + const { isAuthenticated } = useAdminAuth(); + + const { data: membersData = [] } = useQuery({ + queryKey: ["members"], + queryFn: getMembers, + enabled: isAuthenticated, + staleTime: 5 * 60 * 1000, + }); + const members = membersData.filter((m) => !m.is_former); + + const { data: scheduleData, isLoading } = useQuery({ + queryKey: ["variety-schedule", id], + queryFn: () => getVarietySchedule(id), + enabled: isAuthenticated && !!id, + }); + + const [title, setTitle] = useState(""); + const [broadcaster, setBroadcaster] = useState(""); + const [date, setDate] = useState(""); + const [time, setTime] = useState(""); + const [replayUrl, setReplayUrl] = useState(""); + const [thumbnailUrl, setThumbnailUrl] = useState(""); + const [selectedMemberIds, setSelectedMemberIds] = useState([]); + const [saving, setSaving] = useState(false); + const [initialized, setInitialized] = useState(false); + + useEffect(() => { + if (scheduleData && !initialized) { + setTitle(scheduleData.title || ""); + setBroadcaster(scheduleData.broadcaster || ""); + setDate(scheduleData.date || ""); + setTime(scheduleData.time || ""); + setReplayUrl(scheduleData.replayUrl || ""); + setThumbnailUrl(scheduleData.thumbnailUrl || ""); + setSelectedMemberIds(scheduleData.memberIds || []); + setInitialized(true); + } + }, [scheduleData, initialized]); + + const toggleMember = (memberId) => { + setSelectedMemberIds((prev) => + prev.includes(memberId) ? prev.filter((i) => i !== memberId) : [...prev, memberId] + ); + }; + + const toggleAllMembers = () => { + setSelectedMemberIds( + selectedMemberIds.length === members.length ? [] : members.map((m) => m.id) + ); + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + if (!title.trim() || !broadcaster.trim() || !date) { + setToast({ type: "error", message: "필수 항목을 입력해주세요." }); + return; + } + + setSaving(true); + try { + await updateVarietySchedule(id, { + title: title.trim(), + broadcaster: broadcaster.trim(), + date, + time: time || null, + replayUrl: replayUrl.trim() || null, + thumbnailUrl: thumbnailUrl.trim() || null, + memberIds: selectedMemberIds, + }); + sessionStorage.setItem("scheduleToast", JSON.stringify({ type: "success", message: "예능 일정이 수정되었습니다." })); + navigate("/admin/schedule"); + } catch (err) { + setToast({ type: "error", message: err.message || "수정에 실패했습니다." }); + } finally { + setSaving(false); + } + }; + + if (isLoading) { + return ( + +
+ +
+
+ ); + } + + return ( + + setToast(null)} /> + + {/* 프로그램 정보 */} +
+

프로그램 정보

+
+
+ + setTitle(e.target.value)} placeholder="예: 워크돌 EP.15" className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" /> +
+
+ + setBroadcaster(e.target.value)} placeholder="방송사 또는 플랫폼명" className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent mb-2" /> +
+ {broadcasterPresets.map((p) => ( + + ))} +
+
+
+
+ + setDate(e.target.value)} className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" /> +
+
+ + setTime(e.target.value)} className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" /> +
+
+
+
+ + {/* 출연 멤버 */} +
+

출연 멤버

+
+ + {members.map((m) => ( + + ))} +
+
+ + {/* 추가 정보 */} +
+

추가 정보

+
+
+ + setReplayUrl(e.target.value)} placeholder="https://..." className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" /> +
+
+ + setThumbnailUrl(e.target.value)} placeholder="https://..." className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" /> + {thumbnailUrl && 미리보기 { e.target.style.display = 'none'; }} />} +
+
+
+ + {/* 버튼 */} +
+ + +
+
+
+ ); +} + +export default VarietyEditForm; diff --git a/frontend/src/pages/pc/admin/schedules/form/VarietyForm.jsx b/frontend/src/pages/pc/admin/schedules/form/VarietyForm.jsx new file mode 100644 index 0000000..01a5c7e --- /dev/null +++ b/frontend/src/pages/pc/admin/schedules/form/VarietyForm.jsx @@ -0,0 +1,320 @@ +import { useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; +import { motion } from "framer-motion"; +import { Save, Tv, Link2, Image, Calendar, Clock, Users } from "lucide-react"; + +import Toast from "@/components/common/Toast"; +import { useToast } from "@/hooks/common"; +import { useAdminAuth } from "@/hooks/pc/admin"; +import { getMembers } from "@/api/public/members"; +import { createVarietySchedule } from "@/api/admin/variety"; + +/** + * 예능 일정 추가 폼 + */ +function VarietyForm() { + const navigate = useNavigate(); + const { toast, setToast } = useToast(); + const { isAuthenticated } = useAdminAuth(); + + // 멤버 목록 + const { data: membersData = [] } = useQuery({ + queryKey: ["members"], + queryFn: getMembers, + enabled: isAuthenticated, + staleTime: 5 * 60 * 1000, + }); + const members = membersData.filter((m) => !m.is_former); + + // 폼 상태 + const [title, setTitle] = useState(""); + const [broadcaster, setBroadcaster] = useState(""); + const [date, setDate] = useState(""); + const [time, setTime] = useState(""); + const [replayUrl, setReplayUrl] = useState(""); + const [thumbnailUrl, setThumbnailUrl] = useState(""); + const [selectedMemberIds, setSelectedMemberIds] = useState([]); + const [saving, setSaving] = useState(false); + + // 방송사 프리셋 + const broadcasterPresets = [ + "KBS", "MBC", "SBS", "tvN", "JTBC", + "Mnet", "유튜브", "티빙", "웨이브", "쿠팡플레이", + ]; + + // 멤버 토글 + const toggleMember = (memberId) => { + setSelectedMemberIds((prev) => + prev.includes(memberId) + ? prev.filter((id) => id !== memberId) + : [...prev, memberId] + ); + }; + + const toggleAllMembers = () => { + if (selectedMemberIds.length === members.length) { + setSelectedMemberIds([]); + } else { + setSelectedMemberIds(members.map((m) => m.id)); + } + }; + + // 폼 제출 + const handleSubmit = async (e) => { + e.preventDefault(); + + if (!title.trim()) { + setToast({ type: "error", message: "프로그램명을 입력해주세요." }); + return; + } + if (!broadcaster.trim()) { + setToast({ type: "error", message: "방송사/플랫폼을 선택하거나 입력해주세요." }); + return; + } + if (!date) { + setToast({ type: "error", message: "날짜를 선택해주세요." }); + return; + } + + setSaving(true); + + try { + await createVarietySchedule({ + title: title.trim(), + broadcaster: broadcaster.trim(), + date, + time: time || null, + replayUrl: replayUrl.trim() || null, + thumbnailUrl: thumbnailUrl.trim() || null, + memberIds: selectedMemberIds, + }); + + sessionStorage.setItem( + "scheduleToast", + JSON.stringify({ type: "success", message: "예능 일정이 추가되었습니다." }) + ); + navigate("/admin/schedule"); + } catch (err) { + console.error("예능 일정 저장 실패:", err); + setToast({ type: "error", message: err.message || "저장에 실패했습니다." }); + } finally { + setSaving(false); + } + }; + + return ( + <> + setToast(null)} /> + + + {/* 프로그램 정보 */} +
+

프로그램 정보

+ +
+ {/* 프로그램명 */} +
+ + setTitle(e.target.value)} + placeholder="예: 워크돌 EP.15" + className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" + /> +
+ + {/* 방송사/플랫폼 */} +
+ + setBroadcaster(e.target.value)} + placeholder="방송사 또는 플랫폼명" + className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent mb-2" + /> +
+ {broadcasterPresets.map((preset) => ( + + ))} +
+
+ + {/* 날짜/시간 */} +
+
+ + setDate(e.target.value)} + className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" + /> +
+
+ + setTime(e.target.value)} + className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" + /> +
+
+
+
+ + {/* 출연 멤버 */} +
+

+ + 출연 멤버 +

+ +
+ + {members.map((member) => { + const isSelected = selectedMemberIds.includes(member.id); + return ( + + ); + })} +
+
+ + {/* 추가 정보 */} +
+

추가 정보

+ +
+ {/* 다시보기 링크 */} +
+ + setReplayUrl(e.target.value)} + placeholder="https://www.youtube.com/watch?v=... 또는 OTT 링크" + className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" + /> +
+ + {/* 썸네일 URL */} +
+ + setThumbnailUrl(e.target.value)} + placeholder="https://..." + className="w-full px-3 py-2.5 border border-gray-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent" + /> + {thumbnailUrl && ( +
+ 썸네일 미리보기 { e.target.style.display = 'none'; }} + /> +
+ )} +
+
+
+ + {/* 버튼 */} +
+ + +
+
+ + ); +} + +export default VarietyForm; diff --git a/frontend/src/pages/pc/admin/schedules/form/index.jsx b/frontend/src/pages/pc/admin/schedules/form/index.jsx index 0362bfa..45a352f 100644 --- a/frontend/src/pages/pc/admin/schedules/form/index.jsx +++ b/frontend/src/pages/pc/admin/schedules/form/index.jsx @@ -9,6 +9,7 @@ import CategorySelector from "@/components/pc/admin/schedule/CategorySelector"; import YouTubeForm from "./YouTubeForm"; import XForm from "./XForm"; import ConcertForm from "./concert"; +import VarietyForm from "./VarietyForm"; // 애니메이션 variants const containerVariants = { @@ -78,6 +79,9 @@ function ScheduleFormPage() { case '콘서트': return ; + case '예능': + return ; + // 다른 카테고리는 기존 폼으로 리다이렉트 default: return ( diff --git a/frontend/src/routes/pc/admin/index.jsx b/frontend/src/routes/pc/admin/index.jsx index 82235c8..0d9ac4e 100644 --- a/frontend/src/routes/pc/admin/index.jsx +++ b/frontend/src/routes/pc/admin/index.jsx @@ -33,6 +33,7 @@ import AdminScheduleForm from '@/pages/pc/admin/schedules/ScheduleForm'; import AdminScheduleFormPage from '@/pages/pc/admin/schedules/form'; import AdminYouTubeEditForm from '@/pages/pc/admin/schedules/edit/YouTubeEditForm'; import AdminConcertEditForm from '@/pages/pc/admin/schedules/edit/ConcertEditForm'; +import AdminVarietyEditForm from '@/pages/pc/admin/schedules/edit/VarietyEditForm'; import AdminScheduleCategory from '@/pages/pc/admin/schedules/ScheduleCategory'; import AdminScheduleDict from '@/pages/pc/admin/schedules/ScheduleDict'; import AdminScheduleBots from '@/pages/pc/admin/schedules/ScheduleBots'; @@ -59,6 +60,7 @@ export default function AdminRoutes() { } /> } /> } /> + } /> } /> } /> } />