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

436 lines
18 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, 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>
);
}