436 lines
18 KiB
TypeScript
436 lines
18 KiB
TypeScript
import { useState, useEffect, useRef, useCallback } from 'react';
|
||
import { Upload, Image as ImageIcon, Trash2, Copy, CheckCircle2, Search, X, FolderOpen } from 'lucide-react';
|
||
import { toast } from 'react-hot-toast';
|
||
import api, { BASE_DOMAIN } from '../services/api';
|
||
import Spinner from '../components/ui/Spinner';
|
||
import Pagination from '../components/ui/Pagination';
|
||
import ConfirmModal from '../components/ui/ConfirmModal';
|
||
|
||
interface Media {
|
||
id: string;
|
||
url: string;
|
||
filename: string;
|
||
mimetype: string;
|
||
size: number;
|
||
altText?: string;
|
||
title?: string;
|
||
description?: string;
|
||
caption?: string;
|
||
createdAt: string;
|
||
}
|
||
|
||
export default function MediaManager() {
|
||
const [mediaList, setMediaList] = useState<Media[]>([]);
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
const [isUploading, setIsUploading] = useState(false);
|
||
const [search, setSearch] = useState('');
|
||
const [page, setPage] = useState(1);
|
||
const limit = 10;
|
||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||
const [isDragOver, setIsDragOver] = useState(false);
|
||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
|
||
const fetchMedia = useCallback(async () => {
|
||
try {
|
||
const res = await api.get('/admin/media');
|
||
setMediaList(Array.isArray(res.data) ? res.data : res.data?.data || []);
|
||
} catch (error) {
|
||
console.error('Failed to fetch media', error);
|
||
toast.error('خطا در دریافت رسانهها');
|
||
}
|
||
}, []);
|
||
|
||
const uploadFile = useCallback(async (file: File) => {
|
||
const formData = new FormData();
|
||
formData.append('file', file);
|
||
try {
|
||
setIsUploading(true);
|
||
await api.post('/admin/media/upload', formData, {
|
||
headers: { 'Content-Type': 'multipart/form-data' }
|
||
});
|
||
toast.success('تصویر با موفقیت آپلود شد');
|
||
fetchMedia();
|
||
} catch (error) {
|
||
console.error('Upload failed', error);
|
||
toast.error('خطا در آپلود فایل');
|
||
} finally {
|
||
setIsUploading(false);
|
||
}
|
||
}, [fetchMedia]);
|
||
|
||
useEffect(() => {
|
||
let isSubscribed = true;
|
||
api.get('/admin/media').then(res => {
|
||
if (isSubscribed) setMediaList(Array.isArray(res.data) ? res.data : res.data?.data || []);
|
||
}).catch(error => {
|
||
console.error('Failed to fetch media', error);
|
||
toast.error('خطا در دریافت رسانهها');
|
||
}).finally(() => {
|
||
if (isSubscribed) setIsLoading(false);
|
||
});
|
||
return () => {
|
||
isSubscribed = false;
|
||
};
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
const handlePaste = (e: ClipboardEvent) => {
|
||
const items = e.clipboardData?.items;
|
||
if (!items) return;
|
||
for (const item of Array.from(items)) {
|
||
if (item.type.startsWith('image/')) {
|
||
e.preventDefault();
|
||
const file = item.getAsFile();
|
||
if (!file) continue;
|
||
uploadFile(file);
|
||
break;
|
||
}
|
||
}
|
||
};
|
||
document.addEventListener('paste', handlePaste);
|
||
return () => document.removeEventListener('paste', handlePaste);
|
||
}, [uploadFile]);
|
||
|
||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||
const [editingSeoMedia, setEditingSeoMedia] = useState<Media | null>(null);
|
||
const [seoFormData, setSeoFormData] = useState({
|
||
altText: '',
|
||
title: '',
|
||
description: '',
|
||
caption: ''
|
||
});
|
||
|
||
const handleSaveSeo = async () => {
|
||
if (!editingSeoMedia) return;
|
||
try {
|
||
await api.put(`/admin/media/${editingSeoMedia.id}`, seoFormData);
|
||
toast.success('تنظیمات سئوی تصویر با موفقیت ذخیره شد');
|
||
fetchMedia();
|
||
setEditingSeoMedia(null);
|
||
} catch {
|
||
toast.error('خطا در ذخیره تنظیمات سئوی تصویر');
|
||
}
|
||
};
|
||
|
||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
if (!e.target.files || e.target.files.length === 0) return;
|
||
await uploadFile(e.target.files[0]);
|
||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||
};
|
||
|
||
const handleDrop = async (e: React.DragEvent) => {
|
||
e.preventDefault();
|
||
setIsDragOver(false);
|
||
const file = e.dataTransfer.files[0];
|
||
if (file && file.type.startsWith('image/')) {
|
||
await uploadFile(file);
|
||
}
|
||
};
|
||
|
||
const confirmDelete = async () => {
|
||
if (!deleteTargetId) return;
|
||
try {
|
||
await api.delete(`/admin/media/${deleteTargetId}`);
|
||
toast.success('تصویر با موفقیت حذف شد');
|
||
setMediaList(prev => prev.filter(m => m.id !== deleteTargetId));
|
||
} catch (error) {
|
||
console.error('Delete failed', error);
|
||
toast.error('خطا در حذف تصویر');
|
||
} finally {
|
||
setDeleteTargetId(null);
|
||
}
|
||
};
|
||
|
||
const handleCopyUrl = async (media: Media) => {
|
||
const fullUrl = media.url.startsWith('http') ? media.url : `${BASE_DOMAIN}${media.url}`;
|
||
try {
|
||
await navigator.clipboard.writeText(fullUrl);
|
||
setCopiedId(media.id);
|
||
setTimeout(() => setCopiedId(null), 2000);
|
||
} catch {
|
||
const textarea = document.createElement('textarea');
|
||
textarea.value = fullUrl;
|
||
document.body.appendChild(textarea);
|
||
textarea.select();
|
||
document.execCommand('copy');
|
||
document.body.removeChild(textarea);
|
||
setCopiedId(media.id);
|
||
setTimeout(() => setCopiedId(null), 2000);
|
||
}
|
||
};
|
||
|
||
const formatSize = (bytes: number) => {
|
||
if (bytes < 1024) return bytes + ' B';
|
||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||
};
|
||
|
||
const filtered = mediaList.filter(m =>
|
||
!search || m.filename.toLowerCase().includes(search.toLowerCase())
|
||
);
|
||
|
||
const totalSize = mediaList.reduce((sum, m) => sum + m.size, 0);
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<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)} فضا اشغال شده
|
||
</p>
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<span className="text-xs text-gray-400 hidden sm:flex items-center gap-1">
|
||
Ctrl+V برای چسباندن تصویر
|
||
</span>
|
||
<input
|
||
type="file"
|
||
ref={fileInputRef}
|
||
onChange={handleUpload}
|
||
accept="image/*"
|
||
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"
|
||
>
|
||
{isUploading ? <Spinner size="sm" /> : <Upload className="w-4 h-4" />}
|
||
آپلود تصویر جدید
|
||
</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"
|
||
/>
|
||
</div>
|
||
|
||
<div
|
||
onDragOver={e => { e.preventDefault(); setIsDragOver(true); }}
|
||
onDragLeave={() => setIsDragOver(false)}
|
||
onDrop={handleDrop}
|
||
className={`rounded-2xl border-2 border-dashed transition-colors ${
|
||
isDragOver
|
||
? 'border-purple-400 bg-purple-50'
|
||
: 'border-gray-200 bg-white'
|
||
}`}
|
||
>
|
||
{isLoading ? (
|
||
<div className="flex justify-center items-center h-40">
|
||
<Spinner size="lg" className="text-purple-600" />
|
||
</div>
|
||
) : filtered.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">
|
||
{search ? 'فایلی با این نام یافت نشد' : 'گالری خالی است. اولین عکس را آپلود کنید!'}
|
||
</p>
|
||
</div>
|
||
) : (() => {
|
||
const totalPages = Math.ceil(filtered.length / limit) || 1;
|
||
const paginatedList = filtered.slice((page - 1) * limit, page * limit);
|
||
return (
|
||
<div className="space-y-4 p-6">
|
||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||
{paginatedList.map(media => {
|
||
const imgUrl = media.url.startsWith('http') ? media.url : `${BASE_DOMAIN}${media.url}`;
|
||
return (
|
||
<div
|
||
key={media.id}
|
||
className="group relative bg-gray-100 rounded-xl overflow-hidden border border-gray-200 hover:border-purple-400 hover:shadow-lg hover:shadow-purple-100 transition-all cursor-pointer"
|
||
onClick={() => setPreviewUrl(imgUrl)}
|
||
>
|
||
<div className="aspect-square bg-white p-2 flex items-center justify-center border-b border-gray-100">
|
||
<img
|
||
src={imgUrl}
|
||
alt={media.filename}
|
||
className="max-w-full max-h-full object-contain object-center"
|
||
loading="lazy"
|
||
/>
|
||
<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"
|
||
>
|
||
{copiedId === media.id ? <CheckCircle2 className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||
</button>
|
||
<button
|
||
onClick={e => {
|
||
e.stopPropagation();
|
||
setEditingSeoMedia(media);
|
||
setSeoFormData({
|
||
altText: media.altText || '',
|
||
title: media.title || '',
|
||
description: media.description || '',
|
||
caption: media.caption || ''
|
||
});
|
||
}}
|
||
className="w-9 h-9 rounded-full bg-purple-600 text-white flex items-center justify-center hover:bg-purple-700 transform hover:scale-110 transition-transform text-xs font-bold"
|
||
title="تنظیمات سئو"
|
||
>
|
||
SEO
|
||
</button>
|
||
<button
|
||
onClick={e => { e.stopPropagation(); setDeleteTargetId(media.id); }}
|
||
className="w-9 h-9 rounded-full bg-red-500 text-white flex items-center justify-center hover:bg-red-600 transform hover:scale-110 transition-transform"
|
||
title="حذف"
|
||
>
|
||
<Trash2 className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="p-2 bg-white border-t border-gray-100" dir="ltr">
|
||
<p className="text-[11px] text-gray-500 truncate font-medium" title={media.filename}>
|
||
{media.filename}
|
||
</p>
|
||
<div className="flex justify-between items-center mt-0.5">
|
||
<p className="text-[10px] text-gray-400">
|
||
{formatSize(media.size)}
|
||
</p>
|
||
<p className="text-[10px] text-gray-400">
|
||
{new Date(media.createdAt).toLocaleDateString('fa-IR')}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
<Pagination currentPage={page} totalPages={totalPages} onPageChange={setPage} />
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
|
||
{previewUrl && (
|
||
<div
|
||
className="fixed inset-0 z-[200] flex items-center justify-center p-4 bg-gray-900/80 backdrop-blur-sm"
|
||
onClick={() => setPreviewUrl(null)}
|
||
>
|
||
<div className="relative max-w-4xl max-h-[90vh]">
|
||
<img
|
||
src={previewUrl}
|
||
alt="Preview"
|
||
className="max-w-full max-h-[85vh] rounded-2xl shadow-2xl object-contain"
|
||
/>
|
||
<button
|
||
onClick={() => setPreviewUrl(null)}
|
||
className="absolute top-3 left-3 w-10 h-10 rounded-full bg-black/50 text-white flex items-center justify-center hover:bg-black/70 transition-colors"
|
||
>
|
||
<X className="w-5 h-5" />
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
navigator.clipboard.writeText(previewUrl);
|
||
setCopiedId('preview');
|
||
setTimeout(() => setCopiedId(null), 2000);
|
||
}}
|
||
className="absolute bottom-3 right-3 bg-black/50 text-white px-4 py-2 rounded-xl flex items-center gap-2 text-sm font-bold hover:bg-black/70 transition-colors"
|
||
>
|
||
{copiedId === 'preview' ? <CheckCircle2 className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||
کپی URL
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{editingSeoMedia && (
|
||
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4 bg-gray-900/60 backdrop-blur-xs">
|
||
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md overflow-hidden p-6 space-y-4">
|
||
<div className="flex items-center justify-between border-b pb-3">
|
||
<h3 className="text-lg font-bold text-gray-900">تنظیمات سئوی تصویر</h3>
|
||
<button onClick={() => setEditingSeoMedia(null)} className="text-gray-400 hover:text-red-500">
|
||
<X className="w-5 h-5" />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="space-y-3 text-right">
|
||
<div>
|
||
<label className="text-xs font-bold text-gray-700 block mb-1">متن جایگزین (Alt Text) *</label>
|
||
<input
|
||
type="text"
|
||
value={seoFormData.altText}
|
||
onChange={(e) => setSeoFormData({ ...seoFormData, altText: e.target.value })}
|
||
placeholder="مثال: عکس مکمل کانیدروکس گپ کانینا"
|
||
className="w-full px-3 py-2 border rounded-xl text-sm"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="text-xs font-bold text-gray-700 block mb-1">عنوان تصویر (Title)</label>
|
||
<input
|
||
type="text"
|
||
value={seoFormData.title}
|
||
onChange={(e) => setSeoFormData({ ...seoFormData, title: e.target.value })}
|
||
placeholder="عنوان تصویر برای تولتیپ هور"
|
||
className="w-full px-3 py-2 border rounded-xl text-sm"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="text-xs font-bold text-gray-700 block mb-1">توضیحات (Description)</label>
|
||
<textarea
|
||
rows={2}
|
||
value={seoFormData.description}
|
||
onChange={(e) => setSeoFormData({ ...seoFormData, description: e.target.value })}
|
||
placeholder="توضیحات کامل سئو..."
|
||
className="w-full px-3 py-2 border rounded-xl text-sm"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="text-xs font-bold text-gray-700 block mb-1">زیرنویس (Caption)</label>
|
||
<input
|
||
type="text"
|
||
value={seoFormData.caption}
|
||
onChange={(e) => setSeoFormData({ ...seoFormData, caption: e.target.value })}
|
||
placeholder="متن کپشن زیر عکس در مقالات"
|
||
className="w-full px-3 py-2 border rounded-xl text-sm"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex justify-end gap-2 pt-2 border-t">
|
||
<button
|
||
type="button"
|
||
onClick={() => setEditingSeoMedia(null)}
|
||
className="px-4 py-2 rounded-xl text-xs font-bold text-gray-600 bg-gray-100 hover:bg-gray-200"
|
||
>
|
||
انصراف
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={handleSaveSeo}
|
||
className="px-5 py-2 rounded-xl text-xs font-bold text-white bg-purple-600 hover:bg-purple-700 shadow-sm"
|
||
>
|
||
ذخیره سئو
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<ConfirmModal
|
||
isOpen={!!deleteTargetId}
|
||
title="حذف تصویر"
|
||
message="آیا از حذف این تصویر مطمئن هستید؟ این عملیات قابل بازگشت نیست."
|
||
onConfirm={confirmDelete}
|
||
onCancel={() => setDeleteTargetId(null)}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|