Files
flatrender/src/components/admin/AdminResource.tsx
T
soroush.asadi 08d2de8e92 feat(admin): auto-slug from name + "add project" on Projects page
- slug fields auto-fill from the name (slugify keeps Persian + latin letters,
  spaces → "-") until the slug is edited by hand; applies to all data-driven
  forms (categories/tags/blogs/…) and the Templates form
- Projects page (/admin/projects) gains "+ پروژه جدید": pick a template
  (container) + name/aspect/resolution/size/duration/fps/mode → POST /v1/projects.
  Previously a project could only be added while editing a template.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 00:00:56 +03:30

319 lines
14 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.
"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<string, unknown>) => 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<string, unknown>, 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<Record<string, unknown>[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
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 [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<string, unknown> = {};
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<string, unknown>) => {
const init: Record<string, unknown> = {};
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<string, unknown>) => {
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 (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<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>
<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>}
<div className={`${card} overflow-hidden`}>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[#1e2235] text-start text-xs text-gray-500">
{config.columns.map((c) => (
<th key={c.key} className="px-4 py-3 font-medium">{c.label}</th>
))}
{(config.canEdit || config.canDelete || config.rowActions) && (
<th className="px-4 py-3 text-end font-medium">عملیات</th>
)}
</tr>
</thead>
<tbody>
{loading ? (
<tr><td className="px-4 py-8 text-center text-gray-500" colSpan={99}>در حال بارگذاری</td></tr>
) : paged.length === 0 ? (
<tr><td className="px-4 py-8 text-center text-gray-500" colSpan={99}>رکوردی یافت نشد.</td></tr>
) : (
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">
{c.render ? c.render(row) : c.type === "image" ? <AdminThumb src={row[c.key]} /> : formatCell(row[c.key])}
</td>
))}
{(config.canEdit || config.canDelete || config.rowActions) && (
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-2">
{config.rowActions?.(row, reload)}
{config.canEdit && config.fields && (
<button className={btnGhost} onClick={() => openEdit(row)}>ویرایش</button>
)}
{config.canDelete && (
<button
className="rounded-lg border border-red-500/30 px-3 py-1.5 text-xs text-red-300 hover:bg-red-500/10"
onClick={() => remove(row)}
>
حذف
</button>
)}
</div>
</td>
)}
</tr>
))
)}
</tbody>
</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()}>
<div className="flex items-center justify-between border-b border-[#1e2235] px-5 py-3">
<h2 className="text-sm font-semibold text-white">
{editing ? "ویرایش" : "افزودن"} {config.title}
</h2>
<button className="rounded-lg px-2 py-1 text-gray-400 hover:bg-[#161a2e] hover:text-white" onClick={closeForm}></button>
</div>
<div className="grid flex-1 grid-cols-1 gap-4 overflow-y-auto p-5 sm:grid-cols-2">
{config.fields.map((f) => (
<div key={f.key} className={f.type === "textarea" || f.type === "richtext" ? "sm:col-span-2" : ""}>
{f.type !== "checkbox" && (
<label className="mb-1 block text-xs font-medium text-gray-400">
{f.label}{f.required && <span className="text-red-400"> *</span>}
</label>
)}
{f.type === "richtext" ? (
<RichTextField value={String(form[f.key] ?? "")} onChange={(html) => setForm({ ...form, [f.key]: html })} />
) : f.type === "textarea" ? (
<textarea className={`${inputCls} min-h-[160px]`} placeholder={f.placeholder}
value={String(form[f.key] ?? "")} onChange={(e) => setForm({ ...form, [f.key]: e.target.value })} />
) : f.type === "select" ? (
<select className={inputCls} value={String(form[f.key] ?? "")}
onChange={(e) => setForm({ ...form, [f.key]: e.target.value })}>
<option value=""></option>
{f.options?.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
) : f.type === "checkbox" ? (
<label className="flex items-center gap-2 text-sm text-gray-300">
<input type="checkbox" checked={!!form[f.key]} onChange={(e) => setForm({ ...form, [f.key]: e.target.checked })} />
{f.label}
</label>
) : f.type === "image" || f.type === "file" ? (
<FileUploadField
value={String(form[f.key] ?? "")}
onChange={(url) => setForm({ ...form, [f.key]: url })}
accept={f.type === "image" ? "image/*" : "*/*"}
/>
) : (
<input type={f.type === "number" ? "number" : "text"} className={inputCls} placeholder={f.placeholder}
value={String(form[f.key] ?? "")}
onChange={(e) => setField(f.key, f.type === "number" ? Number(e.target.value) : e.target.value)} />
)}
</div>
))}
</div>
<div className="flex items-center justify-end gap-2 border-t border-[#1e2235] px-5 py-3">
<button className={btnGhost} onClick={closeForm}>انصراف</button>
<button className={btn} onClick={submit} disabled={saving}>{saving ? "در حال ذخیره…" : "ذخیره"}</button>
</div>
</div>
</div>
)}
</div>
);
}
function formatCell(v: unknown): ReactNode {
if (v === null || v === undefined || v === "") return <span className="text-gray-600"></span>;
if (typeof v === "boolean") return v ? "✓" : "✗";
if (Array.isArray(v)) return v.join(", ");
if (typeof v === "object") return JSON.stringify(v).slice(0, 40);
const s = String(v);
return s.length > 60 ? s.slice(0, 60) + "…" : s;
}