fix(admin): enhance media manager with all file types and progress, fix videos sync and home display
All checks were successful
Deploy Canina / deploy (push) Successful in 1m49s
All checks were successful
Deploy Canina / deploy (push) Successful in 1m49s
This commit is contained in:
parent
2d83a00169
commit
bea0488efb
@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { X, Upload, Image as ImageIcon, Trash2, CheckCircle2, Clipboard, Eye } from 'lucide-react';
|
||||
import { X, Upload, Image as ImageIcon, Trash2, CheckCircle2, Clipboard, Eye, Video, FileText, Music, Play } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import api, { BASE_DOMAIN } from '../../services/api';
|
||||
import Spinner from './Spinner';
|
||||
@ -12,6 +12,7 @@ interface Media {
|
||||
url: string;
|
||||
filename: string;
|
||||
createdAt: string;
|
||||
mimetype?: string;
|
||||
}
|
||||
|
||||
interface MediaSelectorProps {
|
||||
@ -20,6 +21,16 @@ interface MediaSelectorProps {
|
||||
onSelect: (url: string) => void;
|
||||
multiple?: boolean;
|
||||
selectedUrl?: string;
|
||||
acceptedTypes?: 'all' | 'image' | 'video' | 'audio' | 'document';
|
||||
}
|
||||
|
||||
function getFileType(filename: string, mimetype?: string): 'image' | 'video' | 'audio' | 'pdf' | 'other' {
|
||||
const ext = filename.split('.').pop()?.toLowerCase() || '';
|
||||
if (mimetype?.startsWith('video/') || ['mp4', 'webm', 'ogg', 'mov', 'mkv', 'avi'].includes(ext)) return 'video';
|
||||
if (mimetype?.startsWith('audio/') || ['mp3', 'wav', 'ogg', 'm4a', 'aac'].includes(ext)) return 'audio';
|
||||
if (mimetype === 'application/pdf' || ext === 'pdf') return 'pdf';
|
||||
if (mimetype?.startsWith('image/') || ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext)) return 'image';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
export default function MediaSelector({ isOpen, onClose, onSelect, multiple = false, selectedUrl }: MediaSelectorProps) {
|
||||
@ -27,12 +38,14 @@ export default function MediaSelector({ isOpen, onClose, onSelect, multiple = fa
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const limit = 10;
|
||||
const limit = 12;
|
||||
const [pasteStatus, setPasteStatus] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const pasteTimerRef = useRef<ReturnType<typeof setTimeout>>(null);
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
|
||||
const [filterType, setFilterType] = useState<'all' | 'image' | 'video' | 'audio' | 'pdf'>('all');
|
||||
|
||||
const fetchMedia = useCallback(async () => {
|
||||
try {
|
||||
@ -75,7 +88,7 @@ export default function MediaSelector({ isOpen, onClose, onSelect, multiple = fa
|
||||
const clipboardText = e.clipboardData?.getData('text');
|
||||
if (clipboardText && (clipboardText.startsWith('http://') || clipboardText.startsWith('https://'))) {
|
||||
e.preventDefault();
|
||||
setPasteStatus('لینک تصویر چسبانده شد!');
|
||||
setPasteStatus('لینک فایل چسبانده شد!');
|
||||
onSelect(clipboardText);
|
||||
if (!multiple) onClose();
|
||||
if (pasteTimerRef.current) clearTimeout(pasteTimerRef.current);
|
||||
@ -118,38 +131,44 @@ export default function MediaSelector({ isOpen, onClose, onSelect, multiple = fa
|
||||
}, [isOpen, multiple, onClose, onSelect, fetchMedia]);
|
||||
|
||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!e.target.files || e.target.files.length === 0) return;
|
||||
const files = Array.from(e.target.files);
|
||||
|
||||
setIsUploading(true);
|
||||
let successCount = 0;
|
||||
const files = e.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
setIsUploading(true);
|
||||
setUploadProgress(10);
|
||||
let successCount = 0;
|
||||
try {
|
||||
const uploadPromises = files.map(async (file) => {
|
||||
const uploadPromises = Array.from(files).map(async (file) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return api.post('/admin/media/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
onUploadProgress: (progressEvent) => {
|
||||
const total = progressEvent.total || file.size;
|
||||
const percent = Math.round((progressEvent.loaded * 100) / total);
|
||||
setUploadProgress(percent);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const results = await Promise.allSettled(uploadPromises);
|
||||
results.forEach(res => {
|
||||
results.forEach((res) => {
|
||||
if (res.status === 'fulfilled') successCount++;
|
||||
});
|
||||
|
||||
if (successCount > 0) {
|
||||
toast.success(`${successCount} تصویر با موفقیت آپلود شد`);
|
||||
toast.success(`${successCount} فایل با موفقیت بارگذاری شد`);
|
||||
await fetchMedia();
|
||||
}
|
||||
if (successCount < files.length) {
|
||||
toast.error(`خطا در آپلود ${files.length - successCount} تصویر`);
|
||||
toast.error(`خطا در بارگذاری ${files.length - successCount} فایل`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Upload failed', error);
|
||||
toast.error('خطا در بارگذاری فایلها');
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
setUploadProgress(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
@ -158,11 +177,11 @@ export default function MediaSelector({ isOpen, onClose, onSelect, multiple = fa
|
||||
if (!deleteTargetId) return;
|
||||
try {
|
||||
await api.delete(`/admin/media/${deleteTargetId}`);
|
||||
toast.success('تصویر با موفقیت حذف شد');
|
||||
toast.success('فایل با موفقیت حذف شد');
|
||||
setMediaList(prev => prev.filter(item => item.id !== deleteTargetId));
|
||||
} catch (error) {
|
||||
console.error('Failed to delete media:', error);
|
||||
toast.error('خطا در حذف تصویر');
|
||||
toast.error('خطا در حذف فایل');
|
||||
} finally {
|
||||
setDeleteTargetId(null);
|
||||
}
|
||||
@ -230,21 +249,40 @@ export default function MediaSelector({ isOpen, onClose, onSelect, multiple = fa
|
||||
<ImageIcon className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900">گالری رسانه و تصاویر</h3>
|
||||
<p className="text-xs text-gray-500 font-medium">تصویر مورد نظر را انتخاب یا گروهی آپلود کنید</p>
|
||||
<h3 className="text-lg font-bold text-gray-900">گالری و مدیریت رسانه</h3>
|
||||
<p className="text-xs text-gray-500 font-medium">تصویر، ویدئو، پادکست صوتی یا PDF مورد نظر را انتخاب یا آپلود کنید</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-2 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-xl transition-colors">
|
||||
<button onClick={onClose} className="p-2 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-xl transition-colors cursor-pointer">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Toolbar */}
|
||||
{/* Toolbar & Filter Tabs */}
|
||||
<div className="p-4 border-b border-gray-100 flex justify-between items-center bg-white flex-wrap gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<p className="text-sm font-bold text-gray-600">{mediaList.length} فایل موجود</p>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{[
|
||||
{ id: 'all', label: 'همه فایلها' },
|
||||
{ id: 'image', label: 'تصاویر' },
|
||||
{ id: 'video', label: 'ویدئوها' },
|
||||
{ id: 'audio', label: 'پادکست/صدا' },
|
||||
{ id: 'pdf', label: 'اسناد PDF' },
|
||||
].map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => { setFilterType(tab.id as any); setPage(1); }}
|
||||
className={`px-3 py-1.5 rounded-xl text-xs font-bold transition-all cursor-pointer ${
|
||||
filterType === tab.id
|
||||
? 'bg-purple-600 text-white shadow-xs'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
{pasteStatus && (
|
||||
<span className={`text-xs px-2.5 py-1 rounded-full font-medium ${pasteStatus.includes('موفق') ? 'bg-green-100 text-green-700' : pasteStatus.includes('خطا') ? 'bg-red-100 text-red-700' : 'bg-blue-100 text-blue-700'}`}>
|
||||
<span className={`text-xs px-2.5 py-1 rounded-full font-medium ${pasteStatus.includes('موفق') ? 'bg-green-100 text-green-700' : 'bg-blue-100 text-blue-700'}`}>
|
||||
{pasteStatus}
|
||||
</span>
|
||||
)}
|
||||
@ -252,13 +290,13 @@ export default function MediaSelector({ isOpen, onClose, onSelect, multiple = fa
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-gray-400 hidden sm:flex items-center gap-1">
|
||||
<Clipboard className="w-3.5 h-3.5" />
|
||||
Ctrl+V برای چسباندن تصویر
|
||||
Ctrl+V برای چسباندن
|
||||
</span>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileUpload}
|
||||
accept="image/*"
|
||||
accept="image/*,video/*,audio/*,application/pdf"
|
||||
multiple
|
||||
className="hidden"
|
||||
/>
|
||||
@ -267,53 +305,74 @@ export default function MediaSelector({ isOpen, onClose, onSelect, multiple = fa
|
||||
disabled={isUploading}
|
||||
className="bg-purple-600 hover:bg-purple-700 disabled:opacity-70 text-white px-4 py-2.5 rounded-xl flex items-center gap-2 text-sm font-bold transition-all shadow-md shadow-purple-200 cursor-pointer"
|
||||
>
|
||||
{isUploading ? <Spinner size="sm" /> : <Upload className="w-4 h-4" />}
|
||||
آپلود تصویر جدید (تکی یا گروهی)
|
||||
{isUploading ? (
|
||||
<>
|
||||
<Spinner size="sm" />
|
||||
<span>در حال آپلود {uploadProgress ? `(${uploadProgress}٪)` : ''}...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="w-4 h-4" />
|
||||
<span>آپلود فایل جدید (همگانی)</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload Progress Bar if active */}
|
||||
{isUploading && uploadProgress !== null && (
|
||||
<div className="w-full bg-gray-100 h-1.5 overflow-hidden">
|
||||
<div
|
||||
className="bg-purple-600 h-full transition-all duration-300"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Grid */}
|
||||
<div className="p-6 overflow-y-auto flex-1 bg-gray-50/30">
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center items-center h-40">
|
||||
<Spinner size="lg" className="text-purple-600" />
|
||||
</div>
|
||||
) : mediaList.length === 0 ? (
|
||||
) : filteredMedia.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-60 text-gray-400">
|
||||
<ImageIcon className="w-16 h-16 mb-4 opacity-20" />
|
||||
<p className="font-medium text-gray-500">گالری خالی است. اولین عکس را آپلود کنید!</p>
|
||||
<p className="font-medium text-gray-500">فایلی در این دستهبندی یافت نشد.</p>
|
||||
</div>
|
||||
) : (() => {
|
||||
const totalPages = Math.ceil(mediaList.length / limit) || 1;
|
||||
const paginatedList = mediaList.slice((page - 1) * limit, page * limit);
|
||||
const totalPages = Math.ceil(filteredMedia.length / limit) || 1;
|
||||
const paginatedList = filteredMedia.slice((page - 1) * limit, page * limit);
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
{paginatedList.map((media) => {
|
||||
const imgUrl = media.url.startsWith('http') ? media.url : `${BASE_DOMAIN}${media.url}`;
|
||||
const isSelected = selectedUrl && (imgUrl === selectedUrl || media.url === selectedUrl);
|
||||
const fileUrl = media.url.startsWith('http') ? media.url : `${BASE_DOMAIN}${media.url}`;
|
||||
const isSelected = selectedUrl && (fileUrl === selectedUrl || media.url === selectedUrl);
|
||||
const fileType = getFileType(media.filename, media.mimetype);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={media.id}
|
||||
onClick={() => {
|
||||
onSelect(imgUrl);
|
||||
onSelect(fileUrl);
|
||||
if (!multiple) onClose();
|
||||
}}
|
||||
className={`group relative bg-white rounded-2xl overflow-hidden border-2 cursor-pointer transition-all hover:shadow-lg hover:shadow-purple-100 ${
|
||||
isSelected ? 'border-purple-600 ring-2 ring-purple-300' : 'border-gray-200 hover:border-purple-400'
|
||||
}`}
|
||||
>
|
||||
{/* Top Action Icons (Safe Tap Targets) */}
|
||||
{/* Top Action Icons */}
|
||||
<div className="absolute top-2 left-2 right-2 flex items-center justify-between z-10 pointer-events-none">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPreviewUrl(imgUrl);
|
||||
setPreviewUrl(fileUrl);
|
||||
}}
|
||||
className="pointer-events-auto p-1.5 rounded-lg bg-black/50 hover:bg-black/80 text-white backdrop-blur-xs transition-colors"
|
||||
title="مشاهده بزرگنمایی"
|
||||
title="مشاهده"
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
@ -324,19 +383,54 @@ export default function MediaSelector({ isOpen, onClose, onSelect, multiple = fa
|
||||
setDeleteTargetId(media.id);
|
||||
}}
|
||||
className="pointer-events-auto p-1.5 rounded-lg bg-red-600/80 hover:bg-red-600 text-white backdrop-blur-xs transition-colors"
|
||||
title="حذف تصویر"
|
||||
title="حذف فایل"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="aspect-square bg-gray-50 p-2 flex items-center justify-center border-b border-gray-100 relative">
|
||||
<img
|
||||
src={imgUrl}
|
||||
alt={media.filename}
|
||||
className="max-w-full max-h-full object-contain object-center"
|
||||
loading="lazy"
|
||||
/>
|
||||
{/* Thumbnail View per File Type */}
|
||||
<div className="aspect-square bg-gray-50 p-2 flex items-center justify-center border-b border-gray-100 relative overflow-hidden">
|
||||
{fileType === 'image' ? (
|
||||
<img
|
||||
src={fileUrl}
|
||||
alt={media.filename}
|
||||
className="max-w-full max-h-full object-contain object-center"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : fileType === 'video' ? (
|
||||
<div className="relative w-full h-full flex items-center justify-center bg-gray-900 rounded-xl overflow-hidden">
|
||||
<video
|
||||
src={fileUrl}
|
||||
preload="metadata"
|
||||
className="w-full h-full object-cover opacity-75"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-8 h-8 rounded-full bg-purple-600/90 text-white flex items-center justify-center shadow-lg">
|
||||
<Play className="w-4 h-4 fill-white ml-0.5" />
|
||||
</div>
|
||||
</div>
|
||||
<span className="absolute bottom-1 right-1 bg-black/70 text-white text-[9px] px-1.5 py-0.5 rounded font-mono font-bold">
|
||||
VIDEO
|
||||
</span>
|
||||
</div>
|
||||
) : fileType === 'audio' ? (
|
||||
<div className="flex flex-col items-center justify-center text-amber-600 gap-1">
|
||||
<Music className="w-10 h-10" />
|
||||
<span className="text-[10px] font-black uppercase text-amber-700">Audio / Podcast</span>
|
||||
</div>
|
||||
) : fileType === 'pdf' ? (
|
||||
<div className="flex flex-col items-center justify-center text-rose-600 gap-1">
|
||||
<FileText className="w-10 h-10" />
|
||||
<span className="text-[10px] font-black uppercase text-rose-700">PDF Document</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center text-gray-400 gap-1">
|
||||
<FileText className="w-10 h-10" />
|
||||
<span className="text-[10px] font-black uppercase text-gray-500">File</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSelected && (
|
||||
<div className="absolute bottom-2 right-2 bg-purple-600 text-white text-[10px] font-bold px-2 py-0.5 rounded-full shadow-lg flex items-center gap-1">
|
||||
<CheckCircle2 className="w-3 h-3" />
|
||||
@ -344,6 +438,8 @@ export default function MediaSelector({ isOpen, onClose, onSelect, multiple = fa
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File details footer */}
|
||||
<div className="p-2.5 border-t border-gray-100 bg-white" dir="ltr">
|
||||
<p className="text-[11px] text-gray-700 truncate font-medium" title={media.filename}>
|
||||
{media.filename}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Upload, Image as ImageIcon, Trash2, Copy, CheckCircle2, Search, X, FolderOpen } from 'lucide-react';
|
||||
import { Upload, Image as ImageIcon, Trash2, Copy, CheckCircle2, Search, X, FolderOpen, Video, FileText, Music, Play, Eye } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import api, { BASE_DOMAIN } from '../services/api';
|
||||
import Spinner from '../components/ui/Spinner';
|
||||
@ -20,13 +20,24 @@ interface Media {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
function getFileType(filename: string, mimetype?: string): 'image' | 'video' | 'audio' | 'pdf' | 'other' {
|
||||
const ext = filename.split('.').pop()?.toLowerCase() || '';
|
||||
if (mimetype?.startsWith('video/') || ['mp4', 'webm', 'ogg', 'mov', 'mkv', 'avi'].includes(ext)) return 'video';
|
||||
if (mimetype?.startsWith('audio/') || ['mp3', 'wav', 'ogg', 'm4a', 'aac'].includes(ext)) return 'audio';
|
||||
if (mimetype === 'application/pdf' || ext === 'pdf') return 'pdf';
|
||||
if (mimetype?.startsWith('image/') || ['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext)) return 'image';
|
||||
return 'other';
|
||||
}
|
||||
|
||||
export default function MediaManager() {
|
||||
const [mediaList, setMediaList] = useState<Media[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
|
||||
const [filterType, setFilterType] = useState<'all' | 'image' | 'video' | 'audio' | 'pdf'>('all');
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const limit = 10;
|
||||
const limit = 12;
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
@ -79,13 +90,13 @@ export default function MediaManager() {
|
||||
await api.delete('/admin/media/bulk', {
|
||||
data: { ids: Array.from(selectedIds) }
|
||||
});
|
||||
toast.success(`${selectedIds.size} تصویر با موفقیت حذف شد`);
|
||||
toast.success(`${selectedIds.size} فایل با موفقیت حذف شد`);
|
||||
setSelectedIds(new Set());
|
||||
setIsBulkDeleteModalOpen(false);
|
||||
fetchMedia();
|
||||
} catch (error) {
|
||||
console.error('Bulk delete failed', error);
|
||||
toast.error('خطا در حذف گروهی تصاویر');
|
||||
toast.error('خطا در حذف گروهی فایلها');
|
||||
} finally {
|
||||
setIsBulkDeleting(false);
|
||||
}
|
||||
@ -95,12 +106,18 @@ export default function MediaManager() {
|
||||
if (!files || files.length === 0) return;
|
||||
try {
|
||||
setIsUploading(true);
|
||||
setUploadProgress(10);
|
||||
let successCount = 0;
|
||||
const promises = files.map(async (file) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return api.post('/admin/media/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
onUploadProgress: (progressEvent) => {
|
||||
const total = progressEvent.total || file.size;
|
||||
const percent = Math.round((progressEvent.loaded * 100) / total);
|
||||
setUploadProgress(percent);
|
||||
}
|
||||
});
|
||||
});
|
||||
const results = await Promise.allSettled(promises);
|
||||
@ -108,17 +125,18 @@ export default function MediaManager() {
|
||||
if (res.status === 'fulfilled') successCount++;
|
||||
});
|
||||
if (successCount > 0) {
|
||||
toast.success(`${successCount} تصویر با موفقیت آپلود شد`);
|
||||
toast.success(`${successCount} فایل با موفقیت آپلود شد`);
|
||||
await fetchMedia();
|
||||
}
|
||||
if (successCount < files.length) {
|
||||
toast.error(`خطا در آپلود ${files.length - successCount} تصویر`);
|
||||
toast.error(`خطا در آپلود ${files.length - successCount} فایل`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Upload failed', error);
|
||||
toast.error('خطا در آپلود فایلها');
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
setUploadProgress(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
}
|
||||
}, [fetchMedia]);
|
||||
@ -144,10 +162,8 @@ export default function MediaManager() {
|
||||
if (!items) return;
|
||||
const files: File[] = [];
|
||||
for (const item of Array.from(items)) {
|
||||
if (item.type.startsWith('image/')) {
|
||||
const file = item.getAsFile();
|
||||
if (file) files.push(file);
|
||||
}
|
||||
const file = item.getAsFile();
|
||||
if (file) files.push(file);
|
||||
}
|
||||
if (files.length > 0) {
|
||||
e.preventDefault();
|
||||
@ -188,7 +204,7 @@ export default function MediaManager() {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
if (!e.dataTransfer.files || e.dataTransfer.files.length === 0) return;
|
||||
const files = Array.from(e.dataTransfer.files).filter(f => f.type.startsWith('image/'));
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (files.length > 0) {
|
||||
await uploadFiles(files);
|
||||
}
|
||||
@ -198,11 +214,11 @@ export default function MediaManager() {
|
||||
if (!deleteTargetId) return;
|
||||
try {
|
||||
await api.delete(`/admin/media/${deleteTargetId}`);
|
||||
toast.success('تصویر با موفقیت حذف شد');
|
||||
toast.success('فایل با موفقیت حذف شد');
|
||||
setMediaList(prev => prev.filter(m => m.id !== deleteTargetId));
|
||||
} catch (error) {
|
||||
console.error('Delete failed', error);
|
||||
toast.error('خطا در حذف تصویر');
|
||||
toast.error('خطا در حذف فایل');
|
||||
} finally {
|
||||
setDeleteTargetId(null);
|
||||
}
|
||||
@ -232,19 +248,21 @@ export default function MediaManager() {
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
};
|
||||
|
||||
const filtered = mediaList.filter(m =>
|
||||
!search || m.filename.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
const filtered = mediaList.filter(m => {
|
||||
const matchesSearch = !search || m.filename.toLowerCase().includes(search.toLowerCase());
|
||||
const matchesType = filterType === 'all' || getFileType(m.filename, m.mimetype) === filterType;
|
||||
return matchesSearch && matchesType;
|
||||
});
|
||||
|
||||
const totalSize = mediaList.reduce((sum, m) => sum + m.size, 0);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-6 font-vazir" dir="rtl">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-black text-gray-900 flex items-center gap-2">
|
||||
<FolderOpen className="w-6 h-6 text-purple-600" />
|
||||
مدیریت رسانه
|
||||
مدیریت رسانه و فایلها
|
||||
</h2>
|
||||
<p className="text-gray-500 font-medium mt-1">
|
||||
{mediaList.length} فایل · {formatSize(totalSize)} فضا اشغال شده
|
||||
@ -252,43 +270,88 @@ export default function MediaManager() {
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-gray-400 hidden sm:flex items-center gap-1">
|
||||
Ctrl+V برای چسباندن تصویر
|
||||
Ctrl+V برای چسباندن
|
||||
</span>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleUpload}
|
||||
accept="image/*"
|
||||
accept="image/*,video/*,audio/*,application/pdf"
|
||||
multiple
|
||||
className="hidden"
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isUploading}
|
||||
className="bg-purple-600 hover:bg-purple-700 disabled:opacity-70 text-white px-4 py-2 rounded-xl flex items-center gap-2 text-sm font-bold transition-colors shadow-md shadow-purple-200"
|
||||
className="bg-purple-600 hover:bg-purple-700 disabled:opacity-70 text-white px-4 py-2.5 rounded-xl flex items-center gap-2 text-sm font-bold transition-all shadow-md shadow-purple-200 cursor-pointer"
|
||||
>
|
||||
{isUploading ? <Spinner size="sm" /> : <Upload className="w-4 h-4" />}
|
||||
آپلود تصویر جدید
|
||||
{isUploading ? (
|
||||
<>
|
||||
<Spinner size="sm" />
|
||||
<span>در حال آپلود {uploadProgress ? `(${uploadProgress}٪)` : ''}...</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="w-4 h-4" />
|
||||
<span>آپلود فایل جدید (عکس، ویدئو، پادکست، PDF)</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute right-4 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="جستجو در نام فایلها..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="w-full bg-white border border-gray-200 rounded-xl py-3 pr-11 pl-4 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500/20"
|
||||
/>
|
||||
{/* Upload Progress Bar if active */}
|
||||
{isUploading && uploadProgress !== null && (
|
||||
<div className="w-full bg-gray-100 rounded-full h-2 overflow-hidden shadow-inner">
|
||||
<div
|
||||
className="bg-purple-600 h-full transition-all duration-300 rounded-full"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filter and Search Bar */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 items-center justify-between">
|
||||
<div className="flex items-center gap-2 flex-wrap w-full sm:w-auto">
|
||||
{[
|
||||
{ id: 'all', label: 'همه فایلها' },
|
||||
{ id: 'image', label: 'تصاویر' },
|
||||
{ id: 'video', label: 'ویدئوها' },
|
||||
{ id: 'audio', label: 'پادکست/صوت' },
|
||||
{ id: 'pdf', label: 'اسناد PDF' },
|
||||
].map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => { setFilterType(tab.id as any); setPage(1); }}
|
||||
className={`px-3 py-1.5 rounded-xl text-xs font-bold transition-all cursor-pointer ${
|
||||
filterType === tab.id
|
||||
? 'bg-purple-600 text-white shadow-xs'
|
||||
: 'bg-white border border-gray-200 text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="relative w-full sm:w-72">
|
||||
<Search className="absolute right-4 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="جستجو در نام فایلها..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="w-full bg-white border border-gray-200 rounded-xl py-2.5 pr-11 pl-4 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500/20 font-bold"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
onDragOver={e => { e.preventDefault(); setIsDragOver(true); }}
|
||||
onDragLeave={() => setIsDragOver(false)}
|
||||
onDrop={handleDrop}
|
||||
className={`rounded-2xl border-2 border-dashed transition-colors ${
|
||||
className={`rounded-3xl border-2 border-dashed transition-colors ${
|
||||
isDragOver
|
||||
? 'border-purple-400 bg-purple-50'
|
||||
: 'border-gray-200 bg-white'
|
||||
@ -302,7 +365,7 @@ export default function MediaManager() {
|
||||
<div className="flex flex-col items-center justify-center h-60 text-gray-400">
|
||||
<ImageIcon className="w-16 h-16 mb-4 opacity-20" />
|
||||
<p className="font-medium text-gray-500">
|
||||
{search ? 'فایلی با این نام یافت نشد' : 'گالری خالی است. اولین عکس را آپلود کنید!'}
|
||||
{search ? 'فایلی با این نام یافت نشد' : 'گالری در این دستهبندی خالی است. اولین فایل را آپلود کنید!'}
|
||||
</p>
|
||||
</div>
|
||||
) : (() => {
|
||||
@ -347,20 +410,21 @@ export default function MediaManager() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
{paginatedList.map(media => {
|
||||
const imgUrl = media.url.startsWith('http') ? media.url : `${BASE_DOMAIN}${media.url}`;
|
||||
const fileUrl = media.url.startsWith('http') ? media.url : `${BASE_DOMAIN}${media.url}`;
|
||||
const isSelected = selectedIds.has(media.id);
|
||||
const fileType = getFileType(media.filename, media.mimetype);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={media.id}
|
||||
className={`group relative bg-gray-100 rounded-xl overflow-hidden border transition-all cursor-pointer ${
|
||||
className={`group relative bg-gray-100 rounded-2xl overflow-hidden border transition-all cursor-pointer ${
|
||||
isSelected ? 'border-purple-600 ring-2 ring-purple-400/50 shadow-md bg-purple-50/20' : 'border-gray-200 hover:border-purple-400 hover:shadow-lg hover:shadow-purple-100'
|
||||
}`}
|
||||
onClick={() => setPreviewUrl(imgUrl)}
|
||||
onClick={() => setPreviewUrl(fileUrl)}
|
||||
>
|
||||
<div className="aspect-square bg-white p-2 flex items-center justify-center border-b border-gray-100 relative">
|
||||
<div className="aspect-square bg-white p-2 flex items-center justify-center border-b border-gray-100 relative overflow-hidden">
|
||||
{/* Checkbox badge */}
|
||||
<button
|
||||
type="button"
|
||||
@ -375,17 +439,51 @@ export default function MediaManager() {
|
||||
<CheckCircle2 className={`w-4 h-4 ${isSelected ? 'text-white' : 'text-gray-400'}`} />
|
||||
</button>
|
||||
|
||||
<img
|
||||
src={imgUrl}
|
||||
alt={media.filename}
|
||||
className="max-w-full max-h-full object-contain object-center"
|
||||
loading="lazy"
|
||||
/>
|
||||
{fileType === 'image' ? (
|
||||
<img
|
||||
src={fileUrl}
|
||||
alt={media.filename}
|
||||
className="max-w-full max-h-full object-contain object-center"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : fileType === 'video' ? (
|
||||
<div className="relative w-full h-full flex items-center justify-center bg-gray-900 rounded-xl overflow-hidden">
|
||||
<video
|
||||
src={fileUrl}
|
||||
preload="metadata"
|
||||
className="w-full h-full object-cover opacity-75"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-8 h-8 rounded-full bg-purple-600/90 text-white flex items-center justify-center shadow-lg">
|
||||
<Play className="w-4 h-4 fill-white ml-0.5" />
|
||||
</div>
|
||||
</div>
|
||||
<span className="absolute bottom-1 right-1 bg-black/70 text-white text-[9px] px-1.5 py-0.5 rounded font-mono font-bold">
|
||||
VIDEO
|
||||
</span>
|
||||
</div>
|
||||
) : fileType === 'audio' ? (
|
||||
<div className="flex flex-col items-center justify-center text-amber-600 gap-1">
|
||||
<Music className="w-10 h-10" />
|
||||
<span className="text-[10px] font-black uppercase text-amber-700">Audio / Podcast</span>
|
||||
</div>
|
||||
) : fileType === 'pdf' ? (
|
||||
<div className="flex flex-col items-center justify-center text-rose-600 gap-1">
|
||||
<FileText className="w-10 h-10" />
|
||||
<span className="text-[10px] font-black uppercase text-rose-700">PDF Document</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center text-gray-400 gap-1">
|
||||
<FileText className="w-10 h-10" />
|
||||
<span className="text-[10px] font-black uppercase text-gray-500">File</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); handleCopyUrl(media); }}
|
||||
className="w-9 h-9 rounded-full bg-blue-500 text-white flex items-center justify-center hover:bg-blue-600 transform hover:scale-110 transition-transform"
|
||||
title="کپی URL"
|
||||
title="کپی لینک"
|
||||
>
|
||||
{copiedId === media.id ? <CheckCircle2 className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||||
</button>
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Plus, Search, Edit2, Trash2, Video as VideoIcon, Star, X, Upload, Film } from 'lucide-react';
|
||||
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 from '../services/api';
|
||||
import api, { BASE_DOMAIN } from '../services/api';
|
||||
import ConfirmModal from '../components/ui/ConfirmModal';
|
||||
import MediaSelector from '../components/ui/MediaSelector';
|
||||
|
||||
interface Video {
|
||||
id: string;
|
||||
@ -24,11 +25,9 @@ export default function Videos() {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingVideo, setEditingVideo] = useState<Video | null>(null);
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||
const [isUploadingThumbnail, setIsUploadingThumbnail] = useState(false);
|
||||
const [isUploadingVideo, setIsUploadingVideo] = useState(false);
|
||||
|
||||
const thumbnailInputRef = useRef<HTMLInputElement>(null);
|
||||
const videoInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [isThumbnailMediaOpen, setIsThumbnailMediaOpen] = useState(false);
|
||||
const [isVideoMediaOpen, setIsVideoMediaOpen] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
@ -40,35 +39,35 @@ export default function Videos() {
|
||||
isFeatured: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let isSubscribed = true;
|
||||
api.get('/videos', {
|
||||
params: { search: searchTerm, limit: 100 },
|
||||
}).then(res => {
|
||||
if (isSubscribed) {
|
||||
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 => {
|
||||
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);
|
||||
if (isSubscribed) setVideos([]);
|
||||
}).finally(() => {
|
||||
if (isSubscribed) setIsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
isSubscribed = false;
|
||||
};
|
||||
setVideos([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [searchTerm]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchVideos();
|
||||
}, [fetchVideos]);
|
||||
|
||||
const handleOpenModal = (video?: Video) => {
|
||||
if (video) {
|
||||
setEditingVideo(video);
|
||||
@ -96,41 +95,6 @@ export default function Videos() {
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement> | File, type: 'thumbnail' | 'video') => {
|
||||
let file: File | undefined;
|
||||
if (e instanceof File) {
|
||||
file = e;
|
||||
} else {
|
||||
file = e.target.files?.[0];
|
||||
}
|
||||
if (!file) return;
|
||||
|
||||
const uploadData = new FormData();
|
||||
uploadData.append('file', file);
|
||||
|
||||
if (type === 'thumbnail') setIsUploadingThumbnail(true);
|
||||
else setIsUploadingVideo(true);
|
||||
|
||||
try {
|
||||
const res = await api.post('/admin/media/upload', uploadData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
const fileUrl = res.data.url || res.data.fileUrl;
|
||||
if (type === 'thumbnail') {
|
||||
setFormData(prev => ({ ...prev, thumbnail: fileUrl }));
|
||||
} else {
|
||||
setFormData(prev => ({ ...prev, videoUrl: fileUrl }));
|
||||
}
|
||||
toast.success('فایل با موفقیت آپلود شد');
|
||||
} catch (err) {
|
||||
console.error('Upload failed:', err);
|
||||
toast.error('خطا در آپلود فایل');
|
||||
} finally {
|
||||
setIsUploadingThumbnail(false);
|
||||
setIsUploadingVideo(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
@ -142,7 +106,7 @@ export default function Videos() {
|
||||
toast.success('ویدئو جدید با موفقیت ذخیره شد');
|
||||
}
|
||||
setIsModalOpen(false);
|
||||
fetchVideosList();
|
||||
await fetchVideos();
|
||||
} catch (err) {
|
||||
console.error('Failed to save video:', err);
|
||||
toast.error('خطا در ذخیرهسازی ویدئو');
|
||||
@ -154,10 +118,15 @@ export default function Videos() {
|
||||
try {
|
||||
await api.delete(`/videos/${deleteTargetId}`);
|
||||
toast.success('ویدئو با موفقیت حذف شد');
|
||||
fetchVideosList();
|
||||
} catch (err) {
|
||||
await fetchVideos();
|
||||
} catch (err: any) {
|
||||
console.error('Failed to delete video:', err);
|
||||
toast.error('خطا در حذف ویدئو');
|
||||
if (err?.response?.status === 404) {
|
||||
toast.error('این ویدئو در پایگاه داده یافت نشد (احتمالاً قبلاً حذف شده است).');
|
||||
await fetchVideos();
|
||||
} else {
|
||||
toast.error('خطا در حذف ویدئو');
|
||||
}
|
||||
} finally {
|
||||
setDeleteTargetId(null);
|
||||
}
|
||||
@ -165,7 +134,6 @@ export default function Videos() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6 font-vazir text-right" dir="rtl">
|
||||
{/* Header */}
|
||||
<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">
|
||||
@ -173,145 +141,173 @@ export default function Videos() {
|
||||
مدیریت ویدئوها و آکادمی آموزشی
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500 font-bold mt-1">
|
||||
آپلود مستقیم ویدئو/کاور یا درج کد آیفرم آپارات/یوتیوب برای مشاوره ویدئویی دامپزشکان کنینا
|
||||
آپلود و انتخاب مستقیم ویدئو/کاور از گالری رسانه یا درج کد آیفرم آپارات/یوتیوب برای مشاوره ویدئویی دامپزشکان کنینا
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleOpenModal()}
|
||||
className="bg-purple-600 hover:bg-purple-700 text-white font-bold px-5 py-3 rounded-xl flex items-center justify-center gap-2 transition-all shadow-md"
|
||||
className="bg-purple-600 hover:bg-purple-700 text-white px-5 py-3 rounded-xl font-black text-sm flex items-center gap-2 shadow-lg shadow-purple-600/20 transition-all hover:scale-105 shrink-0 cursor-pointer"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
افزودن ویدئوی جدید
|
||||
<span>افزودن ویدئوی جدید</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="bg-white p-4 rounded-2xl border border-gray-100 shadow-xs flex items-center gap-4">
|
||||
<Search className="w-5 h-5 text-gray-400 shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="جستجو بر اساس عنوان یا نام پزشک..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full bg-transparent border-none text-sm focus:outline-none font-bold"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Videos Grid / Table */}
|
||||
<div className="bg-white rounded-2xl border border-gray-100 overflow-hidden shadow-xs">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-right border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 text-gray-500 text-xs font-black uppercase tracking-wider border-b border-gray-100">
|
||||
<th className="py-4 px-6">کاور & عنوان</th>
|
||||
<th className="py-4 px-6">دکتر / ارائهدهنده</th>
|
||||
<th className="py-4 px-6">زمان</th>
|
||||
<th className="py-4 px-6">نمایش ویژه (صفحه اصلی)</th>
|
||||
<th className="py-4 px-6 text-center">عملیات ادمین</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 text-sm font-bold text-gray-700">
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="py-12 text-center text-gray-400">
|
||||
در حال دریافت لیست ویدئوها...
|
||||
</td>
|
||||
</tr>
|
||||
) : videos.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="py-12 text-center text-gray-400">
|
||||
هیچ ویدئویی ثبت نشده است.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
videos.map((video) => (
|
||||
<tr key={video.id} className="hover:bg-gray-50/50 transition-colors">
|
||||
<td className="py-4 px-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<img
|
||||
src={video.thumbnail || 'https://images.unsplash.com/photo-1576091160550-217359f42f8c?auto=format&fit=crop&q=80&w=200'}
|
||||
alt={video.title}
|
||||
className="w-16 h-10 object-cover rounded-lg border border-gray-200 shrink-0"
|
||||
/>
|
||||
<div>
|
||||
<h4 className="font-black text-gray-900 leading-snug">{video.title}</h4>
|
||||
<p className="text-xs text-gray-400 line-clamp-1 mt-0.5">{video.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-4 px-6 font-black text-purple-700">{video.doctor}</td>
|
||||
<td className="py-4 px-6 font-mono dir-ltr text-right">{video.duration}</td>
|
||||
<td className="py-4 px-6">
|
||||
{video.isFeatured ? (
|
||||
<span className="inline-flex items-center gap-1.5 px-3 py-1 bg-amber-50 text-amber-700 border border-amber-200 rounded-full text-xs font-black">
|
||||
<Star className="w-3.5 h-3.5 text-amber-500 fill-amber-500" />
|
||||
ویژه (صفحه اصلی)
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">عادی</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-4 px-6">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<button
|
||||
onClick={() => handleOpenModal(video)}
|
||||
className="p-2 hover:bg-purple-50 text-purple-600 rounded-xl transition-colors"
|
||||
title="ویرایش"
|
||||
>
|
||||
<Edit2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteTargetId(video.id)}
|
||||
className="p-2 hover:bg-red-50 text-red-600 rounded-xl transition-colors"
|
||||
title="حذف"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
<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>
|
||||
|
||||
{/* Create / Edit Modal */}
|
||||
{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 && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs">
|
||||
<div className="bg-white w-full max-w-xl rounded-3xl p-6 sm:p-8 space-y-6 shadow-2xl relative border border-gray-100 max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between border-b border-gray-100 pb-4">
|
||||
<h3 className="text-xl font-black text-gray-900">
|
||||
{editingVideo ? 'ویرایش ویدئوی آموزشی' : 'افزودن ویدئوی جدید'}
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs font-vazir">
|
||||
<div className="bg-white rounded-3xl max-w-2xl w-full p-6 sm:p-8 shadow-2xl border border-gray-100 max-h-[90vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between pb-4 border-b border-gray-100 mb-6">
|
||||
<h3 className="text-lg font-black text-gray-900 flex items-center gap-2">
|
||||
<VideoIcon className="w-5 h-5 text-purple-600" />
|
||||
{editingVideo ? 'ویرایش ویدئوی آموزشی' : 'افزودن ویدئوی جدید به آکادمی'}
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="w-9 h-9 flex items-center justify-center rounded-xl hover:bg-gray-100 text-gray-400 hover:text-gray-700"
|
||||
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-50 rounded-xl transition-colors cursor-pointer"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSave} className="space-y-4">
|
||||
<form onSubmit={handleSave} className="space-y-5">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-black text-gray-700 block">عنوان ویدئو</label>
|
||||
<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="مثلاً: نحوه آمادهسازی کانیهیدروکس GAG"
|
||||
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-2 gap-4">
|
||||
<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>
|
||||
<label className="text-xs font-black text-gray-700 block">نام مدرس / پزشک ارائهدهنده</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.doctor}
|
||||
onChange={(e) => setFormData({ ...formData, doctor: e.target.value })}
|
||||
placeholder="دکتر کلاوس هنینگ"
|
||||
@ -320,82 +316,80 @@ export default function Videos() {
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-black text-gray-700 block">مدت زمان (مثلاً ۰۲:۳۰)</label>
|
||||
<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>
|
||||
|
||||
{/* Cover Thumbnail with Upload */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-black text-gray-700 block">تصویر کاور (لینک یا آپلود مستقیم)</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={formData.thumbnail}
|
||||
onChange={(e) => setFormData({ ...formData, thumbnail: e.target.value })}
|
||||
placeholder="https://... یا آپلود"
|
||||
className="flex-1 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"
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
ref={thumbnailInputRef}
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
if (e.target.files?.[0]) handleFileUpload(e.target.files[0], 'thumbnail');
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isUploadingThumbnail}
|
||||
onClick={() => thumbnailInputRef.current?.click()}
|
||||
className="px-4 py-3 bg-purple-50 text-purple-700 hover:bg-purple-100 rounded-xl text-xs font-black flex items-center gap-1.5 border border-purple-200"
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
<span>{isUploadingThumbnail ? 'در حال آپلود...' : 'آپلود عکس'}</span>
|
||||
</button>
|
||||
<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"
|
||||
onClick={() => setIsThumbnailMediaOpen(true)}
|
||||
className="px-4 py-3 bg-purple-50 text-purple-700 hover:bg-purple-100 rounded-xl text-xs font-black flex items-center gap-1.5 border border-purple-200 shrink-0 cursor-pointer"
|
||||
>
|
||||
<ImageIcon className="w-4 h-4" />
|
||||
<span>انتخاب از گالری</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Video URL or Embed Tag */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-black text-gray-700 block">آدرس فایل ویدئو MP4 یا کد آیفرم آپارات/یوتیوب</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.videoUrl}
|
||||
onChange={(e) => setFormData({ ...formData, videoUrl: e.target.value })}
|
||||
placeholder="https://... یا کد آیفرم <iframe...>"
|
||||
className="flex-1 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"
|
||||
/>
|
||||
<input
|
||||
type="file"
|
||||
ref={videoInputRef}
|
||||
accept="video/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
if (e.target.files?.[0]) handleFileUpload(e.target.files[0], 'video');
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isUploadingVideo}
|
||||
onClick={() => videoInputRef.current?.click()}
|
||||
className="px-4 py-3 bg-purple-50 text-purple-700 hover:bg-purple-100 rounded-xl text-xs font-black flex items-center gap-1.5 border border-purple-200"
|
||||
>
|
||||
<Film className="w-4 h-4" />
|
||||
<span>{isUploadingVideo ? 'در حال آپلود...' : 'آپلود ویدئو'}</span>
|
||||
</button>
|
||||
<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"
|
||||
onClick={() => setIsVideoMediaOpen(true)}
|
||||
className="px-4 py-3 bg-purple-50 text-purple-700 hover:bg-purple-100 rounded-xl text-xs font-black flex items-center gap-1.5 border border-purple-200 shrink-0 cursor-pointer"
|
||||
>
|
||||
<Film className="w-4 h-4" />
|
||||
<span>انتخاب ویدئو</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-gray-400 font-bold">
|
||||
میتوانید مستقیم لینک MP4 وارد کنید یا کد آیفرم (Embed Code) آپارات/یوتیوب قرار دهید.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
@ -421,17 +415,17 @@ export default function Videos() {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 flex justify-end gap-3">
|
||||
<div className="pt-4 flex justify-end gap-3 border-t border-gray-100">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
className="px-5 py-2.5 rounded-xl border border-gray-200 text-gray-600 font-bold text-sm hover:bg-gray-50"
|
||||
className="px-5 py-2.5 rounded-xl border border-gray-200 text-gray-600 font-bold text-sm hover:bg-gray-50 cursor-pointer"
|
||||
>
|
||||
انصراف
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-6 py-2.5 rounded-xl bg-purple-600 text-white font-bold text-sm hover:bg-purple-700 shadow-md"
|
||||
className="px-6 py-2.5 rounded-xl bg-purple-600 text-white font-bold text-sm hover:bg-purple-700 shadow-md cursor-pointer"
|
||||
>
|
||||
ذخیره اطلاعات
|
||||
</button>
|
||||
@ -441,6 +435,22 @@ export default function Videos() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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="حذف ویدئوی آموزشی"
|
||||
@ -452,7 +462,3 @@ export default function Videos() {
|
||||
);
|
||||
}
|
||||
|
||||
function fetchVideosList() {
|
||||
throw new Error('Function not implemented.');
|
||||
}
|
||||
|
||||
|
||||
@ -81,18 +81,20 @@ export default function VetGallery({ testimonials = [] }: { testimonials?: Testi
|
||||
}, []);
|
||||
|
||||
const activeTestimonials = testimonials.filter(t => t.isActive !== false);
|
||||
const displayItems: DisplayVideoItem[] = activeTestimonials.length > 0
|
||||
? activeTestimonials.map((t, idx) => ({
|
||||
id: t.id || `t-${idx}`,
|
||||
title: (t.authorName || t.vetName || '') + (t.roleTitle ? ` — ${t.roleTitle}` : (t.clinicName ? ` — ${t.clinicName}` : '')),
|
||||
doctor: (t.authorName || t.vetName || '') + (t.roleTitle ? ` (${t.roleTitle})` : ''),
|
||||
duration: "۰۲:۰۰",
|
||||
thumbnail: t.avatarUrl || t.imageUrl || "https://images.unsplash.com/photo-1576091160550-217359f42f8c?auto=format&fit=crop&q=80&w=400",
|
||||
videoUrl: "https://www.w3schools.com/html/mov_bbb.mp4",
|
||||
description: t.content || t.quote || '',
|
||||
quote: t.content || t.quote || ''
|
||||
}))
|
||||
: (apiVideos.length > 0 ? apiVideos : FALLBACK_VIDEOS);
|
||||
const displayItems: DisplayVideoItem[] = apiVideos.length > 0
|
||||
? apiVideos
|
||||
: (activeTestimonials.length > 0
|
||||
? activeTestimonials.map((t, idx) => ({
|
||||
id: t.id || `t-${idx}`,
|
||||
title: (t.authorName || t.vetName || '') + (t.roleTitle ? ` — ${t.roleTitle}` : (t.clinicName ? ` — ${t.clinicName}` : '')),
|
||||
doctor: (t.authorName || t.vetName || '') + (t.roleTitle ? ` (${t.roleTitle})` : ''),
|
||||
duration: "۰۲:۰۰",
|
||||
thumbnail: t.avatarUrl || t.imageUrl || "https://images.unsplash.com/photo-1576091160550-217359f42f8c?auto=format&fit=crop&q=80&w=400",
|
||||
videoUrl: "https://www.w3schools.com/html/mov_bbb.mp4",
|
||||
description: t.content || t.quote || '',
|
||||
quote: t.content || t.quote || ''
|
||||
}))
|
||||
: FALLBACK_VIDEOS);
|
||||
|
||||
return (
|
||||
<section className="py-24 bg-white font-vazir" dir="rtl">
|
||||
|
||||
@ -58,15 +58,23 @@ const FALLBACK_VIDEOS: Video[] = [
|
||||
|
||||
export default function VideosPage() {
|
||||
const router = useRouter();
|
||||
const [videos, setVideos] = useState<Video[]>(FALLBACK_VIDEOS);
|
||||
const [videos, setVideos] = useState<Video[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [selectedVideo, setSelectedVideo] = useState<Video | null>(null);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
React.useEffect(() => {
|
||||
setIsLoading(true);
|
||||
videoService.getVideos({ limit: 100 }).then((data) => {
|
||||
if (data && data.length > 0) {
|
||||
setVideos(data);
|
||||
} else {
|
||||
setVideos(FALLBACK_VIDEOS);
|
||||
}
|
||||
}).catch(() => {
|
||||
setVideos(FALLBACK_VIDEOS);
|
||||
}).finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
@ -144,11 +152,11 @@ export default function VideosPage() {
|
||||
<div className="flex items-center gap-4 text-xs font-bold text-white/40">
|
||||
<div className="flex items-center gap-1">
|
||||
<User className="w-3 h-3" />
|
||||
{video.doctor}
|
||||
{video.doctor || 'کادر علمی کنینا'}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
دسترسی دائمی
|
||||
<span>{video.duration || 'آموزش ویدئویی'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -16,7 +16,12 @@ export const videoService = {
|
||||
getVideos: async (params?: { featured?: boolean; limit?: number; search?: string }) => {
|
||||
try {
|
||||
const res = await api.get('/videos', { params });
|
||||
return res.data.data || res.data || [];
|
||||
const raw = res.data;
|
||||
if (Array.isArray(raw?.videos)) return raw.videos;
|
||||
if (Array.isArray(raw?.data?.videos)) return raw.data.videos;
|
||||
if (Array.isArray(raw?.data)) return raw.data;
|
||||
if (Array.isArray(raw)) return raw;
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch videos from API:', error);
|
||||
return [];
|
||||
|
||||
Loading…
Reference in New Issue
Block a user