feat(admin): category SEO fields, Templates admin, safe project PATCH
Build backend images / build content-svc (push) Failing after 21s
Build backend images / build file-svc (push) Failing after 3m49s
Build backend images / build gateway (push) Failing after 1m2s
Build backend images / build identity-svc (push) Failing after 1m1s
Build backend images / build notification-svc (push) Failing after 1m2s
Build backend images / build render-svc (push) Failing after 1m0s
Build backend images / build studio-svc (push) Failing after 58s

- categories/tags admin forms: add meta title/description/keywords, bot-follow,
  sort, is_active (backend already supported these)
- new Templates admin (/admin/templates): container CRUD with description,
  keywords, publishing, premium, primary mode, category/tag assignment, plus
  editable per-variant aspect & resolution
- content-svc: PATCH /v1/projects/{id} partial update so aspect/resolution edits
  never wipe render/colour data (SharedColorsSvg, RenderAepComp, Folder)
- admin resource proxy: add PATCH passthrough

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-02 14:26:44 +03:30
parent cd95ca2c6f
commit cf5dd4f195
10 changed files with 362 additions and 5 deletions
+277
View File
@@ -0,0 +1,277 @@
"use client";
import { useCallback, useEffect, useState } from "react";
interface Container {
id: string;
slug: string;
name: string;
description?: string | null;
is_published?: boolean;
is_premium?: boolean;
is_mockup?: boolean;
primary_mode?: string;
sort?: number;
category_slugs?: string[];
}
interface Ref { id: string; name: string; slug?: string }
interface Detail extends Container {
keywords?: string | null;
news_text?: string | null;
categories?: Ref[];
tags?: Ref[];
projects?: { id: string; name: string; aspect?: string | null; resolution?: string }[];
}
const PRIMARY_MODES = ["FIX", "FLEXIBLE", "MockUp", "MusicVisualizer", "VoiceOver"];
const RESOLUTIONS = ["HD", "FullHD", "TwoK", "FourK"];
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 ghost = "rounded-lg border border-[#262b40] px-3 py-1.5 text-xs text-gray-300 hover:bg-[#161a2e]";
const inp = "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 lbl = "mb-1 block text-xs font-medium text-gray-400";
interface FormState {
slug: string; name: string; description: string; keywords: string; news_text: string;
is_published: boolean; is_premium: boolean; is_mockup: boolean; primary_mode: string; sort: number;
category_ids: string[]; tag_ids: string[];
}
const emptyForm: FormState = {
slug: "", name: "", description: "", keywords: "", news_text: "",
is_published: false, is_premium: false, is_mockup: false, primary_mode: "FLEXIBLE", sort: 0,
category_ids: [], tag_ids: [],
};
export function TemplatesAdmin() {
const [rows, setRows] = useState<Container[]>([]);
const [cats, setCats] = useState<Ref[]>([]);
const [tags, setTags] = useState<Ref[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [editId, setEditId] = useState<string | null>(null);
const [open, setOpen] = useState(false);
const [form, setForm] = useState<FormState>(emptyForm);
const [projects, setProjects] = useState<NonNullable<Detail["projects"]>>([]);
const [saving, setSaving] = useState(false);
const [savingProj, setSavingProj] = useState<string | null>(null);
const api = (p: string) => `/api/admin/resource/${p}`;
const updateProj = (id: string, patch: Partial<{ aspect: string; resolution: string }>) =>
setProjects((ps) => ps.map((p) => (p.id === id ? { ...p, ...patch } : p)));
const saveProj = async (p: NonNullable<Detail["projects"]>[number]) => {
setSavingProj(p.id);
setError(null);
const res = await fetch(api(`projects/${p.id}`), {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ aspect: p.aspect ?? "", resolution: p.resolution }),
});
if (!res.ok) {
const d = await res.json().catch(() => null);
setError(d?.error ?? "Failed to save variant");
}
setSavingProj(null);
};
const reload = useCallback(async () => {
setLoading(true);
try {
const [c, ct, tg] = await Promise.all([
fetch(api("templates"), { cache: "no-store" }).then((r) => r.json()),
fetch(api("categories"), { cache: "no-store" }).then((r) => r.json()),
fetch(api("tags"), { cache: "no-store" }).then((r) => r.json()),
]);
setRows(c?.items ?? (Array.isArray(c) ? c : []));
setCats(Array.isArray(ct) ? ct : ct?.items ?? []);
setTags(tg?.items ?? (Array.isArray(tg) ? tg : []));
} catch {
setError("Failed to load templates");
} finally {
setLoading(false);
}
}, []);
useEffect(() => { reload(); }, [reload]);
const openNew = () => { setForm(emptyForm); setEditId(null); setProjects([]); 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);
setProjects(d.projects ?? []);
setForm({
slug: d.slug, name: d.name, description: d.description ?? "", keywords: d.keywords ?? "",
news_text: d.news_text ?? "", is_published: !!d.is_published, is_premium: !!d.is_premium,
is_mockup: !!d.is_mockup, primary_mode: d.primary_mode ?? "FLEXIBLE", sort: d.sort ?? 0,
category_ids: (d.categories ?? []).map((c) => c.id), tag_ids: (d.tags ?? []).map((t) => t.id),
});
setOpen(true);
};
const toggle = (key: "category_ids" | "tag_ids", id: string) =>
setForm((f) => ({ ...f, [key]: f[key].includes(id) ? f[key].filter((x) => x !== id) : [...f[key], id] }));
const save = async () => {
setSaving(true); setError(null);
const body = { ...form, sort: Number(form.sort) || 0 };
const res = await fetch(editId ? api(`templates/${editId}`) : api("templates"), {
method: editId ? "PUT" : "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await res.json().catch(() => null);
if (res.ok) { setOpen(false); reload(); }
else setError(data?.error ?? "Save failed");
setSaving(false);
};
const remove = async (row: Container) => {
if (!confirm(`Delete template "${row.name}"?`)) return;
const res = await fetch(api(`templates/${row.id}`), { method: "DELETE" });
if (res.ok) reload(); else setError("Delete failed");
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-white">Templates</h1>
<p className="mt-1 text-sm text-gray-400">Template packs name, description, keywords, categories, tags, publishing.</p>
</div>
<button className={btn} onClick={openNew}>+ New template</button>
</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-left text-xs text-gray-500">
<th className="px-4 py-3">Name</th><th className="px-4 py-3">Slug</th>
<th className="px-4 py-3">Status</th><th className="px-4 py-3">Mode</th>
<th className="px-4 py-3">Sort</th><th className="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={6} className="px-4 py-8 text-center text-gray-500">Loading</td></tr>
) : rows.length === 0 ? (
<tr><td colSpan={6} className="px-4 py-8 text-center text-gray-500">No templates.</td></tr>
) : rows.map((r) => (
<tr key={r.id} className="border-b border-[#161a2e] hover:bg-[#12152a]">
<td className="px-4 py-3 text-gray-200">{r.name}</td>
<td className="px-4 py-3 text-gray-400">{r.slug}</td>
<td className="px-4 py-3">
<span className={r.is_published ? "rounded bg-emerald-500/15 px-1.5 py-0.5 text-[11px] text-emerald-300" : "rounded bg-gray-500/15 px-1.5 py-0.5 text-[11px] text-gray-400"}>
{r.is_published ? "published" : "draft"}
</span>
{r.is_premium ? <span className="ms-1 rounded bg-amber-500/15 px-1.5 py-0.5 text-[11px] text-amber-300">premium</span> : null}
</td>
<td className="px-4 py-3 text-gray-400">{r.primary_mode}</td>
<td className="px-4 py-3 text-gray-400">{r.sort}</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-2">
<button className={ghost} onClick={() => openEdit(r)}>Edit</button>
<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(r)}>Delete</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{open && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={() => setOpen(false)}>
<div className={`${card} w-full max-w-2xl p-5`} onClick={(e) => e.stopPropagation()}>
<h2 className="text-sm font-semibold text-white">{editId ? "Edit" : "New"} template</h2>
<div className="mt-4 grid max-h-[65vh] gap-3 overflow-y-auto pr-1">
<div className="grid gap-3 sm:grid-cols-2">
<div><label className={lbl}>Name *</label><input className={inp} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /></div>
<div><label className={lbl}>Slug *</label><input className={inp} value={form.slug} onChange={(e) => setForm({ ...form, slug: e.target.value })} /></div>
</div>
<div><label className={lbl}>Description</label><textarea className={`${inp} min-h-[80px]`} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} /></div>
<div><label className={lbl}>Keywords (SEO)</label><input className={inp} value={form.keywords} onChange={(e) => setForm({ ...form, keywords: e.target.value })} /></div>
<div><label className={lbl}>News / announcement text</label><textarea className={`${inp} min-h-[50px]`} value={form.news_text} onChange={(e) => setForm({ ...form, news_text: e.target.value })} /></div>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<label className={lbl}>Primary mode</label>
<select className={inp} value={form.primary_mode} onChange={(e) => setForm({ ...form, primary_mode: e.target.value })}>
{PRIMARY_MODES.map((m) => <option key={m} value={m}>{m}</option>)}
</select>
</div>
<div><label className={lbl}>Sort</label><input type="number" className={inp} value={form.sort} onChange={(e) => setForm({ ...form, sort: Number(e.target.value) })} /></div>
</div>
<div className="flex flex-wrap gap-4 text-sm text-gray-300">
<label className="flex items-center gap-2"><input type="checkbox" checked={form.is_published} onChange={(e) => setForm({ ...form, is_published: e.target.checked })} /> Published</label>
<label className="flex items-center gap-2"><input type="checkbox" checked={form.is_premium} onChange={(e) => setForm({ ...form, is_premium: e.target.checked })} /> Premium</label>
<label className="flex items-center gap-2"><input type="checkbox" checked={form.is_mockup} onChange={(e) => setForm({ ...form, is_mockup: e.target.checked })} /> Mockup</label>
</div>
<div>
<label className={lbl}>Categories</label>
<div className="flex flex-wrap gap-2">
{cats.map((c) => (
<button key={c.id} type="button" onClick={() => toggle("category_ids", c.id)}
className={form.category_ids.includes(c.id) ? "rounded-full bg-indigo-600 px-2.5 py-1 text-xs text-white" : "rounded-full border border-[#262b40] px-2.5 py-1 text-xs text-gray-400"}>
{c.name}
</button>
))}
{cats.length === 0 && <span className="text-xs text-gray-600">No categories yet.</span>}
</div>
</div>
<div>
<label className={lbl}>Tags</label>
<div className="flex flex-wrap gap-2">
{tags.map((tg) => (
<button key={tg.id} type="button" onClick={() => toggle("tag_ids", tg.id)}
className={form.tag_ids.includes(tg.id) ? "rounded-full bg-indigo-600 px-2.5 py-1 text-xs text-white" : "rounded-full border border-[#262b40] px-2.5 py-1 text-xs text-gray-400"}>
{tg.name}
</button>
))}
{tags.length === 0 && <span className="text-xs text-gray-600">No tags yet.</span>}
</div>
</div>
{editId && projects.length > 0 && (
<div>
<label className={lbl}>Variants aspect &amp; resolution</label>
<div className="space-y-2 rounded-lg border border-[#262b40] p-2">
{projects.map((p) => (
<div key={p.id} className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate text-xs text-gray-300">{p.name}</span>
<input
className={`${inp} w-24 py-1 text-xs`}
placeholder="16:9"
value={p.aspect ?? ""}
onChange={(e) => updateProj(p.id, { aspect: e.target.value })}
/>
<select
className={`${inp} w-28 py-1 text-xs`}
value={p.resolution ?? "FullHD"}
onChange={(e) => updateProj(p.id, { resolution: e.target.value })}
>
{RESOLUTIONS.map((r) => <option key={r} value={r}>{r}</option>)}
</select>
<button type="button" className={ghost} onClick={() => saveProj(p)} disabled={savingProj === p.id}>
{savingProj === p.id ? "…" : "Save"}
</button>
</div>
))}
</div>
<p className="mt-1 text-[11px] text-gray-600">Edits use a partial update other render/colour data is preserved.</p>
</div>
)}
</div>
<div className="mt-5 flex items-center justify-end gap-2">
<button className={ghost} onClick={() => setOpen(false)}>Cancel</button>
<button className={btn} onClick={save} disabled={saving || !form.name || !form.slug}>{saving ? "Saving…" : "Save"}</button>
</div>
</div>
</div>
)}
</div>
);
}
+8 -1
View File
@@ -50,9 +50,16 @@ export const categoriesConfig: ResourceConfig = {
fields: [
{ key: "name", label: "Name", required: true },
{ key: "slug", label: "Slug", required: true },
{ key: "description", label: "Description", type: "textarea" },
{ key: "description", label: "Description / content", type: "textarea" },
{ key: "image_url", label: "Image URL" },
{ key: "icon", label: "Icon" },
// SEO
{ key: "meta_title", label: "SEO · Meta title" },
{ key: "meta_description", label: "SEO · Meta description", type: "textarea" },
{ key: "meta_keywords", label: "SEO · Meta keywords (comma separated)" },
{ key: "bot_follow", label: "Allow search engines to follow", type: "checkbox", defaultValue: true },
{ key: "sort", label: "Sort order", type: "number", defaultValue: 0 },
{ key: "is_active", label: "Active (visible on site)", type: "checkbox", defaultValue: true },
],
};