@
Build backend images / build content-svc (push) Failing after 19s
Build backend images / build file-svc (push) Failing after 1m53s
Build backend images / build gateway (push) Failing after 16s
Build backend images / build identity-svc (push) Failing after 7m1s
Build backend images / build notification-svc (push) Failing after 7m24s
Build backend images / build render-svc (push) Failing after 3m12s
Build backend images / build studio-svc (push) Failing after 43s

feat: AE template scanner + scene editor + AEP bundle pipeline

Scene editor (admin): per-project Scenes / Shared Colors / Color Presets
manager (ProjectScenes) reachable from each project.

AEP bundle pipeline: upload .aep or .zip → stored once per template at
templates/{project_id}/(bundle.zip|template.aep); render claim probes and
returns is_bundle+md5; node-agent extracts the bundle, locates the .aep
(zip-slip guarded), and caches by md5 so repeated renders extract once.

AE template scanner ("read scenes/colours/configs from the AEP"):
- content-svc importer: POST /v1/projects/{id}/scan/{preview,apply} —
  review-diff-then-merge into scenes/elements/colours (manual edits kept).
- render-svc Go quick-scan: stdlib RIFX parser extracts comp names+durations
  (no AE) → POST /v1/template-scans/{id}/quick.
- render-svc AE scan jobs + node-agent runner: queue → node runs scan.jsx
  (reverse of legacy JSXGenerator conventions: frfinal/frshare/frl_/frd_) →
  posts ScanResult back. Migration 26_render_scan_jobs.
- admin UI: "اسکن از افترافکت" with quick/full engines + diff-review modal.

Verified: importer preview/apply, Go quick-scan end-to-end (synthetic .aep →
scene imported), bundle extract unit tests, RIFX parser unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@
This commit is contained in:
soroush.asadi
2026-06-04 10:39:45 +03:30
parent 264fccf21f
commit 1ff6e494c0
26 changed files with 2691 additions and 27 deletions
@@ -32,9 +32,11 @@ async function forward(
if (!isAdmin) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
const search = req.nextUrl.search ?? "";
// Trailing slash on the collection root avoids the gateway's 307 redirect.
// Trailing slash on the collection root avoids the gateway's 307 redirect
// (which, for POST, would otherwise rely on the client re-sending the body).
const joined = path.join("/");
const gwPath = `/v1/${joined}${path.length === 1 && method === "GET" ? "/" : ""}${search}`;
const isCollectionRoot = path.length === 1 && (method === "GET" || method === "POST");
const gwPath = `/v1/${joined}${isCollectionRoot ? "/" : ""}${search}`;
let body: string | undefined;
if (method === "POST" || method === "PUT" || method === "PATCH") {
+214
View File
@@ -0,0 +1,214 @@
"use client";
import { useCallback, useRef, useState } from "react";
const btn = "rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 disabled:opacity-50";
const ghost = "rounded-lg border border-[#262b40] px-3 py-1.5 text-xs text-gray-300 hover:bg-[#161a2e] disabled:opacity-50";
const card = "rounded-xl border border-[#1e2235] bg-[#0f1120]";
interface SceneDiff {
key: string; title: string; status: string;
elements_added: number; elements_changed: number; elements_removed: number;
colors_added: number; colors_changed: number; colors_removed: number;
}
interface ImportDiff {
applied: boolean;
scenes_added: number; scenes_changed: number; scenes_unchanged: number; scenes_orphan: number;
shared_colors_added: number; shared_colors_changed: number; shared_colors_removed: number;
scenes: SceneDiff[]; orphan_scene_keys: string[];
}
type Step = "idle" | "scanning" | "preview" | "applying" | "done" | "error";
const STATUS_FA: Record<string, string> = {
added: "جدید", changed: "تغییر", unchanged: "بدون تغییر", orphan: "حذف‌شده در AEP",
};
const STATUS_CLS: Record<string, string> = {
added: "bg-emerald-500/15 text-emerald-300",
changed: "bg-amber-500/15 text-amber-300",
unchanged: "bg-gray-500/15 text-gray-400",
orphan: "bg-red-500/15 text-red-300",
};
/** Scan a project's AE template and merge the discovered scenes/colours (review-diff-then-merge). */
export function ProjectScanImport({ projectId, onClose, onApplied }: {
projectId: string; onClose: () => void; onApplied: () => void;
}) {
const [step, setStep] = useState<Step>("idle");
const [statusMsg, setStatusMsg] = useState("");
const [err, setErr] = useState<string | null>(null);
const [scan, setScan] = useState<unknown>(null);
const [diff, setDiff] = useState<ImportDiff | null>(null);
const [removeOrphans, setRemoveOrphans] = useState(false);
const [overwrite, setOverwrite] = useState(true);
const pollRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const fail = (m: string) => { setErr(m); setStep("error"); };
// shared: send a scan to content for a dry-run diff
const preview = useCallback(async (scanResult: unknown) => {
setScan(scanResult);
setStatusMsg("در حال محاسبهٔ تفاوت‌ها…");
const r = await fetch(`/api/admin/resource/projects/${projectId}/scan/preview`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ scan: scanResult }),
});
const d = await r.json().catch(() => null);
if (!r.ok) { fail(d?.error ?? "محاسبهٔ تفاوت‌ها ناموفق بود"); return; }
setDiff(d); setStep("preview");
}, [projectId]);
// Quick scan — headless Go parser (no AE)
const runQuick = async () => {
setStep("scanning"); setErr(null); setStatusMsg("در حال خواندن ساختار پروژه از فایل AEP…");
const r = await fetch(`/api/admin/resource/template-scans/${projectId}/quick`, { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });
const d = await r.json().catch(() => null);
if (!r.ok) { fail(d?.error ?? "اسکن سریع ناموفق بود (آیا فایل AEP آپلود شده؟)"); return; }
await preview(d);
};
// Full scan — queue an AE job on a render node, then poll
const runFull = async () => {
setStep("scanning"); setErr(null); setStatusMsg("در حال ارسال کار اسکن به نود افترافکت…");
const r = await fetch(`/api/admin/resource/template-scans/${projectId}/jobs`, { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });
const d = await r.json().catch(() => null);
if (!r.ok || !d?.id) { fail(d?.error ?? "ایجاد کار اسکن ناموفق بود"); return; }
const jobId = d.id;
const started = Date.now();
const poll = async () => {
const jr = await fetch(`/api/admin/resource/template-scan-jobs/${jobId}`, { cache: "no-store" });
const job = await jr.json().catch(() => null);
if (!jr.ok) { fail(job?.error ?? "خطا در دریافت وضعیت اسکن"); return; }
if (job.status === "done") { await preview(job.result); return; }
if (job.status === "error") { fail("اسکن روی نود ناموفق بود: " + (job.error ?? "")); return; }
if (Date.now() - started > 6 * 60 * 1000) { fail("اسکن طول کشید — آیا یک نود افترافکت آنلاین است؟"); return; }
setStatusMsg(job.status === "running" ? "در حال اجرای اسکریپت اسکن در افترافکت…" : "در صف اجرا روی نود…");
pollRef.current = setTimeout(poll, 3000);
};
poll();
};
const apply = async () => {
setStep("applying"); setStatusMsg("در حال اعمال تغییرات…");
const r = await fetch(`/api/admin/resource/projects/${projectId}/scan/apply`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ scan, options: { remove_orphan_scenes: removeOrphans, remove_orphan_elements: removeOrphans, overwrite_existing: overwrite } }),
});
const d = await r.json().catch(() => null);
if (!r.ok) { fail(d?.error ?? "اعمال ناموفق بود"); return; }
setDiff(d); setStep("done"); onApplied();
};
const close = () => { if (pollRef.current) clearTimeout(pollRef.current); onClose(); };
return (
<div className="fixed inset-0 z-[60] flex items-stretch justify-center bg-black/70 p-2 sm:p-6" dir="rtl" onClick={close}>
<div className={`${card} flex max-h-full w-full max-w-2xl flex-col`} onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between border-b border-[#1e2235] px-5 py-3">
<h2 className="text-sm font-semibold text-white">اسکن از افترافکت خواندن صحنهها و رنگها</h2>
<button className="rounded-lg px-2 py-1 text-gray-400 hover:bg-[#161a2e] hover:text-white" onClick={close}></button>
</div>
<div className="flex-1 overflow-y-auto p-5">
{/* idle → choose engine */}
{step === "idle" && (
<div className="space-y-3">
<p className="text-sm text-gray-400">ساختار قالب را مستقیماً از فایل افترافکت بخوانید. ابتدا یک پیشنمایش از تغییرات میبینید و سپس اعمال میکنید (ویرایشهای دستی حفظ میشوند).</p>
<button className="w-full rounded-lg border border-[#262b40] p-3 text-right hover:bg-[#161a2e]" onClick={runQuick}>
<div className="text-sm font-medium text-white">اسکن سریع (بدون افترافکت)</div>
<div className="mt-0.5 text-xs text-gray-500">فقط نام صحنهها و مدتها را از فایل AEP میخواند. فوری، بدون نیاز به نود. رنگها/فونتها بعداً با اسکن کامل پر میشوند.</div>
</button>
<button className="w-full rounded-lg border border-[#262b40] p-3 text-right hover:bg-[#161a2e]" onClick={runFull}>
<div className="text-sm font-medium text-white">اسکن کامل (روی نود افترافکت)</div>
<div className="mt-0.5 text-xs text-gray-500">صحنهها، عناصر (frl_/frd_)، فونتها، چینش و رنگها (frshare) را کامل میخواند. نیازمند یک نود افترافکت آنلاین است.</div>
</button>
</div>
)}
{(step === "scanning" || step === "applying") && (
<div className="flex flex-col items-center gap-3 py-10 text-center">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-indigo-500 border-t-transparent" />
<p className="text-sm text-gray-300">{statusMsg}</p>
</div>
)}
{step === "error" && (
<div className="space-y-3">
<p className="rounded-lg bg-red-500/10 px-3 py-2 text-sm text-red-300">{err}</p>
<button className={ghost} onClick={() => { setErr(null); setStep("idle"); }}>تلاش دوباره</button>
</div>
)}
{(step === "preview" || step === "done") && diff && (
<div className="space-y-4">
{step === "done" && <p className="rounded-lg bg-emerald-500/10 px-3 py-2 text-sm text-emerald-300">تغییرات با موفقیت اعمال شد </p>}
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
<Stat label="صحنهٔ جدید" value={diff.scenes_added} cls="text-emerald-300" />
<Stat label="تغییر‌یافته" value={diff.scenes_changed} cls="text-amber-300" />
<Stat label="بدون تغییر" value={diff.scenes_unchanged} cls="text-gray-400" />
<Stat label="در AEP نیست" value={diff.scenes_orphan} cls="text-red-300" />
</div>
<p className="text-xs text-gray-500">
رنگهای مشترک: <span className="text-emerald-300">{diff.shared_colors_added} جدید</span> ·{" "}
<span className="text-amber-300">{diff.shared_colors_changed} تغییر</span>
</p>
{diff.scenes.length > 0 && (
<div className="max-h-64 space-y-1 overflow-y-auto rounded-lg border border-[#1e2235] p-2">
{diff.scenes.map((s) => (
<div key={s.key} className="flex items-center justify-between rounded bg-[#0c0e1a] px-2 py-1.5 text-xs">
<span className="flex items-center gap-2">
<span className={`rounded px-1.5 py-0.5 text-[10px] ${STATUS_CLS[s.status] ?? ""}`}>{STATUS_FA[s.status] ?? s.status}</span>
<span className="text-gray-200">{s.title}</span>
<code className="rounded bg-[#1e2235] px-1 text-[10px] text-indigo-300" dir="ltr">{s.key}</code>
</span>
<span className="text-[10px] text-gray-500">
{s.elements_added + s.elements_changed > 0 && `${s.elements_added + s.elements_changed} عنصر · `}
{s.colors_added + s.colors_changed > 0 && `${s.colors_added + s.colors_changed} رنگ`}
</span>
</div>
))}
</div>
)}
{step === "preview" && (
<>
<div className="space-y-1.5 rounded-lg border border-[#1e2235] bg-[#0c0e1a] p-3">
<label className="flex items-center gap-2 text-sm text-gray-300">
<input type="checkbox" checked={overwrite} onChange={(e) => setOverwrite(e.target.checked)} className="h-4 w-4 accent-indigo-500" />
بهروزرسانی موارد موجود از روی اسکن (مقادیر خالیِ اسکن، دادههای فعلی را پاک نمیکند)
</label>
<label className="flex items-center gap-2 text-sm text-gray-300">
<input type="checkbox" checked={removeOrphans} onChange={(e) => setRemoveOrphans(e.target.checked)} className="h-4 w-4 accent-indigo-500" />
حذف صحنهها/عناصری که دیگر در AEP وجود ندارند
</label>
</div>
<div className="flex items-center justify-end gap-2">
<button className={ghost} onClick={() => setStep("idle")}>بازگشت</button>
<button className={btn} onClick={apply}>اعمال تغییرات</button>
</div>
</>
)}
{step === "done" && (
<div className="flex justify-end">
<button className={btn} onClick={close}>بستن</button>
</div>
)}
</div>
)}
</div>
</div>
</div>
);
}
function Stat({ label, value, cls }: { label: string; value: number; cls: string }) {
return (
<div className="rounded-lg border border-[#1e2235] bg-[#0c0e1a] p-2 text-center">
<div className={`text-lg font-semibold ${cls}`}>{value.toLocaleString("fa-IR")}</div>
<div className="text-[10px] text-gray-500">{label}</div>
</div>
);
}
+460
View File
@@ -0,0 +1,460 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { FileUploadField } from "@/components/admin/FileUploadField";
import { ProjectScanImport } from "@/components/admin/ProjectScanImport";
// ── styles ───────────────────────────────────────────────────────────────────
const inp = "rounded-lg border border-[#262b40] bg-[#0c0e1a] px-2.5 py-1.5 text-sm text-gray-100 outline-none focus:border-indigo-500";
const btn = "rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 disabled:opacity-50";
const ghost = "rounded-lg border border-[#262b40] px-2.5 py-1 text-xs text-gray-300 hover:bg-[#161a2e] disabled:opacity-50";
const lbl = "mb-1 block text-xs text-gray-400";
const del = "rounded-lg border border-red-500/30 px-2.5 py-1 text-xs text-red-300 hover:bg-red-500/10";
// ── types (snake_case — matches content-svc JSON) ─────────────────────────────
interface Scene {
id: string; project_id: string; key: string; title: string; localized_title?: string | null;
scene_type: string; image?: string | null; demo?: string | null; scene_color_svg?: string | null;
snapshot_url?: string | null; generate_kf: boolean; default_duration_sec?: number | null;
min_duration_sec?: number | null; max_duration_sec?: number | null; overlap_at_end_sec: number;
can_handle_duration: boolean; manual_color_selection: boolean; sort: number; is_active: boolean;
}
interface SharedColor {
id: string; project_id: string; element_key: string; title: string; icon?: string | null;
attr_value: string; default_color: string; sort: number;
}
interface PresetItem { id?: string; element_key: string; value: string; sort: number }
interface Preset { id: string; project_id: string; name?: string | null; sort: number; items: PresetItem[] }
const SCENE_TYPES = [
{ v: "Normal", l: "معمولی" },
{ v: "Config", l: "پیکربندی" },
{ v: "DesignStart", l: "شروع طراحی" },
{ v: "DesignEnd", l: "پایان طراحی" },
];
const ATTR_VALUES = [
{ v: "fill", l: "Fill (پُرکننده)" },
{ v: "stroke", l: "Stroke (خط دور)" },
{ v: "tracking", l: "Tracking (فاصله)" },
{ v: "dropshadow", l: "Drop Shadow (سایه)" },
];
// localized_title is stored as a JSON string {"fa":"…","en":"…"}
function parseLocalized(v?: string | null): { fa: string; en: string } {
if (!v) return { fa: "", en: "" };
try { const o = JSON.parse(v); return { fa: o.fa ?? "", en: o.en ?? "" }; } catch { return { fa: "", en: "" }; }
}
function buildLocalized(fa: string, en: string): string | null {
if (!fa.trim() && !en.trim()) return null;
return JSON.stringify({ fa: fa.trim(), en: en.trim() });
}
type SceneDraft = Omit<Scene, "id" | "project_id"> & { _fa: string; _en: string };
function emptyDraft(sort: number): SceneDraft {
return {
key: "", title: "", localized_title: null, scene_type: "Normal", image: null, demo: null,
scene_color_svg: null, snapshot_url: null, generate_kf: false, default_duration_sec: null,
min_duration_sec: null, max_duration_sec: null, overlap_at_end_sec: 0, can_handle_duration: true,
manual_color_selection: false, sort, is_active: true, _fa: "", _en: "",
};
}
function sceneToDraft(s: Scene): SceneDraft {
const loc = parseLocalized(s.localized_title);
return { ...s, _fa: loc.fa, _en: loc.en };
}
// =============================================================================
export function ProjectScenes({ projectId }: { projectId: string }) {
const [tab, setTab] = useState<"scenes" | "colors" | "presets">("scenes");
return (
<div dir="rtl">
<div className="mb-4 flex gap-1 rounded-lg border border-[#1e2235] bg-[#0c0e1a] p-1 text-sm">
{([["scenes", "صحنه‌ها"], ["colors", "رنگ‌های مشترک"], ["presets", "پریست‌های رنگ"]] as const).map(([k, l]) => (
<button
key={k}
onClick={() => setTab(k)}
className={`flex-1 rounded-md px-3 py-1.5 transition-colors ${tab === k ? "bg-indigo-600/20 font-medium text-indigo-300" : "text-gray-400 hover:text-white"}`}
>{l}</button>
))}
</div>
{tab === "scenes" && <ScenesTab projectId={projectId} />}
{tab === "colors" && <ColorsTab projectId={projectId} />}
{tab === "presets" && <PresetsTab projectId={projectId} />}
</div>
);
}
// ── Scenes ────────────────────────────────────────────────────────────────────
function ScenesTab({ projectId }: { projectId: string }) {
const [rows, setRows] = useState<Scene[]>([]);
const [loading, setLoading] = useState(true);
const [draft, setDraft] = useState<SceneDraft | null>(null);
const [editId, setEditId] = useState<string | null>(null); // null+draft => new
const [saving, setSaving] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [scanOpen, setScanOpen] = useState(false);
const base = "/api/admin/resource/scenes";
const load = useCallback(async () => {
setLoading(true);
const r = await fetch(`${base}?project_id=${projectId}`, { cache: "no-store" }).then((x) => x.json()).catch(() => null);
setRows(Array.isArray(r) ? r : r?.data ?? []);
setLoading(false);
}, [projectId]);
useEffect(() => { load(); }, [load]);
const save = async () => {
if (!draft) return;
if (!draft.key.trim() || !draft.title.trim()) { setErr("کلید و عنوان صحنه الزامی است"); return; }
setSaving(true); setErr(null);
const body = {
project_id: projectId, key: draft.key.trim(), title: draft.title.trim(),
localized_title: buildLocalized(draft._fa, draft._en), scene_type: draft.scene_type,
image: draft.image || null, demo: draft.demo || null, scene_color_svg: draft.scene_color_svg || null,
snapshot_url: draft.snapshot_url || null, generate_kf: draft.generate_kf,
default_duration_sec: draft.default_duration_sec, min_duration_sec: draft.min_duration_sec,
max_duration_sec: draft.max_duration_sec, overlap_at_end_sec: draft.overlap_at_end_sec ?? 0,
can_handle_duration: draft.can_handle_duration, manual_color_selection: draft.manual_color_selection,
sort: draft.sort ?? 0, is_active: draft.is_active,
};
const res = await fetch(editId ? `${base}/${editId}` : base, {
method: editId ? "PUT" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
});
const d = await res.json().catch(() => null);
if (res.ok) { setDraft(null); setEditId(null); load(); }
else setErr(d?.message ?? d?.error?.message ?? "ذخیرهٔ صحنه ناموفق بود");
setSaving(false);
};
const remove = async (s: Scene) => {
if (!confirm(`صحنهٔ «${s.title}» حذف شود؟`)) return;
await fetch(`${base}/${s.id}`, { method: "DELETE" });
load();
};
if (draft) {
return (
<SceneForm
draft={draft} setDraft={setDraft} saving={saving} err={err}
onCancel={() => { setDraft(null); setEditId(null); setErr(null); }}
onSave={save} isEdit={!!editId}
/>
);
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-gray-500">صحنهها بلوکهای قابلویرایش این قالب هستند. کلید هر صحنه باید با نام کامپوزیشن افترافکت یکی باشد.</p>
<div className="flex shrink-0 items-center gap-2">
<button className="rounded-lg border border-indigo-500/40 px-3 py-2 text-sm text-indigo-300 hover:bg-indigo-600/10" onClick={() => setScanOpen(true)}>اسکن از افترافکت</button>
<button className={btn} onClick={() => { setEditId(null); setDraft(emptyDraft(rows.length)); }}>+ صحنهٔ جدید</button>
</div>
</div>
{scanOpen && (
<ProjectScanImport projectId={projectId} onClose={() => setScanOpen(false)} onApplied={load} />
)}
{loading ? (
<p className="py-6 text-center text-sm text-gray-500">در حال بارگذاری</p>
) : rows.length === 0 ? (
<p className="rounded-lg border border-dashed border-[#262b40] py-6 text-center text-sm text-gray-600">هنوز صحنهای اضافه نشده.</p>
) : (
<ul className="space-y-1.5">
{rows.map((s) => (
<li key={s.id} className="flex items-center justify-between rounded-lg border border-[#1e2235] bg-[#0c0e1a] px-3 py-2">
<div className="flex min-w-0 items-center gap-2">
<span className="text-[10px] text-gray-600">#{s.sort}</span>
<span className="truncate text-sm text-gray-200">{s.title}</span>
<code className="truncate rounded bg-[#1e2235] px-1.5 py-0.5 text-[10px] text-indigo-300" dir="ltr">{s.key}</code>
<span className="rounded bg-[#1e2235] px-1.5 py-0.5 text-[10px] text-gray-400">{SCENE_TYPES.find((t) => t.v === s.scene_type)?.l ?? s.scene_type}</span>
{!s.is_active && <span className="rounded bg-gray-500/15 px-1.5 py-0.5 text-[10px] text-gray-400">غیرفعال</span>}
</div>
<div className="flex shrink-0 items-center gap-2">
<button className={ghost} onClick={() => { setEditId(s.id); setDraft(sceneToDraft(s)); }}>ویرایش</button>
<button className={del} onClick={() => remove(s)}>حذف</button>
</div>
</li>
))}
</ul>
)}
</div>
);
}
function num(v: number | null | undefined) { return v === null || v === undefined ? "" : String(v); }
function toNum(v: string): number | null { return v.trim() === "" ? null : Number(v); }
function SceneForm({ draft, setDraft, onSave, onCancel, saving, err, isEdit }: {
draft: SceneDraft; setDraft: (d: SceneDraft) => void; onSave: () => void; onCancel: () => void;
saving: boolean; err: string | null; isEdit: boolean;
}) {
const set = (p: Partial<SceneDraft>) => setDraft({ ...draft, ...p });
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-white">{isEdit ? "ویرایش صحنه" : "صحنهٔ جدید"}</h3>
<button className={ghost} onClick={onCancel}> بازگشت به فهرست</button>
</div>
{err && <p className="rounded-lg bg-red-500/10 px-3 py-2 text-sm text-red-300">{err}</p>}
<div className="grid gap-3 sm:grid-cols-2">
<div><label className={lbl}>کلید (نام کامپوزیشن AE) *</label><input className={`${inp} w-full`} dir="ltr" value={draft.key} onChange={(e) => set({ key: e.target.value })} placeholder="scene_intro" /></div>
<div><label className={lbl}>عنوان *</label><input className={`${inp} w-full`} value={draft.title} onChange={(e) => set({ title: e.target.value })} /></div>
<div><label className={lbl}>عنوان (فارسی)</label><input className={`${inp} w-full`} value={draft._fa} onChange={(e) => set({ _fa: e.target.value })} /></div>
<div><label className={lbl}>عنوان (انگلیسی)</label><input className={`${inp} w-full`} dir="ltr" value={draft._en} onChange={(e) => set({ _en: e.target.value })} /></div>
<div>
<label className={lbl}>نوع صحنه</label>
<select className={`${inp} w-full`} value={draft.scene_type} onChange={(e) => set({ scene_type: e.target.value })}>
{SCENE_TYPES.map((t) => <option key={t.v} value={t.v}>{t.l}</option>)}
</select>
</div>
<div><label className={lbl}>ترتیب</label><input className={`${inp} w-full`} type="number" dir="ltr" value={num(draft.sort)} onChange={(e) => set({ sort: Number(e.target.value) || 0 })} /></div>
<div><label className={lbl}>مدت پیشفرض (ثانیه)</label><input className={`${inp} w-full`} type="number" step="0.1" dir="ltr" value={num(draft.default_duration_sec)} onChange={(e) => set({ default_duration_sec: toNum(e.target.value) })} /></div>
<div><label className={lbl}>همپوشانی پایان (ثانیه)</label><input className={`${inp} w-full`} type="number" step="0.1" dir="ltr" value={num(draft.overlap_at_end_sec)} onChange={(e) => set({ overlap_at_end_sec: Number(e.target.value) || 0 })} /></div>
<div><label className={lbl}>حداقل مدت (ثانیه)</label><input className={`${inp} w-full`} type="number" step="0.1" dir="ltr" value={num(draft.min_duration_sec)} onChange={(e) => set({ min_duration_sec: toNum(e.target.value) })} /></div>
<div><label className={lbl}>حداکثر مدت (ثانیه)</label><input className={`${inp} w-full`} type="number" step="0.1" dir="ltr" value={num(draft.max_duration_sec)} onChange={(e) => set({ max_duration_sec: toNum(e.target.value) })} /></div>
<div><label className={lbl}>تصویر صحنه</label><FileUploadField value={draft.image ?? ""} onChange={(u) => set({ image: u })} accept="image/*" /></div>
<div><label className={lbl}>دموی صحنه (ویدیو/تصویر)</label><FileUploadField value={draft.demo ?? ""} onChange={(u) => set({ demo: u })} accept="video/*,image/*" /></div>
<div><label className={lbl}>اسنپشات (فریم نمونه)</label><FileUploadField value={draft.snapshot_url ?? ""} onChange={(u) => set({ snapshot_url: u })} accept="image/*" /></div>
<div><label className={lbl}>SVG رنگ (اختیاری)</label><input className={`${inp} w-full`} dir="ltr" value={draft.scene_color_svg ?? ""} onChange={(e) => set({ scene_color_svg: e.target.value })} placeholder="آدرس یا کد SVG" /></div>
</div>
<div className="grid gap-2 rounded-lg border border-[#1e2235] bg-[#0c0e1a] p-3 sm:grid-cols-2">
<Check label="تولید کی‌فریم (Generate KF)" checked={draft.generate_kf} onChange={(v) => set({ generate_kf: v })} />
<Check label="مدت قابل تغییر توسط کاربر" checked={draft.can_handle_duration} onChange={(v) => set({ can_handle_duration: v })} />
<Check label="انتخاب رنگ دستی" checked={draft.manual_color_selection} onChange={(v) => set({ manual_color_selection: v })} />
<Check label="فعال" checked={draft.is_active} onChange={(v) => set({ is_active: v })} />
</div>
<div className="flex items-center justify-end gap-2 border-t border-[#1e2235] pt-3">
<button className={ghost} onClick={onCancel}>انصراف</button>
<button className={btn} onClick={onSave} disabled={saving}>{saving ? "در حال ذخیره…" : isEdit ? "ذخیرهٔ تغییرات" : "افزودن صحنه"}</button>
</div>
</div>
);
}
function Check({ label, checked, onChange }: { label: string; checked: boolean; onChange: (v: boolean) => void }) {
return (
<label className="flex cursor-pointer items-center gap-2 text-sm text-gray-300">
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} className="h-4 w-4 accent-indigo-500" />
{label}
</label>
);
}
// ── Shared Colors ─────────────────────────────────────────────────────────────
function ColorsTab({ projectId }: { projectId: string }) {
const [rows, setRows] = useState<SharedColor[]>([]);
const [loading, setLoading] = useState(true);
const [edit, setEdit] = useState<Partial<SharedColor> | null>(null);
const [saving, setSaving] = useState(false);
const [err, setErr] = useState<string | null>(null);
const base = "/api/admin/resource/shared-colors";
const load = useCallback(async () => {
setLoading(true);
const r = await fetch(`${base}?project_id=${projectId}`, { cache: "no-store" }).then((x) => x.json()).catch(() => null);
setRows(Array.isArray(r) ? r : r?.data ?? []);
setLoading(false);
}, [projectId]);
useEffect(() => { load(); }, [load]);
const save = async () => {
if (!edit) return;
if (!edit.element_key?.trim() || !edit.title?.trim()) { setErr("کلید عنصر و عنوان الزامی است"); return; }
setSaving(true); setErr(null);
const body = {
project_id: projectId, element_key: edit.element_key.trim(), title: edit.title.trim(),
icon: edit.icon || null, attr_value: edit.attr_value || "fill",
default_color: edit.default_color || "#000000", sort: edit.sort ?? rows.length,
};
const res = await fetch(edit.id ? `${base}/${edit.id}` : base, {
method: edit.id ? "PUT" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
});
const d = await res.json().catch(() => null);
if (res.ok) { setEdit(null); load(); } else setErr(d?.message ?? "ذخیره ناموفق بود");
setSaving(false);
};
const remove = async (c: SharedColor) => { if (!confirm(`رنگ «${c.title}» حذف شود؟`)) return; await fetch(`${base}/${c.id}`, { method: "DELETE" }); load(); };
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-xs text-gray-500">رنگهایی که در کل پروژه مشترکاند (کامپوزیشن frshare). کلید عنصر باید با نام لایهٔ frd_ مطابقت داشته باشد.</p>
<button className={btn} onClick={() => setEdit({ attr_value: "fill", default_color: "#3366ff", sort: rows.length })}>+ رنگ جدید</button>
</div>
{edit && (
<div className="space-y-3 rounded-lg border border-[#1e2235] bg-[#0c0e1a] p-3">
{err && <p className="rounded bg-red-500/10 px-3 py-2 text-sm text-red-300">{err}</p>}
<div className="grid gap-3 sm:grid-cols-2">
<div><label className={lbl}>کلید عنصر (frd_) *</label><input className={`${inp} w-full`} dir="ltr" value={edit.element_key ?? ""} onChange={(e) => setEdit({ ...edit, element_key: e.target.value })} placeholder="frd_primary" /></div>
<div><label className={lbl}>عنوان *</label><input className={`${inp} w-full`} value={edit.title ?? ""} onChange={(e) => setEdit({ ...edit, title: e.target.value })} /></div>
<div>
<label className={lbl}>نوع ویژگی</label>
<select className={`${inp} w-full`} value={edit.attr_value ?? "fill"} onChange={(e) => setEdit({ ...edit, attr_value: e.target.value })}>
{ATTR_VALUES.map((a) => <option key={a.v} value={a.v}>{a.l}</option>)}
</select>
</div>
<div>
<label className={lbl}>رنگ پیشفرض</label>
<div className="flex items-center gap-2">
<input type="color" className="h-9 w-12 rounded border border-[#262b40] bg-[#0c0e1a]" value={/^#[0-9a-fA-F]{6}$/.test(edit.default_color ?? "") ? edit.default_color : "#000000"} onChange={(e) => setEdit({ ...edit, default_color: e.target.value })} />
<input className={`${inp} flex-1`} dir="ltr" value={edit.default_color ?? ""} onChange={(e) => setEdit({ ...edit, default_color: e.target.value })} placeholder="#RRGGBB" />
</div>
</div>
<div><label className={lbl}>آیکون (اختیاری)</label><input className={`${inp} w-full`} dir="ltr" value={edit.icon ?? ""} onChange={(e) => setEdit({ ...edit, icon: e.target.value })} /></div>
<div><label className={lbl}>ترتیب</label><input className={`${inp} w-full`} type="number" dir="ltr" value={num(edit.sort)} onChange={(e) => setEdit({ ...edit, sort: Number(e.target.value) || 0 })} /></div>
</div>
<div className="flex justify-end gap-2">
<button className={ghost} onClick={() => { setEdit(null); setErr(null); }}>انصراف</button>
<button className={btn} onClick={save} disabled={saving}>{saving ? "…" : "ذخیره"}</button>
</div>
</div>
)}
{loading ? (
<p className="py-6 text-center text-sm text-gray-500">در حال بارگذاری</p>
) : rows.length === 0 ? (
<p className="rounded-lg border border-dashed border-[#262b40] py-6 text-center text-sm text-gray-600">رنگ مشترکی تعریف نشده.</p>
) : (
<ul className="space-y-1.5">
{rows.map((c) => (
<li key={c.id} className="flex items-center justify-between rounded-lg border border-[#1e2235] bg-[#0c0e1a] px-3 py-2">
<div className="flex min-w-0 items-center gap-2">
<span className="h-5 w-5 shrink-0 rounded border border-[#262b40]" style={{ backgroundColor: c.default_color }} />
<span className="truncate text-sm text-gray-200">{c.title}</span>
<code className="truncate rounded bg-[#1e2235] px-1.5 py-0.5 text-[10px] text-indigo-300" dir="ltr">{c.element_key}</code>
<span className="rounded bg-[#1e2235] px-1.5 py-0.5 text-[10px] text-gray-400">{c.attr_value}</span>
</div>
<div className="flex shrink-0 items-center gap-2">
<button className={ghost} onClick={() => setEdit(c)}>ویرایش</button>
<button className={del} onClick={() => remove(c)}>حذف</button>
</div>
</li>
))}
</ul>
)}
</div>
);
}
// ── Color Presets ─────────────────────────────────────────────────────────────
function PresetsTab({ projectId }: { projectId: string }) {
const [rows, setRows] = useState<Preset[]>([]);
const [colors, setColors] = useState<SharedColor[]>([]);
const [loading, setLoading] = useState(true);
const [edit, setEdit] = useState<Partial<Preset> | null>(null);
const [saving, setSaving] = useState(false);
const [err, setErr] = useState<string | null>(null);
const base = "/api/admin/resource/color-presets";
const load = useCallback(async () => {
setLoading(true);
const [r, c] = await Promise.all([
fetch(`${base}?project_id=${projectId}`, { cache: "no-store" }).then((x) => x.json()).catch(() => null),
fetch(`/api/admin/resource/shared-colors?project_id=${projectId}`, { cache: "no-store" }).then((x) => x.json()).catch(() => null),
]);
setRows(Array.isArray(r) ? r : r?.data ?? []);
setColors(Array.isArray(c) ? c : c?.data ?? []);
setLoading(false);
}, [projectId]);
useEffect(() => { load(); }, [load]);
const colorKeys = useMemo(() => colors.map((c) => ({ key: c.element_key, title: c.title })), [colors]);
const startNew = () => setEdit({ name: "", sort: rows.length, items: [] });
const setItems = (items: PresetItem[]) => setEdit((e) => (e ? { ...e, items } : e));
const save = async () => {
if (!edit) return;
setSaving(true); setErr(null);
const body = {
project_id: projectId, name: edit.name?.trim() || null, sort: edit.sort ?? rows.length,
items: (edit.items ?? []).map((it, i) => ({ element_key: it.element_key, value: it.value, sort: it.sort ?? i })),
};
const res = await fetch(edit.id ? `${base}/${edit.id}` : base, {
method: edit.id ? "PUT" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
});
const d = await res.json().catch(() => null);
if (res.ok) { setEdit(null); load(); } else setErr(d?.message ?? "ذخیره ناموفق بود");
setSaving(false);
};
const remove = async (p: Preset) => { if (!confirm("این پریست حذف شود؟")) return; await fetch(`${base}/${p.id}`, { method: "DELETE" }); load(); };
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<p className="text-xs text-gray-500">پالتهای آمادهٔ رنگ برای کل پروژه. هر آیتم یک «کلید عنصر» را به یک رنگ نگاشت میکند.</p>
<button className={btn} onClick={startNew}>+ پریست جدید</button>
</div>
{edit && (
<div className="space-y-3 rounded-lg border border-[#1e2235] bg-[#0c0e1a] p-3">
{err && <p className="rounded bg-red-500/10 px-3 py-2 text-sm text-red-300">{err}</p>}
<div className="grid gap-3 sm:grid-cols-2">
<div><label className={lbl}>نام پریست</label><input className={`${inp} w-full`} value={edit.name ?? ""} onChange={(e) => setEdit({ ...edit, name: e.target.value })} placeholder="مثلاً تیره" /></div>
<div><label className={lbl}>ترتیب</label><input className={`${inp} w-full`} type="number" dir="ltr" value={num(edit.sort)} onChange={(e) => setEdit({ ...edit, sort: Number(e.target.value) || 0 })} /></div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-gray-400">رنگهای پریست</span>
<button className={ghost} onClick={() => setItems([...(edit.items ?? []), { element_key: colorKeys[0]?.key ?? "", value: "#3366ff", sort: (edit.items ?? []).length }])}>+ افزودن رنگ</button>
</div>
{(edit.items ?? []).length === 0 ? (
<p className="text-[11px] text-gray-600">رنگی اضافه نشده.</p>
) : (edit.items ?? []).map((it, i) => (
<div key={i} className="flex items-center gap-2">
{colorKeys.length > 0 ? (
<select className={`${inp} flex-1`} value={it.element_key} onChange={(e) => { const a = [...(edit.items ?? [])]; a[i] = { ...it, element_key: e.target.value }; setItems(a); }}>
{colorKeys.map((c) => <option key={c.key} value={c.key}>{c.title} ({c.key})</option>)}
</select>
) : (
<input className={`${inp} flex-1`} dir="ltr" value={it.element_key} onChange={(e) => { const a = [...(edit.items ?? [])]; a[i] = { ...it, element_key: e.target.value }; setItems(a); }} placeholder="frd_primary" />
)}
<input type="color" className="h-9 w-12 rounded border border-[#262b40]" value={/^#[0-9a-fA-F]{6}$/.test(it.value) ? it.value : "#000000"} onChange={(e) => { const a = [...(edit.items ?? [])]; a[i] = { ...it, value: e.target.value }; setItems(a); }} />
<input className={`${inp} w-28`} dir="ltr" value={it.value} onChange={(e) => { const a = [...(edit.items ?? [])]; a[i] = { ...it, value: e.target.value }; setItems(a); }} />
<button className={del} onClick={() => setItems((edit.items ?? []).filter((_, j) => j !== i))}></button>
</div>
))}
</div>
<div className="flex justify-end gap-2">
<button className={ghost} onClick={() => { setEdit(null); setErr(null); }}>انصراف</button>
<button className={btn} onClick={save} disabled={saving}>{saving ? "…" : "ذخیره"}</button>
</div>
</div>
)}
{loading ? (
<p className="py-6 text-center text-sm text-gray-500">در حال بارگذاری</p>
) : rows.length === 0 ? (
<p className="rounded-lg border border-dashed border-[#262b40] py-6 text-center text-sm text-gray-600">پریستی تعریف نشده.</p>
) : (
<ul className="space-y-1.5">
{rows.map((p) => (
<li key={p.id} className="flex items-center justify-between rounded-lg border border-[#1e2235] bg-[#0c0e1a] px-3 py-2">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-sm text-gray-200">{p.name || "بدون نام"}</span>
<span className="flex items-center gap-0.5">
{p.items.slice(0, 8).map((it) => <span key={it.id ?? it.element_key} className="h-4 w-4 rounded-sm border border-[#262b40]" style={{ backgroundColor: it.value }} title={it.element_key} />)}
</span>
<span className="text-[10px] text-gray-600">{p.items.length} رنگ</span>
</div>
<div className="flex shrink-0 items-center gap-2">
<button className={ghost} onClick={() => setEdit(p)}>ویرایش</button>
<button className={del} onClick={() => remove(p)}>حذف</button>
</div>
</li>
))}
</ul>
)}
</div>
);
}
+43 -3
View File
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useState } from "react";
import { AdminThumb } from "@/components/admin/AdminThumb";
import { FileUploadField } from "@/components/admin/FileUploadField";
import { ProjectAssets } from "@/components/admin/ProjectAssets";
import { ProjectScenes } from "@/components/admin/ProjectScenes";
interface Proj {
id: string; container_id: string; container_name: string; container_slug: string;
@@ -30,6 +31,8 @@ export function ProjectsAdmin() {
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(false);
const [openAssets, setOpenAssets] = useState<Proj | null>(null);
const [openScenes, setOpenScenes] = useState<Proj | null>(null);
const [aepMsg, setAepMsg] = useState<string | null>(null);
const [containers, setContainers] = useState<{ id: string; name: string }[]>([]);
const [showCreate, setShowCreate] = useState(false);
const [nf, setNf] = useState({ ...emptyNew });
@@ -74,10 +77,30 @@ export function ProjectsAdmin() {
};
const attachAep = async (p: Proj, url: string) => {
if (!url) return;
setAepMsg("در حال ذخیرهٔ قالب…");
// 1. Copy the uploaded file into the canonical per-template location
// (templates/{project_id}/…) so every render of this template reuses it.
const bundleRes = await fetch(`/api/admin/resource/template-bundles/${p.id}`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ source_url: url }),
});
const bundle = await bundleRes.json().catch(() => null);
// 2. Record the canonical location + md5 on the project (keeps the raw URL even
// if the copy step is unavailable, so nothing is lost).
const meta: Record<string, unknown> = { aep_file_url: url, render_aep_comp: p.render_aep_comp || "flatrender" };
if (bundleRes.ok && bundle) {
meta.aep_minio_bucket = bundle.bucket;
meta.aep_minio_key = bundle.key;
meta.aep_file_md5 = bundle.md5;
meta.aep_file_size_bytes = bundle.size_bytes;
}
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" }),
body: JSON.stringify(meta),
});
if (!bundleRes.ok) setAepMsg(`ذخیره شد، اما آماده‌سازی قالب ناموفق بود: ${bundle?.error ?? "خطای ناشناخته"}`);
else setAepMsg(bundle?.is_bundle ? "باندل zip آپلود و برای رندر آماده شد ✓" : "فایل افترافکت ذخیره شد ✓");
load();
};
const remove = async (p: Proj) => {
@@ -178,7 +201,8 @@ export function ProjectsAdmin() {
</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={ghost} onClick={() => setOpenScenes(p)}>صحنهها</button>
<button className={ghost} onClick={() => { setAepMsg(null); 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>
@@ -200,8 +224,10 @@ export function ProjectsAdmin() {
<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>
<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" />
<p className="mt-1 text-[11px] text-gray-500">برای پروژههایی که فوتیج/فونت دارند، کل پروژه را بهصورت فایل zip آپلود کنید؛ هنگام رندر روی نود استخراج میشود.</p>
{aepMsg && <p className="mt-1 text-[11px] text-indigo-300">{aepMsg}</p>}
</div>
<ProjectAssets projectId={openAssets.id} />
</div>
@@ -211,6 +237,20 @@ export function ProjectsAdmin() {
</div>
</div>
)}
{openScenes && (
<div className="fixed inset-0 z-50 flex items-stretch justify-center bg-black/70 p-2 sm:p-6" dir="rtl" onClick={() => setOpenScenes(null)}>
<div className={`${card} flex max-h-full w-full max-w-4xl flex-col`} onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between border-b border-[#1e2235] px-5 py-3">
<h2 className="text-sm font-semibold text-white">صحنهها و رنگها {openScenes.name} <span className="text-gray-500">({openScenes.container_name})</span></h2>
<button className="rounded-lg px-2 py-1 text-gray-400 hover:bg-[#161a2e] hover:text-white" onClick={() => setOpenScenes(null)}></button>
</div>
<div className="flex-1 overflow-y-auto p-5">
<ProjectScenes projectId={openScenes.id} />
</div>
</div>
</div>
)}
</div>
);
}