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

- 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:
soroush.asadi
2026-06-03 00:39:33 +03:30
parent c4839bd35f
commit 7fe5f8a563
13 changed files with 364 additions and 2 deletions
+78
View File
@@ -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>
);
}
+129
View File
@@ -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>
);
}