feat(admin): standalone Projects page + per-project asset manager
Build backend images / build content-svc (push) Failing after 1m36s
Build backend images / build file-svc (push) Failing after 1m28s
Build backend images / build gateway (push) Failing after 2m11s
Build backend images / build identity-svc (push) Failing after 2m11s
Build backend images / build notification-svc (push) Failing after 3m46s
Build backend images / build render-svc (push) Failing after 55s
Build backend images / build studio-svc (push) Failing after 1m2s
Build backend images / build content-svc (push) Failing after 1m36s
Build backend images / build file-svc (push) Failing after 1m28s
Build backend images / build gateway (push) Failing after 2m11s
Build backend images / build identity-svc (push) Failing after 2m11s
Build backend images / build notification-svc (push) Failing after 3m46s
Build backend images / build render-svc (push) Failing after 55s
Build backend images / build studio-svc (push) Failing after 1m2s
- content-svc: GET /v1/projects (browse/search all projects across containers,
paginated, admin) returning template name/slug + AE status; project_assets
table (mig 23) + entity; GET/POST/DELETE /v1/projects/{id}/assets
- /admin/projects: searchable, paginated list of every renderable project with
thumbnail, template, aspect/resolution, AE-file + publish status
- ProjectAssets component: list/upload/delete named footage/image/audio/font
files per project (reused in the projects page; AE file upload alongside)
- nav + fa/en "Projects" label
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -314,6 +314,49 @@ public class TemplateService(ContentDbContext db)
|
||||
p.AepFileUrl, p.AepFileSizeBytes, p.RenderAepComp
|
||||
);
|
||||
|
||||
/// <summary>Browse/search all projects (template items) across containers.</summary>
|
||||
public async Task<PagedResponse<ProjectListItemResponse>> ListProjectsAsync(string? q, Guid? containerId, int page, int pageSize)
|
||||
{
|
||||
if (page < 1) page = 1;
|
||||
if (pageSize < 1 || pageSize > 200) pageSize = 30;
|
||||
var query = db.Projects.Where(p => p.DeletedAt == null);
|
||||
if (containerId.HasValue) query = query.Where(p => p.ContainerId == containerId.Value);
|
||||
if (!string.IsNullOrWhiteSpace(q)) query = query.Where(p => EF.Functions.ILike(p.Name, $"%{q}%"));
|
||||
var total = await query.LongCountAsync();
|
||||
var items = await query
|
||||
.OrderByDescending(p => p.UpdatedAt)
|
||||
.Skip((page - 1) * pageSize).Take(pageSize)
|
||||
.Select(p => new ProjectListItemResponse(
|
||||
p.Id, p.ContainerId, p.Container.Name, p.Container.Slug, p.Name, p.Image,
|
||||
p.Aspect, p.Resolution.ToString(), p.AepFileUrl, p.RenderAepComp, p.IsPublished, p.Sort))
|
||||
.ToListAsync();
|
||||
return new PagedResponse<ProjectListItemResponse>(items,
|
||||
new PaginationMeta(page, pageSize, total, (int)Math.Ceiling((double)total / pageSize)));
|
||||
}
|
||||
|
||||
public async Task<List<ProjectAssetResponse>> ListAssetsAsync(Guid projectId) =>
|
||||
await db.ProjectAssets.Where(a => a.ProjectId == projectId).OrderBy(a => a.Sort)
|
||||
.Select(a => new ProjectAssetResponse(a.Id, a.ProjectId, a.Name, a.Kind, a.Url, a.SizeBytes, a.Sort))
|
||||
.ToListAsync();
|
||||
|
||||
public async Task<ProjectAssetResponse> AddAssetAsync(Guid projectId, CreateAssetRequest req)
|
||||
{
|
||||
var a = new ProjectAsset
|
||||
{
|
||||
ProjectId = projectId, Name = req.Name, Kind = string.IsNullOrWhiteSpace(req.Kind) ? "footage" : req.Kind,
|
||||
Url = req.Url, MinioKey = req.MinioKey, SizeBytes = req.SizeBytes, Sort = req.Sort,
|
||||
};
|
||||
db.ProjectAssets.Add(a);
|
||||
await db.SaveChangesAsync();
|
||||
return new ProjectAssetResponse(a.Id, a.ProjectId, a.Name, a.Kind, a.Url, a.SizeBytes, a.Sort);
|
||||
}
|
||||
|
||||
public async Task DeleteAssetAsync(Guid assetId)
|
||||
{
|
||||
var a = await db.ProjectAssets.FindAsync(assetId);
|
||||
if (a != null) { db.ProjectAssets.Remove(a); await db.SaveChangesAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>Attach an uploaded After Effects file (and render composition) to a project.</summary>
|
||||
public async Task<ProjectDetailResponse> SetProjectAepAsync(Guid id, SetAepRequest req)
|
||||
{
|
||||
|
||||
@@ -57,10 +57,35 @@ public record SetSortRequest(int Sort);
|
||||
[Route("v1/projects")]
|
||||
public class ProjectsController(TemplateService svc) : ControllerBase
|
||||
{
|
||||
// Browse/search all projects across templates (admin).
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> List([FromQuery] string? q, [FromQuery] Guid? containerId,
|
||||
[FromQuery] int page = 1, [FromQuery] int pageSize = 30) =>
|
||||
Ok(await svc.ListProjectsAsync(q, containerId, page, pageSize));
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
public async Task<IActionResult> GetProject(Guid id) =>
|
||||
Ok(await svc.GetProjectDetailAsync(id));
|
||||
|
||||
// ── Per-project assets (footage / images / audio / fonts) ──────────────────
|
||||
[HttpGet("{id:guid}/assets")]
|
||||
public async Task<IActionResult> ListAssets(Guid id) =>
|
||||
Ok(await svc.ListAssetsAsync(id));
|
||||
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpPost("{id:guid}/assets")]
|
||||
public async Task<IActionResult> AddAsset(Guid id, [FromBody] CreateAssetRequest req) =>
|
||||
Ok(await svc.AddAssetAsync(id, req));
|
||||
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpDelete("{id:guid}/assets/{assetId:guid}")]
|
||||
public async Task<IActionResult> DeleteAsset(Guid id, Guid assetId)
|
||||
{
|
||||
await svc.DeleteAssetAsync(assetId);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[Authorize(Roles = "Admin")]
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> CreateProject([FromBody] CreateProjectRequest req) =>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace FlatRender.ContentSvc.Domain.Entities;
|
||||
|
||||
/// <summary>A named asset file (footage/image/audio/font) attached to a project, alongside its .aep.</summary>
|
||||
public class ProjectAsset
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public Guid ProjectId { get; set; }
|
||||
public string Name { get; set; } = default!;
|
||||
public string Kind { get; set; } = "footage"; // footage | image | audio | font | other
|
||||
public string Url { get; set; } = default!;
|
||||
public string? MinioKey { get; set; }
|
||||
public long? SizeBytes { get; set; }
|
||||
public int Sort { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -19,6 +19,7 @@ public class ContentDbContext(DbContextOptions<ContentDbContext> options) : DbCo
|
||||
public DbSet<ContainerCategory> ContainerCategories => Set<ContainerCategory>();
|
||||
public DbSet<ContainerTag> ContainerTags => Set<ContainerTag>();
|
||||
public DbSet<Project> Projects => Set<Project>();
|
||||
public DbSet<ProjectAsset> ProjectAssets => Set<ProjectAsset>();
|
||||
|
||||
// Scenes
|
||||
public DbSet<Scene> Scenes => Set<Scene>();
|
||||
@@ -203,6 +204,21 @@ public class ContentDbContext(DbContextOptions<ContentDbContext> options) : DbCo
|
||||
e.Property(x => x.SizeBytes).HasColumnName("size_bytes");
|
||||
e.Property(x => x.CreatedAt).HasColumnName("created_at");
|
||||
});
|
||||
|
||||
mb.Entity<ProjectAsset>(e =>
|
||||
{
|
||||
e.ToTable("project_assets");
|
||||
e.HasKey(x => x.Id);
|
||||
e.Property(x => x.Id).HasColumnName("id");
|
||||
e.Property(x => x.ProjectId).HasColumnName("project_id");
|
||||
e.Property(x => x.Name).HasColumnName("name").IsRequired();
|
||||
e.Property(x => x.Kind).HasColumnName("kind").IsRequired();
|
||||
e.Property(x => x.Url).HasColumnName("url").IsRequired();
|
||||
e.Property(x => x.MinioKey).HasColumnName("minio_key");
|
||||
e.Property(x => x.SizeBytes).HasColumnName("size_bytes");
|
||||
e.Property(x => x.Sort).HasColumnName("sort");
|
||||
e.Property(x => x.CreatedAt).HasColumnName("created_at");
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigureTemplates(ModelBuilder mb)
|
||||
|
||||
@@ -244,6 +244,15 @@ 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 CreateAssetRequest(
|
||||
string Name,
|
||||
string? Kind,
|
||||
string Url,
|
||||
string? MinioKey,
|
||||
long? SizeBytes,
|
||||
int Sort = 0
|
||||
);
|
||||
|
||||
public record SetAepRequest(
|
||||
string? AepFileUrl,
|
||||
string? AepMinioBucket,
|
||||
|
||||
@@ -134,6 +134,23 @@ public record ProjectResponse(
|
||||
string RenderAepComp
|
||||
);
|
||||
|
||||
public record ProjectListItemResponse(
|
||||
Guid Id,
|
||||
Guid ContainerId,
|
||||
string ContainerName,
|
||||
string ContainerSlug,
|
||||
string Name,
|
||||
string? Image,
|
||||
string? Aspect,
|
||||
string Resolution,
|
||||
string? AepFileUrl,
|
||||
string RenderAepComp,
|
||||
bool IsPublished,
|
||||
int Sort
|
||||
);
|
||||
|
||||
public record ProjectAssetResponse(Guid Id, Guid ProjectId, string Name, string Kind, string Url, long? SizeBytes, int Sort);
|
||||
|
||||
public record ProjectDetailResponse(
|
||||
Guid Id,
|
||||
Guid ContainerId,
|
||||
|
||||
Reference in New Issue
Block a user