527 lines
22 KiB
TypeScript
527 lines
22 KiB
TypeScript
import React, { useState, useEffect, useCallback } from 'react';
|
||
import { Plus, Search, Edit2, Trash2, Video as VideoIcon, Star, X, ImageIcon, Film, Play } from 'lucide-react';
|
||
import { toast } from 'react-hot-toast';
|
||
import api, { BASE_DOMAIN } from '../services/api';
|
||
import ConfirmModal from '../components/ui/ConfirmModal';
|
||
import MediaSelector from '../components/ui/MediaSelector';
|
||
import Button from '../components/ui/Button';
|
||
import Modal from '../components/ui/Modal';
|
||
|
||
|
||
interface Video {
|
||
id: string;
|
||
title: string;
|
||
doctor: string;
|
||
doctorId?: string;
|
||
duration: string;
|
||
thumbnail: string;
|
||
videoUrl: string;
|
||
description: string;
|
||
viewsCount: number;
|
||
isFeatured: boolean;
|
||
createdAt: string;
|
||
doctorRef?: {
|
||
id: string;
|
||
name: string;
|
||
title?: string;
|
||
avatarUrl?: string;
|
||
};
|
||
}
|
||
|
||
interface DoctorOption {
|
||
id: string;
|
||
name: string;
|
||
title?: string;
|
||
}
|
||
|
||
export default function Videos() {
|
||
const [videos, setVideos] = useState<Video[]>([]);
|
||
const [doctorsList, setDoctorsList] = useState<DoctorOption[]>([]);
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
const [searchTerm, setSearchTerm] = useState('');
|
||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||
const [editingVideo, setEditingVideo] = useState<Video | null>(null);
|
||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||
|
||
const [isThumbnailMediaOpen, setIsThumbnailMediaOpen] = useState(false);
|
||
const [isVideoMediaOpen, setIsVideoMediaOpen] = useState(false);
|
||
|
||
const [formData, setFormData] = useState({
|
||
title: '',
|
||
doctor: '',
|
||
doctorId: '',
|
||
duration: '۰۲:۰۰',
|
||
thumbnail: '',
|
||
videoUrl: '',
|
||
description: '',
|
||
isFeatured: false,
|
||
});
|
||
|
||
const fetchVideos = useCallback(async () => {
|
||
try {
|
||
setIsLoading(true);
|
||
const res = await api.get('/videos', {
|
||
params: { search: searchTerm, limit: 100 },
|
||
});
|
||
const raw = res.data;
|
||
const videoList = Array.isArray(raw?.videos)
|
||
? raw.videos
|
||
: Array.isArray(raw?.data?.videos)
|
||
? raw.data.videos
|
||
: Array.isArray(raw?.data)
|
||
? raw.data
|
||
: Array.isArray(raw)
|
||
? raw
|
||
: [];
|
||
setVideos(videoList);
|
||
} catch (err) {
|
||
console.error('Failed to fetch videos:', err);
|
||
setVideos([]);
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
}, [searchTerm]);
|
||
|
||
const fetchDoctors = useCallback(async () => {
|
||
try {
|
||
const res = await api.get('/doctors', { params: { limit: 100 } });
|
||
const raw = res.data;
|
||
const list = Array.isArray(raw?.doctors)
|
||
? raw.doctors
|
||
: Array.isArray(raw?.data?.doctors)
|
||
? raw.data.doctors
|
||
: Array.isArray(raw)
|
||
? raw
|
||
: [];
|
||
setDoctorsList(list);
|
||
} catch (e) {
|
||
console.error('Failed to fetch doctors list:', e);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
fetchVideos();
|
||
fetchDoctors();
|
||
}, [fetchVideos, fetchDoctors]);
|
||
|
||
const handleOpenModal = (video?: Video) => {
|
||
if (video) {
|
||
setEditingVideo(video);
|
||
setFormData({
|
||
title: video.title,
|
||
doctor: video.doctor,
|
||
doctorId: video.doctorId || '',
|
||
duration: video.duration || '۰۲:۰۰',
|
||
thumbnail: video.thumbnail || '',
|
||
videoUrl: video.videoUrl,
|
||
description: video.description || '',
|
||
isFeatured: Boolean(video.isFeatured),
|
||
});
|
||
} else {
|
||
setEditingVideo(null);
|
||
setFormData({
|
||
title: '',
|
||
doctor: '',
|
||
doctorId: '',
|
||
duration: '۰۲:۰۰',
|
||
thumbnail: '',
|
||
videoUrl: '',
|
||
description: '',
|
||
isFeatured: false,
|
||
});
|
||
}
|
||
setIsModalOpen(true);
|
||
};
|
||
|
||
const handleSave = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
try {
|
||
if (editingVideo) {
|
||
await api.put(`/videos/${editingVideo.id}`, formData);
|
||
toast.success('ویدئو با موفقیت ویرایش شد');
|
||
} else {
|
||
await api.post('/videos', formData);
|
||
toast.success('ویدئو جدید با موفقیت ذخیره شد');
|
||
}
|
||
setIsModalOpen(false);
|
||
await fetchVideos();
|
||
} catch (err) {
|
||
console.error('Failed to save video:', err);
|
||
toast.error('خطا در ذخیرهسازی ویدئو');
|
||
}
|
||
};
|
||
|
||
const confirmDelete = async () => {
|
||
if (!deleteTargetId) return;
|
||
try {
|
||
await api.delete(`/videos/${deleteTargetId}`);
|
||
toast.success('ویدئو با موفقیت حذف شد');
|
||
await fetchVideos();
|
||
} catch (err: any) {
|
||
console.error('Failed to delete video:', err);
|
||
if (err?.response?.status === 404) {
|
||
toast.error('این ویدئو در پایگاه داده یافت نشد (احتمالاً قبلاً حذف شده است).');
|
||
await fetchVideos();
|
||
} else {
|
||
toast.error('خطا در حذف ویدئو');
|
||
}
|
||
} finally {
|
||
setDeleteTargetId(null);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-6 font-vazir text-right" dir="rtl">
|
||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 bg-white p-6 rounded-2xl border border-gray-100 shadow-xs">
|
||
<div>
|
||
<h1 className="text-2xl font-black text-gray-900 flex items-center gap-3">
|
||
<VideoIcon className="w-7 h-7 text-purple-600" />
|
||
مدیریت ویدئوها و آکادمی آموزشی
|
||
</h1>
|
||
<p className="text-sm text-gray-500 font-bold mt-1">
|
||
آپلود و انتخاب مستقیم ویدئو/کاور از گالری رسانه یا درج کد آیفرم آپارات/یوتیوب برای مشاوره ویدئویی دامپزشکان کنینا
|
||
</p>
|
||
</div>
|
||
<Button
|
||
variant="primary"
|
||
size="sm"
|
||
startIcon={Plus}
|
||
onClick={() => handleOpenModal()}
|
||
>
|
||
افزودن ویدئوی جدید
|
||
</Button>
|
||
</div>
|
||
|
||
|
||
<div className="bg-white p-4 rounded-2xl border border-gray-100 shadow-xs flex items-center gap-4">
|
||
<div className="relative flex-1">
|
||
<Search className="w-5 h-5 text-gray-400 absolute right-4 top-1/2 -translate-y-1/2" />
|
||
<input
|
||
type="text"
|
||
placeholder="جستجو در عناوین، نام دامپزشک یا توضیحات ویدئو..."
|
||
value={searchTerm}
|
||
onChange={(e) => setSearchTerm(e.target.value)}
|
||
className="w-full bg-gray-50 border border-gray-200 rounded-xl pr-12 pl-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500/20 font-bold"
|
||
/>
|
||
</div>
|
||
<div className="text-xs font-black text-gray-400 bg-gray-50 px-4 py-3 rounded-xl border border-gray-200 shrink-0">
|
||
تعداد کل: {videos.length} مورد
|
||
</div>
|
||
</div>
|
||
|
||
{isLoading ? (
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||
<div key={i} className="bg-white rounded-3xl p-5 border border-gray-100 shadow-xs animate-pulse space-y-4">
|
||
<div className="aspect-video bg-gray-100 rounded-2xl" />
|
||
<div className="h-5 bg-gray-100 rounded-lg w-3/4" />
|
||
<div className="h-4 bg-gray-100 rounded-lg w-1/2" />
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : videos.length === 0 ? (
|
||
<div className="bg-white rounded-3xl p-12 text-center border border-gray-100">
|
||
<VideoIcon className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||
<h3 className="text-lg font-black text-gray-700">هیچ ویدئویی یافت نشد</h3>
|
||
<p className="text-sm text-gray-400 font-bold mt-1">با کلیک روی دکمه افزودن، اولین ویدئو را اضافه کنید.</p>
|
||
</div>
|
||
) : (
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||
{videos.map((video) => {
|
||
const thumbUrl = video.thumbnail?.startsWith('http') ? video.thumbnail : (video.thumbnail ? `${BASE_DOMAIN}${video.thumbnail}` : '');
|
||
return (
|
||
<div
|
||
key={video.id}
|
||
className="bg-white rounded-3xl overflow-hidden border border-gray-100 shadow-xs hover:shadow-xl transition-all duration-300 flex flex-col group"
|
||
>
|
||
<div className="aspect-video bg-gray-900 relative overflow-hidden flex items-center justify-center">
|
||
{thumbUrl ? (
|
||
<img
|
||
src={thumbUrl}
|
||
alt={video.title}
|
||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
|
||
/>
|
||
) : video.videoUrl && !video.videoUrl.includes('<iframe') ? (
|
||
<video
|
||
src={video.videoUrl.startsWith('http') ? video.videoUrl : `${BASE_DOMAIN}${video.videoUrl}`}
|
||
preload="metadata"
|
||
className="w-full h-full object-cover opacity-80"
|
||
/>
|
||
) : (
|
||
<VideoIcon className="w-12 h-12 text-gray-600" />
|
||
)}
|
||
|
||
<div className="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||
<div className="w-12 h-12 rounded-full bg-white/90 text-purple-600 flex items-center justify-center shadow-lg">
|
||
<Play className="w-6 h-6 fill-purple-600 ml-1" />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="absolute bottom-3 left-3 bg-black/70 backdrop-blur-xs text-white text-[10px] font-mono font-bold px-2 py-0.5 rounded-lg">
|
||
{video.duration || '۰۲:۰۰'}
|
||
</div>
|
||
|
||
{video.isFeatured && (
|
||
<div className="absolute top-3 right-3 bg-amber-500 text-white text-[10px] font-black px-2.5 py-1 rounded-full shadow-md flex items-center gap-1">
|
||
<Star className="w-3 h-3 fill-white" />
|
||
<span>ویژه صفحه اصلی</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="p-5 flex-1 flex flex-col justify-between space-y-4">
|
||
<div>
|
||
<h3 className="font-black text-gray-900 text-base line-clamp-1 group-hover:text-purple-600 transition-colors">
|
||
{video.title}
|
||
</h3>
|
||
<p className="text-xs font-bold text-gray-400 mt-1 flex items-center gap-1.5">
|
||
<span>ارائهدهنده:</span>
|
||
<span className="text-gray-700">{video.doctor || 'کادر علمی کنینا'}</span>
|
||
</p>
|
||
{video.description && (
|
||
<p className="text-xs text-gray-500 font-medium mt-3 line-clamp-2 leading-relaxed">
|
||
{video.description}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
<div className="pt-4 border-t border-gray-100 flex items-center justify-between">
|
||
<div className="flex items-center gap-1 text-[11px] font-bold text-gray-400">
|
||
<span>{video.viewsCount || 0} بازدید</span>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
onClick={() => handleOpenModal(video)}
|
||
className="p-2 rounded-xl text-gray-400 hover:text-purple-600 hover:bg-purple-50 transition-colors cursor-pointer"
|
||
title="ویرایش ویدئو"
|
||
>
|
||
<Edit2 className="w-4 h-4" />
|
||
</button>
|
||
<button
|
||
onClick={() => setDeleteTargetId(video.id)}
|
||
className="p-2 rounded-xl text-gray-400 hover:text-red-600 hover:bg-red-50 transition-colors cursor-pointer"
|
||
title="حذف ویدئو"
|
||
>
|
||
<Trash2 className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{isModalOpen && (
|
||
<Modal
|
||
isOpen={isModalOpen}
|
||
onClose={() => setIsModalOpen(false)}
|
||
title={editingVideo ? 'ویرایش ویدئوی آموزشی' : 'افزودن ویدئوی جدید به آکادمی'}
|
||
icon={VideoIcon}
|
||
maxWidth="2xl"
|
||
footer={
|
||
<div className="flex justify-end gap-2.5 w-full">
|
||
<Button
|
||
variant="secondary"
|
||
size="sm"
|
||
type="button"
|
||
onClick={() => setIsModalOpen(false)}
|
||
>
|
||
انصراف
|
||
</Button>
|
||
<Button
|
||
variant="primary"
|
||
size="sm"
|
||
type="submit"
|
||
form="videoForm"
|
||
>
|
||
ذخیره اطلاعات
|
||
</Button>
|
||
</div>
|
||
}
|
||
>
|
||
<div className="space-y-5 text-right font-vazir">
|
||
<form id="videoForm" onSubmit={handleSave} className="space-y-5">
|
||
<div className="space-y-1.5">
|
||
<label className="text-xs font-black text-gray-700 block">عنوان ویدئو *</label>
|
||
<input
|
||
type="text"
|
||
required
|
||
value={formData.title}
|
||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||
placeholder="مثلاً: نحوه مصرف مکمل کانیهیدروکس در سگهای بالغ"
|
||
className="w-full bg-gray-50 border border-gray-200 rounded-xl py-3 px-4 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500/20 font-bold"
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||
<div className="space-y-1.5">
|
||
<label className="text-xs font-black text-gray-700 block">انتخاب یا نام پزشک / مدرس ارائهدهنده</label>
|
||
{doctorsList.length > 0 && (
|
||
<select
|
||
value={formData.doctorId}
|
||
onChange={(e) => {
|
||
const selectedDocId = e.target.value;
|
||
const docObj = doctorsList.find(d => d.id === selectedDocId);
|
||
setFormData(prev => ({
|
||
...prev,
|
||
doctorId: selectedDocId,
|
||
doctor: docObj ? docObj.name : prev.doctor,
|
||
}));
|
||
}}
|
||
className="w-full bg-gray-50 border border-gray-200 rounded-xl py-2.5 px-3 text-xs mb-2 focus:outline-none focus:ring-2 focus:ring-purple-500/20 font-bold"
|
||
>
|
||
<option value="">-- انتخاب از پزشکان ثبتشده در سیستم --</option>
|
||
{doctorsList.map((doc) => (
|
||
<option key={doc.id} value={doc.id}>
|
||
{doc.name} {doc.title ? `(${doc.title})` : ''}
|
||
</option>
|
||
))}
|
||
</select>
|
||
)}
|
||
<input
|
||
type="text"
|
||
value={formData.doctor}
|
||
onChange={(e) => setFormData({ ...formData, doctor: e.target.value })}
|
||
placeholder="نام نمایشی پزشک (مثلاً: دکتر کلاوس هنینگ)"
|
||
className="w-full bg-gray-50 border border-gray-200 rounded-xl py-2.5 px-4 text-xs focus:outline-none focus:ring-2 focus:ring-purple-500/20 font-bold"
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-1.5">
|
||
<label className="text-xs font-black text-gray-700 block">مدت زمان ویدئو</label>
|
||
<input
|
||
type="text"
|
||
value={formData.duration}
|
||
onChange={(e) => setFormData({ ...formData, duration: e.target.value })}
|
||
placeholder="۰۲:۴۵"
|
||
className="w-full bg-gray-50 border border-gray-200 rounded-xl py-3 px-4 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500/20 font-bold"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<label className="text-xs font-black text-gray-700 block">تصویر کاور ویدئو</label>
|
||
<div className="flex items-center gap-3">
|
||
{formData.thumbnail && (
|
||
<div className="w-14 h-14 rounded-xl overflow-hidden bg-gray-100 border border-gray-200 shrink-0">
|
||
<img
|
||
src={formData.thumbnail.startsWith('http') ? formData.thumbnail : `${BASE_DOMAIN}${formData.thumbnail}`}
|
||
alt="پیشنمایش کاور"
|
||
className="w-full h-full object-cover"
|
||
/>
|
||
</div>
|
||
)}
|
||
<div className="flex-1 flex gap-2">
|
||
<input
|
||
type="text"
|
||
value={formData.thumbnail}
|
||
onChange={(e) => setFormData({ ...formData, thumbnail: e.target.value })}
|
||
placeholder="آدرس تصویر یا انتخاب از گالری..."
|
||
className="flex-1 bg-gray-50 border border-gray-200 rounded-xl py-3 px-4 text-xs font-mono focus:outline-none focus:ring-2 focus:ring-purple-500/20"
|
||
/>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
startIcon={ImageIcon}
|
||
onClick={() => setIsThumbnailMediaOpen(true)}
|
||
>
|
||
انتخاب کاور
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<label className="text-xs font-black text-gray-700 block">آدرس فایل ویدئو (MP4 یا کد آیفرم آپارات/یوتیوب) *</label>
|
||
<div className="flex items-center gap-3">
|
||
{formData.videoUrl && !formData.videoUrl.includes('<iframe') && (
|
||
<div className="w-14 h-14 rounded-xl overflow-hidden bg-gray-900 border border-gray-200 shrink-0 flex items-center justify-center">
|
||
<video
|
||
src={formData.videoUrl.startsWith('http') ? formData.videoUrl : `${BASE_DOMAIN}${formData.videoUrl}`}
|
||
preload="metadata"
|
||
className="w-full h-full object-cover"
|
||
/>
|
||
</div>
|
||
)}
|
||
<div className="flex-1 flex gap-2">
|
||
<input
|
||
type="text"
|
||
required
|
||
value={formData.videoUrl}
|
||
onChange={(e) => setFormData({ ...formData, videoUrl: e.target.value })}
|
||
placeholder="https://... یا کد آیفرم آپارات/یوتیوب"
|
||
className="flex-1 bg-gray-50 border border-gray-200 rounded-xl py-3 px-4 text-xs font-mono focus:outline-none focus:ring-2 focus:ring-purple-500/20"
|
||
/>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="sm"
|
||
startIcon={Film}
|
||
onClick={() => setIsVideoMediaOpen(true)}
|
||
>
|
||
انتخاب ویدئو
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-1.5">
|
||
<label className="text-xs font-black text-gray-700 block">توضیحات تکمیلی</label>
|
||
<textarea
|
||
value={formData.description}
|
||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||
className="w-full bg-gray-50 border border-gray-200 rounded-xl py-3 px-4 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500/20 h-24 font-bold"
|
||
placeholder="توضیحات مختصر در مورد محتوای ویدئو..."
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3 p-4 bg-amber-50 rounded-xl border border-amber-200">
|
||
<input
|
||
type="checkbox"
|
||
id="isFeaturedCheck"
|
||
checked={formData.isFeatured}
|
||
onChange={(e) => setFormData({ ...formData, isFeatured: e.target.checked })}
|
||
className="w-4 h-4 text-amber-600 rounded focus:ring-amber-500 cursor-pointer"
|
||
/>
|
||
<label htmlFor="isFeaturedCheck" className="text-xs font-black text-amber-900 cursor-pointer">
|
||
نمایش به عنوان ویدئوی ویژه در بخش «مشاوره ویدئویی» صفحه اصلی سایت
|
||
</label>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</Modal>
|
||
)}
|
||
|
||
|
||
{/* Cover Thumbnail Media Selector Modal */}
|
||
<MediaSelector
|
||
isOpen={isThumbnailMediaOpen}
|
||
onClose={() => setIsThumbnailMediaOpen(false)}
|
||
onSelect={(url) => setFormData(prev => ({ ...prev, thumbnail: url }))}
|
||
selectedUrl={formData.thumbnail}
|
||
/>
|
||
|
||
{/* Video File Media Selector Modal */}
|
||
<MediaSelector
|
||
isOpen={isVideoMediaOpen}
|
||
onClose={() => setIsVideoMediaOpen(false)}
|
||
onSelect={(url) => setFormData(prev => ({ ...prev, videoUrl: url }))}
|
||
selectedUrl={formData.videoUrl}
|
||
/>
|
||
|
||
<ConfirmModal
|
||
isOpen={!!deleteTargetId}
|
||
title="حذف ویدئوی آموزشی"
|
||
message="آیا از حذف این ویدئوی آموزشی اطمینان دارید؟ این عملیات قابل بازگشت نیست."
|
||
onConfirm={confirmDelete}
|
||
onCancel={() => setDeleteTargetId(null)}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|