feat(admin): search + pagination on all data-driven tables
- AdminResource: client-side search box (matches across all fields) + 25/page pagination with prev/next and a filtered-count footer - bumped pageSize on server-paged configs (users/blogs/comments/discounts/music/ fonts/tags) so search/paginate covers the full set Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -43,6 +43,8 @@ const btn = "rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white
|
||||
const btnGhost = "rounded-lg border border-[#262b40] px-3 py-1.5 text-xs text-gray-300 hover:bg-[#161a2e]";
|
||||
const inputCls = "w-full rounded-lg border border-[#262b40] bg-[#0c0e1a] px-3 py-2 text-sm text-gray-100 outline-none focus:border-indigo-500";
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
export function AdminResource({ config }: { config: ResourceConfig }) {
|
||||
const idKey = config.idKey ?? "id";
|
||||
const [rows, setRows] = useState<Record<string, unknown>[]>([]);
|
||||
@@ -51,6 +53,8 @@ export function AdminResource({ config }: { config: ResourceConfig }) {
|
||||
const [editing, setEditing] = useState<Record<string, unknown> | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [form, setForm] = useState<Record<string, unknown>>({});
|
||||
const [query, setQuery] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const url = (suffix = "") => `/api/admin/resource/${config.basePath}${suffix}`;
|
||||
@@ -129,6 +133,13 @@ export function AdminResource({ config }: { config: ResourceConfig }) {
|
||||
}
|
||||
};
|
||||
|
||||
// Client-side search (across all fields) + pagination.
|
||||
const q = query.trim().toLowerCase();
|
||||
const filtered = q ? rows.filter((r) => JSON.stringify(r).toLowerCase().includes(q)) : rows;
|
||||
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
|
||||
const safePage = Math.min(page, totalPages);
|
||||
const paged = filtered.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -136,9 +147,16 @@ export function AdminResource({ config }: { config: ResourceConfig }) {
|
||||
<h1 className="text-xl font-semibold text-white">{config.title}</h1>
|
||||
{config.description && <p className="mt-1 text-sm text-gray-400">{config.description}</p>}
|
||||
</div>
|
||||
{config.canCreate && config.fields && (
|
||||
<button className={btn} onClick={openCreate}>+ مورد جدید</button>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
className="w-52 rounded-lg border border-[#262b40] bg-[#0c0e1a] px-3 py-2 text-sm text-gray-100 outline-none focus:border-indigo-500"
|
||||
placeholder="جستجو…" value={query}
|
||||
onChange={(e) => { setQuery(e.target.value); setPage(1); }}
|
||||
/>
|
||||
{config.canCreate && config.fields && (
|
||||
<button className={btn} onClick={openCreate}>+ مورد جدید</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <p className="rounded-lg bg-red-500/10 px-3 py-2 text-sm text-red-300">{error}</p>}
|
||||
@@ -158,10 +176,10 @@ export function AdminResource({ config }: { config: ResourceConfig }) {
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td className="px-4 py-8 text-center text-gray-500" colSpan={99}>در حال بارگذاری…</td></tr>
|
||||
) : rows.length === 0 ? (
|
||||
) : paged.length === 0 ? (
|
||||
<tr><td className="px-4 py-8 text-center text-gray-500" colSpan={99}>رکوردی یافت نشد.</td></tr>
|
||||
) : (
|
||||
rows.map((row, i) => (
|
||||
paged.map((row, i) => (
|
||||
<tr key={String(row[idKey] ?? i)} className="border-b border-[#161a2e] hover:bg-[#12152a]">
|
||||
{config.columns.map((c) => (
|
||||
<td key={c.key} className="px-4 py-3 text-gray-200">
|
||||
@@ -193,6 +211,19 @@ export function AdminResource({ config }: { config: ResourceConfig }) {
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{!loading && filtered.length > 0 && (
|
||||
<div className="flex items-center justify-between text-xs text-gray-500" dir="rtl">
|
||||
<span>{filtered.length.toLocaleString("fa-IR")} مورد{q ? " (فیلترشده)" : ""}</span>
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<button className={btnGhost} disabled={safePage <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}>قبلی</button>
|
||||
<span>صفحهٔ {safePage.toLocaleString("fa-IR")} از {totalPages.toLocaleString("fa-IR")}</span>
|
||||
<button className={btnGhost} disabled={safePage >= totalPages} onClick={() => setPage((p) => Math.min(totalPages, p + 1))}>بعدی</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(creating || editing) && config.fields && (
|
||||
<div className="fixed inset-0 z-50 flex items-stretch justify-center bg-black/70 p-2 sm:p-6" onClick={closeForm}>
|
||||
<div className={`${card} flex max-h-full w-full max-w-5xl flex-col`} onClick={(e) => e.stopPropagation()}>
|
||||
|
||||
@@ -70,6 +70,7 @@ export const tagsConfig: ResourceConfig = {
|
||||
title: "برچسبها",
|
||||
description: "برچسبهای کلیدواژه برای قالبها و محتوا.",
|
||||
basePath: "tags",
|
||||
listQuery: "pageSize=500&page_size=500",
|
||||
listKey: "items",
|
||||
canCreate: true,
|
||||
canEdit: true,
|
||||
@@ -91,6 +92,7 @@ export const fontsConfig: ResourceConfig = {
|
||||
title: "فونتها",
|
||||
description: "فونتهای در دسترس در ویرایشگرهای استودیو.",
|
||||
basePath: "fonts",
|
||||
listQuery: "pageSize=500&page_size=500",
|
||||
listKey: "items",
|
||||
canCreate: true,
|
||||
canEdit: true,
|
||||
@@ -115,6 +117,7 @@ export const musicConfig: ResourceConfig = {
|
||||
title: "موسیقی",
|
||||
description: "ترکهای صوتی موجود در کتابخانهٔ موسیقی استودیو.",
|
||||
basePath: "music",
|
||||
listQuery: "pageSize=500&page_size=500",
|
||||
listKey: "items",
|
||||
canCreate: true,
|
||||
canEdit: false,
|
||||
@@ -143,6 +146,7 @@ export const blogsConfig: ResourceConfig = {
|
||||
title: "مقالات بلاگ",
|
||||
description: "مقالات سیستم مدیریت محتوا (تولیدشده توسط هوش مصنوعی نیز).",
|
||||
basePath: "blogs",
|
||||
listQuery: "pageSize=500&page_size=500",
|
||||
listKey: "items",
|
||||
canCreate: true,
|
||||
canEdit: true,
|
||||
@@ -213,6 +217,7 @@ export const commentsConfig: ResourceConfig = {
|
||||
title: "نظرات",
|
||||
description: "مدیریت نظرات کاربران روی مقالات و قالبها.",
|
||||
basePath: "comments",
|
||||
listQuery: "pageSize=500&page_size=500",
|
||||
listKey: "data",
|
||||
canCreate: false,
|
||||
canEdit: false,
|
||||
@@ -264,6 +269,7 @@ export const usersConfig: ResourceConfig = {
|
||||
title: "کاربران",
|
||||
description: "حسابهای این مجموعه. مسدودسازی یا مدیریت در زیر.",
|
||||
basePath: "users",
|
||||
listQuery: "pageSize=500&page_size=500",
|
||||
listKey: "data",
|
||||
columns: [
|
||||
{ key: "email", label: "ایمیل" },
|
||||
@@ -298,6 +304,7 @@ export const discountsConfig: ResourceConfig = {
|
||||
title: "تخفیفها",
|
||||
description: "کدهای تخفیف / کوپن. (کدها اینجا ساخته میشوند؛ هنوز API ویرایش/حذف وجود ندارد.)",
|
||||
basePath: "discounts",
|
||||
listQuery: "pageSize=500&page_size=500",
|
||||
listKey: "data",
|
||||
canCreate: true,
|
||||
columns: [
|
||||
|
||||
Reference in New Issue
Block a user