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

290 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

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