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;
|
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 }) {
|
export function AdminResource({ config }: { config: ResourceConfig }) {
|
||||||
const idKey = config.idKey ?? "id";
|
const idKey = config.idKey ?? "id";
|
||||||
const [rows, setRows] = useState<Record<string, unknown>[]>([]);
|
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 [form, setForm] = useState<Record<string, unknown>>({});
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
const [slugTouched, setSlugTouched] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
const url = (suffix = "") => `/api/admin/resource/${config.basePath}${suffix}`;
|
const url = (suffix = "") => `/api/admin/resource/${config.basePath}${suffix}`;
|
||||||
@@ -80,10 +92,23 @@ export function AdminResource({ config }: { config: ResourceConfig }) {
|
|||||||
reload();
|
reload();
|
||||||
}, [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 openCreate = () => {
|
||||||
const init: Record<string, unknown> = {};
|
const init: Record<string, unknown> = {};
|
||||||
config.fields?.forEach((f) => (init[f.key] = f.defaultValue ?? (f.type === "checkbox" ? false : "")));
|
config.fields?.forEach((f) => (init[f.key] = f.defaultValue ?? (f.type === "checkbox" ? false : "")));
|
||||||
setForm(init);
|
setForm(init);
|
||||||
|
setSlugTouched(false); // new record → keep syncing slug from name
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
};
|
};
|
||||||
@@ -92,6 +117,7 @@ export function AdminResource({ config }: { config: ResourceConfig }) {
|
|||||||
const init: Record<string, unknown> = {};
|
const init: Record<string, unknown> = {};
|
||||||
config.fields?.forEach((f) => (init[f.key] = row[f.key] ?? (f.type === "checkbox" ? false : "")));
|
config.fields?.forEach((f) => (init[f.key] = row[f.key] ?? (f.type === "checkbox" ? false : "")));
|
||||||
setForm(init);
|
setForm(init);
|
||||||
|
setSlugTouched(true); // existing record → never auto-rewrite its slug
|
||||||
setEditing(row);
|
setEditing(row);
|
||||||
setCreating(false);
|
setCreating(false);
|
||||||
};
|
};
|
||||||
@@ -266,7 +292,7 @@ export function AdminResource({ config }: { config: ResourceConfig }) {
|
|||||||
) : (
|
) : (
|
||||||
<input type={f.type === "number" ? "number" : "text"} className={inputCls} placeholder={f.placeholder}
|
<input type={f.type === "number" ? "number" : "text"} className={inputCls} placeholder={f.placeholder}
|
||||||
value={String(form[f.key] ?? "")}
|
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>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -15,6 +15,13 @@ interface Proj {
|
|||||||
const card = "rounded-xl border border-[#1e2235] bg-[#0f1120]";
|
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 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 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() {
|
export function ProjectsAdmin() {
|
||||||
const [rows, setRows] = useState<Proj[]>([]);
|
const [rows, setRows] = useState<Proj[]>([]);
|
||||||
@@ -23,6 +30,11 @@ export function ProjectsAdmin() {
|
|||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [hasMore, setHasMore] = useState(false);
|
const [hasMore, setHasMore] = useState(false);
|
||||||
const [openAssets, setOpenAssets] = useState<Proj | null>(null);
|
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 () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -35,6 +47,32 @@ export function ProjectsAdmin() {
|
|||||||
}, [q, page]);
|
}, [q, page]);
|
||||||
useEffect(() => { load(); }, [load]);
|
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) => {
|
const attachAep = async (p: Proj, url: string) => {
|
||||||
await fetch(`/api/admin/resource/projects/${p.id}/aep`, {
|
await fetch(`/api/admin/resource/projects/${p.id}/aep`, {
|
||||||
method: "PATCH", headers: { "Content-Type": "application/json" },
|
method: "PATCH", headers: { "Content-Type": "application/json" },
|
||||||
@@ -57,9 +95,58 @@ export function ProjectsAdmin() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<input className={inp} placeholder="جستجوی نام پروژه…" value={q} onChange={(e) => { setPage(1); setQ(e.target.value); }} />
|
<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>
|
||||||
</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`}>
|
<div className={`${card} overflow-hidden`}>
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead><tr className="border-b border-[#1e2235] text-start text-xs text-gray-500">
|
<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 { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { slugify } from "@/components/admin/AdminResource";
|
||||||
import { FileUploadField } from "@/components/admin/FileUploadField";
|
import { FileUploadField } from "@/components/admin/FileUploadField";
|
||||||
import { AdminThumb } from "@/components/admin/AdminThumb";
|
import { AdminThumb } from "@/components/admin/AdminThumb";
|
||||||
|
|
||||||
@@ -64,6 +65,7 @@ export function TemplatesAdmin() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [editId, setEditId] = useState<string | null>(null);
|
const [editId, setEditId] = useState<string | null>(null);
|
||||||
|
const [slugTouched, setSlugTouched] = useState(false);
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [form, setForm] = useState<FormState>(emptyForm);
|
const [form, setForm] = useState<FormState>(emptyForm);
|
||||||
const [projects, setProjects] = useState<Proj[]>([]);
|
const [projects, setProjects] = useState<Proj[]>([]);
|
||||||
@@ -166,12 +168,13 @@ export function TemplatesAdmin() {
|
|||||||
|
|
||||||
useEffect(() => { reload(); }, [reload]);
|
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) => {
|
const openEdit = async (row: Container) => {
|
||||||
setError(null);
|
setError(null);
|
||||||
const d: Detail = await fetch(api(`templates/${row.slug}`), { cache: "no-store" }).then((r) => r.json());
|
const d: Detail = await fetch(api(`templates/${row.slug}`), { cache: "no-store" }).then((r) => r.json());
|
||||||
setEditId(d.id);
|
setEditId(d.id);
|
||||||
|
setSlugTouched(true); // existing template → keep its slug stable
|
||||||
setProjects(d.projects ?? []);
|
setProjects(d.projects ?? []);
|
||||||
setForm({
|
setForm({
|
||||||
slug: d.slug, name: d.name, description: d.description ?? "", keywords: d.keywords ?? "",
|
slug: d.slug, name: d.name, description: d.description ?? "", keywords: d.keywords ?? "",
|
||||||
@@ -267,8 +270,8 @@ export function TemplatesAdmin() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="grid flex-1 gap-3 overflow-y-auto p-5">
|
<div className="grid flex-1 gap-3 overflow-y-auto p-5">
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<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.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 })} /></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>
|
||||||
<div><label className={lbl}>توضیحات</label><textarea className={`${inp} min-h-[80px]`} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} /></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">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
|||||||
Reference in New Issue
Block a user