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:
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user