Files
flatrender/src/components/admin/ProjectPresetStories.tsx
T
soroush.asadi 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
feat(presets): admin preset stories (premade example videos) end-to-end
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>
2026-06-11 05:24:14 +03:30

198 lines
12 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}