feat(presets): admin preset stories (premade example videos) end-to-end
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>
This commit is contained in:
soroush.asadi
2026-06-11 05:24:14 +03:30
parent 23624f7db9
commit ab568c0663
14 changed files with 550 additions and 23 deletions
+2 -1
View File
@@ -617,7 +617,8 @@
"mostPopular": "Most Popular"
},
"componentsTemplatesTemplateDetailExamples": {
"heading": "Videos created using this template"
"heading": "Videos created using this template",
"usePreset": "Use this example"
},
"componentsTemplatesTemplateDetailInfo": {
"sceneCount": "{count} scenes",
+2 -1
View File
@@ -617,7 +617,8 @@
"mostPopular": "محبوب‌ترین"
},
"componentsTemplatesTemplateDetailExamples": {
"heading": "ویدیوهای ساخته‌شده با این قالب"
"heading": "ویدیوهای ساخته‌شده با این قالب",
"usePreset": "استفاده از این نمونه"
},
"componentsTemplatesTemplateDetailInfo": {
"sceneCount": "{count} صحنه",
@@ -0,0 +1,99 @@
using FlatRender.ContentSvc.Domain.Entities;
using FlatRender.ContentSvc.Infrastructure.Data;
using FlatRender.ContentSvc.Models;
using Microsoft.EntityFrameworkCore;
namespace FlatRender.ContentSvc.Application.Services;
/// <summary>
/// CRUD for admin-authored preset stories (premade example videos) per template.
/// Public callers see published stories only; admins see drafts too.
/// </summary>
public class PresetStoryService(ContentDbContext db)
{
public async Task<List<PresetStorySummary>> GetByProjectAsync(Guid projectId, bool publishedOnly) =>
await db.PresetStories
.Where(s => s.ProjectId == projectId && (!publishedOnly || s.IsPublished))
.OrderBy(s => s.Sort).ThenByDescending(s => s.CreatedAt)
.Select(s => new PresetStorySummary(
s.Id, s.ProjectId, s.Name, s.Description, s.Demo, s.MusicId,
s.Sort, s.IsPublished, s.Scenes.Count, s.CreatedAt, s.UpdatedAt))
.ToListAsync();
public async Task<PresetStoryResponse?> GetAsync(Guid id, bool publishedOnly)
{
var s = await db.PresetStories
.Include(x => x.Scenes).ThenInclude(ps => ps.Scene)
.FirstOrDefaultAsync(x => x.Id == id);
if (s == null || (publishedOnly && !s.IsPublished)) return null;
return ToResponse(s);
}
public async Task<PresetStoryResponse> CreateAsync(SavePresetStoryRequest req)
{
var story = new PresetStory
{
ProjectId = req.ProjectId,
Name = req.Name,
Description = req.Description,
Demo = req.Demo,
MusicId = req.MusicId,
ScenesSpa = req.ScenesSpa,
Sort = req.Sort,
IsPublished = req.IsPublished,
Scenes = MapScenes(req.Scenes),
};
db.PresetStories.Add(story);
await db.SaveChangesAsync();
return await ReloadAsync(story.Id);
}
public async Task<PresetStoryResponse> UpdateAsync(Guid id, SavePresetStoryRequest req)
{
var story = await db.PresetStories.Include(s => s.Scenes).FirstOrDefaultAsync(s => s.Id == id)
?? throw new KeyNotFoundException($"Preset story {id} not found");
story.Name = req.Name;
story.Description = req.Description;
story.Demo = req.Demo;
story.MusicId = req.MusicId;
if (req.ScenesSpa != null) story.ScenesSpa = req.ScenesSpa;
story.Sort = req.Sort;
story.IsPublished = req.IsPublished;
story.UpdatedAt = DateTime.UtcNow;
// Replace the scene set wholesale (small, ordered list).
if (req.Scenes != null)
{
db.PresetScenes.RemoveRange(story.Scenes);
story.Scenes = MapScenes(req.Scenes);
}
await db.SaveChangesAsync();
return await ReloadAsync(story.Id);
}
public async Task DeleteAsync(Guid id)
{
var story = await db.PresetStories.FirstOrDefaultAsync(s => s.Id == id)
?? throw new KeyNotFoundException($"Preset story {id} not found");
story.DeletedAt = DateTime.UtcNow; // soft delete (global query filter hides it)
await db.SaveChangesAsync();
}
// ── helpers ───────────────────────────────────────────────────────────────
private static List<PresetScene> MapScenes(List<PresetSceneInput>? scenes) =>
(scenes ?? []).Select(s => new PresetScene
{
SceneId = s.SceneId,
Sort = s.Sort,
DefaultDurationSec = s.DefaultDurationSec,
}).ToList();
private async Task<PresetStoryResponse> ReloadAsync(Guid id) =>
ToResponse(await db.PresetStories.Include(x => x.Scenes).ThenInclude(ps => ps.Scene)
.FirstAsync(x => x.Id == id));
private static PresetStoryResponse ToResponse(PresetStory s) => new(
s.Id, s.ProjectId, s.Name, s.Description, s.Demo, s.MusicId, s.ScenesSpa,
s.Sort, s.IsPublished, s.CreatedAt, s.UpdatedAt,
s.Scenes.OrderBy(ps => ps.Sort).Select(ps => new PresetSceneResponse(
ps.Id, ps.SceneId, ps.Scene?.Key, ps.Scene?.Title, ps.Sort, ps.DefaultDurationSec)).ToList());
}
@@ -0,0 +1,43 @@
using FlatRender.ContentSvc.Application.Services;
using FlatRender.ContentSvc.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace FlatRender.ContentSvc.Controllers;
[ApiController]
[Route("v1/preset-stories")]
public class PresetStoriesController(PresetStoryService svc) : ControllerBase
{
// Anonymous + non-admin callers only see published stories; admins see drafts too.
private bool IsAdmin => User.IsInRole("Admin");
[HttpGet]
public async Task<IActionResult> List([FromQuery(Name = "project_id")] Guid projectId) =>
Ok(await svc.GetByProjectAsync(projectId, publishedOnly: !IsAdmin));
[HttpGet("{id:guid}")]
public async Task<IActionResult> Get(Guid id)
{
var s = await svc.GetAsync(id, publishedOnly: !IsAdmin);
return s == null ? NotFound() : Ok(s);
}
[Authorize(Roles = "Admin")]
[HttpPost]
public async Task<IActionResult> Create([FromBody] SavePresetStoryRequest req) =>
Ok(await svc.CreateAsync(req));
[Authorize(Roles = "Admin")]
[HttpPut("{id:guid}")]
public async Task<IActionResult> Update(Guid id, [FromBody] SavePresetStoryRequest req) =>
Ok(await svc.UpdateAsync(id, req));
[Authorize(Roles = "Admin")]
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(Guid id)
{
await svc.DeleteAsync(id);
return NoContent();
}
}
@@ -0,0 +1,32 @@
namespace FlatRender.ContentSvc.Models;
// ── Preset stories (admin-authored premade example videos per template) ───────
// A preset story is a ready-made, pre-filled version of a template that a user can
// pick to start a project already populated with text + media. The filled values
// live in ScenesSpa (the studio scene JSON); PresetScene rows order the scenes.
public record PresetSceneInput(Guid SceneId, int Sort, decimal? DefaultDurationSec);
public record PresetSceneResponse(
Guid Id, Guid SceneId, string? SceneKey, string? SceneTitle, int Sort, decimal? DefaultDurationSec
);
// Light projection for list endpoints (omits the heavy ScenesSpa blob).
public record PresetStorySummary(
Guid Id, Guid ProjectId, string Name, string? Description, string? Demo,
Guid? MusicId, int Sort, bool IsPublished, int SceneCount,
DateTime CreatedAt, DateTime UpdatedAt
);
// Full story incl. scenes + the filled-values blob (GET one).
public record PresetStoryResponse(
Guid Id, Guid ProjectId, string Name, string? Description, string? Demo,
Guid? MusicId, string? ScenesSpa, int Sort, bool IsPublished,
DateTime CreatedAt, DateTime UpdatedAt, List<PresetSceneResponse> Scenes
);
public record SavePresetStoryRequest(
Guid ProjectId, string Name, string? Description = null, string? Demo = null,
Guid? MusicId = null, string? ScenesSpa = null, int Sort = 0, bool IsPublished = true,
List<PresetSceneInput>? Scenes = null
);
@@ -65,6 +65,7 @@ builder.Services.AddScoped<TemplateService>();
builder.Services.AddScoped<CmsService>();
builder.Services.AddScoped<AiContentService>();
builder.Services.AddScoped<SceneColorService>();
builder.Services.AddScoped<PresetStoryService>();
builder.Services.AddScoped<AepImportService>();
// HTTP client for the OpenAI-compatible AI provider (base URL is per-tenant config).
+1
View File
@@ -121,6 +121,7 @@ func main() {
v1.Any("/scene-elements/*path", apiRL, optionalAuth, content.Handler())
v1.Any("/shared-colors/*path", apiRL, optionalAuth, content.Handler())
v1.Any("/color-presets/*path", apiRL, optionalAuth, content.Handler())
v1.Any("/preset-stories/*path", apiRL, optionalAuth, content.Handler())
// ── File Service ─────────────────────────────────────────────────────────
v1.Any("/files/*path", apiRL, auth, file.Handler())
+28
View File
@@ -0,0 +1,28 @@
import { NextResponse } from "next/server";
import { gatewayFetch } from "@/lib/api/gateway";
export const dynamic = "force-dynamic";
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/**
* Public list of PUBLISHED preset stories (premade example videos) for a content
* project. No auth → the gateway/content-svc returns published stories only, which
* is exactly what the template-detail "example videos" section needs.
*/
export async function GET(request: Request) {
const projectId = new URL(request.url).searchParams.get("project_id") ?? "";
if (!UUID_RE.test(projectId)) {
return NextResponse.json({ stories: [] });
}
try {
const res = await gatewayFetch(`/v1/preset-stories/?project_id=${projectId}`);
if (!res.ok) return NextResponse.json({ stories: [] });
const data = await res.json().catch(() => null);
const stories = Array.isArray(data) ? data : (data?.data ?? []);
return NextResponse.json({ stories });
} catch {
return NextResponse.json({ stories: [] });
}
}
+3
View File
@@ -43,6 +43,8 @@ const createProjectSchema = z.object({
// The original/template project id may be passed explicitly or carried inside
// scene_data.templateId by the legacy create helpers.
original_project_id: z.string().uuid().optional(),
// Start the project pre-bound to an admin-authored preset story (premade video).
preset_story_id: z.string().uuid().optional(),
});
export async function GET() {
@@ -109,6 +111,7 @@ export async function POST(request: Request) {
const result = await createSavedProject({
original_project_id: contentProjectId,
name: parsed.data.name,
preset_story_id: parsed.data.preset_story_id,
copy_default_values: true,
});
@@ -0,0 +1,197 @@
"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>
);
}
+17
View File
@@ -7,6 +7,7 @@ import { FileUploadField } from "@/components/admin/FileUploadField";
import { ProjectAssets } from "@/components/admin/ProjectAssets";
import { ProjectMediaBundle } from "@/components/admin/ProjectMediaBundle";
import { ProjectScenes } from "@/components/admin/ProjectScenes";
import { ProjectPresetStories } from "@/components/admin/ProjectPresetStories";
interface Proj {
id: string; container_id: string; container_name: string; container_slug: string;
@@ -38,6 +39,7 @@ export function ProjectsAdmin() {
const [hasMore, setHasMore] = useState(false);
const [openAssets, setOpenAssets] = useState<Proj | null>(null);
const [openScenes, setOpenScenes] = useState<Proj | null>(null);
const [openStories, setOpenStories] = useState<Proj | null>(null);
const [aepMsg, setAepMsg] = useState<string | null>(null);
const [dupOf, setDupOf] = useState<Proj | null>(null);
const [dupForm, setDupForm] = useState({ aspect: "1:1", width: 1080, height: 1080, resolution: "FullHD", name: "" });
@@ -236,6 +238,7 @@ export function ProjectsAdmin() {
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-2">
<button className={ghost} onClick={() => setOpenScenes(p)}>صحنهها</button>
<button className={ghost} onClick={() => setOpenStories(p)}>ویدیوهای نمونه</button>
<button className={ghost} onClick={() => { setAepMsg(null); setOpenAssets(p); }}>فایلها</button>
<button className={ghost} onClick={() => openDuplicate(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>
@@ -321,6 +324,20 @@ export function ProjectsAdmin() {
</div>
</div>
)}
{openStories && (
<div className="fixed inset-0 z-50 flex items-stretch justify-center bg-black/70 p-2 sm:p-6" dir="rtl" onClick={() => setOpenStories(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">ویدیوهای نمونه {openStories.name} <span className="text-gray-500">({openStories.container_name})</span></h2>
<button className="rounded-lg px-2 py-1 text-gray-400 hover:bg-[#161a2e] hover:text-white" onClick={() => setOpenStories(null)}></button>
</div>
<div className="flex-1 overflow-y-auto p-5">
<ProjectPresetStories projectId={openStories.id} />
</div>
</div>
</div>
)}
</div>
);
}
@@ -23,6 +23,10 @@ export function TemplateDetailContent({ template }: TemplateDetailContentProps)
aspects[0] ?? "16:9"
);
// The content project (variant) UUID for the selected aspect — keys preset stories.
const variantProjectId =
(template.variants?.find((v) => v.aspect === selectedAspect) ?? template.variants?.[0])?.projectId;
return (
<div className="mx-auto max-w-7xl px-4 py-8 lg:px-8 lg:py-12">
<TemplateDetailBreadcrumb templateName={template.name} />
@@ -36,7 +40,7 @@ export function TemplateDetailContent({ template }: TemplateDetailContentProps)
<TemplateDetailInfo template={template} selectedAspect={selectedAspect} />
</div>
<TemplateDetailExamples templateId={template.id} />
<TemplateDetailExamples templateId={template.id} projectId={variantProjectId} />
</div>
);
}
@@ -1,36 +1,133 @@
import { useTranslations } from "next-intl";
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { Loader2, Play } from "lucide-react";
import { toast } from "@/components/ui/use-toast";
import {
createProjectFromTemplate,
studioPathForProject,
} from "@/lib/create-project-from-template";
import { getVideoTemplateExampleImageSrc } from "@/lib/video-templates-catalog";
interface PresetStory {
id: string;
name: string;
description?: string | null;
demo?: string | null;
scene_count?: number;
}
interface TemplateDetailExamplesProps {
templateId: string;
/** Content project (variant) UUID for the selected aspect — keys the preset stories. */
projectId?: string;
}
const EXAMPLE_COUNT = 5;
const isVideo = (url: string) => /\.(mp4|webm|mov|m4v)(\?|$)/i.test(url);
export function TemplateDetailExamples({ templateId }: TemplateDetailExamplesProps) {
export function TemplateDetailExamples({ templateId, projectId }: TemplateDetailExamplesProps) {
const t = useTranslations("auto.componentsTemplatesTemplateDetailExamples");
const router = useRouter();
const [stories, setStories] = useState<PresetStory[]>([]);
const [loaded, setLoaded] = useState(false);
const [busyId, setBusyId] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
if (!projectId) { setStories([]); setLoaded(true); return; }
setLoaded(false);
fetch(`/api/preset-stories?project_id=${projectId}`, { cache: "no-store" })
.then((r) => r.json())
.then((d) => { if (!cancelled) setStories(Array.isArray(d?.stories) ? d.stories : []); })
.catch(() => { if (!cancelled) setStories([]); })
.finally(() => { if (!cancelled) setLoaded(true); });
return () => { cancelled = true; };
}, [projectId]);
const startFromPreset = async (story: PresetStory) => {
setBusyId(story.id);
const result = await createProjectFromTemplate({
id: projectId ?? templateId,
name: story.name,
category: "Video",
presetStoryId: story.id,
});
if (!result.ok) {
setBusyId(null);
if (result.status === 401) {
router.push(`/auth?next=${encodeURIComponent(`/templates/${templateId}`)}`);
return;
}
toast({ title: result.error });
return;
}
router.push(studioPathForProject(result.project.id, result.project.type));
};
// While loading, or when an admin has published real example videos, show them.
const hasStories = loaded && stories.length > 0;
return (
<section className="mt-12">
<h2 className="mb-5 font-heading text-xl font-bold text-gray-900">
{t("heading")}
</h2>
<h2 className="mb-5 font-heading text-xl font-bold text-gray-900">{t("heading")}</h2>
{hasStories ? (
<div className="flex gap-4 overflow-x-auto pb-2 scroll-smooth [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{Array.from({ length: EXAMPLE_COUNT }).map((_, index) => (
<div
key={index}
className="aspect-[16/10] w-[260px] shrink-0 overflow-hidden rounded-xl bg-gray-100"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={getVideoTemplateExampleImageSrc(templateId, index)}
alt=""
{stories.map((story) => (
<div key={story.id} className="w-[260px] shrink-0">
<div className="group relative aspect-[16/10] w-full overflow-hidden rounded-xl bg-gray-100">
{story.demo ? (
isVideo(story.demo) ? (
<video
src={story.demo}
className="h-full w-full object-cover"
muted loop playsInline
onMouseEnter={(e) => void e.currentTarget.play().catch(() => {})}
onMouseLeave={(e) => { e.currentTarget.pause(); e.currentTarget.currentTime = 0; }}
/>
) : (
// eslint-disable-next-line @next/next/no-img-element
<img src={story.demo} alt={story.name} className="h-full w-full object-cover" />
)
) : (
<div className="grid h-full w-full place-items-center bg-gradient-to-br from-blue-100 to-indigo-100 text-blue-300">
<Play className="h-8 w-8" />
</div>
)}
<button
type="button"
onClick={() => startFromPreset(story)}
disabled={busyId !== null}
className="absolute inset-0 grid place-items-center bg-black/0 opacity-0 transition group-hover:bg-black/40 group-hover:opacity-100 disabled:cursor-wait"
>
<span className="inline-flex items-center gap-2 rounded-full bg-white px-4 py-2 text-sm font-semibold text-gray-900 shadow">
{busyId === story.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />}
{t("usePreset")}
</span>
</button>
</div>
<p className="mt-2 truncate text-sm font-medium text-gray-800">{story.name}</p>
{story.description && (
<p className="truncate text-xs text-gray-500">{story.description}</p>
)}
</div>
))}
</div>
) : (
// Fallback: placeholder thumbnails when no preset stories are published yet.
<div className="flex gap-4 overflow-x-auto pb-2 scroll-smooth [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{Array.from({ length: EXAMPLE_COUNT }).map((_, index) => (
<div key={index} className="aspect-[16/10] w-[260px] shrink-0 overflow-hidden rounded-xl bg-gray-100">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={getVideoTemplateExampleImageSrc(templateId, index)} alt="" className="h-full w-full object-cover" />
</div>
))}
</div>
)}
</section>
);
}
+3
View File
@@ -26,6 +26,8 @@ export async function createProjectFromTemplate(input: {
id: string;
name: string;
category: TemplateCatalogCategory;
/** Start pre-bound to an admin-authored preset story (premade example video). */
presetStoryId?: string;
}): Promise<CreateProjectFromTemplateResult> {
const type = catalogCategoryToProjectType(input.category);
const scene_data = {
@@ -40,6 +42,7 @@ export async function createProjectFromTemplate(input: {
name: input.name,
type,
scene_data,
...(input.presetStoryId ? { preset_story_id: input.presetStoryId } : {}),
}),
});