canina/frontend/admin-panel/src/pages/Videos.tsx

527 lines
22 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}