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

This commit is contained in:
parsa aghaei 2026-08-17 15:18:34 +03:30
parent 2d83a00169
commit bea0488efb
6 changed files with 571 additions and 356 deletions

View File

@ -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}

View File

@ -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>

View File

@ -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.');
}

View File

@ -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">

View File

@ -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>

View File

@ -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 [];