"use client"; import { useCallback, useEffect, useState, type ReactNode } from "react"; import { FileUploadField } from "@/components/admin/FileUploadField"; import { AdminThumb } from "@/components/admin/AdminThumb"; import { RichTextField } from "@/components/admin/RichTextField"; export interface FieldDef { key: string; label: string; type?: "text" | "textarea" | "richtext" | "number" | "checkbox" | "select" | "image" | "file"; options?: { value: string; label: string }[]; required?: boolean; placeholder?: string; defaultValue?: string | number | boolean; } export interface ColumnDef { key: string; label: string; type?: "text" | "image"; render?: (row: Record) => ReactNode; } export interface ResourceConfig { title: string; description?: string; basePath: string; // e.g. "categories" idKey?: string; // default "id" listKey?: string; // wrap key, e.g. "items"; omit if response is a bare array listQuery?: string; // extra query string appended to the list fetch, e.g. "includeInactive=true" columns: ColumnDef[]; fields?: FieldDef[]; canCreate?: boolean; canEdit?: boolean; canDelete?: boolean; rowActions?: (row: Record, reload: () => void) => ReactNode; } const card = "rounded-xl border border-[#1e2235] bg-[#0f1120]"; const btn = "rounded-lg bg-indigo-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-indigo-500 disabled:opacity-50"; 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; /** URL-safe slug; keeps unicode letters (incl. Persian) + digits, spaces → "-". */ export function slugify(s: string): string { return s .trim() .toLowerCase() .replace(/\s+/g, "-") .replace(/[^\p{L}\p{N}-]+/gu, "") .replace(/-+/g, "-") .replace(/^-|-$/g, ""); } export function AdminResource({ config }: { config: ResourceConfig }) { const idKey = config.idKey ?? "id"; const [rows, setRows] = useState[]>([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [editing, setEditing] = useState | null>(null); const [creating, setCreating] = useState(false); const [form, setForm] = useState>({}); const [query, setQuery] = useState(""); const [page, setPage] = useState(1); const [slugTouched, setSlugTouched] = useState(false); const [saving, setSaving] = useState(false); const url = (suffix = "") => `/api/admin/resource/${config.basePath}${suffix}`; const reload = useCallback(async () => { setLoading(true); setError(null); try { const res = await fetch(url(config.listQuery ? `?${config.listQuery}` : ""), { cache: "no-store" }); const data = await res.json(); if (!res.ok) throw new Error(data?.error ?? "Failed to load"); const list = config.listKey ? data?.[config.listKey] : data; setRows(Array.isArray(list) ? list : (data?.items ?? [])); } catch (e) { setError(e instanceof Error ? e.message : "Failed to load"); } finally { setLoading(false); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [config.basePath, config.listKey, config.listQuery]); useEffect(() => { reload(); }, [reload]); const hasSlug = !!config.fields?.some((f) => f.key === "slug"); // Update a field; auto-fill slug from name until the slug is edited by hand. const setField = (key: string, value: unknown) => { setForm((prev) => { const next = { ...prev, [key]: value }; if (key === "name" && hasSlug && !slugTouched) next.slug = slugify(String(value ?? "")); return next; }); if (key === "slug") setSlugTouched(true); }; const openCreate = () => { const init: Record = {}; config.fields?.forEach((f) => (init[f.key] = f.defaultValue ?? (f.type === "checkbox" ? false : ""))); setForm(init); setSlugTouched(false); // new record → keep syncing slug from name setCreating(true); setEditing(null); }; const openEdit = (row: Record) => { const init: Record = {}; config.fields?.forEach((f) => (init[f.key] = row[f.key] ?? (f.type === "checkbox" ? false : ""))); setForm(init); setSlugTouched(true); // existing record → never auto-rewrite its slug setEditing(row); setCreating(false); }; const closeForm = () => { setCreating(false); setEditing(null); setForm({}); }; const submit = async () => { setSaving(true); setError(null); try { const isEdit = !!editing; const res = await fetch(isEdit ? url(`/${editing![idKey]}`) : url(), { method: isEdit ? "PUT" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(form), }); const data = await res.json().catch(() => null); if (!res.ok) throw new Error(data?.error ?? "Save failed"); closeForm(); reload(); } catch (e) { setError(e instanceof Error ? e.message : "Save failed"); } finally { setSaving(false); } }; const remove = async (row: Record) => { if (!confirm("حذف این مورد؟")) return; const res = await fetch(url(`/${row[idKey]}`), { method: "DELETE" }); if (res.ok) reload(); else { const d = await res.json().catch(() => null); setError(d?.error ?? "Delete failed"); } }; // 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 (

{config.title}

{config.description &&

{config.description}

}
{ setQuery(e.target.value); setPage(1); }} /> {config.canCreate && config.fields && ( )}
{error &&

{error}

}
{config.columns.map((c) => ( ))} {(config.canEdit || config.canDelete || config.rowActions) && ( )} {loading ? ( ) : paged.length === 0 ? ( ) : ( paged.map((row, i) => ( {config.columns.map((c) => ( ))} {(config.canEdit || config.canDelete || config.rowActions) && ( )} )) )}
{c.label}عملیات
در حال بارگذاری…
رکوردی یافت نشد.
{c.render ? c.render(row) : c.type === "image" ? : formatCell(row[c.key])}
{config.rowActions?.(row, reload)} {config.canEdit && config.fields && ( )} {config.canDelete && ( )}
{!loading && filtered.length > 0 && (
{filtered.length.toLocaleString("fa-IR")} مورد{q ? " (فیلترشده)" : ""} {totalPages > 1 && (
صفحهٔ {safePage.toLocaleString("fa-IR")} از {totalPages.toLocaleString("fa-IR")}
)}
)} {(creating || editing) && config.fields && (
e.stopPropagation()}>

{editing ? "ویرایش" : "افزودن"} — {config.title}

{config.fields.map((f) => (
{f.type !== "checkbox" && ( )} {f.type === "richtext" ? ( setForm({ ...form, [f.key]: html })} /> ) : f.type === "textarea" ? (