271 lines
14 KiB
TypeScript
271 lines
14 KiB
TypeScript
import { useState, useEffect } from 'react';
|
||
import { Search, Plus, Edit2, Trash2, FileText, Image as ImageIcon, CheckCircle2, XCircle } from 'lucide-react';
|
||
import api from '../services/api';
|
||
import Spinner from '../components/ui/Spinner';
|
||
import Pagination from '../components/ui/Pagination';
|
||
import MediaSelector from '../components/ui/MediaSelector';
|
||
|
||
export default function Blogs() {
|
||
const [blogs, setBlogs] = useState<any[]>([]);
|
||
const [isLoading, setIsLoading] = useState(true);
|
||
const [search, setSearch] = useState('');
|
||
const [page, setPage] = useState(1);
|
||
const [totalPages, setTotalPages] = useState(1);
|
||
const limit = 10;
|
||
|
||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||
const [isMediaSelectorOpen, setIsMediaSelectorOpen] = useState(false);
|
||
const [editingBlog, setEditingBlog] = useState<any>(null);
|
||
|
||
const [formData, setFormData] = useState({
|
||
title: '',
|
||
slug: '',
|
||
content: '',
|
||
isPublished: true,
|
||
metaTitle: '',
|
||
metaDescription: '',
|
||
keywords: '',
|
||
imageUrl: ''
|
||
});
|
||
|
||
const fetchBlogs = async () => {
|
||
try {
|
||
setIsLoading(true);
|
||
const res = await api.get('/admin/blogs', { params: { page, limit, search } });
|
||
if (res.data?.data) {
|
||
setBlogs(res.data.data);
|
||
setTotalPages(res.data.meta?.lastPage || 1);
|
||
}
|
||
} catch (err) {
|
||
console.error(err);
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
const timer = setTimeout(() => {
|
||
fetchBlogs();
|
||
}, 500);
|
||
return () => clearTimeout(timer);
|
||
}, [search, page]);
|
||
|
||
const handleSave = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
try {
|
||
if (editingBlog) {
|
||
await api.put(`/admin/blogs/${editingBlog.id}`, formData);
|
||
} else {
|
||
await api.post('/admin/blogs', formData);
|
||
}
|
||
setIsModalOpen(false);
|
||
fetchBlogs();
|
||
} catch (error) {
|
||
console.error('Save failed', error);
|
||
alert('خطا در ذخیره مقاله');
|
||
}
|
||
};
|
||
|
||
const handleDelete = async (id: string) => {
|
||
if (!window.confirm('آیا از حذف این مقاله مطمئن هستید؟')) return;
|
||
try {
|
||
await api.delete(`/admin/blogs/${id}`);
|
||
fetchBlogs();
|
||
} catch (error) {
|
||
console.error('Delete failed', error);
|
||
}
|
||
};
|
||
|
||
const openModal = (blog: any = null) => {
|
||
if (blog) {
|
||
setEditingBlog(blog);
|
||
setFormData({
|
||
title: blog.title,
|
||
slug: blog.slug,
|
||
content: blog.content,
|
||
isPublished: blog.isPublished,
|
||
metaTitle: blog.metaTitle || '',
|
||
metaDescription: blog.metaDescription || '',
|
||
keywords: blog.keywords || '',
|
||
imageUrl: blog.imageUrl || ''
|
||
});
|
||
} else {
|
||
setEditingBlog(null);
|
||
setFormData({
|
||
title: '', slug: '', content: '', isPublished: true, metaTitle: '', metaDescription: '', keywords: '', imageUrl: ''
|
||
});
|
||
}
|
||
setIsModalOpen(true);
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<div className="flex flex-col sm:flex-row justify-between gap-4">
|
||
<div>
|
||
<h2 className="text-2xl font-black text-gray-900 flex items-center gap-2">
|
||
<FileText className="w-6 h-6 text-purple-600" />
|
||
وبلاگ و مقالات
|
||
</h2>
|
||
<p className="text-gray-500 font-medium mt-1">مدیریت محتوای آموزشی و مقالات علمی سایت</p>
|
||
</div>
|
||
<button
|
||
onClick={() => openModal()}
|
||
className="bg-purple-600 hover:bg-purple-700 text-white px-5 py-2.5 rounded-xl flex items-center gap-2 font-bold transition-all shadow-md shadow-purple-200"
|
||
>
|
||
<Plus className="w-5 h-5" />
|
||
مقاله جدید
|
||
</button>
|
||
</div>
|
||
|
||
<div className="bg-white p-4 rounded-2xl shadow-sm border border-gray-200 flex flex-col sm:flex-row gap-4">
|
||
<div className="relative flex-1">
|
||
<Search className="w-5 h-5 absolute right-4 top-1/2 -translate-y-1/2 text-gray-400" />
|
||
<input
|
||
type="text"
|
||
placeholder="جستجو در مقالات..."
|
||
value={search}
|
||
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
|
||
className="w-full pl-4 pr-12 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none transition-all"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-right">
|
||
<thead className="bg-gray-50 border-b border-gray-100">
|
||
<tr>
|
||
<th className="py-4 px-6 text-sm font-bold text-gray-500">تصویر</th>
|
||
<th className="py-4 px-6 text-sm font-bold text-gray-500">عنوان مقاله</th>
|
||
<th className="py-4 px-6 text-sm font-bold text-gray-500">نویسنده</th>
|
||
<th className="py-4 px-6 text-sm font-bold text-gray-500">وضعیت انتشار</th>
|
||
<th className="py-4 px-6 text-sm font-bold text-gray-500 w-24">عملیات</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-gray-100">
|
||
{isLoading ? (
|
||
<tr><td colSpan={5} className="py-12 text-center"><Spinner size="lg" className="mx-auto text-purple-600" /></td></tr>
|
||
) : blogs.length === 0 ? (
|
||
<tr><td colSpan={5} className="py-12 text-center text-gray-500 font-medium">هیچ مقالهای یافت نشد</td></tr>
|
||
) : (
|
||
blogs.map((blog) => (
|
||
<tr key={blog.id} className="hover:bg-gray-50/50 transition-colors">
|
||
<td className="py-3 px-6">
|
||
{blog.imageUrl ? (
|
||
<img src={blog.imageUrl.startsWith('http') ? blog.imageUrl : `https://apicanina.parsaaghayi.ir${blog.imageUrl}`} alt="blog" className="w-16 h-10 rounded-lg object-cover border border-gray-200" />
|
||
) : (
|
||
<div className="w-16 h-10 rounded-lg bg-gray-100 flex items-center justify-center text-gray-400">
|
||
<ImageIcon className="w-4 h-4" />
|
||
</div>
|
||
)}
|
||
</td>
|
||
<td className="py-4 px-6 font-bold text-gray-900">{blog.title}</td>
|
||
<td className="py-4 px-6 text-gray-500 text-sm">{(blog.author?.firstName && blog.author?.lastName) ? `${blog.author.firstName} ${blog.author.lastName}` : 'نامشخص'}</td>
|
||
<td className="py-4 px-6">
|
||
{blog.isPublished ? (
|
||
<span className="flex items-center gap-1 text-green-600 text-sm font-bold"><CheckCircle2 className="w-4 h-4"/>منتشر شده</span>
|
||
) : (
|
||
<span className="flex items-center gap-1 text-orange-600 text-sm font-bold"><XCircle className="w-4 h-4"/>پیشنویس</span>
|
||
)}
|
||
</td>
|
||
<td className="py-4 px-6">
|
||
<div className="flex items-center gap-2">
|
||
<button onClick={() => openModal(blog)} className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"><Edit2 className="w-4 h-4" /></button>
|
||
<button onClick={() => handleDelete(blog.id)} className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"><Trash2 className="w-4 h-4" /></button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{!isLoading && totalPages > 1 && (
|
||
<div className="p-4 border-t border-gray-100 flex justify-center bg-gray-50/50">
|
||
<Pagination currentPage={page} totalPages={totalPages} onPageChange={setPage} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{isModalOpen && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-gray-900/60 backdrop-blur-sm">
|
||
<div className="bg-white rounded-2xl w-full max-w-4xl max-h-[95vh] flex flex-col shadow-2xl animate-in zoom-in duration-200">
|
||
<div className="px-6 py-4 border-b border-gray-100 flex justify-between items-center bg-gray-50/50">
|
||
<h3 className="text-xl font-bold text-gray-900">
|
||
{editingBlog ? 'ویرایش مقاله' : 'افزودن مقاله جدید'}
|
||
</h3>
|
||
<button onClick={() => setIsModalOpen(false)} className="text-gray-400 hover:text-red-500 transition-colors">
|
||
<XCircle className="w-6 h-6" />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="p-6 overflow-y-auto flex-1">
|
||
<form id="blogForm" onSubmit={handleSave} className="space-y-6">
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<div className="space-y-2">
|
||
<label className="text-sm font-bold text-gray-700">عنوان مقاله *</label>
|
||
<input required type="text" value={formData.title} onChange={(e) => setFormData({...formData, title: e.target.value})} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none" />
|
||
</div>
|
||
<div className="space-y-2">
|
||
<label className="text-sm font-bold text-gray-700">اسلاگ (Slug) *</label>
|
||
<input required type="text" value={formData.slug} dir="ltr" onChange={(e) => setFormData({...formData, slug: e.target.value})} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none font-mono text-sm" />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<label className="text-sm font-bold text-gray-700">محتوای اصلی مقاله *</label>
|
||
<textarea required value={formData.content} onChange={(e) => setFormData({...formData, content: e.target.value})} rows={10} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none font-mono text-sm" dir="rtl" placeholder="محتوای مقاله (متن ساده یا HTML)"></textarea>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||
<div className="space-y-4">
|
||
<label className="text-sm font-bold text-gray-700">تصویر شاخص</label>
|
||
<div className="flex items-center gap-4">
|
||
{formData.imageUrl ? (
|
||
<img src={formData.imageUrl.startsWith('http') ? formData.imageUrl : `https://apicanina.parsaaghayi.ir${formData.imageUrl}`} alt="preview" className="w-24 h-16 rounded-xl object-cover border border-gray-200" />
|
||
) : (
|
||
<div className="w-24 h-16 rounded-xl bg-gray-100 flex items-center justify-center text-gray-400">
|
||
<ImageIcon className="w-6 h-6" />
|
||
</div>
|
||
)}
|
||
<button type="button" onClick={() => setIsMediaSelectorOpen(true)} className="px-4 py-2 border-2 border-dashed border-gray-300 rounded-xl text-gray-600 font-bold hover:border-purple-500 hover:text-purple-600">انتخاب از گالری</button>
|
||
</div>
|
||
<label className="flex items-center gap-3 cursor-pointer p-4 border border-gray-200 rounded-xl hover:bg-gray-50 transition-colors">
|
||
<input type="checkbox" checked={formData.isPublished} onChange={(e) => setFormData({...formData, isPublished: e.target.checked})} className="w-5 h-5 text-purple-600 rounded focus:ring-purple-500" />
|
||
<span className="font-bold text-gray-700">انتشار فوری در سایت</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div className="space-y-4 border border-gray-200 p-4 rounded-xl bg-gray-50/50">
|
||
<h4 className="font-bold text-purple-700 text-sm mb-2">تنظیمات سئو (SEO)</h4>
|
||
<div className="space-y-2">
|
||
<label className="text-xs font-bold text-gray-700">عنوان متا</label>
|
||
<input type="text" value={formData.metaTitle} onChange={(e) => setFormData({...formData, metaTitle: e.target.value})} className="w-full px-3 py-2 rounded-lg border border-gray-200 focus:border-purple-500 outline-none text-sm" />
|
||
</div>
|
||
<div className="space-y-2">
|
||
<label className="text-xs font-bold text-gray-700">کلمات کلیدی</label>
|
||
<input type="text" value={formData.keywords} onChange={(e) => setFormData({...formData, keywords: e.target.value})} className="w-full px-3 py-2 rounded-lg border border-gray-200 focus:border-purple-500 outline-none text-sm" />
|
||
</div>
|
||
<div className="space-y-2">
|
||
<label className="text-xs font-bold text-gray-700">توضیحات متا</label>
|
||
<textarea rows={2} value={formData.metaDescription} onChange={(e) => setFormData({...formData, metaDescription: e.target.value})} className="w-full px-3 py-2 rounded-lg border border-gray-200 focus:border-purple-500 outline-none text-sm"></textarea>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</form>
|
||
</div>
|
||
|
||
<div className="p-4 border-t border-gray-100 bg-gray-50 flex justify-end gap-3 rounded-b-2xl">
|
||
<button type="button" onClick={() => setIsModalOpen(false)} className="px-5 py-2.5 rounded-xl font-bold text-gray-600 hover:bg-gray-200 transition-colors">انصراف</button>
|
||
<button type="submit" form="blogForm" className="bg-purple-600 hover:bg-purple-700 text-white px-8 py-2.5 rounded-xl font-bold transition-all shadow-md shadow-purple-200">ذخیره مقاله</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<MediaSelector isOpen={isMediaSelectorOpen} onClose={() => setIsMediaSelectorOpen(false)} onSelect={(url) => setFormData({...formData, imageUrl: url})} />
|
||
</div>
|
||
);
|
||
}
|