feat(admin): project (template-item) manager + After Effects file upload
Build backend images / build content-svc (push) Failing after 50s
Build backend images / build file-svc (push) Failing after 1m5s
Build backend images / build gateway (push) Failing after 3m13s
Build backend images / build identity-svc (push) Failing after 1m32s
Build backend images / build notification-svc (push) Failing after 5m7s
Build backend images / build render-svc (push) Failing after 1m2s
Build backend images / build studio-svc (push) Failing after 54s

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 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-03 00:23:50 +03:30
parent 675b60d858
commit c4839bd35f
5 changed files with 157 additions and 27 deletions
@@ -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
);
/// <summary>Attach an uploaded After Effects file (and render composition) to a project.</summary>
public async Task<ProjectDetailResponse> 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,
@@ -77,6 +77,12 @@ public class ProjectsController(TemplateService svc) : ControllerBase
public async Task<IActionResult> 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<IActionResult> SetAep(Guid id, [FromBody] SetAepRequest req) =>
Ok(await svc.SetProjectAepAsync(id, req));
[Authorize(Roles = "Admin")]
[HttpDelete("{id:guid}")]
public async Task<IActionResult> DeleteProject(Guid id)
@@ -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,
@@ -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(
+115 -23
View File
@@ -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<string | null>(null);
const [open, setOpen] = useState(false);
const [form, setForm] = useState<FormState>(emptyForm);
const [projects, setProjects] = useState<NonNullable<Detail["projects"]>>([]);
const [projects, setProjects] = useState<Proj[]>([]);
const [saving, setSaving] = useState(false);
const [savingProj, setSavingProj] = useState<string | null>(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<Proj>) =>
setProjects((ps) => ps.map((p) => (p.id === id ? { ...p, ...patch } : p)));
const saveProj = async (p: NonNullable<Detail["projects"]>[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 && <span className="text-xs text-gray-600">هنوز برچسبی نیست.</span>}
</div>
</div>
{editId && projects.length > 0 && (
{editId ? (
<div>
<label className={lbl}>نسخهها تناسب و کیفیت</label>
<label className={lbl}>نسخهها (پروژهها) و فایل افترافکت</label>
<p className="mb-2 text-[11px] text-gray-500">هر نسخه = یک خروجی با تناسب/کیفیت مشخص و یک فایل پروژهٔ افترافکت (.aep).</p>
<div className="space-y-2 rounded-lg border border-[#262b40] p-2">
{projects.length === 0 && <p className="px-1 text-xs text-gray-600">هنوز نسخهای ندارد. از فرم پایین اضافه کنید.</p>}
{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 })}
>
<div key={p.id} className="rounded-lg border border-[#1e2235] bg-[#0c0e1a] p-2">
<div className="flex flex-wrap items-center gap-2">
<span className="min-w-[90px] flex-1 truncate text-xs text-gray-200">{p.name}</span>
{p.aep_file_url
? <span className="rounded bg-emerald-500/15 px-1.5 py-0.5 text-[10px] text-emerald-300">AE </span>
: <span className="rounded bg-amber-500/15 px-1.5 py-0.5 text-[10px] text-amber-300">بدون فایل AE</span>}
<input className={`${inp} w-20 py-1 text-xs`} placeholder="16:9" value={p.aspect ?? ""} onChange={(e) => updateProj(p.id, { aspect: e.target.value })} />
<select className={`${inp} w-24 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 ? "…" : "ذخیره"}
</button>
<button type="button" className={ghost} onClick={() => saveProj(p)} disabled={savingProj === p.id}>{savingProj === p.id ? "…" : "ذخیره"}</button>
<button type="button" className="rounded-lg border border-red-500/30 px-2 py-1 text-xs text-red-300 hover:bg-red-500/10" onClick={() => removeProject(p)}>حذف</button>
</div>
<div className="mt-2 flex flex-wrap items-end gap-2 border-t border-[#1e2235] pt-2">
<div className="flex-1">
<label className="mb-0.5 block text-[10px] text-gray-500">فایل افترافکت (.aep / .zip)</label>
<FileUploadField value={p.aep_file_url ?? ""} onChange={(u) => attachAep(p, u)} accept=".aep,.aepx,.zip" />
</div>
<div>
<label className="mb-0.5 block text-[10px] text-gray-500">نام کامپوزیشن رندر</label>
<div className="flex gap-1">
<input className={`${inp} w-32 py-1 text-xs`} dir="ltr" placeholder="flatrender" value={p.render_aep_comp ?? ""} onChange={(e) => updateProj(p.id, { render_aep_comp: e.target.value })} />
<button type="button" className={ghost} onClick={() => saveComp(p)} disabled={savingProj === p.id}>ذخیره</button>
</div>
</div>
</div>
</div>
))}
</div>
<p className="mt-1 text-[11px] text-gray-600">ویرایش بهصورت جزئی انجام میشود سایر دادههای رندر/رنگ حفظ میشوند.</p>
{/* Add new project / variant */}
<div className="mt-2 rounded-lg border border-dashed border-[#262b40] p-2">
<p className="mb-2 text-[11px] font-medium text-gray-400">افزودن نسخهٔ جدید</p>
<div className="flex flex-wrap items-end gap-2">
<input className={`${inp} w-36 py-1 text-xs`} placeholder="نام نسخه" value={newProj.name} onChange={(e) => setNewProj({ ...newProj, name: e.target.value })} />
<input className={`${inp} w-20 py-1 text-xs`} type="number" placeholder="عرض" value={newProj.width} onChange={(e) => setNewProj({ ...newProj, width: Number(e.target.value) })} />
<input className={`${inp} w-20 py-1 text-xs`} type="number" placeholder="ارتفاع" value={newProj.height} onChange={(e) => setNewProj({ ...newProj, height: Number(e.target.value) })} />
<input className={`${inp} w-16 py-1 text-xs`} placeholder="16:9" value={newProj.aspect} onChange={(e) => setNewProj({ ...newProj, aspect: e.target.value })} />
<select className={`${inp} w-24 py-1 text-xs`} value={newProj.resolution} onChange={(e) => setNewProj({ ...newProj, resolution: e.target.value })}>
{RESOLUTIONS.map((r) => <option key={r} value={r}>{r}</option>)}
</select>
<input className={`${inp} w-20 py-1 text-xs`} type="number" placeholder="ثانیه" value={newProj.duration} onChange={(e) => setNewProj({ ...newProj, duration: Number(e.target.value) })} />
<input className={`${inp} w-16 py-1 text-xs`} type="number" placeholder="fps" value={newProj.fps} onChange={(e) => setNewProj({ ...newProj, fps: Number(e.target.value) })} />
<select className={`${inp} w-32 py-1 text-xs`} value={newProj.mode} onChange={(e) => setNewProj({ ...newProj, mode: e.target.value })}>
{CHOOSE_MODES.map((m) => <option key={m} value={m}>{m}</option>)}
</select>
<button type="button" className={btn} onClick={addProject} disabled={addingProj || !newProj.name}>{addingProj ? "…" : "+ افزودن"}</button>
</div>
</div>
</div>
) : (
<p className="rounded-lg border border-dashed border-[#262b40] p-3 text-[11px] text-gray-500">پس از ذخیرهٔ قالب، میتوانید نسخهها و فایلهای افترافکت را اضافه کنید.</p>
)}
</div>
<div className="mt-5 flex items-center justify-end gap-2">