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 });
|
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 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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 />} />
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
|
|
|
||||||
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_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,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue