@
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
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:
@@ -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") {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user