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:
@@ -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);
|
||||||
+2
-1
@@ -333,7 +333,8 @@
|
|||||||
"homeEvents": "Home Events",
|
"homeEvents": "Home Events",
|
||||||
"comments": "Comments",
|
"comments": "Comments",
|
||||||
"routes": "Internal Routes",
|
"routes": "Internal Routes",
|
||||||
"integrations": "Integrations"
|
"integrations": "Integrations",
|
||||||
|
"projects": "Projects"
|
||||||
},
|
},
|
||||||
"appAdminNodesPage": {
|
"appAdminNodesPage": {
|
||||||
"title": "Render Nodes",
|
"title": "Render Nodes",
|
||||||
|
|||||||
+2
-1
@@ -333,7 +333,8 @@
|
|||||||
"homeEvents": "رویدادهای صفحه اصلی",
|
"homeEvents": "رویدادهای صفحه اصلی",
|
||||||
"comments": "نظرات",
|
"comments": "نظرات",
|
||||||
"routes": "مسیرهای داخلی",
|
"routes": "مسیرهای داخلی",
|
||||||
"integrations": "یکپارچهسازیها"
|
"integrations": "یکپارچهسازیها",
|
||||||
|
"projects": "پروژهها"
|
||||||
},
|
},
|
||||||
"appAdminNodesPage": {
|
"appAdminNodesPage": {
|
||||||
"title": "نودهای رندر",
|
"title": "نودهای رندر",
|
||||||
|
|||||||
@@ -314,6 +314,49 @@ public class TemplateService(ContentDbContext db)
|
|||||||
p.AepFileUrl, p.AepFileSizeBytes, p.RenderAepComp
|
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>
|
/// <summary>Attach an uploaded After Effects file (and render composition) to a project.</summary>
|
||||||
public async Task<ProjectDetailResponse> SetProjectAepAsync(Guid id, SetAepRequest req)
|
public async Task<ProjectDetailResponse> SetProjectAepAsync(Guid id, SetAepRequest req)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -57,10 +57,35 @@ public record SetSortRequest(int Sort);
|
|||||||
[Route("v1/projects")]
|
[Route("v1/projects")]
|
||||||
public class ProjectsController(TemplateService svc) : ControllerBase
|
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}")]
|
[HttpGet("{id:guid}")]
|
||||||
public async Task<IActionResult> GetProject(Guid id) =>
|
public async Task<IActionResult> GetProject(Guid id) =>
|
||||||
Ok(await svc.GetProjectDetailAsync(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")]
|
[Authorize(Roles = "Admin")]
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
public async Task<IActionResult> CreateProject([FromBody] CreateProjectRequest req) =>
|
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<ContainerCategory> ContainerCategories => Set<ContainerCategory>();
|
||||||
public DbSet<ContainerTag> ContainerTags => Set<ContainerTag>();
|
public DbSet<ContainerTag> ContainerTags => Set<ContainerTag>();
|
||||||
public DbSet<Project> Projects => Set<Project>();
|
public DbSet<Project> Projects => Set<Project>();
|
||||||
|
public DbSet<ProjectAsset> ProjectAssets => Set<ProjectAsset>();
|
||||||
|
|
||||||
// Scenes
|
// Scenes
|
||||||
public DbSet<Scene> Scenes => Set<Scene>();
|
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.SizeBytes).HasColumnName("size_bytes");
|
||||||
e.Property(x => x.CreatedAt).HasColumnName("created_at");
|
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)
|
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
|
// Partial update — only non-null fields are applied, so editing an aspect/resolution
|
||||||
// never wipes render/colour data that the full UpdateProjectRequest would require.
|
// 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(
|
public record SetAepRequest(
|
||||||
string? AepFileUrl,
|
string? AepFileUrl,
|
||||||
string? AepMinioBucket,
|
string? AepMinioBucket,
|
||||||
|
|||||||
@@ -134,6 +134,23 @@ public record ProjectResponse(
|
|||||||
string RenderAepComp
|
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(
|
public record ProjectDetailResponse(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
Guid ContainerId,
|
Guid ContainerId,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export default async function AdminLayout({
|
|||||||
{ href: "/admin/stats", label: t("stats") },
|
{ href: "/admin/stats", label: t("stats") },
|
||||||
{ href: "/admin/categories", label: t("categories") },
|
{ href: "/admin/categories", label: t("categories") },
|
||||||
{ href: "/admin/templates", label: t("templates") },
|
{ href: "/admin/templates", label: t("templates") },
|
||||||
|
{ href: "/admin/projects", label: t("projects") },
|
||||||
{ href: "/admin/ranking", label: t("ranking") },
|
{ href: "/admin/ranking", label: t("ranking") },
|
||||||
{ href: "/admin/tags", label: t("tags") },
|
{ href: "/admin/tags", label: t("tags") },
|
||||||
{ href: "/admin/fonts", label: t("fonts") },
|
{ href: "/admin/fonts", label: t("fonts") },
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ProjectsAdmin } from "@/components/admin/ProjectsAdmin";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <ProjectsAdmin />;
|
||||||
|
}
|
||||||
@@ -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<Asset[]>([]);
|
||||||
|
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 (
|
||||||
|
<div className="rounded-lg border border-[#262b40] p-2">
|
||||||
|
<p className="mb-2 text-[11px] font-medium text-gray-400">فایلهای پروژه (فوتیج/تصویر/صدا/فونت)</p>
|
||||||
|
{assets.length === 0 ? (
|
||||||
|
<p className="px-1 text-xs text-gray-600">هنوز فایلی اضافه نشده.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="mb-2 space-y-1">
|
||||||
|
{assets.map((a) => (
|
||||||
|
<li key={a.id} className="flex items-center justify-between rounded bg-[#0c0e1a] px-2 py-1 text-xs">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="rounded bg-[#1e2235] px-1.5 py-0.5 text-[10px] text-gray-400">{KINDS.find((k) => k.v === a.kind)?.l ?? a.kind}</span>
|
||||||
|
<a href={a.url} target="_blank" rel="noreferrer" className="text-gray-200 hover:underline" dir="ltr">{a.name}</a>
|
||||||
|
</span>
|
||||||
|
<button className="text-red-300 hover:text-red-200" onClick={() => remove(a.id)}>حذف</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap items-end gap-2 border-t border-[#1e2235] pt-2">
|
||||||
|
<input className={`${inp} w-36`} placeholder="نام فایل (اختیاری)" value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
|
<select className={inp} value={kind} onChange={(e) => setKind(e.target.value)}>
|
||||||
|
{KINDS.map((k) => <option key={k.v} value={k.v}>{k.l}</option>)}
|
||||||
|
</select>
|
||||||
|
<FileUploadField value="" onChange={add} accept="*/*" />
|
||||||
|
{busy && <span className="text-[11px] text-gray-500">در حال افزودن…</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<Proj[]>([]);
|
||||||
|
const [q, setQ] = useState("");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [hasMore, setHasMore] = useState(false);
|
||||||
|
const [openAssets, setOpenAssets] = useState<Proj | null>(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 (
|
||||||
|
<div className="space-y-4" dir="rtl">
|
||||||
|
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-white">پروژهها (آیتمهای قالب)</h1>
|
||||||
|
<p className="mt-1 text-sm text-gray-400">همهٔ نسخههای قابلرندر در همهٔ قالبها. فایل افترافکت و فایلهای هر پروژه را اینجا مدیریت کنید.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input className={inp} placeholder="جستجوی نام پروژه…" value={q} onChange={(e) => { setPage(1); setQ(e.target.value); }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`${card} overflow-hidden`}>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead><tr className="border-b border-[#1e2235] text-start text-xs text-gray-500">
|
||||||
|
<th className="px-4 py-3">تصویر</th><th className="px-4 py-3">نام</th><th className="px-4 py-3">قالب</th>
|
||||||
|
<th className="px-4 py-3">تناسب</th><th className="px-4 py-3">کیفیت</th><th className="px-4 py-3">فایل AE</th>
|
||||||
|
<th className="px-4 py-3">وضعیت</th><th className="px-4 py-3 text-end">عملیات</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{loading ? (
|
||||||
|
<tr><td colSpan={8} className="px-4 py-8 text-center text-gray-500">در حال بارگذاری…</td></tr>
|
||||||
|
) : rows.length === 0 ? (
|
||||||
|
<tr><td colSpan={8} className="px-4 py-8 text-center text-gray-500">پروژهای یافت نشد.</td></tr>
|
||||||
|
) : rows.map((p) => (
|
||||||
|
<tr key={p.id} className="border-b border-[#161a2e] hover:bg-[#12152a]">
|
||||||
|
<td className="px-4 py-3"><AdminThumb src={p.image} size={40} /></td>
|
||||||
|
<td className="px-4 py-3 text-gray-200">{p.name}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-400">{p.container_name}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-400">{p.aspect ?? "—"}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-400">{p.resolution}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{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">ندارد</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{p.is_published
|
||||||
|
? <span className="rounded bg-emerald-500/15 px-1.5 py-0.5 text-[10px] text-emerald-300">منتشر</span>
|
||||||
|
: <span className="rounded bg-gray-500/15 px-1.5 py-0.5 text-[10px] text-gray-400">پیشنویس</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<button className={ghost} onClick={() => setOpenAssets(p)}>فایلها</button>
|
||||||
|
<button className="rounded-lg border border-red-500/30 px-2.5 py-1 text-xs text-red-300 hover:bg-red-500/10" onClick={() => remove(p)}>حذف</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-center gap-3 text-sm text-gray-400">
|
||||||
|
<button className={ghost} disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>قبلی</button>
|
||||||
|
<span>صفحهٔ {page.toLocaleString("fa-IR")}</span>
|
||||||
|
<button className={ghost} disabled={!hasMore} onClick={() => setPage((p) => p + 1)}>بعدی</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{openAssets && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" dir="rtl" onClick={() => setOpenAssets(null)}>
|
||||||
|
<div className={`${card} max-h-[85vh] w-full max-w-xl overflow-y-auto p-5`} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h2 className="text-sm font-semibold text-white">مدیریت فایلها — {openAssets.name} <span className="text-gray-500">({openAssets.container_name})</span></h2>
|
||||||
|
<div className="mt-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-gray-400">فایل افترافکت (.aep / .zip)</label>
|
||||||
|
<FileUploadField value={openAssets.aep_file_url ?? ""} onChange={(u) => { attachAep(openAssets, u); setOpenAssets({ ...openAssets, aep_file_url: u }); }} accept=".aep,.aepx,.zip" />
|
||||||
|
</div>
|
||||||
|
<ProjectAssets projectId={openAssets.id} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex justify-end">
|
||||||
|
<button className={ghost} onClick={() => setOpenAssets(null)}>بستن</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user