diff --git a/messages/en.json b/messages/en.json index 360ae8d..75cfe09 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/fa.json b/messages/fa.json index fad6776..4584778 100644 --- a/messages/fa.json +++ b/messages/fa.json @@ -617,7 +617,8 @@ "mostPopular": "محبوب‌ترین" }, "componentsTemplatesTemplateDetailExamples": { - "heading": "ویدیوهای ساخته‌شده با این قالب" + "heading": "ویدیوهای ساخته‌شده با این قالب", + "usePreset": "استفاده از این نمونه" }, "componentsTemplatesTemplateDetailInfo": { "sceneCount": "{count} صحنه", diff --git a/services/content/FlatRender.ContentSvc/Application/Services/PresetStoryService.cs b/services/content/FlatRender.ContentSvc/Application/Services/PresetStoryService.cs new file mode 100644 index 0000000..87e7abc --- /dev/null +++ b/services/content/FlatRender.ContentSvc/Application/Services/PresetStoryService.cs @@ -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; + +/// +/// CRUD for admin-authored preset stories (premade example videos) per template. +/// Public callers see published stories only; admins see drafts too. +/// +public class PresetStoryService(ContentDbContext db) +{ + public async Task> 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 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 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 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 MapScenes(List? scenes) => + (scenes ?? []).Select(s => new PresetScene + { + SceneId = s.SceneId, + Sort = s.Sort, + DefaultDurationSec = s.DefaultDurationSec, + }).ToList(); + + private async Task 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()); +} diff --git a/services/content/FlatRender.ContentSvc/Controllers/PresetStoriesController.cs b/services/content/FlatRender.ContentSvc/Controllers/PresetStoriesController.cs new file mode 100644 index 0000000..f4a2c1c --- /dev/null +++ b/services/content/FlatRender.ContentSvc/Controllers/PresetStoriesController.cs @@ -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 List([FromQuery(Name = "project_id")] Guid projectId) => + Ok(await svc.GetByProjectAsync(projectId, publishedOnly: !IsAdmin)); + + [HttpGet("{id:guid}")] + public async Task Get(Guid id) + { + var s = await svc.GetAsync(id, publishedOnly: !IsAdmin); + return s == null ? NotFound() : Ok(s); + } + + [Authorize(Roles = "Admin")] + [HttpPost] + public async Task Create([FromBody] SavePresetStoryRequest req) => + Ok(await svc.CreateAsync(req)); + + [Authorize(Roles = "Admin")] + [HttpPut("{id:guid}")] + public async Task Update(Guid id, [FromBody] SavePresetStoryRequest req) => + Ok(await svc.UpdateAsync(id, req)); + + [Authorize(Roles = "Admin")] + [HttpDelete("{id:guid}")] + public async Task Delete(Guid id) + { + await svc.DeleteAsync(id); + return NoContent(); + } +} diff --git a/services/content/FlatRender.ContentSvc/Models/PresetStory.cs b/services/content/FlatRender.ContentSvc/Models/PresetStory.cs new file mode 100644 index 0000000..ac654c0 --- /dev/null +++ b/services/content/FlatRender.ContentSvc/Models/PresetStory.cs @@ -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 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? Scenes = null +); diff --git a/services/content/FlatRender.ContentSvc/Program.cs b/services/content/FlatRender.ContentSvc/Program.cs index 2711a38..ae51748 100644 --- a/services/content/FlatRender.ContentSvc/Program.cs +++ b/services/content/FlatRender.ContentSvc/Program.cs @@ -65,6 +65,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); // HTTP client for the OpenAI-compatible AI provider (base URL is per-tenant config). diff --git a/services/gateway/cmd/server/main.go b/services/gateway/cmd/server/main.go index 2ba3114..59aa64b 100644 --- a/services/gateway/cmd/server/main.go +++ b/services/gateway/cmd/server/main.go @@ -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()) diff --git a/src/app/api/preset-stories/route.ts b/src/app/api/preset-stories/route.ts new file mode 100644 index 0000000..fbbf543 --- /dev/null +++ b/src/app/api/preset-stories/route.ts @@ -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: [] }); + } +} diff --git a/src/app/api/projects/route.ts b/src/app/api/projects/route.ts index 9a0b1da..3093145 100644 --- a/src/app/api/projects/route.ts +++ b/src/app/api/projects/route.ts @@ -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, }); diff --git a/src/components/admin/ProjectPresetStories.tsx b/src/components/admin/ProjectPresetStories.tsx new file mode 100644 index 0000000..79fa62f --- /dev/null +++ b/src/components/admin/ProjectPresetStories.tsx @@ -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([]); + const [scenes, setScenes] = useState([]); + const [loading, setLoading] = useState(true); + const [draft, setDraft] = useState(null); + const [editId, setEditId] = useState(null); + const [saving, setSaving] = useState(false); + const [err, setErr] = useState(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) => 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) => { + 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 ( +
+
+

{editId ? "ویرایش ویدیوی نمونه" : "ویدیوی نمونهٔ جدید"}

+ +
+ {err &&

{err}

} + +
+
set({ name: e.target.value })} />
+
set({ sort: Number(e.target.value) || 0 })} />
+
set({ description: e.target.value })} />
+
set({ demo: u })} accept="video/*,image/*" />
+
+ +
+ +
+ + {/* Scene list */} +
+
+ صحنه‌های این ویدیو (ترتیب + مدت) + +
+ {scenes.length === 0 &&

این پروژه هنوز صحنه‌ای ندارد. ابتدا از «صحنه‌ها» صحنه تعریف کنید.

} + {draft.scenes.length === 0 ? ( +

هنوز صحنه‌ای انتخاب نشده.

+ ) : draft.scenes.map((ps, i) => ( +
+ #{i} + + setSceneAt(i, { default_duration_sec: toNum(e.target.value) })} /> + +
+ ))} +
+ +
+ مقادیر آماده (JSON پیشرفته) — اختیاری +