290 lines
11 KiB
TypeScript
290 lines
11 KiB
TypeScript
import { useState, useEffect, useRef } from 'react';
|
||
import { Upload, Image as ImageIcon, Trash2, Copy, CheckCircle2, Search, X, FolderOpen } from 'lucide-react';
|
||
import api, { BASE_DOMAIN } from '../services/api';
|
||
import Spinner from '../components/ui/Spinner';
|
||
|
||
interface Media {
|
||
id: string;
|
||
url: string;
|
||
filename: string;
|
||
mimetype: string;
|
||
size: number;
|
||
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 [copiedId, setCopiedId] = useState<string | null>(null);
|
||
const [isDragOver, setIsDragOver] = useState(false);
|
||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
const pasteTimerRef = useRef<ReturnType<typeof setTimeout>>(null);
|
||
|
||
useEffect(() => {
|
||
fetchMedia();
|
||
}, []);
|
||
|
||
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);
|
||
}, []);
|
||
|
||
const fetchMedia = async () => {
|
||
try {
|
||
setIsLoading(true);
|
||
const res = await api.get('/admin/media');
|
||
if (res.data?.success) {
|
||
setMediaList(res.data.data);
|
||
}
|
||
} catch (error) {
|
||
console.error('Error fetching media', error);
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
const uploadFile = 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' }
|
||
});
|
||
fetchMedia();
|
||
} catch (error) {
|
||
console.error('Upload failed', error);
|
||
alert('خطا در آپلود فایل');
|
||
} finally {
|
||
setIsUploading(false);
|
||
}
|
||
};
|
||
|
||
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 handleDelete = async (id: string) => {
|
||
if (!window.confirm('آیا از حذف این تصویر مطمئن هستید؟')) return;
|
||
try {
|
||
await api.delete(`/admin/media/${id}`);
|
||
setMediaList(prev => prev.filter(m => m.id !== id));
|
||
} catch (error) {
|
||
console.error('Delete failed', error);
|
||
}
|
||
};
|
||
|
||
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);
|
||
if (pasteTimerRef.current) clearTimeout(pasteTimerRef.current);
|
||
pasteTimerRef.current = 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);
|
||
if (pasteTimerRef.current) clearTimeout(pasteTimerRef.current);
|
||
pasteTimerRef.current = 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>
|
||
) : (
|
||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 p-6">
|
||
{filtered.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(); handleDelete(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>
|
||
)}
|
||
</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>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|