feat: 일정 추가 폼 카테고리별 분기 마이그레이션
- form/index.jsx: 카테고리 선택 메인 페이지 - form/YouTubeForm.jsx: YouTube URL 기반 일정 추가 - form/XForm.jsx: X 게시글 ID 기반 일정 추가 - form/components/CategorySelector.jsx: 카테고리 선택기 - edit/YouTubeEditForm.jsx: YouTube 일정 수정 폼 - App.jsx 라우트 업데이트 - /admin/schedule/new → 새로운 카테고리 선택 폼 - /admin/schedule/new-legacy → 기존 레거시 폼 - /admin/schedule/youtube/:id/edit → YouTube 수정 폼 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
2a50a07a29
commit
1d5626568a
7 changed files with 1455 additions and 1 deletions
|
|
@ -503,6 +503,11 @@ frontend-temp/src/
|
||||||
- [x] AdminScheduleCategory 마이그레이션
|
- [x] AdminScheduleCategory 마이그레이션
|
||||||
- [x] AdminScheduleDict 마이그레이션
|
- [x] AdminScheduleDict 마이그레이션
|
||||||
- [x] AdminScheduleBots 마이그레이션
|
- [x] AdminScheduleBots 마이그레이션
|
||||||
|
- [x] 카테고리별 폼 분기 페이지 마이그레이션 (form/index.jsx)
|
||||||
|
- [x] YouTubeForm 마이그레이션 (form/YouTubeForm.jsx)
|
||||||
|
- [x] XForm 마이그레이션 (form/XForm.jsx)
|
||||||
|
- [x] CategorySelector 마이그레이션 (form/components/CategorySelector.jsx)
|
||||||
|
- [x] YouTubeEditForm 마이그레이션 (edit/YouTubeEditForm.jsx)
|
||||||
|
|
||||||
### 6단계: 검증
|
### 6단계: 검증
|
||||||
- [ ] 공개 페이지 동작 확인 (PC/Mobile)
|
- [ ] 공개 페이지 동작 확인 (PC/Mobile)
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,8 @@ import AdminAlbumForm from '@/pages/pc/admin/albums/AlbumForm';
|
||||||
import AdminAlbumPhotos from '@/pages/pc/admin/albums/AlbumPhotos';
|
import AdminAlbumPhotos from '@/pages/pc/admin/albums/AlbumPhotos';
|
||||||
import AdminSchedules from '@/pages/pc/admin/schedules/Schedules';
|
import AdminSchedules from '@/pages/pc/admin/schedules/Schedules';
|
||||||
import AdminScheduleForm from '@/pages/pc/admin/schedules/ScheduleForm';
|
import AdminScheduleForm from '@/pages/pc/admin/schedules/ScheduleForm';
|
||||||
|
import AdminScheduleFormPage from '@/pages/pc/admin/schedules/form';
|
||||||
|
import AdminYouTubeEditForm from '@/pages/pc/admin/schedules/edit/YouTubeEditForm';
|
||||||
import AdminScheduleCategory from '@/pages/pc/admin/schedules/ScheduleCategory';
|
import AdminScheduleCategory from '@/pages/pc/admin/schedules/ScheduleCategory';
|
||||||
import AdminScheduleDict from '@/pages/pc/admin/schedules/ScheduleDict';
|
import AdminScheduleDict from '@/pages/pc/admin/schedules/ScheduleDict';
|
||||||
import AdminScheduleBots from '@/pages/pc/admin/schedules/ScheduleBots';
|
import AdminScheduleBots from '@/pages/pc/admin/schedules/ScheduleBots';
|
||||||
|
|
@ -83,8 +85,10 @@ function App() {
|
||||||
<Route path="/admin/albums/:id/edit" element={<AdminAlbumForm />} />
|
<Route path="/admin/albums/:id/edit" element={<AdminAlbumForm />} />
|
||||||
<Route path="/admin/albums/:albumId/photos" element={<AdminAlbumPhotos />} />
|
<Route path="/admin/albums/:albumId/photos" element={<AdminAlbumPhotos />} />
|
||||||
<Route path="/admin/schedule" element={<AdminSchedules />} />
|
<Route path="/admin/schedule" element={<AdminSchedules />} />
|
||||||
<Route path="/admin/schedule/new" element={<AdminScheduleForm />} />
|
<Route path="/admin/schedule/new" element={<AdminScheduleFormPage />} />
|
||||||
|
<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/youtube/:id/edit" element={<AdminYouTubeEditForm />} />
|
||||||
<Route path="/admin/schedule/categories" element={<AdminScheduleCategory />} />
|
<Route path="/admin/schedule/categories" element={<AdminScheduleCategory />} />
|
||||||
<Route path="/admin/schedule/dict" element={<AdminScheduleDict />} />
|
<Route path="/admin/schedule/dict" element={<AdminScheduleDict />} />
|
||||||
<Route path="/admin/schedule/bots" element={<AdminScheduleBots />} />
|
<Route path="/admin/schedule/bots" element={<AdminScheduleBots />} />
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,539 @@
|
||||||
|
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/pc/admin/Layout";
|
||||||
|
import Toast from "@/components/common/Toast";
|
||||||
|
import { useAdminAuth } from "@/hooks/pc/admin";
|
||||||
|
import { useToast } from "@/hooks/common";
|
||||||
|
|
||||||
|
// 애니메이션 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.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.videoId}`
|
||||||
|
: `https://www.youtube.com/watch?v=${schedule.videoId}`;
|
||||||
|
|
||||||
|
// 날짜 포맷팅 함수 (datetime 문자열 파싱)
|
||||||
|
const formatDatetime = (datetime) => {
|
||||||
|
if (!datetime) return "";
|
||||||
|
// datetime: "2025-01-20T14:00:00" 또는 "2025-01-20"
|
||||||
|
const dateStr = datetime.split("T")[0];
|
||||||
|
const timeStr = datetime.includes("T") ? datetime.split("T")[1]?.slice(0, 5) : "";
|
||||||
|
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()];
|
||||||
|
return `${year}년 ${month}월 ${day}일 (${dayName}) ${timeStr}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
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.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.channelName}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<span className="text-gray-400">업로드:</span>{" "}
|
||||||
|
{formatDatetime(schedule.datetime)}
|
||||||
|
</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.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.channelName}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<span className="text-gray-400">업로드:</span>{" "}
|
||||||
|
{formatDatetime(schedule.datetime)}
|
||||||
|
</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;
|
||||||
346
frontend-temp/src/pages/pc/admin/schedules/form/XForm.jsx
Normal file
346
frontend-temp/src/pages/pc/admin/schedules/form/XForm.jsx
Normal file
|
|
@ -0,0 +1,346 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import {
|
||||||
|
Hash,
|
||||||
|
Loader2,
|
||||||
|
Check,
|
||||||
|
AlertCircle,
|
||||||
|
Save,
|
||||||
|
Image as ImageIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
import Toast from "@/components/common/Toast";
|
||||||
|
import { useToast } from "@/hooks/common";
|
||||||
|
|
||||||
|
// X 로고 아이콘
|
||||||
|
const XLogo = ({ size = 24, className = "" }) => (
|
||||||
|
<svg
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* X(Twitter) 일정 추가 폼
|
||||||
|
* - 게시글 ID 입력 시 자동으로 정보 조회
|
||||||
|
*/
|
||||||
|
function XForm() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { toast, setToast } = useToast();
|
||||||
|
|
||||||
|
const [postId, setPostId] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [postInfo, setPostInfo] = useState(null);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
// 게시글 ID 추출 (URL에서도 추출 가능)
|
||||||
|
const extractPostId = (input) => {
|
||||||
|
// 숫자만 있으면 그대로 반환
|
||||||
|
if (/^\d+$/.test(input.trim())) {
|
||||||
|
return input.trim();
|
||||||
|
}
|
||||||
|
// URL에서 추출
|
||||||
|
const match = input.match(/status\/(\d+)/);
|
||||||
|
return match ? match[1] : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// X 게시글 정보 조회
|
||||||
|
const fetchPostInfo = async () => {
|
||||||
|
const id = extractPostId(postId);
|
||||||
|
if (!id) {
|
||||||
|
setError("게시글 ID 또는 URL을 입력해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setPostInfo(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem("adminToken");
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/admin/x/post-info?postId=${id}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
throw new Error(data.error || "게시글 정보를 가져올 수 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
setPostInfo(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 입력 후 엔터 키
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
fetchPostInfo();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 초기화
|
||||||
|
const handleReset = () => {
|
||||||
|
setPostId("");
|
||||||
|
setPostInfo(null);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 폼 제출
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!postInfo) {
|
||||||
|
setError("먼저 게시글 ID를 입력하고 조회해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem("adminToken");
|
||||||
|
|
||||||
|
const response = await fetch("/api/admin/x/schedule", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
postId: postInfo.postId,
|
||||||
|
title: postInfo.title,
|
||||||
|
content: postInfo.text,
|
||||||
|
imageUrls: postInfo.imageUrls,
|
||||||
|
date: postInfo.date,
|
||||||
|
time: postInfo.time,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
throw new Error(data.error || "일정 저장에 실패했습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionStorage.setItem(
|
||||||
|
"scheduleToast",
|
||||||
|
JSON.stringify({
|
||||||
|
type: "success",
|
||||||
|
message: "X 일정이 추가되었습니다.",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
navigate("/admin/schedule");
|
||||||
|
} catch (err) {
|
||||||
|
setToast({
|
||||||
|
type: "error",
|
||||||
|
message: err.message,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Toast toast={toast} onClose={() => setToast(null)} />
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{/* 게시글 ID 입력 */}
|
||||||
|
<div className="bg-white rounded-2xl shadow-sm p-8">
|
||||||
|
<div className="flex items-center gap-2 mb-6">
|
||||||
|
<XLogo size={24} className="text-black" />
|
||||||
|
<h2 className="text-lg font-bold text-gray-900">X 게시글</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* ID 입력 필드 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
게시글 ID 또는 URL *
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<Hash
|
||||||
|
size={18}
|
||||||
|
className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={postId}
|
||||||
|
onChange={(e) => setPostId(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="1234567890 또는 https://x.com/realfromis_9/status/1234567890"
|
||||||
|
className="w-full pl-12 pr-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-gray-900 focus:border-transparent"
|
||||||
|
disabled={loading || postInfo}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!postInfo ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={fetchPostInfo}
|
||||||
|
disabled={loading || !postId.trim()}
|
||||||
|
className="px-6 py-3 bg-black text-white rounded-xl hover:bg-gray-800 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={18} className="animate-spin" />
|
||||||
|
조회 중...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"조회"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleReset}
|
||||||
|
className="px-6 py-3 bg-gray-100 text-gray-600 rounded-xl hover:bg-gray-200 transition-colors whitespace-nowrap"
|
||||||
|
>
|
||||||
|
다시 입력
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 에러 메시지 */}
|
||||||
|
{error && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="flex items-center gap-2 p-4 bg-red-50 text-red-600 rounded-xl"
|
||||||
|
>
|
||||||
|
<AlertCircle size={18} />
|
||||||
|
<span>{error}</span>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 게시글 정보 미리보기 */}
|
||||||
|
{postInfo && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="border border-green-200 bg-green-50 rounded-xl p-6"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<Check size={18} className="text-green-500" />
|
||||||
|
<span className="text-sm font-medium text-green-600">
|
||||||
|
게시글 정보를 가져왔습니다
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 프로필 */}
|
||||||
|
{postInfo.profile?.displayName && (
|
||||||
|
<div className="flex items-center gap-3 mb-4 pb-4 border-b border-green-200">
|
||||||
|
{postInfo.profile.avatarUrl && (
|
||||||
|
<img
|
||||||
|
src={postInfo.profile.avatarUrl}
|
||||||
|
alt=""
|
||||||
|
className="w-10 h-10 rounded-full"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-gray-900">
|
||||||
|
{postInfo.profile.displayName}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500">@{postInfo.username}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 제목 (첫 문단) */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<p className="text-xs text-gray-400 mb-1">제목 (자동 추출)</p>
|
||||||
|
<p className="text-lg font-bold text-gray-900">{postInfo.title}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 전체 내용 */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<p className="text-xs text-gray-400 mb-1">전체 내용</p>
|
||||||
|
<p className="text-gray-700 whitespace-pre-wrap text-sm">
|
||||||
|
{postInfo.text}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 이미지 */}
|
||||||
|
{postInfo.imageUrls?.length > 0 && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<p className="text-xs text-gray-400 mb-2 flex items-center gap-1">
|
||||||
|
<ImageIcon size={12} />
|
||||||
|
이미지 ({postInfo.imageUrls.length}개)
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{postInfo.imageUrls.map((url, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="aspect-square bg-gray-200 rounded-lg overflow-hidden"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt={`이미지 ${index + 1}`}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 날짜/시간 */}
|
||||||
|
<div className="text-sm text-gray-500">
|
||||||
|
<span className="text-gray-400">게시:</span>{" "}
|
||||||
|
{postInfo.date} {postInfo.time}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 버튼 */}
|
||||||
|
<div 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={!postInfo || 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>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default XForm;
|
||||||
301
frontend-temp/src/pages/pc/admin/schedules/form/YouTubeForm.jsx
Normal file
301
frontend-temp/src/pages/pc/admin/schedules/form/YouTubeForm.jsx
Normal file
|
|
@ -0,0 +1,301 @@
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import {
|
||||||
|
Youtube,
|
||||||
|
Link as LinkIcon,
|
||||||
|
Loader2,
|
||||||
|
Check,
|
||||||
|
AlertCircle,
|
||||||
|
Save,
|
||||||
|
} from "lucide-react";
|
||||||
|
import Toast from "@/components/common/Toast";
|
||||||
|
import { useToast } from "@/hooks/common";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* YouTube 일정 추가 폼
|
||||||
|
* - URL 입력 시 자동으로 영상 정보 조회
|
||||||
|
* - 조회된 정보로 일정 저장
|
||||||
|
*/
|
||||||
|
function YouTubeForm() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { toast, setToast } = useToast();
|
||||||
|
|
||||||
|
const [url, setUrl] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [videoInfo, setVideoInfo] = useState(null);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
// YouTube URL에서 영상 정보 조회
|
||||||
|
const fetchVideoInfo = async () => {
|
||||||
|
if (!url.trim()) {
|
||||||
|
setError("YouTube URL을 입력해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setVideoInfo(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem("adminToken");
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/admin/youtube/video-info?url=${encodeURIComponent(url)}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
throw new Error(data.error || "영상 정보를 가져올 수 없습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
setVideoInfo(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// URL 입력 후 엔터 키
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
fetchVideoInfo();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 초기화
|
||||||
|
const handleReset = () => {
|
||||||
|
setUrl("");
|
||||||
|
setVideoInfo(null);
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 폼 제출
|
||||||
|
const handleSubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!videoInfo) {
|
||||||
|
setError("먼저 YouTube URL을 입력하고 조회해주세요.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem("adminToken");
|
||||||
|
|
||||||
|
const response = await fetch("/api/admin/youtube/schedule", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
videoId: videoInfo.videoId,
|
||||||
|
title: videoInfo.title,
|
||||||
|
channelId: videoInfo.channelId,
|
||||||
|
channelName: videoInfo.channelName,
|
||||||
|
date: videoInfo.date,
|
||||||
|
time: videoInfo.time,
|
||||||
|
videoType: videoInfo.videoType,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
throw new Error(data.error || "일정 저장에 실패했습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 성공 메시지를 sessionStorage에 저장하고 목록 페이지로 이동
|
||||||
|
sessionStorage.setItem(
|
||||||
|
"scheduleToast",
|
||||||
|
JSON.stringify({
|
||||||
|
type: "success",
|
||||||
|
message: "YouTube 일정이 추가되었습니다.",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
navigate("/admin/schedule");
|
||||||
|
} catch (err) {
|
||||||
|
setToast({
|
||||||
|
type: "error",
|
||||||
|
message: err.message,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Toast toast={toast} onClose={() => setToast(null)} />
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{/* YouTube URL 입력 */}
|
||||||
|
<div 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">YouTube 영상</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* URL 입력 필드 */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
YouTube URL *
|
||||||
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<LinkIcon
|
||||||
|
size={18}
|
||||||
|
className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="https://www.youtube.com/watch?v=... 또는 https://youtu.be/..."
|
||||||
|
className="w-full pl-12 pr-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-transparent"
|
||||||
|
disabled={loading || videoInfo}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!videoInfo ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={fetchVideoInfo}
|
||||||
|
disabled={loading || !url.trim()}
|
||||||
|
className="px-6 py-3 bg-red-500 text-white rounded-xl hover:bg-red-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 size={18} className="animate-spin" />
|
||||||
|
조회 중...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"조회"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleReset}
|
||||||
|
className="px-6 py-3 bg-gray-100 text-gray-600 rounded-xl hover:bg-gray-200 transition-colors whitespace-nowrap"
|
||||||
|
>
|
||||||
|
다시 입력
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 에러 메시지 */}
|
||||||
|
{error && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: -10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="flex items-center gap-2 p-4 bg-red-50 text-red-600 rounded-xl"
|
||||||
|
>
|
||||||
|
<AlertCircle size={18} />
|
||||||
|
<span>{error}</span>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 영상 정보 미리보기 */}
|
||||||
|
{videoInfo && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="border border-green-200 bg-green-50 rounded-xl p-6"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
{/* 썸네일 */}
|
||||||
|
<div className="flex-shrink-0 w-48 aspect-video bg-gray-200 rounded-lg overflow-hidden">
|
||||||
|
<img
|
||||||
|
src={`https://img.youtube.com/vi/${videoInfo.videoId}/mqdefault.jpg`}
|
||||||
|
alt={videoInfo.title}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 정보 */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<Check size={18} className="text-green-500" />
|
||||||
|
<span className="text-sm font-medium text-green-600">
|
||||||
|
영상 정보를 가져왔습니다
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="text-lg font-bold text-gray-900 mb-2 line-clamp-2">
|
||||||
|
{videoInfo.title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-1 text-sm text-gray-600">
|
||||||
|
<p>
|
||||||
|
<span className="text-gray-400">채널:</span>{" "}
|
||||||
|
{videoInfo.channelName}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className="text-gray-400">업로드:</span>{" "}
|
||||||
|
{videoInfo.date} {videoInfo.time}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span className="text-gray-400">유형:</span>{" "}
|
||||||
|
<span
|
||||||
|
className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||||
|
videoInfo.videoType === "shorts"
|
||||||
|
? "bg-pink-100 text-pink-600"
|
||||||
|
: "bg-blue-100 text-blue-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{videoInfo.videoType === "shorts" ? "Shorts" : "Video"}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 버튼 */}
|
||||||
|
<div 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={!videoInfo || 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>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default YouTubeForm;
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { Settings } from "lucide-react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 카테고리 선택 컴포넌트
|
||||||
|
*/
|
||||||
|
function CategorySelector({ categories, selectedId, onChange }) {
|
||||||
|
// 색상 스타일 (기본 색상 또는 커스텀 HEX)
|
||||||
|
const getColorStyle = (color) => {
|
||||||
|
const colorMap = {
|
||||||
|
blue: "bg-blue-500",
|
||||||
|
green: "bg-green-500",
|
||||||
|
purple: "bg-purple-500",
|
||||||
|
red: "bg-red-500",
|
||||||
|
pink: "bg-pink-500",
|
||||||
|
yellow: "bg-yellow-500",
|
||||||
|
orange: "bg-orange-500",
|
||||||
|
gray: "bg-gray-500",
|
||||||
|
cyan: "bg-cyan-500",
|
||||||
|
indigo: "bg-indigo-500",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!color) return { className: "bg-gray-500" };
|
||||||
|
if (color.startsWith("#")) {
|
||||||
|
return { style: { backgroundColor: color } };
|
||||||
|
}
|
||||||
|
return { className: colorMap[color] || "bg-gray-500" };
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-2xl shadow-sm p-8">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h2 className="text-lg font-bold text-gray-900">카테고리 선택</h2>
|
||||||
|
<Link
|
||||||
|
to="/admin/schedule/categories"
|
||||||
|
className="flex items-center gap-1 text-xs text-gray-400 hover:text-primary transition-colors"
|
||||||
|
>
|
||||||
|
<Settings size={12} />
|
||||||
|
카테고리 관리
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 gap-3">
|
||||||
|
{categories.map((category) => {
|
||||||
|
const colorStyle = getColorStyle(category.color);
|
||||||
|
const isSelected = selectedId === category.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={category.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(category.id)}
|
||||||
|
className={`flex items-center justify-center gap-2 px-4 py-4 rounded-xl border-2 transition-all ${
|
||||||
|
isSelected
|
||||||
|
? "border-primary bg-primary/5 shadow-sm"
|
||||||
|
: "border-gray-200 hover:border-gray-300 hover:bg-gray-50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`w-3 h-3 rounded-full ${colorStyle.className || ""}`}
|
||||||
|
style={colorStyle.style}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={`text-sm font-medium ${
|
||||||
|
isSelected ? "text-primary" : "text-gray-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{category.name}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CategorySelector;
|
||||||
181
frontend-temp/src/pages/pc/admin/schedules/form/index.jsx
Normal file
181
frontend-temp/src/pages/pc/admin/schedules/form/index.jsx
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useNavigate, Link } from "react-router-dom";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
import { Home, ChevronRight } from "lucide-react";
|
||||||
|
import AdminLayout from "@/components/pc/admin/Layout";
|
||||||
|
import { useAdminAuth } from "@/hooks/pc/admin";
|
||||||
|
import * as categoriesApi from "@/api/pc/admin/categories";
|
||||||
|
import CategorySelector from "./components/CategorySelector";
|
||||||
|
import YouTubeForm from "./YouTubeForm";
|
||||||
|
import XForm from "./XForm";
|
||||||
|
|
||||||
|
// 애니메이션 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" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 카테고리 ID 상수
|
||||||
|
const CATEGORY_IDS = {
|
||||||
|
YOUTUBE: 2,
|
||||||
|
X: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 일정 추가 페이지 (카테고리별 폼 분기)
|
||||||
|
*/
|
||||||
|
function ScheduleFormPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user, isAuthenticated } = useAdminAuth();
|
||||||
|
|
||||||
|
const [categories, setCategories] = useState([]);
|
||||||
|
const [selectedCategory, setSelectedCategory] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [isInitialLoad, setIsInitialLoad] = useState(true);
|
||||||
|
|
||||||
|
// 카테고리 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated) return;
|
||||||
|
|
||||||
|
const fetchCategories = async () => {
|
||||||
|
try {
|
||||||
|
const data = await categoriesApi.getCategories();
|
||||||
|
setCategories(data);
|
||||||
|
// 첫 번째 카테고리를 기본값으로
|
||||||
|
if (data.length > 0) {
|
||||||
|
setSelectedCategory(data[0].id);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("카테고리 로드 오류:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchCategories();
|
||||||
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
|
// 카테고리에 따른 폼 렌더링
|
||||||
|
const renderForm = () => {
|
||||||
|
switch (selectedCategory) {
|
||||||
|
case CATEGORY_IDS.YOUTUBE:
|
||||||
|
return <YouTubeForm />;
|
||||||
|
|
||||||
|
case CATEGORY_IDS.X:
|
||||||
|
return <XForm />;
|
||||||
|
|
||||||
|
// 다른 카테고리는 기존 폼으로 리다이렉트
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-2xl shadow-sm p-8 text-center">
|
||||||
|
<p className="text-gray-500 mb-4">
|
||||||
|
이 카테고리는 아직 전용 폼이 없습니다.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/admin/schedule/new-legacy?category=${selectedCategory}`)}
|
||||||
|
className="px-6 py-3 bg-primary text-white rounded-xl hover:bg-primary-dark transition-colors"
|
||||||
|
>
|
||||||
|
기존 폼으로 추가하기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminLayout user={user}>
|
||||||
|
<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">일정 추가</span>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* 타이틀 */}
|
||||||
|
<motion.div variants={itemVariants} className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 mb-2">일정 추가</h1>
|
||||||
|
<p className="text-gray-500">카테고리를 선택하고 일정을 등록하세요</p>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* 카테고리 선택 */}
|
||||||
|
<motion.div variants={itemVariants} className="mb-6">
|
||||||
|
<CategorySelector
|
||||||
|
categories={categories}
|
||||||
|
selectedId={selectedCategory}
|
||||||
|
onChange={(id) => {
|
||||||
|
setSelectedCategory(id);
|
||||||
|
setIsInitialLoad(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* 카테고리별 폼 */}
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<motion.div
|
||||||
|
key={selectedCategory}
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
transition: {
|
||||||
|
duration: isInitialLoad ? 0.4 : 0.15,
|
||||||
|
ease: "easeOut",
|
||||||
|
delay: isInitialLoad ? 0.3 : 0,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
exit={{ opacity: 0, y: -10, transition: { duration: 0.1 } }}
|
||||||
|
>
|
||||||
|
{renderForm()}
|
||||||
|
</motion.div>
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.div>
|
||||||
|
</AdminLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ScheduleFormPage;
|
||||||
Loading…
Add table
Reference in a new issue