From 08d2de8e92bc0b494ef568d403477b216aa01678 Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Thu, 4 Jun 2026 00:00:56 +0330 Subject: [PATCH] feat(admin): auto-slug from name + "add project" on Projects page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/components/admin/AdminResource.tsx | 28 +++++++- src/components/admin/ProjectsAdmin.tsx | 87 +++++++++++++++++++++++++ src/components/admin/TemplatesAdmin.tsx | 9 ++- 3 files changed, 120 insertions(+), 4 deletions(-) diff --git a/src/components/admin/AdminResource.tsx b/src/components/admin/AdminResource.tsx index 9ca2784..dda4c08 100644 --- a/src/components/admin/AdminResource.tsx +++ b/src/components/admin/AdminResource.tsx @@ -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[]>([]); @@ -55,6 +66,7 @@ export function AdminResource({ config }: { config: ResourceConfig }) { 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}`; @@ -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 = {}; 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 = {}; 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 }) { ) : ( 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)} /> )} ))} diff --git a/src/components/admin/ProjectsAdmin.tsx b/src/components/admin/ProjectsAdmin.tsx index 52cefa5..f8bc581 100644 --- a/src/components/admin/ProjectsAdmin.tsx +++ b/src/components/admin/ProjectsAdmin.tsx @@ -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([]); @@ -23,6 +30,11 @@ export function ProjectsAdmin() { const [page, setPage] = useState(1); const [hasMore, setHasMore] = useState(false); const [openAssets, setOpenAssets] = useState(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(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() {
{ setPage(1); setQ(e.target.value); }} /> +
+ {showCreate && ( +
setShowCreate(false)}> +
e.stopPropagation()}> +
+

پروژهٔ جدید (نسخهٔ قابل‌رندر)

+ +
+
+ {err &&

{err}

} +
+ + +
+
setNf({ ...nf, name: e.target.value })} placeholder="مثلاً ۱۶:۹ فول‌اچ‌دی" />
+
+ + +
+
+ + +
+
setNf({ ...nf, width: Number(e.target.value) })} />
+
setNf({ ...nf, height: Number(e.target.value) })} />
+
setNf({ ...nf, duration: Number(e.target.value) })} />
+
setNf({ ...nf, fps: Number(e.target.value) })} />
+
+ + +
+
+
+ + +
+
+
+ )} +
diff --git a/src/components/admin/TemplatesAdmin.tsx b/src/components/admin/TemplatesAdmin.tsx index 061efec..7947d87 100644 --- a/src/components/admin/TemplatesAdmin.tsx +++ b/src/components/admin/TemplatesAdmin.tsx @@ -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(null); const [editId, setEditId] = useState(null); + const [slugTouched, setSlugTouched] = useState(false); const [open, setOpen] = useState(false); const [form, setForm] = useState(emptyForm); const [projects, setProjects] = useState([]); @@ -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() {
-
setForm({ ...form, name: e.target.value })} />
-
setForm({ ...form, slug: e.target.value })} />
+
{ const v = e.target.value; setForm({ ...form, name: v, slug: slugTouched ? form.slug : slugify(v) }); }} />
+
{ setForm({ ...form, slug: e.target.value }); setSlugTouched(true); }} />