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
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:
+2
-1
@@ -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
@@ -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).
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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: [] });
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
<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>
|
||||
<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">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 } : {}),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user