From cf5dd4f1950efd897ce3eb85681619528683d318 Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Tue, 2 Jun 2026 14:26:44 +0330 Subject: [PATCH] feat(admin): category SEO fields, Templates admin, safe project PATCH - 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 --- messages/en.json | 3 +- messages/fa.json | 3 +- .../Application/Services/TemplateService.cs | 36 +++ .../Controllers/TemplatesController.cs | 6 + .../Models/Requests/Requests.cs | 18 ++ src/app/[locale]/admin/layout.tsx | 1 + src/app/[locale]/admin/templates/page.tsx | 7 + src/app/api/admin/resource/[...path]/route.ts | 7 +- src/components/admin/TemplatesAdmin.tsx | 277 ++++++++++++++++++ src/components/admin/admin-resources.tsx | 9 +- 10 files changed, 362 insertions(+), 5 deletions(-) create mode 100644 src/app/[locale]/admin/templates/page.tsx create mode 100644 src/components/admin/TemplatesAdmin.tsx diff --git a/messages/en.json b/messages/en.json index 4b8fae1..1012ff6 100644 --- a/messages/en.json +++ b/messages/en.json @@ -319,7 +319,8 @@ "blogs": "Blog", "slides": "Slides", "users": "Users", - "plans": "Plans" + "plans": "Plans", + "templates": "Templates" }, "appAdminNodesPage": { "title": "Render Nodes", diff --git a/messages/fa.json b/messages/fa.json index f99ca1f..6965518 100644 --- a/messages/fa.json +++ b/messages/fa.json @@ -319,7 +319,8 @@ "blogs": "بلاگ", "slides": "اسلایدها", "users": "کاربران", - "plans": "پلن‌ها" + "plans": "پلن‌ها", + "templates": "قالب‌ها" }, "appAdminNodesPage": { "title": "نودهای رندر", diff --git a/services/content/FlatRender.ContentSvc/Application/Services/TemplateService.cs b/services/content/FlatRender.ContentSvc/Application/Services/TemplateService.cs index 6ac3802..e563d09 100644 --- a/services/content/FlatRender.ContentSvc/Application/Services/TemplateService.cs +++ b/services/content/FlatRender.ContentSvc/Application/Services/TemplateService.cs @@ -199,6 +199,42 @@ public class TemplateService(ContentDbContext db) return await GetProjectDetailAsync(project.Id); } + /// Partial update — only applies supplied (non-null) fields, so editing an + /// aspect/resolution never clears render/colour data the full update would require. + public async Task 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(req.Resolution, true, out var resolution)) + throw new ArgumentException($"Invalid Resolution: {req.Resolution}"); + project.Resolution = resolution; + } + if (req.ChooseMode != null) + { + if (!Enum.TryParse(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) diff --git a/services/content/FlatRender.ContentSvc/Controllers/TemplatesController.cs b/services/content/FlatRender.ContentSvc/Controllers/TemplatesController.cs index 7b7e3d3..65d5af3 100644 --- a/services/content/FlatRender.ContentSvc/Controllers/TemplatesController.cs +++ b/services/content/FlatRender.ContentSvc/Controllers/TemplatesController.cs @@ -60,6 +60,12 @@ public class ProjectsController(TemplateService svc) : ControllerBase public async Task 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 PatchProject(Guid id, [FromBody] PatchProjectRequest req) => + Ok(await svc.PatchProjectAsync(id, req)); + [Authorize(Roles = "Admin")] [HttpDelete("{id:guid}")] public async Task DeleteProject(Guid id) diff --git a/services/content/FlatRender.ContentSvc/Models/Requests/Requests.cs b/services/content/FlatRender.ContentSvc/Models/Requests/Requests.cs index ee67827..f4df328 100644 --- a/services/content/FlatRender.ContentSvc/Models/Requests/Requests.cs +++ b/services/content/FlatRender.ContentSvc/Models/Requests/Requests.cs @@ -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( diff --git a/src/app/[locale]/admin/layout.tsx b/src/app/[locale]/admin/layout.tsx index ce36141..55f387f 100644 --- a/src/app/[locale]/admin/layout.tsx +++ b/src/app/[locale]/admin/layout.tsx @@ -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") }, diff --git a/src/app/[locale]/admin/templates/page.tsx b/src/app/[locale]/admin/templates/page.tsx new file mode 100644 index 0000000..1d50cb9 --- /dev/null +++ b/src/app/[locale]/admin/templates/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { TemplatesAdmin } from "@/components/admin/TemplatesAdmin"; + +export default function Page() { + return ; +} diff --git a/src/app/api/admin/resource/[...path]/route.ts b/src/app/api/admin/resource/[...path]/route.ts index bf0d05a..d36f46e 100644 --- a/src/app/api/admin/resource/[...path]/route.ts +++ b/src/app/api/admin/resource/[...path]/route.ts @@ -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 { 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"); } diff --git a/src/components/admin/TemplatesAdmin.tsx b/src/components/admin/TemplatesAdmin.tsx new file mode 100644 index 0000000..7611671 --- /dev/null +++ b/src/components/admin/TemplatesAdmin.tsx @@ -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([]); + const [cats, setCats] = useState([]); + const [tags, setTags] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [editId, setEditId] = useState(null); + const [open, setOpen] = useState(false); + const [form, setForm] = useState(emptyForm); + const [projects, setProjects] = useState>([]); + const [saving, setSaving] = useState(false); + const [savingProj, setSavingProj] = useState(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[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 ( +
+
+
+

Templates

+

Template packs — name, description, keywords, categories, tags, publishing.

+
+ +
+ + {error &&

{error}

} + +
+ + + + + + + + + + {loading ? ( + + ) : rows.length === 0 ? ( + + ) : rows.map((r) => ( + + + + + + + + + ))} + +
NameSlugStatusModeSortActions
Loading…
No templates.
{r.name}{r.slug} + + {r.is_published ? "published" : "draft"} + + {r.is_premium ? premium : null} + {r.primary_mode}{r.sort} +
+ + +
+
+
+ + {open && ( +
setOpen(false)}> +
e.stopPropagation()}> +

{editId ? "Edit" : "New"} template

+
+
+
setForm({ ...form, name: e.target.value })} />
+
setForm({ ...form, slug: e.target.value })} />
+
+