From c4839bd35fe9474b9af3a392f94d82287d3e364e Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Wed, 3 Jun 2026 00:23:50 +0330 Subject: [PATCH] feat(admin): project (template-item) manager + After Effects file upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The admin could edit a container but not manage its renderable projects or attach AE files. Now, inside the template editor: - add a new project/variant under the container (name, WxH, aspect, resolution, duration, fps, choose-mode) → POST /v1/projects (maps via container_id) - upload the After Effects file (.aep/.zip) per project → new PATCH /v1/projects/{id}/aep (sets AepFileUrl/Minio/Md5/Size + RenderAepComp), with an "AE ✓ / بدون فایل" status badge - set the render composition name; delete a variant - ProjectResponse now surfaces aep_file_url / aep_file_size_bytes / render_aep_comp Additive only — the existing aspect/resolution variant editing is unchanged. Co-Authored-By: Claude Opus 4.8 --- .../Application/Services/TemplateService.cs | 21 ++- .../Controllers/TemplatesController.cs | 6 + .../Models/Requests/Requests.cs | 10 ++ .../Models/Responses/Responses.cs | 5 +- src/components/admin/TemplatesAdmin.tsx | 142 +++++++++++++++--- 5 files changed, 157 insertions(+), 27 deletions(-) diff --git a/services/content/FlatRender.ContentSvc/Application/Services/TemplateService.cs b/services/content/FlatRender.ContentSvc/Application/Services/TemplateService.cs index f0dbd15..8a81a83 100644 --- a/services/content/FlatRender.ContentSvc/Application/Services/TemplateService.cs +++ b/services/content/FlatRender.ContentSvc/Application/Services/TemplateService.cs @@ -310,9 +310,28 @@ public class TemplateService(ContentDbContext db) p.OriginalWidth, p.OriginalHeight, p.Aspect, p.ProjectDurationSec, p.MinDurationSec, p.MaxDurationSec, p.FreeFps, p.ChooseMode.ToString(), p.Resolution.ToString(), - p.IsPublished, p.Sort + p.IsPublished, p.Sort, + p.AepFileUrl, p.AepFileSizeBytes, p.RenderAepComp ); + /// Attach an uploaded After Effects file (and render composition) to a project. + public async Task SetProjectAepAsync(Guid id, SetAepRequest req) + { + var project = await db.Projects.FirstOrDefaultAsync(p => p.Id == id && p.DeletedAt == null) + ?? throw new KeyNotFoundException($"Project {id} not found"); + if (req.AepFileUrl != null) project.AepFileUrl = req.AepFileUrl; + if (req.AepMinioBucket != null) project.AepMinioBucket = req.AepMinioBucket; + if (req.AepMinioKey != null) project.AepMinioKey = req.AepMinioKey; + if (req.AepFileMd5 != null) project.AepFileMd5 = req.AepFileMd5; + if (req.AepFileSizeBytes.HasValue) project.AepFileSizeBytes = req.AepFileSizeBytes; + if (!string.IsNullOrWhiteSpace(req.RenderAepComp)) project.RenderAepComp = req.RenderAepComp; + if (req.Folder != null) project.Folder = req.Folder; + project.AepUploadedAt = DateTime.UtcNow; + project.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + return await GetProjectDetailAsync(id); + } + private static ProjectDetailResponse MapProjectDetail(Project p) => new( p.Id, p.ContainerId, p.Name, p.Description, p.Image, p.FullDemo, p.DemoScriptTag, p.DownloadLink, p.OriginalWidth, p.OriginalHeight, p.Aspect, diff --git a/services/content/FlatRender.ContentSvc/Controllers/TemplatesController.cs b/services/content/FlatRender.ContentSvc/Controllers/TemplatesController.cs index 9706b9e..be29c8a 100644 --- a/services/content/FlatRender.ContentSvc/Controllers/TemplatesController.cs +++ b/services/content/FlatRender.ContentSvc/Controllers/TemplatesController.cs @@ -77,6 +77,12 @@ public class ProjectsController(TemplateService svc) : ControllerBase public async Task PatchProject(Guid id, [FromBody] PatchProjectRequest req) => Ok(await svc.PatchProjectAsync(id, req)); + // Attach an uploaded After Effects file (.aep) + render composition to a project. + [Authorize(Roles = "Admin")] + [HttpPatch("{id:guid}/aep")] + public async Task SetAep(Guid id, [FromBody] SetAepRequest req) => + Ok(await svc.SetProjectAepAsync(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 407406b..cc28e53 100644 --- a/services/content/FlatRender.ContentSvc/Models/Requests/Requests.cs +++ b/services/content/FlatRender.ContentSvc/Models/Requests/Requests.cs @@ -244,6 +244,16 @@ public record UpdateProjectRequest( // 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 SetAepRequest( + string? AepFileUrl, + string? AepMinioBucket, + string? AepMinioKey, + string? AepFileMd5, + long? AepFileSizeBytes, + string? RenderAepComp, + string? Folder +); + public record PatchProjectRequest( string? Name, string? Description, diff --git a/services/content/FlatRender.ContentSvc/Models/Responses/Responses.cs b/services/content/FlatRender.ContentSvc/Models/Responses/Responses.cs index bb9ccde..d322f85 100644 --- a/services/content/FlatRender.ContentSvc/Models/Responses/Responses.cs +++ b/services/content/FlatRender.ContentSvc/Models/Responses/Responses.cs @@ -128,7 +128,10 @@ public record ProjectResponse( string ChooseMode, string Resolution, bool IsPublished, - int Sort + int Sort, + string? AepFileUrl, + long? AepFileSizeBytes, + string RenderAepComp ); public record ProjectDetailResponse( diff --git a/src/components/admin/TemplatesAdmin.tsx b/src/components/admin/TemplatesAdmin.tsx index d7b5ea3..74a7ed6 100644 --- a/src/components/admin/TemplatesAdmin.tsx +++ b/src/components/admin/TemplatesAdmin.tsx @@ -27,11 +27,16 @@ interface Detail extends Container { full_demo?: string | null; categories?: Ref[]; tags?: Ref[]; - projects?: { id: string; name: string; aspect?: string | null; resolution?: string }[]; + projects?: Proj[]; +} +interface Proj { + id: string; name: string; aspect?: string | null; resolution?: string; + aep_file_url?: string | null; aep_file_size_bytes?: number | null; render_aep_comp?: string; } const PRIMARY_MODES = ["FIX", "FLEXIBLE", "MockUp", "MusicVisualizer", "VoiceOver"]; const RESOLUTIONS = ["HD", "FullHD", "TwoK", "FourK"]; +const CHOOSE_MODES = ["FIX", "FLEXIBLE", "MockUp", "MusicVisualizer", "VoiceOver"]; 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"; @@ -61,16 +66,25 @@ export function TemplatesAdmin() { const [editId, setEditId] = useState(null); const [open, setOpen] = useState(false); const [form, setForm] = useState(emptyForm); - const [projects, setProjects] = useState>([]); + const [projects, setProjects] = useState([]); const [saving, setSaving] = useState(false); const [savingProj, setSavingProj] = useState(null); + const emptyNewProj = { name: "", width: 1920, height: 1080, aspect: "16:9", resolution: "FullHD", duration: 15, fps: 30, mode: "FLEXIBLE" }; + const [newProj, setNewProj] = useState({ ...emptyNewProj }); + const [addingProj, setAddingProj] = useState(false); const api = (p: string) => `/api/admin/resource/${p}`; - const updateProj = (id: string, patch: Partial<{ aspect: string; resolution: string }>) => + const updateProj = (id: string, patch: Partial) => setProjects((ps) => ps.map((p) => (p.id === id ? { ...p, ...patch } : p))); - const saveProj = async (p: NonNullable[number]) => { + // Re-fetch the open container's projects after add / upload. + const refreshProjects = async (slug: string) => { + const d: Detail = await fetch(api(`templates/${slug}`), { cache: "no-store" }).then((r) => r.json()); + setProjects(d.projects ?? []); + }; + + const saveProj = async (p: Proj) => { setSavingProj(p.id); setError(null); const res = await fetch(api(`projects/${p.id}`), { @@ -85,6 +99,53 @@ export function TemplatesAdmin() { setSavingProj(null); }; + // Attach an uploaded After Effects file (+ composition) to a project. + const attachAep = async (p: Proj, url: string) => { + setSavingProj(p.id); setError(null); + const res = await fetch(api(`projects/${p.id}/aep`), { + method: "PATCH", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ aep_file_url: url, render_aep_comp: p.render_aep_comp || "flatrender" }), + }); + if (res.ok) { updateProj(p.id, { aep_file_url: url }); } + else { const d = await res.json().catch(() => null); setError(d?.error ?? "اتصال فایل AE ناموفق بود"); } + setSavingProj(null); + }; + + const saveComp = async (p: Proj) => { + setSavingProj(p.id); + await fetch(api(`projects/${p.id}/aep`), { + method: "PATCH", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ render_aep_comp: p.render_aep_comp || "flatrender" }), + }); + setSavingProj(null); + }; + + // Create a new project (variant) under the current container. + const addProject = async () => { + if (!editId || !newProj.name) return; + setAddingProj(true); setError(null); + const res = await fetch(api("projects"), { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + container_id: editId, name: newProj.name, + original_width: Number(newProj.width) || 1920, original_height: Number(newProj.height) || 1080, + aspect: newProj.aspect, project_duration_sec: Number(newProj.duration) || 15, + free_fps: Number(newProj.fps) || 30, choose_mode: newProj.mode, resolution: newProj.resolution, + vip_factor: 1.0, render_aep_comp: "flatrender", is_published: true, sort: projects.length, + }), + }); + const d = await res.json().catch(() => null); + if (res.ok) { setNewProj({ ...emptyNewProj }); await refreshProjects(form.slug); } + else setError(d?.error ?? "ساخت نسخه ناموفق بود"); + setAddingProj(false); + }; + + const removeProject = async (p: Proj) => { + if (!confirm(`نسخهٔ «${p.name}» حذف شود؟`)) return; + const res = await fetch(api(`projects/${p.id}`), { method: "DELETE" }); + if (res.ok) await refreshProjects(form.slug); + }; + const reload = useCallback(async () => { setLoading(true); try { @@ -253,34 +314,65 @@ export function TemplatesAdmin() { {tags.length === 0 && هنوز برچسبی نیست.} - {editId && projects.length > 0 && ( + {editId ? (
- + +

هر نسخه = یک خروجی با تناسب/کیفیت مشخص و یک فایل پروژهٔ افترافکت (.aep).

+ {projects.length === 0 &&

هنوز نسخه‌ای ندارد. از فرم پایین اضافه کنید.

} {projects.map((p) => ( -
- {p.name} - updateProj(p.id, { aspect: e.target.value })} - /> - - +
+
+ {p.name} + {p.aep_file_url + ? AE ✓ + : بدون فایل AE} + updateProj(p.id, { aspect: e.target.value })} /> + + + +
+
+
+ + attachAep(p, u)} accept=".aep,.aepx,.zip" /> +
+
+ +
+ updateProj(p.id, { render_aep_comp: e.target.value })} /> + +
+
+
))}
-

ویرایش به‌صورت جزئی انجام می‌شود — سایر داده‌های رندر/رنگ حفظ می‌شوند.

+ + {/* Add new project / variant */} +
+

افزودن نسخهٔ جدید

+
+ setNewProj({ ...newProj, name: e.target.value })} /> + setNewProj({ ...newProj, width: Number(e.target.value) })} /> + setNewProj({ ...newProj, height: Number(e.target.value) })} /> + setNewProj({ ...newProj, aspect: e.target.value })} /> + + setNewProj({ ...newProj, duration: Number(e.target.value) })} /> + setNewProj({ ...newProj, fps: Number(e.target.value) })} /> + + +
+
+ ) : ( +

پس از ذخیرهٔ قالب، می‌توانید نسخه‌ها و فایل‌های افترافکت را اضافه کنید.

)}