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:
parent
2d469739b7
commit
4a4a163abe
7 changed files with 781 additions and 44 deletions
|
|
@ -132,6 +132,110 @@ export default async function youtubeRoutes(fastify) {
|
|||
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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -112,6 +112,16 @@ export default async function schedulesRoutes(fastify) {
|
|||
}
|
||||
|
||||
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 = {
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
|
|
@ -122,6 +132,15 @@ export default async function schedulesRoutes(fastify) {
|
|||
name: s.category_name,
|
||||
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,
|
||||
updated_at: s.updated_at,
|
||||
};
|
||||
|
|
@ -256,6 +275,28 @@ async function handleMonthlySchedules(db, year, month) {
|
|||
ORDER BY s.date ASC, s.time ASC
|
||||
`, [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(`
|
||||
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 = {
|
||||
id: s.id,
|
||||
title: s.title,
|
||||
|
|
@ -290,6 +337,7 @@ async function handleMonthlySchedules(db, year, month) {
|
|||
name: s.category_name,
|
||||
color: s.category_color,
|
||||
},
|
||||
members,
|
||||
};
|
||||
|
||||
// source 정보 추가 (YouTube: 2, X: 3)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ import ScheduleFormPage from './pages/pc/admin/schedule/form';
|
|||
import AdminScheduleCategory from './pages/pc/admin/AdminScheduleCategory';
|
||||
import AdminScheduleBots from './pages/pc/admin/AdminScheduleBots';
|
||||
import AdminScheduleDict from './pages/pc/admin/AdminScheduleDict';
|
||||
import YouTubeEditForm from './pages/pc/admin/schedule/edit/YouTubeEditForm';
|
||||
|
||||
// 레이아웃
|
||||
import PCLayout from './components/pc/Layout';
|
||||
|
|
@ -76,6 +77,7 @@ function App() {
|
|||
<Route path="/admin/schedule/new" element={<ScheduleFormPage />} />
|
||||
<Route path="/admin/schedule/new-legacy" 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/bots" element={<AdminScheduleBots />} />
|
||||
<Route path="/admin/schedule/dict" element={<AdminScheduleDict />} />
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import {
|
|||
Home, ChevronRight, Calendar, Plus, Edit2, Trash2,
|
||||
ChevronLeft, Search, ChevronDown, Bot, Tag, ArrowLeft, ExternalLink, Clock, Link2, Book
|
||||
} from 'lucide-react';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { useQuery, useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useInView } from 'react-intersection-observer';
|
||||
|
||||
|
|
@ -27,6 +27,24 @@ const decodeHtmlEntities = (text) => {
|
|||
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로 불필요한 리렌더링 방지
|
||||
const ScheduleItem = memo(function ScheduleItem({
|
||||
schedule,
|
||||
|
|
@ -119,7 +137,7 @@ const ScheduleItem = memo(function ScheduleItem({
|
|||
</a>
|
||||
)}
|
||||
<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"
|
||||
>
|
||||
<Edit2 size={18} />
|
||||
|
|
@ -139,7 +157,8 @@ const ScheduleItem = memo(function ScheduleItem({
|
|||
|
||||
function AdminSchedule() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Zustand 스토어에서 상태 가져오기
|
||||
const {
|
||||
searchInput, setSearchInput,
|
||||
|
|
@ -150,11 +169,10 @@ function AdminSchedule() {
|
|||
currentDate, setCurrentDate,
|
||||
scrollPosition, setScrollPosition,
|
||||
} = useScheduleStore();
|
||||
|
||||
|
||||
const { user, isAuthenticated } = useAdminAuth();
|
||||
|
||||
// 로컬 상태 (페이지 이동 시 유지할 필요 없는 것들)
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { toast, setToast } = useToast();
|
||||
const scrollContainerRef = useRef(null);
|
||||
const searchContainerRef = useRef(null); // 검색 컨테이너 (외부 클릭 감지용)
|
||||
|
|
@ -288,8 +306,12 @@ function AdminSchedule() {
|
|||
|
||||
const getDaysInMonth = (y, m) => new Date(y, m + 1, 0).getDate();
|
||||
|
||||
// 일정 목록 (API에서 로드)
|
||||
const [schedules, setSchedules] = useState([]);
|
||||
// 일정 목록 (React Query로 캐싱)
|
||||
const { data: schedules = [], isLoading: loading } = useQuery({
|
||||
queryKey: ['adminSchedules', year, month + 1],
|
||||
queryFn: () => schedulesApi.getSchedules(year, month + 1),
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
// 카테고리는 일정 데이터에서 추출
|
||||
const categories = useMemo(() => {
|
||||
|
|
@ -386,15 +408,11 @@ function AdminSchedule() {
|
|||
if (savedToast) {
|
||||
setToast(JSON.parse(savedToast));
|
||||
sessionStorage.removeItem('scheduleToast');
|
||||
// 추가/수정 후 돌아왔을 때 캐시 무효화
|
||||
queryClient.invalidateQueries({ queryKey: ['adminSchedules'] });
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
}, [isAuthenticated, queryClient]);
|
||||
|
||||
|
||||
// 월이 변경될 때마다 일정 로드
|
||||
useEffect(() => {
|
||||
fetchSchedules();
|
||||
}, [year, month]);
|
||||
|
||||
// 스크롤 위치 복원
|
||||
useEffect(() => {
|
||||
if (scrollContainerRef.current && scrollPosition > 0) {
|
||||
|
|
@ -414,21 +432,6 @@ function AdminSchedule() {
|
|||
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(() => {
|
||||
const handleClickOutside = (event) => {
|
||||
|
|
@ -466,7 +469,6 @@ function AdminSchedule() {
|
|||
const firstDay = `${newDate.getFullYear()}-${String(newDate.getMonth() + 1).padStart(2, '0')}-01`;
|
||||
setSelectedDate(firstDay);
|
||||
}
|
||||
setSchedules([]); // 이전 달 데이터 즉시 초기화
|
||||
};
|
||||
|
||||
const nextMonth = () => {
|
||||
|
|
@ -481,7 +483,6 @@ function AdminSchedule() {
|
|||
const firstDay = `${newDate.getFullYear()}-${String(newDate.getMonth() + 1).padStart(2, '0')}-01`;
|
||||
setSelectedDate(firstDay);
|
||||
}
|
||||
setSchedules([]); // 이전 달 데이터 즉시 초기화
|
||||
};
|
||||
|
||||
// 년도 범위 이동 (12년 단위, 2025년 이전 불가)
|
||||
|
|
@ -529,12 +530,13 @@ function AdminSchedule() {
|
|||
// 일정 삭제
|
||||
const handleDelete = async () => {
|
||||
if (!scheduleToDelete) return;
|
||||
|
||||
|
||||
setDeleting(true);
|
||||
try {
|
||||
await schedulesApi.deleteSchedule(scheduleToDelete.id);
|
||||
setToast({ type: 'success', message: '일정이 삭제되었습니다.' });
|
||||
fetchSchedules();
|
||||
// 캐시 무효화하여 목록 새로고침
|
||||
queryClient.invalidateQueries({ queryKey: ['adminSchedules', year, month + 1] });
|
||||
} catch (error) {
|
||||
console.error('삭제 오류:', error);
|
||||
setToast({ type: 'error', message: error.message || '삭제 중 오류가 발생했습니다.' });
|
||||
|
|
@ -1277,19 +1279,16 @@ function AdminSchedule() {
|
|||
</span>
|
||||
)}
|
||||
</div>
|
||||
{schedule.member_names && (
|
||||
{(schedule.members?.length > 0 || schedule.member_names) && (
|
||||
<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">
|
||||
프로미스나인
|
||||
</span>
|
||||
) : (
|
||||
schedule.member_names.split(',').map((name, i) => (
|
||||
{(() => {
|
||||
const memberList = schedule.members?.map(m => m.name) || schedule.member_names?.split(',') || [];
|
||||
return memberList.map((name, i) => (
|
||||
<span key={i} className="px-2 py-0.5 bg-primary/10 text-primary text-xs font-medium rounded-full">
|
||||
{name.trim()}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -1307,7 +1306,7 @@ function AdminSchedule() {
|
|||
</a>
|
||||
)}
|
||||
<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"
|
||||
>
|
||||
<Edit2 size={18} />
|
||||
|
|
|
|||
537
frontend/src/pages/pc/admin/schedule/edit/YouTubeEditForm.jsx
Normal file
537
frontend/src/pages/pc/admin/schedule/edit/YouTubeEditForm.jsx
Normal 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;
|
||||
|
|
@ -274,7 +274,7 @@ function Schedule() {
|
|||
category_name: s.category?.name,
|
||||
category_color: s.category?.color,
|
||||
// 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,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue