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
+2 -1
View File
@@ -319,7 +319,8 @@
"blogs": "Blog",
"slides": "Slides",
"users": "Users",
"plans": "Plans"
"plans": "Plans",
"templates": "Templates"
},
"appAdminNodesPage": {
"title": "Render Nodes",
+2 -1
View File
@@ -319,7 +319,8 @@
"blogs": "بلاگ",
"slides": "اسلایدها",
"users": "کاربران",
"plans": "پلن‌ها"
"plans": "پلن‌ها",
"templates": "قالب‌ها"
},
"appAdminNodesPage": {
"title": "نودهای رندر",
@@ -199,6 +199,42 @@ public class TemplateService(ContentDbContext db)
return await GetProjectDetailAsync(project.Id);
}
/// <summary>Partial update — only applies supplied (non-null) fields, so editing an
/// aspect/resolution never clears render/colour data the full update would require.</summary>
public async Task<ProjectDetailResponse> PatchProjectAsync(Guid id, PatchProjectRequest req)
{
var project = await db.Projects.FindAsync(id)
?? throw new KeyNotFoundException($"Project {id} not found");
if (req.Name != null) project.Name = req.Name;
if (req.Description != null) project.Description = req.Description;
if (req.Aspect != null) project.Aspect = req.Aspect;
if (req.Resolution != null)
{
if (!Enum.TryParse<ResolutionKind>(req.Resolution, true, out var resolution))
throw new ArgumentException($"Invalid Resolution: {req.Resolution}");
project.Resolution = resolution;
}
if (req.ChooseMode != null)
{
if (!Enum.TryParse<ChooseMode>(req.ChooseMode, true, out var chooseMode))
throw new ArgumentException($"Invalid ChooseMode: {req.ChooseMode}");
project.ChooseMode = chooseMode;
}
if (req.OriginalWidth.HasValue) project.OriginalWidth = req.OriginalWidth.Value;
if (req.OriginalHeight.HasValue) project.OriginalHeight = req.OriginalHeight.Value;
if (req.ProjectDurationSec.HasValue) project.ProjectDurationSec = req.ProjectDurationSec.Value;
if (req.MinDurationSec.HasValue) project.MinDurationSec = req.MinDurationSec;
if (req.MaxDurationSec.HasValue) project.MaxDurationSec = req.MaxDurationSec;
if (req.FreeFps.HasValue) project.FreeFps = req.FreeFps.Value;
if (req.IsPublished.HasValue) project.IsPublished = req.IsPublished.Value;
if (req.Sort.HasValue) project.Sort = req.Sort.Value;
project.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return await GetProjectDetailAsync(project.Id);
}
public async Task DeleteProjectAsync(Guid id)
{
var project = await db.Projects.FindAsync(id)
@@ -60,6 +60,12 @@ public class ProjectsController(TemplateService svc) : ControllerBase
public async Task<IActionResult> UpdateProject(Guid id, [FromBody] UpdateProjectRequest req) =>
Ok(await svc.UpdateProjectAsync(id, req));
// Partial update (aspect / resolution / dimensions / duration) without wiping other fields.
[Authorize(Roles = "Admin")]
[HttpPatch("{id:guid}")]
public async Task<IActionResult> PatchProject(Guid id, [FromBody] PatchProjectRequest req) =>
Ok(await svc.PatchProjectAsync(id, req));
[Authorize(Roles = "Admin")]
[HttpDelete("{id:guid}")]
public async Task<IActionResult> DeleteProject(Guid id)
@@ -215,6 +215,24 @@ public record UpdateProjectRequest(
int Sort
);
// Partial update — only non-null fields are applied, so editing an aspect/resolution
// never wipes render/colour data that the full UpdateProjectRequest would require.
public record PatchProjectRequest(
string? Name,
string? Description,
string? Aspect,
string? Resolution,
string? ChooseMode,
int? OriginalWidth,
int? OriginalHeight,
decimal? ProjectDurationSec,
decimal? MinDurationSec,
decimal? MaxDurationSec,
int? FreeFps,
bool? IsPublished,
int? Sort
);
// ── CMS ──────────────────────────────────────────────────────────────────────
public record CreateBlogRequest(
+1
View File
@@ -17,6 +17,7 @@ export default async function AdminLayout({
const t = await getTranslations("auto.appAdminLayout");
const links: { href: string; label: string }[] = [
{ href: "/admin/categories", label: t("categories") },
{ href: "/admin/templates", label: t("templates") },
{ href: "/admin/tags", label: t("tags") },
{ href: "/admin/fonts", label: t("fonts") },
{ href: "/admin/blogs", label: t("blogs") },
@@ -0,0 +1,7 @@
"use client";
import { TemplatesAdmin } from "@/components/admin/TemplatesAdmin";
export default function Page() {
return <TemplatesAdmin />;
}
@@ -19,7 +19,7 @@ export const dynamic = "force-dynamic";
async function forward(
req: NextRequest,
path: string[],
method: "GET" | "POST" | "PUT" | "DELETE"
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"
): Promise<NextResponse> {
const token = await getAccessToken();
if (!token) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
@@ -37,7 +37,7 @@ async function forward(
const gwPath = `/v1/${joined}${path.length === 1 && method === "GET" ? "/" : ""}${search}`;
let body: string | undefined;
if (method === "POST" || method === "PUT") {
if (method === "POST" || method === "PUT" || method === "PATCH") {
const json = await req.json().catch(() => ({}));
body = JSON.stringify(json);
}
@@ -91,6 +91,9 @@ export async function POST(req: NextRequest, ctx: { params: { path: string[] } }
export async function PUT(req: NextRequest, ctx: { params: { path: string[] } }) {
return forward(req, ctx.params.path, "PUT");
}
export async function PATCH(req: NextRequest, ctx: { params: { path: string[] } }) {
return forward(req, ctx.params.path, "PATCH");
}
export async function DELETE(req: NextRequest, ctx: { params: { path: string[] } }) {
return forward(req, ctx.params.path, "DELETE");
}
+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 },
],
};