diff --git a/backend/db/migrations/23_content_project_assets.sql b/backend/db/migrations/23_content_project_assets.sql new file mode 100644 index 0000000..e3e1996 --- /dev/null +++ b/backend/db/migrations/23_content_project_assets.sql @@ -0,0 +1,20 @@ +-- ===================================================================== +-- CONTENT SCHEMA — Part 23: per-project assets (footage / images / audio / fonts) +-- Named asset files attached to a renderable project, alongside its .aep. +-- ===================================================================== + +SET search_path TO content, public; + +CREATE TABLE IF NOT EXISTS project_assets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id UUID NOT NULL, + name TEXT NOT NULL, + kind TEXT NOT NULL DEFAULT 'footage', -- footage | image | audio | font | other + url TEXT NOT NULL, + minio_key TEXT, + size_bytes BIGINT, + sort INT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_project_assets_project ON project_assets (project_id, sort); diff --git a/messages/en.json b/messages/en.json index 1500cd5..266d542 100644 --- a/messages/en.json +++ b/messages/en.json @@ -333,7 +333,8 @@ "homeEvents": "Home Events", "comments": "Comments", "routes": "Internal Routes", - "integrations": "Integrations" + "integrations": "Integrations", + "projects": "Projects" }, "appAdminNodesPage": { "title": "Render Nodes", diff --git a/messages/fa.json b/messages/fa.json index 6d134ed..5756a98 100644 --- a/messages/fa.json +++ b/messages/fa.json @@ -333,7 +333,8 @@ "homeEvents": "رویدادهای صفحه اصلی", "comments": "نظرات", "routes": "مسیرهای داخلی", - "integrations": "یکپارچه‌سازی‌ها" + "integrations": "یکپارچه‌سازی‌ها", + "projects": "پروژه‌ها" }, "appAdminNodesPage": { "title": "نودهای رندر", diff --git a/services/content/FlatRender.ContentSvc/Application/Services/TemplateService.cs b/services/content/FlatRender.ContentSvc/Application/Services/TemplateService.cs index 8a81a83..2e3bf72 100644 --- a/services/content/FlatRender.ContentSvc/Application/Services/TemplateService.cs +++ b/services/content/FlatRender.ContentSvc/Application/Services/TemplateService.cs @@ -314,6 +314,49 @@ public class TemplateService(ContentDbContext db) p.AepFileUrl, p.AepFileSizeBytes, p.RenderAepComp ); + /// Browse/search all projects (template items) across containers. + public async Task> 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(items, + new PaginationMeta(page, pageSize, total, (int)Math.Ceiling((double)total / pageSize))); + } + + public async Task> 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 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(); } + } + /// Attach an uploaded After Effects file (and render composition) to a project. public async Task SetProjectAepAsync(Guid id, SetAepRequest req) { diff --git a/services/content/FlatRender.ContentSvc/Controllers/TemplatesController.cs b/services/content/FlatRender.ContentSvc/Controllers/TemplatesController.cs index be29c8a..bcc96c9 100644 --- a/services/content/FlatRender.ContentSvc/Controllers/TemplatesController.cs +++ b/services/content/FlatRender.ContentSvc/Controllers/TemplatesController.cs @@ -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 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 GetProject(Guid id) => Ok(await svc.GetProjectDetailAsync(id)); + // ── Per-project assets (footage / images / audio / fonts) ────────────────── + [HttpGet("{id:guid}/assets")] + public async Task ListAssets(Guid id) => + Ok(await svc.ListAssetsAsync(id)); + + [Authorize(Roles = "Admin")] + [HttpPost("{id:guid}/assets")] + public async Task AddAsset(Guid id, [FromBody] CreateAssetRequest req) => + Ok(await svc.AddAssetAsync(id, req)); + + [Authorize(Roles = "Admin")] + [HttpDelete("{id:guid}/assets/{assetId:guid}")] + public async Task DeleteAsset(Guid id, Guid assetId) + { + await svc.DeleteAssetAsync(assetId); + return NoContent(); + } + [Authorize(Roles = "Admin")] [HttpPost] public async Task CreateProject([FromBody] CreateProjectRequest req) => diff --git a/services/content/FlatRender.ContentSvc/Domain/Entities/ProjectAsset.cs b/services/content/FlatRender.ContentSvc/Domain/Entities/ProjectAsset.cs new file mode 100644 index 0000000..8cf2876 --- /dev/null +++ b/services/content/FlatRender.ContentSvc/Domain/Entities/ProjectAsset.cs @@ -0,0 +1,15 @@ +namespace FlatRender.ContentSvc.Domain.Entities; + +/// A named asset file (footage/image/audio/font) attached to a project, alongside its .aep. +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; +} diff --git a/services/content/FlatRender.ContentSvc/Infrastructure/Data/ContentDbContext.cs b/services/content/FlatRender.ContentSvc/Infrastructure/Data/ContentDbContext.cs index 05d8691..1dbad4d 100644 --- a/services/content/FlatRender.ContentSvc/Infrastructure/Data/ContentDbContext.cs +++ b/services/content/FlatRender.ContentSvc/Infrastructure/Data/ContentDbContext.cs @@ -19,6 +19,7 @@ public class ContentDbContext(DbContextOptions options) : DbCo public DbSet ContainerCategories => Set(); public DbSet ContainerTags => Set(); public DbSet Projects => Set(); + public DbSet ProjectAssets => Set(); // Scenes public DbSet Scenes => Set(); @@ -203,6 +204,21 @@ public class ContentDbContext(DbContextOptions options) : DbCo e.Property(x => x.SizeBytes).HasColumnName("size_bytes"); e.Property(x => x.CreatedAt).HasColumnName("created_at"); }); + + mb.Entity(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) diff --git a/services/content/FlatRender.ContentSvc/Models/Requests/Requests.cs b/services/content/FlatRender.ContentSvc/Models/Requests/Requests.cs index cc28e53..d141328 100644 --- a/services/content/FlatRender.ContentSvc/Models/Requests/Requests.cs +++ b/services/content/FlatRender.ContentSvc/Models/Requests/Requests.cs @@ -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, diff --git a/services/content/FlatRender.ContentSvc/Models/Responses/Responses.cs b/services/content/FlatRender.ContentSvc/Models/Responses/Responses.cs index d322f85..5e8418c 100644 --- a/services/content/FlatRender.ContentSvc/Models/Responses/Responses.cs +++ b/services/content/FlatRender.ContentSvc/Models/Responses/Responses.cs @@ -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, diff --git a/src/app/[locale]/admin/layout.tsx b/src/app/[locale]/admin/layout.tsx index 8dd17cf..42ed9bd 100644 --- a/src/app/[locale]/admin/layout.tsx +++ b/src/app/[locale]/admin/layout.tsx @@ -19,6 +19,7 @@ export default async function AdminLayout({ { href: "/admin/stats", label: t("stats") }, { href: "/admin/categories", label: t("categories") }, { href: "/admin/templates", label: t("templates") }, + { href: "/admin/projects", label: t("projects") }, { href: "/admin/ranking", label: t("ranking") }, { href: "/admin/tags", label: t("tags") }, { href: "/admin/fonts", label: t("fonts") }, diff --git a/src/app/[locale]/admin/projects/page.tsx b/src/app/[locale]/admin/projects/page.tsx new file mode 100644 index 0000000..2d29315 --- /dev/null +++ b/src/app/[locale]/admin/projects/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { ProjectsAdmin } from "@/components/admin/ProjectsAdmin"; + +export default function Page() { + return ; +} diff --git a/src/components/admin/ProjectAssets.tsx b/src/components/admin/ProjectAssets.tsx new file mode 100644 index 0000000..2492daf --- /dev/null +++ b/src/components/admin/ProjectAssets.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; + +import { FileUploadField } from "@/components/admin/FileUploadField"; + +interface Asset { id: string; name: string; kind: string; url: string; size_bytes?: number | null; sort: number } + +const inp = "rounded-lg border border-[#262b40] bg-[#0c0e1a] px-2 py-1 text-sm text-gray-100 outline-none focus:border-indigo-500"; + +const KINDS = [ + { v: "footage", l: "ویدیو/فوتیج" }, + { v: "image", l: "تصویر" }, + { v: "audio", l: "صدا" }, + { v: "font", l: "فونت" }, + { v: "other", l: "سایر" }, +]; + +/** Manage the named asset files (footage/images/audio/fonts) attached to one project. */ +export function ProjectAssets({ projectId }: { projectId: string }) { + const [assets, setAssets] = useState([]); + const [name, setName] = useState(""); + const [kind, setKind] = useState("footage"); + const [busy, setBusy] = useState(false); + const base = `/api/admin/resource/projects/${projectId}/assets`; + + const load = useCallback(async () => { + const r = await fetch(base, { cache: "no-store" }).then((x) => x.json()).catch(() => null); + setAssets(Array.isArray(r) ? r : r?.data ?? []); + }, [base]); + useEffect(() => { load(); }, [load]); + + const add = async (url: string) => { + if (!url) return; + setBusy(true); + const fileName = name || url.split("/").pop() || "asset"; + const res = await fetch(base, { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: fileName, kind, url, sort: assets.length }), + }); + if (res.ok) { setName(""); load(); } + setBusy(false); + }; + + const remove = async (id: string) => { + await fetch(`${base}/${id}`, { method: "DELETE" }); + load(); + }; + + return ( +
+

فایل‌های پروژه (فوتیج/تصویر/صدا/فونت)

+ {assets.length === 0 ? ( +

هنوز فایلی اضافه نشده.

+ ) : ( +
    + {assets.map((a) => ( +
  • + + {KINDS.find((k) => k.v === a.kind)?.l ?? a.kind} + {a.name} + + +
  • + ))} +
+ )} +
+ setName(e.target.value)} /> + + + {busy && در حال افزودن…} +
+
+ ); +} diff --git a/src/components/admin/ProjectsAdmin.tsx b/src/components/admin/ProjectsAdmin.tsx new file mode 100644 index 0000000..52cefa5 --- /dev/null +++ b/src/components/admin/ProjectsAdmin.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; + +import { AdminThumb } from "@/components/admin/AdminThumb"; +import { FileUploadField } from "@/components/admin/FileUploadField"; +import { ProjectAssets } from "@/components/admin/ProjectAssets"; + +interface Proj { + id: string; container_id: string; container_name: string; container_slug: string; + name: string; image?: string | null; aspect?: string | null; resolution: string; + aep_file_url?: string | null; render_aep_comp: string; is_published: boolean; sort: number; +} + +const card = "rounded-xl border border-[#1e2235] bg-[#0f1120]"; +const inp = "rounded-lg border border-[#262b40] bg-[#0c0e1a] px-3 py-2 text-sm text-gray-100 outline-none focus:border-indigo-500"; +const ghost = "rounded-lg border border-[#262b40] px-2.5 py-1 text-xs text-gray-300 hover:bg-[#161a2e] disabled:opacity-50"; + +export function ProjectsAdmin() { + const [rows, setRows] = useState([]); + const [q, setQ] = useState(""); + const [loading, setLoading] = useState(true); + const [page, setPage] = useState(1); + const [hasMore, setHasMore] = useState(false); + const [openAssets, setOpenAssets] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + const r = await fetch(`/api/admin/resource/projects?q=${encodeURIComponent(q)}&page=${page}&pageSize=30`, { cache: "no-store" }) + .then((x) => x.json()).catch(() => null); + const items: Proj[] = r?.items ?? (Array.isArray(r) ? r : []); + setRows(items); + setHasMore(!!r?.meta && page < (r.meta.total_pages ?? r.meta.totalPages ?? 1)); + setLoading(false); + }, [q, page]); + useEffect(() => { load(); }, [load]); + + const attachAep = async (p: Proj, url: string) => { + await fetch(`/api/admin/resource/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" }), + }); + load(); + }; + const remove = async (p: Proj) => { + if (!confirm(`پروژهٔ «${p.name}» حذف شود؟`)) return; + await fetch(`/api/admin/resource/projects/${p.id}`, { method: "DELETE" }); + load(); + }; + + return ( +
+
+
+

پروژه‌ها (آیتم‌های قالب)

+

همهٔ نسخه‌های قابل‌رندر در همهٔ قالب‌ها. فایل افترافکت و فایل‌های هر پروژه را اینجا مدیریت کنید.

+
+
+ { setPage(1); setQ(e.target.value); }} /> +
+
+ +
+ + + + + + + + {loading ? ( + + ) : rows.length === 0 ? ( + + ) : rows.map((p) => ( + + + + + + + + + + + ))} + +
تصویرنامقالبتناسبکیفیتفایل AEوضعیتعملیات
در حال بارگذاری…
پروژه‌ای یافت نشد.
{p.name}{p.container_name}{p.aspect ?? "—"}{p.resolution} + {p.aep_file_url + ? AE ✓ + : ندارد} + + {p.is_published + ? منتشر + : پیش‌نویس} + +
+ + +
+
+
+ +
+ + صفحهٔ {page.toLocaleString("fa-IR")} + +
+ + {openAssets && ( +
setOpenAssets(null)}> +
e.stopPropagation()}> +

مدیریت فایل‌ها — {openAssets.name} ({openAssets.container_name})

+
+
+ + { attachAep(openAssets, u); setOpenAssets({ ...openAssets, aep_file_url: u }); }} accept=".aep,.aepx,.zip" /> +
+ +
+
+ +
+
+
+ )} +
+ ); +}