feat: YouTube 일정 수정 폼 구현

- YouTube 일정 수정 API (PUT /api/admin/youtube/schedule/:id)
- 멤버 선택, 영상 유형(video/shorts) 수정 기능
- 일정 API에 멤버 배열 추가 (5명 이상 시 "프로미스나인")
- 관리 페이지 React Query 캐싱 적용
- Shorts/Video 별 UI 레이아웃 분리
- React Query 사용 가이드 문서화

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-01-20 14:06:02 +09:00
parent 2d469739b7
commit 4a4a163abe
7 changed files with 781 additions and 44 deletions

View file

@ -132,6 +132,110 @@ export default async function youtubeRoutes(fastify) {
return reply.code(500).send({ error: err.message }); return reply.code(500).send({ error: err.message });
} }
}); });
/**
* PUT /api/admin/youtube/schedule/:id
* YouTube 일정 수정 (멤버, 영상 유형 수정 가능)
*/
fastify.put('/schedule/:id', {
schema: {
tags: ['admin/youtube'],
summary: 'YouTube 일정 수정',
security: [{ bearerAuth: [] }],
params: {
type: 'object',
properties: {
id: { type: 'integer' },
},
required: ['id'],
},
body: {
type: 'object',
properties: {
memberIds: { type: 'array', items: { type: 'integer' } },
videoType: { type: 'string', enum: ['video', 'shorts'] },
},
},
},
preHandler: [fastify.authenticate],
}, async (request, reply) => {
const { id } = request.params;
const { memberIds = [], videoType } = request.body;
try {
// 일정 존재 확인
const [schedules] = await db.query(
'SELECT id, title, date, time FROM schedules WHERE id = ? AND category_id = ?',
[id, YOUTUBE_CATEGORY_ID]
);
if (schedules.length === 0) {
return reply.code(404).send({ error: 'YouTube 일정을 찾을 수 없습니다.' });
}
// 영상 유형 수정
if (videoType) {
await db.query(
'UPDATE schedule_youtube SET video_type = ? WHERE schedule_id = ?',
[videoType, id]
);
}
// 기존 멤버 삭제
await db.query('DELETE FROM schedule_members WHERE schedule_id = ?', [id]);
// 새 멤버 추가
if (memberIds.length > 0) {
const values = memberIds.map(memberId => [id, memberId]);
await db.query(
'INSERT INTO schedule_members (schedule_id, member_id) VALUES ?',
[values]
);
}
// 멤버 이름 조회 (Meilisearch 동기화용)
let memberNames = '';
if (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(',');
}
// YouTube 채널 정보 조회
const [youtubeInfo] = await db.query(
'SELECT channel_name FROM schedule_youtube WHERE schedule_id = ?',
[id]
);
const channelName = youtubeInfo[0]?.channel_name || '';
// 카테고리 정보 조회
const [categoryRows] = await db.query(
'SELECT name, color FROM schedule_categories WHERE id = ?',
[YOUTUBE_CATEGORY_ID]
);
const category = categoryRows[0] || {};
// Meilisearch 동기화
const schedule = schedules[0];
await addOrUpdateSchedule(meilisearch, {
id: schedule.id,
title: schedule.title,
date: schedule.date,
time: schedule.time || '',
category_id: YOUTUBE_CATEGORY_ID,
category_name: category.name || '',
category_color: category.color || '',
member_names: memberNames,
source_name: channelName,
});
return { success: true };
} catch (err) {
fastify.log.error(`YouTube 일정 수정 오류: ${err.message}`);
return reply.code(500).send({ error: err.message });
}
});
} }
/** /**

View file

@ -112,6 +112,16 @@ export default async function schedulesRoutes(fastify) {
} }
const s = schedules[0]; const s = schedules[0];
// 멤버 정보 조회
const [members] = await db.query(`
SELECT m.id, m.name
FROM schedule_members sm
JOIN members m ON sm.member_id = m.id
WHERE sm.schedule_id = ?
ORDER BY m.id
`, [id]);
const result = { const result = {
id: s.id, id: s.id,
title: s.title, title: s.title,
@ -122,6 +132,15 @@ export default async function schedulesRoutes(fastify) {
name: s.category_name, name: s.category_name,
color: s.category_color, color: s.category_color,
}, },
members: members,
youtube: s.youtube_video_id ? {
videoId: s.youtube_video_id,
videoType: s.youtube_video_type,
channelName: s.youtube_channel,
} : null,
x: s.x_post_id ? {
postId: s.x_post_id,
} : null,
created_at: s.created_at, created_at: s.created_at,
updated_at: s.updated_at, updated_at: s.updated_at,
}; };
@ -256,6 +275,28 @@ async function handleMonthlySchedules(db, year, month) {
ORDER BY s.date ASC, s.time ASC ORDER BY s.date ASC, s.time ASC
`, [startDate, endDate]); `, [startDate, endDate]);
// 일정 멤버 조회
const scheduleIds = schedules.map(s => s.id);
let memberMap = {};
if (scheduleIds.length > 0) {
const [scheduleMembers] = await db.query(`
SELECT sm.schedule_id, m.name
FROM schedule_members sm
JOIN members m ON sm.member_id = m.id
WHERE sm.schedule_id IN (?)
ORDER BY m.id
`, [scheduleIds]);
// 일정별 멤버 그룹화
for (const sm of scheduleMembers) {
if (!memberMap[sm.schedule_id]) {
memberMap[sm.schedule_id] = [];
}
memberMap[sm.schedule_id].push({ name: sm.name });
}
}
// 생일 조회 // 생일 조회
const [birthdays] = await db.query(` const [birthdays] = await db.query(`
SELECT m.id, m.name, m.name_en, m.birth_date, SELECT m.id, m.name, m.name_en, m.birth_date,
@ -281,6 +322,12 @@ async function handleMonthlySchedules(db, year, month) {
}; };
} }
// 멤버 정보 (5명 이상이면 프로미스나인)
const scheduleMembers = memberMap[s.id] || [];
const members = scheduleMembers.length >= 5
? [{ name: '프로미스나인' }]
: scheduleMembers;
const schedule = { const schedule = {
id: s.id, id: s.id,
title: s.title, title: s.title,
@ -290,6 +337,7 @@ async function handleMonthlySchedules(db, year, month) {
name: s.category_name, name: s.category_name,
color: s.category_color, color: s.category_color,
}, },
members,
}; };
// source 정보 추가 (YouTube: 2, X: 3) // source 정보 추가 (YouTube: 2, X: 3)

View file

@ -164,6 +164,53 @@ docker exec caddy caddy reload --config /etc/caddy/Caddyfile
--- ---
## 프론트엔드 개발 가이드
### React Query 사용 (데이터 페칭)
데이터 페칭 시 `useEffect` 대신 `useQuery`를 사용합니다.
**이유:**
- `useEffect`는 React StrictMode에서 2번 실행됨 (개발 모드)
- `useQuery`는 자동 캐싱, 중복 요청 방지, 에러/로딩 상태 관리 제공
**예시:**
```jsx
// ❌ Bad - useEffect 사용
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch('/api/data')
.then(res => res.json())
.then(data => setData(data))
.finally(() => setLoading(false));
}, []);
// ✅ Good - useQuery 사용
import { useQuery } from '@tanstack/react-query';
const { data, isLoading } = useQuery({
queryKey: ['data'],
queryFn: () => fetch('/api/data').then(res => res.json()),
});
```
**캐시 무효화:**
```jsx
import { useQueryClient } from '@tanstack/react-query';
const queryClient = useQueryClient();
// 특정 쿼리 무효화
queryClient.invalidateQueries({ queryKey: ['schedules'] });
// 모든 쿼리 무효화
queryClient.invalidateQueries();
```
---
## 유용한 명령어 ## 유용한 명령어
```bash ```bash

View file

@ -42,6 +42,7 @@ import ScheduleFormPage from './pages/pc/admin/schedule/form';
import AdminScheduleCategory from './pages/pc/admin/AdminScheduleCategory'; import AdminScheduleCategory from './pages/pc/admin/AdminScheduleCategory';
import AdminScheduleBots from './pages/pc/admin/AdminScheduleBots'; import AdminScheduleBots from './pages/pc/admin/AdminScheduleBots';
import AdminScheduleDict from './pages/pc/admin/AdminScheduleDict'; import AdminScheduleDict from './pages/pc/admin/AdminScheduleDict';
import YouTubeEditForm from './pages/pc/admin/schedule/edit/YouTubeEditForm';
// //
import PCLayout from './components/pc/Layout'; import PCLayout from './components/pc/Layout';
@ -76,6 +77,7 @@ function App() {
<Route path="/admin/schedule/new" element={<ScheduleFormPage />} /> <Route path="/admin/schedule/new" element={<ScheduleFormPage />} />
<Route path="/admin/schedule/new-legacy" element={<AdminScheduleForm />} /> <Route path="/admin/schedule/new-legacy" element={<AdminScheduleForm />} />
<Route path="/admin/schedule/:id/edit" element={<AdminScheduleForm />} /> <Route path="/admin/schedule/:id/edit" element={<AdminScheduleForm />} />
<Route path="/admin/schedule/:id/edit/youtube" element={<YouTubeEditForm />} />
<Route path="/admin/schedule/categories" element={<AdminScheduleCategory />} /> <Route path="/admin/schedule/categories" element={<AdminScheduleCategory />} />
<Route path="/admin/schedule/bots" element={<AdminScheduleBots />} /> <Route path="/admin/schedule/bots" element={<AdminScheduleBots />} />
<Route path="/admin/schedule/dict" element={<AdminScheduleDict />} /> <Route path="/admin/schedule/dict" element={<AdminScheduleDict />} />

View file

@ -5,7 +5,7 @@ import {
Home, ChevronRight, Calendar, Plus, Edit2, Trash2, Home, ChevronRight, Calendar, Plus, Edit2, Trash2,
ChevronLeft, Search, ChevronDown, Bot, Tag, ArrowLeft, ExternalLink, Clock, Link2, Book ChevronLeft, Search, ChevronDown, Bot, Tag, ArrowLeft, ExternalLink, Clock, Link2, Book
} from 'lucide-react'; } from 'lucide-react';
import { useInfiniteQuery } from '@tanstack/react-query'; import { useQuery, useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
import { useInView } from 'react-intersection-observer'; import { useInView } from 'react-intersection-observer';
@ -27,6 +27,24 @@ const decodeHtmlEntities = (text) => {
return textarea.value; return textarea.value;
}; };
// ID
const CATEGORY_IDS = {
YOUTUBE: 2,
X: 3,
};
//
const getEditPath = (scheduleId, categoryId) => {
switch (categoryId) {
case CATEGORY_IDS.YOUTUBE:
return `/admin/schedule/${scheduleId}/edit/youtube`;
case CATEGORY_IDS.X:
return `/admin/schedule/${scheduleId}/edit/x`;
default:
return `/admin/schedule/${scheduleId}/edit`;
}
};
// - React.memo // - React.memo
const ScheduleItem = memo(function ScheduleItem({ const ScheduleItem = memo(function ScheduleItem({
schedule, schedule,
@ -119,7 +137,7 @@ const ScheduleItem = memo(function ScheduleItem({
</a> </a>
)} )}
<button <button
onClick={() => navigate(`/admin/schedule/${schedule.id}/edit`)} onClick={() => navigate(getEditPath(schedule.id, schedule.category_id))}
className="p-2 hover:bg-gray-200 rounded-lg transition-colors text-gray-500" className="p-2 hover:bg-gray-200 rounded-lg transition-colors text-gray-500"
> >
<Edit2 size={18} /> <Edit2 size={18} />
@ -139,6 +157,7 @@ const ScheduleItem = memo(function ScheduleItem({
function AdminSchedule() { function AdminSchedule() {
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient();
// Zustand // Zustand
const { const {
@ -154,7 +173,6 @@ function AdminSchedule() {
const { user, isAuthenticated } = useAdminAuth(); const { user, isAuthenticated } = useAdminAuth();
// ( ) // ( )
const [loading, setLoading] = useState(false);
const { toast, setToast } = useToast(); const { toast, setToast } = useToast();
const scrollContainerRef = useRef(null); const scrollContainerRef = useRef(null);
const searchContainerRef = useRef(null); // ( ) const searchContainerRef = useRef(null); // ( )
@ -288,8 +306,12 @@ function AdminSchedule() {
const getDaysInMonth = (y, m) => new Date(y, m + 1, 0).getDate(); const getDaysInMonth = (y, m) => new Date(y, m + 1, 0).getDate();
// (API ) // (React Query )
const [schedules, setSchedules] = useState([]); const { data: schedules = [], isLoading: loading } = useQuery({
queryKey: ['adminSchedules', year, month + 1],
queryFn: () => schedulesApi.getSchedules(year, month + 1),
enabled: isAuthenticated,
});
// //
const categories = useMemo(() => { const categories = useMemo(() => {
@ -386,14 +408,10 @@ function AdminSchedule() {
if (savedToast) { if (savedToast) {
setToast(JSON.parse(savedToast)); setToast(JSON.parse(savedToast));
sessionStorage.removeItem('scheduleToast'); sessionStorage.removeItem('scheduleToast');
// /
queryClient.invalidateQueries({ queryKey: ['adminSchedules'] });
} }
}, [isAuthenticated]); }, [isAuthenticated, queryClient]);
//
useEffect(() => {
fetchSchedules();
}, [year, month]);
// //
useEffect(() => { useEffect(() => {
@ -414,21 +432,6 @@ function AdminSchedule() {
setScrollPosition(e.target.scrollTop); setScrollPosition(e.target.scrollTop);
}; };
//
const fetchSchedules = async () => {
setLoading(true);
try {
const data = await schedulesApi.getSchedules(year, month + 1);
setSchedules(data);
} catch (error) {
console.error('일정 로드 오류:', error);
} finally {
setLoading(false);
}
};
// //
useEffect(() => { useEffect(() => {
const handleClickOutside = (event) => { const handleClickOutside = (event) => {
@ -466,7 +469,6 @@ function AdminSchedule() {
const firstDay = `${newDate.getFullYear()}-${String(newDate.getMonth() + 1).padStart(2, '0')}-01`; const firstDay = `${newDate.getFullYear()}-${String(newDate.getMonth() + 1).padStart(2, '0')}-01`;
setSelectedDate(firstDay); setSelectedDate(firstDay);
} }
setSchedules([]); //
}; };
const nextMonth = () => { const nextMonth = () => {
@ -481,7 +483,6 @@ function AdminSchedule() {
const firstDay = `${newDate.getFullYear()}-${String(newDate.getMonth() + 1).padStart(2, '0')}-01`; const firstDay = `${newDate.getFullYear()}-${String(newDate.getMonth() + 1).padStart(2, '0')}-01`;
setSelectedDate(firstDay); setSelectedDate(firstDay);
} }
setSchedules([]); //
}; };
// (12 , 2025 ) // (12 , 2025 )
@ -534,7 +535,8 @@ function AdminSchedule() {
try { try {
await schedulesApi.deleteSchedule(scheduleToDelete.id); await schedulesApi.deleteSchedule(scheduleToDelete.id);
setToast({ type: 'success', message: '일정이 삭제되었습니다.' }); setToast({ type: 'success', message: '일정이 삭제되었습니다.' });
fetchSchedules(); //
queryClient.invalidateQueries({ queryKey: ['adminSchedules', year, month + 1] });
} catch (error) { } catch (error) {
console.error('삭제 오류:', error); console.error('삭제 오류:', error);
setToast({ type: 'error', message: error.message || '삭제 중 오류가 발생했습니다.' }); setToast({ type: 'error', message: error.message || '삭제 중 오류가 발생했습니다.' });
@ -1277,19 +1279,16 @@ function AdminSchedule() {
</span> </span>
)} )}
</div> </div>
{schedule.member_names && ( {(schedule.members?.length > 0 || schedule.member_names) && (
<div className="flex flex-wrap gap-1.5 mt-2"> <div className="flex flex-wrap gap-1.5 mt-2">
{schedule.member_names.split(',').length >= 5 ? ( {(() => {
<span className="px-2 py-0.5 bg-primary/10 text-primary text-xs font-medium rounded-full"> const memberList = schedule.members?.map(m => m.name) || schedule.member_names?.split(',') || [];
프로미스나인 return memberList.map((name, i) => (
</span>
) : (
schedule.member_names.split(',').map((name, i) => (
<span key={i} className="px-2 py-0.5 bg-primary/10 text-primary text-xs font-medium rounded-full"> <span key={i} className="px-2 py-0.5 bg-primary/10 text-primary text-xs font-medium rounded-full">
{name.trim()} {name.trim()}
</span> </span>
)) ));
)} })()}
</div> </div>
)} )}
</div> </div>
@ -1307,7 +1306,7 @@ function AdminSchedule() {
</a> </a>
)} )}
<button <button
onClick={() => navigate(`/admin/schedule/${schedule.id}/edit`)} onClick={() => navigate(getEditPath(schedule.id, schedule.category_id))}
className="p-2 hover:bg-gray-200 rounded-lg transition-colors text-gray-500" className="p-2 hover:bg-gray-200 rounded-lg transition-colors text-gray-500"
> >
<Edit2 size={18} /> <Edit2 size={18} />

View file

@ -0,0 +1,537 @@
import { useState, useEffect } from "react";
import { useNavigate, useParams, Link } from "react-router-dom";
import { motion } from "framer-motion";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
Youtube,
Loader2,
Save,
ExternalLink,
Home,
ChevronRight,
Users,
Check,
} from "lucide-react";
import AdminLayout from "../../../../../components/admin/AdminLayout";
import Toast from "../../../../../components/Toast";
import useAdminAuth from "../../../../../hooks/useAdminAuth";
import useToast from "../../../../../hooks/useToast";
// variants
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.1,
},
},
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: { duration: 0.4, ease: "easeOut" },
},
};
/**
* YouTube 일정 수정
* - 기존 일정 데이터 로드
* - 멤버 선택 수정
*/
function YouTubeEditForm() {
const navigate = useNavigate();
const { id } = useParams();
const queryClient = useQueryClient();
const { user, isAuthenticated } = useAdminAuth();
const { toast, setToast } = useToast();
const [saving, setSaving] = useState(false);
const [selectedMembers, setSelectedMembers] = useState([]);
const [videoType, setVideoType] = useState("video");
const [isInitialized, setIsInitialized] = useState(false);
//
const { data: schedule, isLoading: scheduleLoading } = useQuery({
queryKey: ["schedule", id],
queryFn: async () => {
const token = localStorage.getItem("adminToken");
const res = await fetch(`/api/schedules/${id}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error("일정을 찾을 수 없습니다.");
return res.json();
},
enabled: isAuthenticated && !!id,
});
//
const { data: membersData = [], isLoading: membersLoading } = useQuery({
queryKey: ["members"],
queryFn: async () => {
const res = await fetch("/api/members");
if (!res.ok) throw new Error("멤버 목록을 불러올 수 없습니다.");
return res.json();
},
enabled: isAuthenticated,
});
//
const members = membersData.filter((m) => !m.is_former);
//
useEffect(() => {
if (schedule && !isInitialized) {
// YouTube
if (schedule.category?.id !== 2) {
setToast({ type: "error", message: "YouTube 일정이 아닙니다." });
navigate("/admin/schedule");
return;
}
setSelectedMembers(schedule.members?.map((m) => m.id) || []);
setVideoType(schedule.youtube?.videoType || "video");
setIsInitialized(true);
}
}, [schedule, isInitialized, navigate, setToast]);
const loading = scheduleLoading || membersLoading;
//
const toggleMember = (memberId) => {
setSelectedMembers((prev) =>
prev.includes(memberId)
? prev.filter((id) => id !== memberId)
: [...prev, memberId]
);
};
// /
const toggleAllMembers = () => {
if (selectedMembers.length === members.length) {
setSelectedMembers([]);
} else {
setSelectedMembers(members.map((m) => m.id));
}
};
//
const handleSubmit = async (e) => {
e.preventDefault();
setSaving(true);
try {
const token = localStorage.getItem("adminToken");
const response = await fetch(`/api/admin/youtube/schedule/${id}`, {
method: "PUT",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
memberIds: selectedMembers,
videoType,
}),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || "수정에 실패했습니다.");
}
//
queryClient.invalidateQueries({ queryKey: ["schedule", id] });
sessionStorage.setItem(
"scheduleToast",
JSON.stringify({
type: "success",
message: "YouTube 일정이 수정되었습니다.",
})
);
navigate("/admin/schedule");
} catch (err) {
setToast({
type: "error",
message: err.message,
});
} finally {
setSaving(false);
}
};
if (loading) {
return (
<AdminLayout user={user}>
<div className="flex items-center justify-center min-h-[400px]">
<div className="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin" />
</div>
</AdminLayout>
);
}
if (!schedule) {
return null;
}
const videoUrl = videoType === "shorts"
? `https://www.youtube.com/shorts/${schedule.youtube?.videoId}`
: `https://www.youtube.com/watch?v=${schedule.youtube?.videoId}`;
//
const formatDate = (dateStr, timeStr) => {
if (!dateStr) return "";
const date = new Date(dateStr);
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
const dayNames = ["일", "월", "화", "수", "목", "금", "토"];
const dayName = dayNames[date.getDay()];
const time = timeStr ? timeStr.slice(0, 5) : "";
return `${year}${month}${day}일 (${dayName}) ${time}`;
};
return (
<AdminLayout user={user}>
<Toast toast={toast} onClose={() => setToast(null)} />
<motion.div
className="max-w-4xl mx-auto px-6 py-8"
variants={containerVariants}
initial="hidden"
animate="visible"
>
{/* 브레드크럼 */}
<motion.div
variants={itemVariants}
className="flex items-center gap-2 text-sm text-gray-400 mb-8"
>
<Link
to="/admin/dashboard"
className="hover:text-primary transition-colors"
>
<Home size={16} />
</Link>
<ChevronRight size={14} />
<Link
to="/admin/schedule"
className="hover:text-primary transition-colors"
>
일정 관리
</Link>
<ChevronRight size={14} />
<span className="text-gray-700">YouTube 일정 수정</span>
</motion.div>
<form onSubmit={handleSubmit} className="space-y-6">
{videoType === "shorts" ? (
/* Shorts 레이아웃: 영상(왼쪽) + 정보/멤버(오른쪽) */
<motion.div
variants={itemVariants}
className="bg-white rounded-2xl shadow-sm p-8"
>
<div className="flex gap-8">
{/* 왼쪽: 영상 */}
<div className="flex-shrink-0 w-96">
<div className="bg-black rounded-xl overflow-hidden">
<div className="relative aspect-[9/16]">
<iframe
src={`https://www.youtube.com/embed/${schedule.youtube?.videoId}`}
title={schedule.title}
className="absolute inset-0 w-full h-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
</div>
</div>
{/* 오른쪽: 정보 + 멤버 선택 */}
<div className="flex-1 min-w-0">
{/* 영상 정보 */}
<div className="flex items-center gap-2 mb-4">
<Youtube size={24} className="text-red-500" />
<h2 className="text-lg font-bold text-gray-900">영상 정보</h2>
</div>
<h3 className="text-base font-bold text-gray-900 mb-3 line-clamp-2">
{schedule.title}
</h3>
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-gray-600 mb-3">
<span>
<span className="text-gray-400">채널:</span>{" "}
{schedule.youtube?.channelName}
</span>
<span>
<span className="text-gray-400">업로드:</span>{" "}
{formatDate(schedule.date, schedule.time)}
</span>
</div>
<div className="flex items-center gap-4 mb-4">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-400">유형:</span>
<button
type="button"
onClick={() => setVideoType("video")}
className="px-3 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-500 hover:bg-gray-200 transition-colors"
>
Video
</button>
<button
type="button"
onClick={() => setVideoType("shorts")}
className="px-3 py-1 rounded-full text-xs font-medium bg-pink-500 text-white transition-colors"
>
Shorts
</button>
</div>
<a
href={videoUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm text-red-500 hover:text-red-600 transition-colors"
>
<ExternalLink size={14} />
YouTube
</a>
</div>
{/* 멤버 선택 */}
<div className="border-t border-gray-100 pt-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Users size={18} className="text-primary" />
<h2 className="text-base font-bold text-gray-900">출연 멤버</h2>
</div>
<button
type="button"
onClick={toggleAllMembers}
className="text-sm text-primary hover:underline"
>
{selectedMembers.length === members.length
? "전체 해제"
: "전체 선택"}
</button>
</div>
<div className="grid grid-cols-3 gap-3">
{members.map((member) => {
const isSelected = selectedMembers.includes(member.id);
return (
<button
key={member.id}
type="button"
onClick={() => toggleMember(member.id)}
className={`relative rounded-xl overflow-hidden transition-all ${
isSelected
? "ring-2 ring-primary ring-offset-2"
: "hover:opacity-80"
}`}
>
<div className="aspect-[3/4] bg-gray-100">
{member.image_url ? (
<img
src={member.image_url}
alt={member.name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-gray-200">
<Users size={20} className="text-gray-400" />
</div>
)}
</div>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 to-transparent p-2">
<p className="text-white text-xs font-medium text-center">
{member.name}
</p>
</div>
{isSelected && (
<div className="absolute top-1.5 right-1.5 w-5 h-5 bg-primary rounded-full flex items-center justify-center">
<Check size={12} className="text-white" />
</div>
)}
</button>
);
})}
</div>
</div>
</div>
</div>
</motion.div>
) : (
/* Video 레이아웃: 기존 세로 배치 */
<>
<motion.div
variants={itemVariants}
className="bg-white rounded-2xl shadow-sm p-8"
>
<div className="flex items-center gap-2 mb-6">
<Youtube size={24} className="text-red-500" />
<h2 className="text-lg font-bold text-gray-900">영상 정보</h2>
</div>
<div className="w-full bg-black rounded-xl overflow-hidden mb-6">
<div className="relative aspect-video">
<iframe
src={`https://www.youtube.com/embed/${schedule.youtube?.videoId}`}
title={schedule.title}
className="absolute inset-0 w-full h-full"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
</div>
<div className="space-y-3">
<h3 className="text-lg font-bold text-gray-900">
{schedule.title}
</h3>
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 text-sm text-gray-600">
<span>
<span className="text-gray-400">채널:</span>{" "}
{schedule.youtube?.channelName}
</span>
<span>
<span className="text-gray-400">업로드:</span>{" "}
{formatDate(schedule.date, schedule.time)}
</span>
<div className="flex items-center gap-2">
<span className="text-gray-400">유형:</span>
<button
type="button"
onClick={() => setVideoType("video")}
className="px-3 py-1 rounded-full text-xs font-medium bg-blue-500 text-white transition-colors"
>
Video
</button>
<button
type="button"
onClick={() => setVideoType("shorts")}
className="px-3 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-500 hover:bg-gray-200 transition-colors"
>
Shorts
</button>
</div>
</div>
<a
href={videoUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-sm text-red-500 hover:text-red-600 transition-colors"
>
<ExternalLink size={16} />
YouTube에서 보기
</a>
</div>
</motion.div>
<motion.div
variants={itemVariants}
className="bg-white rounded-2xl shadow-sm p-8"
>
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-2">
<Users size={20} className="text-primary" />
<h2 className="text-lg font-bold text-gray-900">출연 멤버</h2>
</div>
<button
type="button"
onClick={toggleAllMembers}
className="text-sm text-primary hover:underline"
>
{selectedMembers.length === members.length
? "전체 해제"
: "전체 선택"}
</button>
</div>
<div className="grid grid-cols-5 gap-4">
{members.map((member) => {
const isSelected = selectedMembers.includes(member.id);
return (
<button
key={member.id}
type="button"
onClick={() => toggleMember(member.id)}
className={`relative rounded-xl overflow-hidden transition-all ${
isSelected
? "ring-2 ring-primary ring-offset-2"
: "hover:opacity-80"
}`}
>
<div className="aspect-[3/4] bg-gray-100">
{member.image_url ? (
<img
src={member.image_url}
alt={member.name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-gray-200">
<Users size={24} className="text-gray-400" />
</div>
)}
</div>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/70 to-transparent p-3">
<p className="text-white text-sm font-medium">
{member.name}
</p>
</div>
{isSelected && (
<div className="absolute top-2 right-2 w-6 h-6 bg-primary rounded-full flex items-center justify-center">
<Check size={14} className="text-white" />
</div>
)}
</button>
);
})}
</div>
</motion.div>
</>
)}
{/* 버튼 */}
<motion.div
variants={itemVariants}
className="flex items-center justify-end gap-4"
>
<button
type="button"
onClick={() => navigate("/admin/schedule")}
className="px-6 py-3 text-gray-700 hover:bg-gray-100 rounded-xl transition-colors font-medium"
>
취소
</button>
<button
type="submit"
disabled={saving}
className="flex items-center gap-2 px-6 py-3 bg-primary text-white rounded-xl hover:bg-primary-dark transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? (
<>
<Loader2 size={18} className="animate-spin" />
저장 ...
</>
) : (
<>
<Save size={18} />
저장하기
</>
)}
</button>
</motion.div>
</form>
</motion.div>
</AdminLayout>
);
}
export default YouTubeEditForm;

View file

@ -274,7 +274,7 @@ function Schedule() {
category_name: s.category?.name, category_name: s.category?.name,
category_color: s.category?.color, category_color: s.category?.color,
// members ( ) // members ( )
member_names: Array.isArray(s.members) ? s.members.join(',') : s.member_names, member_names: Array.isArray(s.members) ? s.members.map(m => m.name).join(',') : s.member_names,
}; };
}) })
); );