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>
This commit is contained in:
soroush.asadi
2026-06-04 00:00:56 +03:30
parent d955d951b5
commit 08d2de8e92
3 changed files with 120 additions and 4 deletions
+27 -1
View File
@@ -45,6 +45,17 @@ const inputCls = "w-full rounded-lg border border-[#262b40] bg-[#0c0e1a] px-3 py
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>[]>([]);
@@ -55,6 +66,7 @@ export function AdminResource({ config }: { config: ResourceConfig }) {
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}`;
@@ -80,10 +92,23 @@ export function AdminResource({ config }: { config: ResourceConfig }) {
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);
};
@@ -92,6 +117,7 @@ export function AdminResource({ config }: { config: ResourceConfig }) {
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);
};
@@ -266,7 +292,7 @@ export function AdminResource({ config }: { config: ResourceConfig }) {
) : (
<input type={f.type === "number" ? "number" : "text"} className={inputCls} placeholder={f.placeholder}
value={String(form[f.key] ?? "")}
onChange={(e) => setForm({ ...form, [f.key]: f.type === "number" ? Number(e.target.value) : e.target.value })} />
onChange={(e) => setField(f.key, f.type === "number" ? Number(e.target.value) : e.target.value)} />
)}
</div>
))}
+87
View File
@@ -15,6 +15,13 @@ interface Proj {
const card = "rounded-xl border border-[#1e2235] bg-[#0f1120]";
const inp = "rounded-lg border border-[#262b40] bg-[#0c0e1a] px-3 py-2 text-sm text-gray-100 outline-none focus:border-indigo-500";
const ghost = "rounded-lg border border-[#262b40] px-2.5 py-1 text-xs text-gray-300 hover:bg-[#161a2e] disabled:opacity-50";
const btn = "rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 disabled:opacity-50";
const lbl = "mb-1 block text-xs text-gray-400";
const RESOLUTIONS = ["HD", "FullHD", "TwoK", "FourK"];
const MODES = ["FIX", "FLEXIBLE", "MockUp", "MusicVisualizer", "VoiceOver"];
const ASPECTS = ["16:9", "9:16", "1:1", "4:5", "21:9"];
const emptyNew = { container_id: "", name: "", width: 1920, height: 1080, aspect: "16:9", resolution: "FullHD", duration: 15, fps: 30, mode: "FLEXIBLE" };
export function ProjectsAdmin() {
const [rows, setRows] = useState<Proj[]>([]);
@@ -23,6 +30,11 @@ export function ProjectsAdmin() {
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(false);
const [openAssets, setOpenAssets] = useState<Proj | null>(null);
const [containers, setContainers] = useState<{ id: string; name: string }[]>([]);
const [showCreate, setShowCreate] = useState(false);
const [nf, setNf] = useState({ ...emptyNew });
const [saving, setSaving] = useState(false);
const [err, setErr] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
@@ -35,6 +47,32 @@ export function ProjectsAdmin() {
}, [q, page]);
useEffect(() => { load(); }, [load]);
// Templates (containers) for the "create project" picker.
useEffect(() => {
fetch(`/api/admin/resource/templates?pageSize=200`, { cache: "no-store" })
.then((x) => x.json()).then((r) => setContainers((r?.items ?? r?.data ?? []).map((c: { id: string; name: string }) => ({ id: c.id, name: c.name }))))
.catch(() => {});
}, []);
const createProject = async () => {
if (!nf.container_id || !nf.name) { setErr("انتخاب قالب و نام پروژه لازم است"); return; }
setSaving(true); setErr(null);
const res = await fetch("/api/admin/resource/projects", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({
container_id: nf.container_id, name: nf.name,
original_width: Number(nf.width) || 1920, original_height: Number(nf.height) || 1080,
aspect: nf.aspect, project_duration_sec: Number(nf.duration) || 15,
free_fps: Number(nf.fps) || 30, choose_mode: nf.mode, resolution: nf.resolution,
vip_factor: 1.0, render_aep_comp: "flatrender", is_published: true, sort: 0,
}),
});
const d = await res.json().catch(() => null);
if (res.ok) { setShowCreate(false); setNf({ ...emptyNew }); load(); }
else setErr(d?.message ?? d?.error?.message ?? d?.error ?? "ساخت پروژه ناموفق بود");
setSaving(false);
};
const attachAep = async (p: Proj, url: string) => {
await fetch(`/api/admin/resource/projects/${p.id}/aep`, {
method: "PATCH", headers: { "Content-Type": "application/json" },
@@ -57,9 +95,58 @@ export function ProjectsAdmin() {
</div>
<div className="flex items-center gap-2">
<input className={inp} placeholder="جستجوی نام پروژه…" value={q} onChange={(e) => { setPage(1); setQ(e.target.value); }} />
<button className={btn} onClick={() => { setErr(null); setShowCreate(true); }}>+ پروژه جدید</button>
</div>
</div>
{showCreate && (
<div className="fixed inset-0 z-50 flex items-stretch justify-center bg-black/70 p-2 sm:p-6" onClick={() => setShowCreate(false)}>
<div className={`${card} flex max-h-full w-full max-w-2xl 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">پروژهٔ جدید (نسخهٔ قابلرندر)</h2>
<button className="rounded-lg px-2 py-1 text-gray-400 hover:bg-[#161a2e] hover:text-white" onClick={() => setShowCreate(false)}></button>
</div>
<div className="grid flex-1 gap-3 overflow-y-auto p-5 sm:grid-cols-2">
{err && <p className="sm:col-span-2 rounded-lg bg-red-500/10 px-3 py-2 text-sm text-red-300">{err}</p>}
<div className="sm:col-span-2">
<label className={lbl}>قالب (که این نسخه به آن تعلق دارد) *</label>
<select className={`${inp} w-full`} value={nf.container_id} onChange={(e) => setNf({ ...nf, container_id: e.target.value })}>
<option value=""> انتخاب قالب </option>
{containers.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<div className="sm:col-span-2"><label className={lbl}>نام نسخه *</label><input className={`${inp} w-full`} value={nf.name} onChange={(e) => setNf({ ...nf, name: e.target.value })} placeholder="مثلاً ۱۶:۹ فول‌اچ‌دی" /></div>
<div>
<label className={lbl}>تناسب</label>
<select className={`${inp} w-full`} value={nf.aspect} onChange={(e) => setNf({ ...nf, aspect: e.target.value })}>
{ASPECTS.map((a) => <option key={a} value={a}>{a}</option>)}
</select>
</div>
<div>
<label className={lbl}>کیفیت</label>
<select className={`${inp} w-full`} value={nf.resolution} onChange={(e) => setNf({ ...nf, resolution: e.target.value })}>
{RESOLUTIONS.map((r) => <option key={r} value={r}>{r}</option>)}
</select>
</div>
<div><label className={lbl}>عرض (px)</label><input className={`${inp} w-full`} type="number" dir="ltr" value={nf.width} onChange={(e) => setNf({ ...nf, width: Number(e.target.value) })} /></div>
<div><label className={lbl}>ارتفاع (px)</label><input className={`${inp} w-full`} type="number" dir="ltr" value={nf.height} onChange={(e) => setNf({ ...nf, height: Number(e.target.value) })} /></div>
<div><label className={lbl}>مدت (ثانیه)</label><input className={`${inp} w-full`} type="number" dir="ltr" value={nf.duration} onChange={(e) => setNf({ ...nf, duration: Number(e.target.value) })} /></div>
<div><label className={lbl}>نرخ فریم</label><input className={`${inp} w-full`} type="number" dir="ltr" value={nf.fps} onChange={(e) => setNf({ ...nf, fps: Number(e.target.value) })} /></div>
<div className="sm:col-span-2">
<label className={lbl}>حالت</label>
<select className={`${inp} w-full`} value={nf.mode} onChange={(e) => setNf({ ...nf, mode: e.target.value })}>
{MODES.map((m) => <option key={m} value={m}>{m}</option>)}
</select>
</div>
</div>
<div className="flex items-center justify-end gap-2 border-t border-[#1e2235] px-5 py-3">
<button className={ghost} onClick={() => setShowCreate(false)}>انصراف</button>
<button className={btn} onClick={createProject} disabled={saving || !nf.container_id || !nf.name}>{saving ? "در حال ذخیره…" : "ساخت پروژه"}</button>
</div>
</div>
</div>
)}
<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">
+6 -3
View File
@@ -2,6 +2,7 @@
import { useCallback, useEffect, useState } from "react";
import { slugify } from "@/components/admin/AdminResource";
import { FileUploadField } from "@/components/admin/FileUploadField";
import { AdminThumb } from "@/components/admin/AdminThumb";
@@ -64,6 +65,7 @@ export function TemplatesAdmin() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [editId, setEditId] = useState<string | null>(null);
const [slugTouched, setSlugTouched] = useState(false);
const [open, setOpen] = useState(false);
const [form, setForm] = useState<FormState>(emptyForm);
const [projects, setProjects] = useState<Proj[]>([]);
@@ -166,12 +168,13 @@ export function TemplatesAdmin() {
useEffect(() => { reload(); }, [reload]);
const openNew = () => { setForm(emptyForm); setEditId(null); setProjects([]); setOpen(true); };
const openNew = () => { setForm(emptyForm); setEditId(null); setProjects([]); setSlugTouched(false); setOpen(true); };
const openEdit = async (row: Container) => {
setError(null);
const d: Detail = await fetch(api(`templates/${row.slug}`), { cache: "no-store" }).then((r) => r.json());
setEditId(d.id);
setSlugTouched(true); // existing template → keep its slug stable
setProjects(d.projects ?? []);
setForm({
slug: d.slug, name: d.name, description: d.description ?? "", keywords: d.keywords ?? "",
@@ -267,8 +270,8 @@ export function TemplatesAdmin() {
</div>
<div className="grid flex-1 gap-3 overflow-y-auto p-5">
<div className="grid gap-3 sm:grid-cols-2">
<div><label className={lbl}>نام *</label><input className={inp} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /></div>
<div><label className={lbl}>اسلاگ (نشانی) *</label><input className={inp} value={form.slug} onChange={(e) => setForm({ ...form, slug: e.target.value })} /></div>
<div><label className={lbl}>نام *</label><input className={inp} value={form.name} onChange={(e) => { const v = e.target.value; setForm({ ...form, name: v, slug: slugTouched ? form.slug : slugify(v) }); }} /></div>
<div><label className={lbl}>اسلاگ (نشانی) خودکار از نام</label><input className={inp} value={form.slug} onChange={(e) => { setForm({ ...form, slug: e.target.value }); setSlugTouched(true); }} /></div>
</div>
<div><label className={lbl}>توضیحات</label><textarea className={`${inp} min-h-[80px]`} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} /></div>
<div className="grid gap-3 sm:grid-cols-2">