ab568c0663
Build backend images / build content-svc (push) Failing after 31s
Build backend images / build file-svc (push) Failing after 31s
Build backend images / build gateway (push) Failing after 31s
Build backend images / build identity-svc (push) Failing after 30s
Build backend images / build notification-svc (push) Failing after 30s
Build backend images / build render-svc (push) Failing after 31s
Build backend images / build studio-svc (push) Failing after 31s
Epic A — admins author premade example videos per template; users pick one on the template detail page to start a pre-filled project. Backend (content-svc): - PresetStory DTOs + PresetStoryService (admin CRUD + public published-only filter via role check + soft-delete) + PresetStoriesController (/v1/preset-stories) - DI registration; gateway route /v1/preset-stories (optionalAuth, public read) Frontend: - ProjectPresetStories admin authoring UI (name/description/demo upload/published/ sort + scene picker with order+duration + advanced scenes_spa); «ویدیوهای نمونه» button + modal in ProjectsAdmin - TemplateDetailExamples renders real published stories (image/video preview, hover → "use this example" → creates a pre-bound project), falls back to placeholders when none; selected aspect's variant id keys the fetch - public /api/preset-stories route; preset_story_id plumbed through createProjectFromTemplate + projects POST route; usePreset i18n (fa+en) Verified: full CRUD via gateway (public hides unpublished); creating a project with presetStoryId persists selected_preset_story_id on the saved project. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
198 lines
12 KiB
TypeScript
198 lines
12 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, useState } from "react";
|
||
|
||
import { FileUploadField } from "@/components/admin/FileUploadField";
|
||
|
||
// ── styles (match ProjectScenes) ──────────────────────────────────────────────
|
||
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 PresetScene { id?: string; scene_id: string; scene_key?: string | null; scene_title?: string | null; sort: number; default_duration_sec?: number | null }
|
||
interface StorySummary { id: string; project_id: string; name: string; description?: string | null; demo?: string | null; sort: number; is_published: boolean; scene_count: number }
|
||
interface StoryFull extends StorySummary { music_id?: string | null; scenes_spa?: string | null; scenes: PresetScene[] }
|
||
interface Scene { id: string; key: string; title: string; default_duration_sec?: number | null }
|
||
|
||
type Draft = {
|
||
name: string; description: string; demo: string; is_published: boolean; sort: number;
|
||
scenes_spa: string; scenes: PresetScene[];
|
||
};
|
||
|
||
function emptyDraft(sort: number): Draft {
|
||
return { name: "", description: "", demo: "", is_published: true, sort, scenes_spa: "", scenes: [] };
|
||
}
|
||
|
||
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); }
|
||
|
||
export function ProjectPresetStories({ projectId }: { projectId: string }) {
|
||
const [rows, setRows] = useState<StorySummary[]>([]);
|
||
const [scenes, setScenes] = useState<Scene[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [draft, setDraft] = useState<Draft | null>(null);
|
||
const [editId, setEditId] = useState<string | null>(null);
|
||
const [saving, setSaving] = useState(false);
|
||
const [err, setErr] = useState<string | null>(null);
|
||
const base = "/api/admin/resource/preset-stories";
|
||
|
||
const load = useCallback(async () => {
|
||
setLoading(true);
|
||
const [r, sc] = await Promise.all([
|
||
fetch(`${base}?project_id=${projectId}`, { cache: "no-store" }).then((x) => x.json()).catch(() => null),
|
||
fetch(`/api/admin/resource/scenes?project_id=${projectId}`, { cache: "no-store" }).then((x) => x.json()).catch(() => null),
|
||
]);
|
||
setRows(Array.isArray(r) ? r : r?.data ?? []);
|
||
setScenes(Array.isArray(sc) ? sc : sc?.data ?? []);
|
||
setLoading(false);
|
||
}, [projectId]);
|
||
useEffect(() => { load(); }, [load]);
|
||
|
||
const openNew = () => { setEditId(null); setErr(null); setDraft(emptyDraft(rows.length)); };
|
||
const openEdit = async (s: StorySummary) => {
|
||
setErr(null);
|
||
const full: StoryFull | null = await fetch(`${base}/${s.id}`, { cache: "no-store" }).then((x) => x.json()).catch(() => null);
|
||
if (!full) { setErr("بارگذاری ویدیوی نمونه ناموفق بود"); return; }
|
||
setEditId(s.id);
|
||
setDraft({
|
||
name: full.name, description: full.description ?? "", demo: full.demo ?? "",
|
||
is_published: full.is_published, sort: full.sort, scenes_spa: full.scenes_spa ?? "",
|
||
scenes: (full.scenes ?? []).map((p) => ({ ...p })),
|
||
});
|
||
};
|
||
|
||
const set = (p: Partial<Draft>) => setDraft((d) => (d ? { ...d, ...p } : d));
|
||
|
||
const save = async () => {
|
||
if (!draft) return;
|
||
if (!draft.name.trim()) { setErr("نام ویدیوی نمونه الزامی است"); return; }
|
||
setSaving(true); setErr(null);
|
||
const body = {
|
||
project_id: projectId, name: draft.name.trim(), description: draft.description || null,
|
||
demo: draft.demo || null, is_published: draft.is_published, sort: draft.sort ?? 0,
|
||
scenes_spa: draft.scenes_spa || null,
|
||
scenes: draft.scenes.map((s, i) => ({ scene_id: s.scene_id, sort: i, default_duration_sec: s.default_duration_sec ?? null })),
|
||
};
|
||
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: StorySummary) => {
|
||
if (!confirm(`ویدیوی نمونهٔ «${s.name}» حذف شود؟`)) return;
|
||
await fetch(`${base}/${s.id}`, { method: "DELETE" });
|
||
load();
|
||
};
|
||
|
||
// ── scene-list editing ──────────────────────────────────────────────────────
|
||
const addScene = () => {
|
||
if (!draft) return;
|
||
const first = scenes[0];
|
||
set({ scenes: [...draft.scenes, { scene_id: first?.id ?? "", scene_key: first?.key, scene_title: first?.title, sort: draft.scenes.length, default_duration_sec: first?.default_duration_sec ?? null }] });
|
||
};
|
||
const setSceneAt = (i: number, p: Partial<PresetScene>) => {
|
||
if (!draft) return;
|
||
const a = [...draft.scenes]; a[i] = { ...a[i], ...p }; set({ scenes: a });
|
||
};
|
||
const removeSceneAt = (i: number) => { if (!draft) return; set({ scenes: draft.scenes.filter((_, j) => j !== i) }); };
|
||
|
||
if (draft) {
|
||
return (
|
||
<div dir="rtl" className="space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<h3 className="text-sm font-semibold text-white">{editId ? "ویرایش ویدیوی نمونه" : "ویدیوی نمونهٔ جدید"}</h3>
|
||
<button className={ghost} onClick={() => { setDraft(null); setEditId(null); setErr(null); }}>→ بازگشت به فهرست</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}>نام *</label><input className={`${inp} w-full`} value={draft.name} onChange={(e) => set({ name: e.target.value })} /></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 className="sm:col-span-2"><label className={lbl}>توضیح</label><input className={`${inp} w-full`} value={draft.description} onChange={(e) => set({ description: e.target.value })} /></div>
|
||
<div className="sm:col-span-2"><label className={lbl}>پیشنمایش (تصویر یا ویدیو)</label><FileUploadField value={draft.demo} onChange={(u) => set({ demo: u })} accept="video/*,image/*" /></div>
|
||
</div>
|
||
|
||
<div className="rounded-lg border border-[#1e2235] bg-[#0c0e1a] p-3">
|
||
<label className="flex w-fit cursor-pointer items-center gap-2 text-sm text-gray-300">
|
||
<input type="checkbox" checked={draft.is_published} onChange={(e) => set({ is_published: e.target.checked })} className="h-4 w-4 accent-indigo-500" />
|
||
منتشر شده (برای کاربران نمایش داده شود)
|
||
</label>
|
||
</div>
|
||
|
||
{/* Scene list */}
|
||
<div className="space-y-2 rounded-lg border border-[#1e2235] bg-[#0c0e1a] p-3">
|
||
<div className="flex items-center justify-between">
|
||
<span className="text-xs font-medium text-gray-300">صحنههای این ویدیو (ترتیب + مدت)</span>
|
||
<button className={ghost} type="button" onClick={addScene} disabled={scenes.length === 0}>+ افزودن صحنه</button>
|
||
</div>
|
||
{scenes.length === 0 && <p className="text-[11px] text-amber-300/80">این پروژه هنوز صحنهای ندارد. ابتدا از «صحنهها» صحنه تعریف کنید.</p>}
|
||
{draft.scenes.length === 0 ? (
|
||
<p className="text-[11px] text-gray-600">هنوز صحنهای انتخاب نشده.</p>
|
||
) : draft.scenes.map((ps, i) => (
|
||
<div key={i} className="flex items-center gap-2">
|
||
<span className="text-[10px] text-gray-600">#{i}</span>
|
||
<select className={`${inp} flex-1`} value={ps.scene_id} onChange={(e) => setSceneAt(i, { scene_id: e.target.value })}>
|
||
{scenes.map((s) => <option key={s.id} value={s.id}>{s.title} ({s.key})</option>)}
|
||
</select>
|
||
<input className={`${inp} w-28`} type="number" step="0.1" dir="ltr" placeholder="مدت" value={num(ps.default_duration_sec)} onChange={(e) => setSceneAt(i, { default_duration_sec: toNum(e.target.value) })} />
|
||
<button className={del} type="button" onClick={() => removeSceneAt(i)}>✕</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<details className="rounded-lg border border-[#1e2235] bg-[#0c0e1a] p-3">
|
||
<summary className="cursor-pointer text-xs text-gray-400">مقادیر آماده (JSON پیشرفته) — اختیاری</summary>
|
||
<textarea className={`${inp} mt-2 h-28 w-full font-mono`} dir="ltr" value={draft.scenes_spa} onChange={(e) => set({ scenes_spa: e.target.value })} placeholder='{"scenes":[…]} // مقادیر متن/تصویر از پیش پر شده' />
|
||
<p className="mt-1 text-[10px] text-gray-500">حالت پیشرفته: وضعیت کامل صحنهها با مقادیر پر شده. در حال حاضر میتوان از استودیو خروجی گرفت و اینجا چسباند.</p>
|
||
</details>
|
||
|
||
<div className="flex items-center justify-end gap-2 border-t border-[#1e2235] pt-3">
|
||
<button className={ghost} onClick={() => { setDraft(null); setEditId(null); setErr(null); }}>انصراف</button>
|
||
<button className={btn} onClick={save} disabled={saving}>{saving ? "در حال ذخیره…" : editId ? "ذخیرهٔ تغییرات" : "افزودن ویدیوی نمونه"}</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div dir="rtl" className="space-y-3">
|
||
<div className="flex items-center justify-between gap-2">
|
||
<p className="text-xs text-gray-500">ویدیوهای نمونهٔ آمادهٔ این قالب. کاربر میتواند یکی را انتخاب کند و پروژهاش از روی آن پر میشود.</p>
|
||
<button className={btn} onClick={openNew}>+ ویدیوی نمونه</button>
|
||
</div>
|
||
{err && <p className="rounded-lg bg-red-500/10 px-3 py-2 text-sm text-red-300">{err}</p>}
|
||
{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">
|
||
{s.demo
|
||
? <span className="h-8 w-12 shrink-0 rounded bg-cover bg-center" style={{ backgroundImage: `url(${s.demo})` }} />
|
||
: <span className="grid h-8 w-12 shrink-0 place-items-center rounded bg-[#161a2e] text-[9px] text-gray-600">—</span>}
|
||
<span className="truncate text-sm text-gray-200">{s.name}</span>
|
||
<span className="rounded bg-[#1e2235] px-1.5 py-0.5 text-[10px] text-gray-400">{s.scene_count} صحنه</span>
|
||
{!s.is_published && <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={() => openEdit(s)}>ویرایش</button>
|
||
<button className={del} onClick={() => remove(s)}>حذف</button>
|
||
</div>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|