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
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:
@@ -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(
|
||||
|
||||
@@ -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 })}
|
||||
>
|
||||
{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>
|
||||
<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="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">
|
||||
|
||||
Reference in New Issue
Block a user