feat: 예능 카테고리 관리 기능 구현

- 백엔드: 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) <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-04-04 18:16:16 +09:00
parent 96969e1bf0
commit 73f84fd7ac
10 changed files with 789 additions and 1 deletions

View file

@ -3,6 +3,7 @@ export const CATEGORY_IDS = {
YOUTUBE: 2,
X: 3,
CONCERT: 6,
VARIETY: 10,
BIRTHDAY: 8,
DEBUT: 9,
};

View file

@ -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);
}
});
}

View file

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

View file

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

View file

@ -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),
});
}

View file

@ -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`;
}

View file

@ -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 (
<AdminLayout>
<div className="flex items-center justify-center h-64">
<Loader2 className="w-8 h-8 text-primary animate-spin" />
</div>
</AdminLayout>
);
}
return (
<AdminLayout>
<Toast toast={toast} onClose={() => setToast(null)} />
<motion.form
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
onSubmit={handleSubmit}
className="space-y-6"
>
{/* 프로그램 정보 */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
<h2 className="text-lg font-bold text-gray-900 mb-6">프로그램 정보</h2>
<div className="space-y-4">
<div>
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 mb-1.5"><Tv size={14} />프로그램명 *</label>
<input type="text" value={title} onChange={(e) => 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" />
</div>
<div>
<label className="text-sm font-medium text-gray-700 mb-1.5 block">방송사/플랫폼 *</label>
<input type="text" value={broadcaster} onChange={(e) => 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" />
<div className="flex flex-wrap gap-1.5">
{broadcasterPresets.map((p) => (
<button key={p} type="button" onClick={() => setBroadcaster(p)} className={`px-3 py-1 text-xs rounded-full border transition-colors ${broadcaster === p ? "border-primary bg-primary text-white" : "border-gray-200 text-gray-500 hover:border-gray-300"}`}>{p}</button>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 mb-1.5"><Calendar size={14} />날짜 *</label>
<input type="date" value={date} onChange={(e) => 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" />
</div>
<div>
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 mb-1.5"><Clock size={14} />시간 (선택)</label>
<input type="time" value={time} onChange={(e) => 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" />
</div>
</div>
</div>
</div>
{/* 출연 멤버 */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
<h2 className="flex items-center gap-2 text-lg font-bold text-gray-900 mb-6"><Users size={18} />출연 멤버</h2>
<div className="flex flex-wrap gap-2">
<button type="button" onClick={toggleAllMembers} className={`px-4 py-1.5 rounded-full border text-sm transition-colors ${selectedMemberIds.length === members.length ? "border-primary bg-primary text-white" : "border-gray-200 text-gray-500 hover:border-gray-300"}`}>{selectedMemberIds.length === members.length ? "전체 해제" : "전체 선택"}</button>
{members.map((m) => (
<button key={m.id} type="button" onClick={() => toggleMember(m.id)} className={`flex items-center gap-2 pr-3.5 pl-1.5 py-1.5 rounded-full border transition-colors ${selectedMemberIds.includes(m.id) ? "border-primary" : "border-gray-200"}`}>
<div className="w-9 h-9 rounded-full overflow-hidden bg-gray-200 flex-shrink-0">{m.image_url ? <img src={m.image_url} alt={m.name} className="w-full h-full object-cover" /> : <div className="w-full h-full bg-gray-300" />}</div>
<span className="text-sm text-gray-700">{m.name}</span>
</button>
))}
</div>
</div>
{/* 추가 정보 */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
<h2 className="text-lg font-bold text-gray-900 mb-6">추가 정보</h2>
<div className="space-y-4">
<div>
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 mb-1.5"><Link2 size={14} />다시보기 링크 (선택)</label>
<input type="url" value={replayUrl} onChange={(e) => 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" />
</div>
<div>
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 mb-1.5"><Image size={14} />썸네일 이미지 URL (선택)</label>
<input type="url" value={thumbnailUrl} onChange={(e) => 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 && <img src={thumbnailUrl} alt="미리보기" className="mt-2 h-32 rounded-lg object-cover" onError={(e) => { e.target.style.display = 'none'; }} />}
</div>
</div>
</div>
{/* 버튼 */}
<div className="flex items-center justify-end gap-4">
<button type="button" onClick={() => navigate("/admin/schedule")} className="px-6 py-2.5 text-gray-600 hover:text-gray-900 transition-colors">취소</button>
<button type="submit" disabled={saving} className="flex items-center gap-2 px-6 py-2.5 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors disabled:opacity-50">
{saving ? (<><span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />수정 ...</>) : (<><Save size={18} />수정</>)}
</button>
</div>
</motion.form>
</AdminLayout>
);
}
export default VarietyEditForm;

View file

@ -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 (
<>
<Toast toast={toast} onClose={() => setToast(null)} />
<motion.form
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, ease: [0.25, 0.1, 0.25, 1] }}
onSubmit={handleSubmit}
className="space-y-6"
>
{/* 프로그램 정보 */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
<h2 className="text-lg font-bold text-gray-900 mb-6">프로그램 정보</h2>
<div className="space-y-4">
{/* 프로그램명 */}
<div>
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 mb-1.5">
<Tv size={14} />
프로그램명 *
</label>
<input
type="text"
value={title}
onChange={(e) => 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"
/>
</div>
{/* 방송사/플랫폼 */}
<div>
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 mb-1.5">
방송사/플랫폼 *
</label>
<input
type="text"
value={broadcaster}
onChange={(e) => 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"
/>
<div className="flex flex-wrap gap-1.5">
{broadcasterPresets.map((preset) => (
<button
key={preset}
type="button"
onClick={() => setBroadcaster(preset)}
className={`px-3 py-1 text-xs rounded-full border transition-colors ${
broadcaster === preset
? "border-primary bg-primary text-white"
: "border-gray-200 text-gray-500 hover:border-gray-300"
}`}
>
{preset}
</button>
))}
</div>
</div>
{/* 날짜/시간 */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 mb-1.5">
<Calendar size={14} />
날짜 *
</label>
<input
type="date"
value={date}
onChange={(e) => 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"
/>
</div>
<div>
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 mb-1.5">
<Clock size={14} />
시간 (선택)
</label>
<input
type="time"
value={time}
onChange={(e) => 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"
/>
</div>
</div>
</div>
</div>
{/* 출연 멤버 */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
<h2 className="flex items-center gap-2 text-lg font-bold text-gray-900 mb-6">
<Users size={18} />
출연 멤버
</h2>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={toggleAllMembers}
className={`flex items-center justify-center px-4 py-1.5 rounded-full border text-sm transition-colors ${
selectedMemberIds.length === members.length
? "border-primary bg-primary text-white"
: "border-gray-200 text-gray-500 hover:border-gray-300"
}`}
>
{selectedMemberIds.length === members.length ? "전체 해제" : "전체 선택"}
</button>
{members.map((member) => {
const isSelected = selectedMemberIds.includes(member.id);
return (
<button
key={member.id}
type="button"
onClick={() => toggleMember(member.id)}
className={`flex items-center gap-2 pr-3.5 pl-1.5 py-1.5 rounded-full border transition-colors ${
isSelected ? "border-primary" : "border-gray-200"
}`}
>
<div className="w-9 h-9 rounded-full overflow-hidden bg-gray-200 flex-shrink-0">
{member.image_url ? (
<img src={member.image_url} alt={member.name} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full bg-gray-300" />
)}
</div>
<span className="text-sm text-gray-700">{member.name}</span>
</button>
);
})}
</div>
</div>
{/* 추가 정보 */}
<div className="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
<h2 className="text-lg font-bold text-gray-900 mb-6">추가 정보</h2>
<div className="space-y-4">
{/* 다시보기 링크 */}
<div>
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 mb-1.5">
<Link2 size={14} />
다시보기 링크 (선택)
</label>
<input
type="url"
value={replayUrl}
onChange={(e) => 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"
/>
</div>
{/* 썸네일 URL */}
<div>
<label className="flex items-center gap-2 text-sm font-medium text-gray-700 mb-1.5">
<Image size={14} />
썸네일 이미지 URL (선택)
</label>
<input
type="url"
value={thumbnailUrl}
onChange={(e) => 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 && (
<div className="mt-2">
<img
src={thumbnailUrl}
alt="썸네일 미리보기"
className="h-32 rounded-lg object-cover"
onError={(e) => { e.target.style.display = 'none'; }}
/>
</div>
)}
</div>
</div>
</div>
{/* 버튼 */}
<div className="flex items-center justify-end gap-4">
<button
type="button"
onClick={() => navigate("/admin/schedule")}
className="px-6 py-2.5 text-gray-600 hover:text-gray-900 transition-colors"
>
취소
</button>
<button
type="submit"
disabled={saving}
className="flex items-center gap-2 px-6 py-2.5 bg-primary text-white rounded-lg hover:bg-primary-dark transition-colors disabled:opacity-50"
>
{saving ? (
<>
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
저장 ...
</>
) : (
<>
<Save size={18} />
저장
</>
)}
</button>
</div>
</motion.form>
</>
);
}
export default VarietyForm;

View file

@ -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 <ConcertForm />;
case '예능':
return <VarietyForm />;
//
default:
return (

View file

@ -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() {
<Route path="/admin/schedule/:id/edit" element={<RequireAuth><AdminScheduleForm /></RequireAuth>} />
<Route path="/admin/schedule/:id/edit/youtube" element={<RequireAuth><AdminYouTubeEditForm /></RequireAuth>} />
<Route path="/admin/schedule/concert/:seriesId/edit" element={<RequireAuth><AdminConcertEditForm /></RequireAuth>} />
<Route path="/admin/schedule/:id/edit/variety" element={<RequireAuth><AdminVarietyEditForm /></RequireAuth>} />
<Route path="/admin/schedule/categories" element={<RequireAuth><AdminScheduleCategory /></RequireAuth>} />
<Route path="/admin/schedule/dict" element={<RequireAuth><AdminScheduleDict /></RequireAuth>} />
<Route path="/admin/schedule/bots" element={<RequireAuth><AdminScheduleBots /></RequireAuth>} />