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:
parent
96969e1bf0
commit
73f84fd7ac
10 changed files with 789 additions and 1 deletions
|
|
@ -3,6 +3,7 @@ export const CATEGORY_IDS = {
|
|||
YOUTUBE: 2,
|
||||
X: 3,
|
||||
CONCERT: 6,
|
||||
VARIETY: 10,
|
||||
BIRTHDAY: 8,
|
||||
DEBUT: 9,
|
||||
};
|
||||
|
|
|
|||
220
backend/src/routes/admin/variety.js
Normal file
220
backend/src/routes/admin/variety.js
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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' });
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
31
frontend/src/api/admin/variety.js
Normal file
31
frontend/src/api/admin/variety.js
Normal 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),
|
||||
});
|
||||
}
|
||||
|
|
@ -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`;
|
||||
}
|
||||
|
|
|
|||
196
frontend/src/pages/pc/admin/schedules/edit/VarietyEditForm.jsx
Normal file
196
frontend/src/pages/pc/admin/schedules/edit/VarietyEditForm.jsx
Normal 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;
|
||||
320
frontend/src/pages/pc/admin/schedules/form/VarietyForm.jsx
Normal file
320
frontend/src/pages/pc/admin/schedules/form/VarietyForm.jsx
Normal 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;
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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>} />
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue