- Fix seed-products.ts TS error (implicit any) and BOM handling - Re-run full seed to restore correct Persian encoding in DB - Fix productService query→search param mismatch - Fix IngredientWiki hardcoded port 4000→use settingsStore - Restore seed-products-data.json from git after accidental corruption
229 lines
11 KiB
TypeScript
229 lines
11 KiB
TypeScript
import { useState, useEffect } from 'react';
|
||
import { Search, Plus, Edit2, Trash2, BookOpen, XCircle } from 'lucide-react';
|
||
import api from '../services/api';
|
||
import Spinner from '../components/ui/Spinner';
|
||
import Pagination from '../components/ui/Pagination';
|
||
|
||
export default function Wiki() {
|
||
const [terms, setTerms] = 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 [editingTerm, setEditingTerm] = useState<any>(null);
|
||
|
||
const [formData, setFormData] = useState({
|
||
key: '',
|
||
term: '',
|
||
definition: '',
|
||
wikiId: 'general',
|
||
metaTitle: '',
|
||
metaDescription: '',
|
||
keywords: ''
|
||
});
|
||
|
||
const fetchTerms = async () => {
|
||
try {
|
||
setIsLoading(true);
|
||
const res = await api.get('/admin/wiki', { params: { page, limit, search } });
|
||
if (res.data?.data) {
|
||
setTerms(res.data.data);
|
||
setTotalPages(res.data.meta?.lastPage || 1);
|
||
}
|
||
} catch (err) {
|
||
console.error(err);
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
const timer = setTimeout(() => {
|
||
fetchTerms();
|
||
}, 500);
|
||
return () => clearTimeout(timer);
|
||
}, [search, page]);
|
||
|
||
const handleSave = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
try {
|
||
if (editingTerm) {
|
||
await api.put(`/admin/wiki/${editingTerm.key}`, formData);
|
||
} else {
|
||
await api.post('/admin/wiki', formData);
|
||
}
|
||
setIsModalOpen(false);
|
||
fetchTerms();
|
||
} catch (error) {
|
||
console.error('Save failed', error);
|
||
alert('خطا در ذخیره اصطلاح علمی. ممکن است کلید تکراری باشد.');
|
||
}
|
||
};
|
||
|
||
const handleDelete = async (key: string) => {
|
||
if (!window.confirm('آیا از حذف این مورد مطمئن هستید؟')) return;
|
||
try {
|
||
await api.delete(`/admin/wiki/${key}`);
|
||
fetchTerms();
|
||
} catch (error) {
|
||
console.error('Delete failed', error);
|
||
}
|
||
};
|
||
|
||
const openModal = (term: any = null) => {
|
||
if (term) {
|
||
setEditingTerm(term);
|
||
setFormData({
|
||
key: term.key,
|
||
term: term.term,
|
||
definition: term.definition,
|
||
wikiId: term.wikiId,
|
||
metaTitle: term.metaTitle || '',
|
||
metaDescription: term.metaDescription || '',
|
||
keywords: term.keywords || ''
|
||
});
|
||
} else {
|
||
setEditingTerm(null);
|
||
setFormData({
|
||
key: '', term: '', definition: '', wikiId: 'general', metaTitle: '', metaDescription: '', keywords: ''
|
||
});
|
||
}
|
||
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">
|
||
<BookOpen className="w-6 h-6 text-purple-600" />
|
||
دانشنامه کانینا (Wiki)
|
||
</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">شناسه (Key)</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={4} className="py-12 text-center"><Spinner size="lg" className="mx-auto text-purple-600" /></td></tr>
|
||
) : terms.length === 0 ? (
|
||
<tr><td colSpan={4} className="py-12 text-center text-gray-500 font-medium">هیچ رکوردی یافت نشد</td></tr>
|
||
) : (
|
||
terms.map((t) => (
|
||
<tr key={t.key} className="hover:bg-gray-50/50 transition-colors">
|
||
<td className="py-4 px-6 text-gray-500 font-mono text-sm">{t.key}</td>
|
||
<td className="py-4 px-6 font-bold text-gray-900">{t.term}</td>
|
||
<td className="py-4 px-6 text-gray-500 text-sm max-w-xs truncate">{t.definition}</td>
|
||
<td className="py-4 px-6">
|
||
<div className="flex items-center gap-2">
|
||
<button onClick={() => openModal(t)} className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"><Edit2 className="w-4 h-4" /></button>
|
||
<button onClick={() => handleDelete(t.key)} 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-2xl 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">
|
||
{editingTerm ? 'ویرایش دانشنامه' : 'افزودن مفهوم جدید'}
|
||
</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="wikiForm" 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">شناسه یکتا (Key) *</label>
|
||
<input required type="text" value={formData.key} disabled={!!editingTerm} dir="ltr" onChange={(e) => setFormData({ ...formData, key: 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 disabled:bg-gray-100" />
|
||
</div>
|
||
<div className="space-y-2">
|
||
<label className="text-sm font-bold text-gray-700">نام اصطلاح (Term) *</label>
|
||
<input required type="text" value={formData.term} onChange={(e) => setFormData({ ...formData, term: e.target.value })} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none" />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<label className="text-sm font-bold text-gray-700">توضیح علمی *</label>
|
||
<textarea required value={formData.definition} onChange={(e) => setFormData({ ...formData, definition: e.target.value })} rows={5} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none"></textarea>
|
||
</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>
|
||
|
||
</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="wikiForm" 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>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|