451 lines
15 KiB
TypeScript
451 lines
15 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect } from "react";
|
|
import { useAuth } from "@/contexts/AuthContext";
|
|
import { contentVideosApi, ContentVideo, ContentVideoCreate } from "@/lib/api";
|
|
import {
|
|
Youtube,
|
|
Plus,
|
|
Trash2,
|
|
Edit,
|
|
GripVertical,
|
|
X,
|
|
ExternalLink,
|
|
Eye,
|
|
EyeOff,
|
|
} from "lucide-react";
|
|
|
|
interface YouTubeVideoManagerProps {
|
|
entityType: "project" | "solution" | "product";
|
|
entityId: number;
|
|
entityTitle?: string;
|
|
}
|
|
|
|
export default function YouTubeVideoManager({
|
|
entityType,
|
|
entityId,
|
|
entityTitle,
|
|
}: YouTubeVideoManagerProps) {
|
|
const { token } = useAuth();
|
|
const [videos, setVideos] = useState<ContentVideo[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [showModal, setShowModal] = useState(false);
|
|
const [editingVideo, setEditingVideo] = useState<ContentVideo | null>(null);
|
|
const [formData, setFormData] = useState<Partial<ContentVideoCreate>>({
|
|
youtube_id: "",
|
|
title_ko: "",
|
|
title_en: "",
|
|
title_ja: "",
|
|
title_zh: "",
|
|
description_ko: "",
|
|
entity_type: entityType,
|
|
entity_id: entityId,
|
|
is_active: true,
|
|
});
|
|
|
|
const fetchVideos = async () => {
|
|
if (!token) return;
|
|
try {
|
|
const data = await contentVideosApi.adminList(token, entityType, entityId);
|
|
setVideos(data);
|
|
} catch (error) {
|
|
console.error("Failed to fetch videos:", error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchVideos();
|
|
}, [token, entityType, entityId]);
|
|
|
|
const extractYoutubeId = (input: string): string => {
|
|
if (!input) return "";
|
|
// Already a plain ID
|
|
if (/^[a-zA-Z0-9_-]{11}$/.test(input)) return input;
|
|
// Extract from URL
|
|
const match = input.match(
|
|
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([a-zA-Z0-9_-]{11})/
|
|
);
|
|
return match ? match[1] : input;
|
|
};
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
if (!token) return;
|
|
|
|
try {
|
|
const youtubeId = extractYoutubeId(formData.youtube_id || "");
|
|
const payload = {
|
|
...formData,
|
|
youtube_id: youtubeId,
|
|
entity_type: entityType,
|
|
entity_id: entityId,
|
|
};
|
|
|
|
if (editingVideo) {
|
|
await contentVideosApi.adminUpdate(editingVideo.id, payload, token);
|
|
} else {
|
|
await contentVideosApi.adminCreate(payload as ContentVideoCreate, token);
|
|
}
|
|
|
|
setShowModal(false);
|
|
setEditingVideo(null);
|
|
resetForm();
|
|
fetchVideos();
|
|
} catch (error) {
|
|
console.error("Failed to save video:", error);
|
|
alert("저장에 실패했습니다.");
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (id: number) => {
|
|
if (!token || !confirm("이 영상을 삭제하시겠습니까?")) return;
|
|
|
|
try {
|
|
await contentVideosApi.adminDelete(id, token);
|
|
fetchVideos();
|
|
} catch (error) {
|
|
console.error("Failed to delete video:", error);
|
|
}
|
|
};
|
|
|
|
const handleToggleActive = async (video: ContentVideo) => {
|
|
if (!token) return;
|
|
|
|
try {
|
|
await contentVideosApi.adminUpdate(
|
|
video.id,
|
|
{ is_active: !video.is_active },
|
|
token
|
|
);
|
|
fetchVideos();
|
|
} catch (error) {
|
|
console.error("Failed to toggle active:", error);
|
|
}
|
|
};
|
|
|
|
const resetForm = () => {
|
|
setFormData({
|
|
youtube_id: "",
|
|
title_ko: "",
|
|
title_en: "",
|
|
title_ja: "",
|
|
title_zh: "",
|
|
description_ko: "",
|
|
entity_type: entityType,
|
|
entity_id: entityId,
|
|
is_active: true,
|
|
});
|
|
};
|
|
|
|
const openEditModal = (video: ContentVideo) => {
|
|
setEditingVideo(video);
|
|
setFormData({
|
|
youtube_id: video.youtube_id,
|
|
title_ko: video.title_ko,
|
|
title_en: video.title_en || "",
|
|
title_ja: video.title_ja || "",
|
|
title_zh: video.title_zh || "",
|
|
description_ko: video.description_ko || "",
|
|
is_active: video.is_active,
|
|
});
|
|
setShowModal(true);
|
|
};
|
|
|
|
const openAddModal = () => {
|
|
setEditingVideo(null);
|
|
resetForm();
|
|
setShowModal(true);
|
|
};
|
|
|
|
return (
|
|
<div className="bg-white rounded-xl shadow-sm p-6">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center gap-2">
|
|
<Youtube className="w-5 h-5 text-red-500" />
|
|
<h3 className="text-lg font-semibold text-gray-900">YouTube 영상</h3>
|
|
{entityTitle && (
|
|
<span className="text-sm text-gray-500">- {entityTitle}</span>
|
|
)}
|
|
</div>
|
|
<button
|
|
onClick={openAddModal}
|
|
className="flex items-center gap-2 px-3 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition text-sm"
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
영상 추가
|
|
</button>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="text-center py-8 text-gray-500">로딩 중...</div>
|
|
) : videos.length === 0 ? (
|
|
<div className="text-center py-8 text-gray-500 border-2 border-dashed rounded-lg">
|
|
<Youtube className="w-12 h-12 mx-auto mb-2 text-gray-300" />
|
|
<p>등록된 영상이 없습니다</p>
|
|
<button
|
|
onClick={openAddModal}
|
|
className="mt-2 text-red-500 hover:underline text-sm"
|
|
>
|
|
첫 번째 영상 추가하기
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{videos.map((video) => (
|
|
<div
|
|
key={video.id}
|
|
className={`flex items-center gap-4 p-3 rounded-lg border ${
|
|
video.is_active ? "bg-white" : "bg-gray-50 opacity-60"
|
|
}`}
|
|
>
|
|
<GripVertical className="w-5 h-5 text-gray-400 cursor-move" />
|
|
|
|
{/* Thumbnail */}
|
|
<div className="relative w-32 h-20 flex-shrink-0">
|
|
<img
|
|
src={video.thumbnail_url}
|
|
alt={video.title_ko}
|
|
className="w-full h-full object-cover rounded"
|
|
onError={(e) => {
|
|
(e.target as HTMLImageElement).src = `https://img.youtube.com/vi/${video.youtube_id}/hqdefault.jpg`;
|
|
}}
|
|
/>
|
|
<div className="absolute inset-0 flex items-center justify-center">
|
|
<div className="w-10 h-10 bg-red-500/80 rounded-full flex items-center justify-center">
|
|
<div className="w-0 h-0 border-l-[12px] border-l-white border-y-[8px] border-y-transparent ml-1" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Info */}
|
|
<div className="flex-1 min-w-0">
|
|
<h4 className="font-medium text-gray-900 truncate">
|
|
{video.title_ko}
|
|
</h4>
|
|
{video.title_en && (
|
|
<p className="text-sm text-gray-500 truncate">{video.title_en}</p>
|
|
)}
|
|
<p className="text-xs text-gray-400 mt-1">
|
|
ID: {video.youtube_id}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex items-center gap-2">
|
|
<a
|
|
href={video.youtube_url}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="p-2 text-gray-500 hover:text-blue-500 transition"
|
|
title="YouTube에서 보기"
|
|
>
|
|
<ExternalLink className="w-4 h-4" />
|
|
</a>
|
|
<button
|
|
onClick={() => handleToggleActive(video)}
|
|
className={`p-2 transition ${
|
|
video.is_active
|
|
? "text-green-500 hover:text-green-600"
|
|
: "text-gray-400 hover:text-gray-600"
|
|
}`}
|
|
title={video.is_active ? "활성" : "비활성"}
|
|
>
|
|
{video.is_active ? (
|
|
<Eye className="w-4 h-4" />
|
|
) : (
|
|
<EyeOff className="w-4 h-4" />
|
|
)}
|
|
</button>
|
|
<button
|
|
onClick={() => openEditModal(video)}
|
|
className="p-2 text-gray-500 hover:text-blue-500 transition"
|
|
title="수정"
|
|
>
|
|
<Edit className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
onClick={() => handleDelete(video.id)}
|
|
className="p-2 text-gray-500 hover:text-red-500 transition"
|
|
title="삭제"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Modal */}
|
|
{showModal && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
|
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
|
|
<div className="flex items-center justify-between p-4 border-b">
|
|
<h3 className="text-lg font-semibold">
|
|
{editingVideo ? "영상 수정" : "영상 추가"}
|
|
</h3>
|
|
<button
|
|
onClick={() => setShowModal(false)}
|
|
className="p-1 hover:bg-gray-100 rounded"
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit} className="p-4 space-y-4">
|
|
{/* YouTube URL/ID */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
YouTube URL 또는 ID *
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={formData.youtube_id || ""}
|
|
onChange={(e) =>
|
|
setFormData({ ...formData, youtube_id: e.target.value })
|
|
}
|
|
placeholder="https://youtube.com/watch?v=... 또는 영상 ID"
|
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
|
|
required
|
|
/>
|
|
<p className="text-xs text-gray-500 mt-1">
|
|
전체 URL이나 영상 ID(11자리)를 입력하세요
|
|
</p>
|
|
</div>
|
|
|
|
{/* Preview */}
|
|
{formData.youtube_id && (
|
|
<div className="relative aspect-video bg-gray-100 rounded-lg overflow-hidden">
|
|
<img
|
|
src={`https://img.youtube.com/vi/${extractYoutubeId(
|
|
formData.youtube_id
|
|
)}/maxresdefault.jpg`}
|
|
alt="Preview"
|
|
className="w-full h-full object-cover"
|
|
onError={(e) => {
|
|
(e.target as HTMLImageElement).src = `https://img.youtube.com/vi/${extractYoutubeId(
|
|
formData.youtube_id || ""
|
|
)}/hqdefault.jpg`;
|
|
}}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Title KO */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
제목 (한국어) *
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={formData.title_ko || ""}
|
|
onChange={(e) =>
|
|
setFormData({ ...formData, title_ko: e.target.value })
|
|
}
|
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
{/* Title EN */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Title (English)
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={formData.title_en || ""}
|
|
onChange={(e) =>
|
|
setFormData({ ...formData, title_en: e.target.value })
|
|
}
|
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
|
|
/>
|
|
</div>
|
|
|
|
{/* Title JA */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
タイトル (日本語)
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={formData.title_ja || ""}
|
|
onChange={(e) =>
|
|
setFormData({ ...formData, title_ja: e.target.value })
|
|
}
|
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
|
|
/>
|
|
</div>
|
|
|
|
{/* Title ZH */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
标题 (中文)
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={formData.title_zh || ""}
|
|
onChange={(e) =>
|
|
setFormData({ ...formData, title_zh: e.target.value })
|
|
}
|
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
|
|
/>
|
|
</div>
|
|
|
|
{/* Description */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
설명 (선택)
|
|
</label>
|
|
<textarea
|
|
value={formData.description_ko || ""}
|
|
onChange={(e) =>
|
|
setFormData({ ...formData, description_ko: e.target.value })
|
|
}
|
|
rows={3}
|
|
className="w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-red-500 focus:border-transparent"
|
|
/>
|
|
</div>
|
|
|
|
{/* Active Toggle */}
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="checkbox"
|
|
id="is_active"
|
|
checked={formData.is_active}
|
|
onChange={(e) =>
|
|
setFormData({ ...formData, is_active: e.target.checked })
|
|
}
|
|
className="w-4 h-4 text-red-500 rounded"
|
|
/>
|
|
<label htmlFor="is_active" className="text-sm text-gray-700">
|
|
활성화 (웹사이트에 표시)
|
|
</label>
|
|
</div>
|
|
|
|
{/* Buttons */}
|
|
<div className="flex justify-end gap-2 pt-4">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowModal(false)}
|
|
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-lg hover:bg-gray-200 transition"
|
|
>
|
|
취소
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
className="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition"
|
|
>
|
|
{editingVideo ? "수정" : "추가"}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|