From 1cd1e504d967a327cf6c5219aaf2cd731b8e44a6 Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Wed, 3 Jun 2026 17:22:38 +0330 Subject: [PATCH] feat(dashboard): "My Renders" page for users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /dashboard/renders: user's own render jobs (live status + progress bar + cancel) and finished exports (thumbnail + size/duration + download); bilingual fa/en - server lib my-renders.ts (user-scoped /v1/renders + /v1/exports via session JWT) - user action routes: POST /api/renders/[id]/cancel, GET /api/exports/[id]/download (presigned URL) - dashboard sidebar: "رندرهای من / My Renders" nav item Co-Authored-By: Claude Opus 4.8 --- messages/en.json | 16 ++- messages/fa.json | 16 ++- src/app/[locale]/dashboard/renders/page.tsx | 20 +++ .../api/exports/[exportId]/download/route.ts | 19 +++ src/app/api/renders/[jobId]/cancel/route.ts | 20 +++ .../dashboard/DashboardSidebarNav.tsx | 2 + src/components/dashboard/MyRenders.tsx | 128 ++++++++++++++++++ src/lib/api/my-renders.ts | 66 +++++++++ 8 files changed, 285 insertions(+), 2 deletions(-) create mode 100644 src/app/[locale]/dashboard/renders/page.tsx create mode 100644 src/app/api/exports/[exportId]/download/route.ts create mode 100644 src/app/api/renders/[jobId]/cancel/route.ts create mode 100644 src/components/dashboard/MyRenders.tsx create mode 100644 src/lib/api/my-renders.ts diff --git a/messages/en.json b/messages/en.json index 4cb4b7e..f962ceb 100644 --- a/messages/en.json +++ b/messages/en.json @@ -663,7 +663,8 @@ "templates": "Templates", "upgrade": "Upgrade", "settings": "Settings", - "navLabel": "Dashboard" + "navLabel": "Dashboard", + "myRenders": "My Renders" }, "componentsDashboardDashboardTopBar": { "searchPlaceholder": "Search projects..." @@ -1226,6 +1227,19 @@ "savedAsBlog": "Saved as blog post", "saveError": "Could not save post", "mustConfigure": "Configure and enable OpenAI above before generating." + }, + "componentsDashboardMyRenders": { + "title": "My Renders", + "subtitle": "Status of your renders and ready-to-download videos.", + "processing": "Processing", + "ready": "Ready to download", + "empty": "No renders yet.", + "emptyReady": "No ready videos.", + "cancel": "Cancel", + "download": "Download", + "confirm": "Cancel this render?", + "failed": "Failed", + "refresh": "Refresh" } } } diff --git a/messages/fa.json b/messages/fa.json index dd7476a..b6b6945 100644 --- a/messages/fa.json +++ b/messages/fa.json @@ -663,7 +663,8 @@ "templates": "قالب‌ها", "upgrade": "ارتقا", "settings": "تنظیمات", - "navLabel": "داشبورد" + "navLabel": "داشبورد", + "myRenders": "رندرهای من" }, "componentsDashboardDashboardTopBar": { "searchPlaceholder": "جستجوی پروژه‌ها..." @@ -1226,6 +1227,19 @@ "savedAsBlog": "به‌عنوان پست بلاگ ذخیره شد", "saveError": "ذخیره پست ناموفق بود", "mustConfigure": "پیش از تولید، OpenAI را در بالا پیکربندی و فعال کنید." + }, + "componentsDashboardMyRenders": { + "title": "رندرهای من", + "subtitle": "وضعیت رندرها و ویدیوهای آمادهٔ شما.", + "processing": "در حال پردازش", + "ready": "آمادهٔ دانلود", + "empty": "هنوز رندری ندارید.", + "emptyReady": "ویدیوی آماده‌ای نیست.", + "cancel": "لغو", + "download": "دانلود", + "confirm": "این رندر لغو شود؟", + "failed": "ناموفق", + "refresh": "بروزرسانی" } } } diff --git a/src/app/[locale]/dashboard/renders/page.tsx b/src/app/[locale]/dashboard/renders/page.tsx new file mode 100644 index 0000000..ffd4ec1 --- /dev/null +++ b/src/app/[locale]/dashboard/renders/page.tsx @@ -0,0 +1,20 @@ +import type { Metadata } from "next"; + +import { MyRenders } from "@/components/dashboard/MyRenders"; +import { ACTIVE_STEPS, listMyExports, listMyRenders } from "@/lib/api/my-renders"; +import { createPageMetadata } from "@/lib/metadata"; + +export const metadata: Metadata = createPageMetadata({ + title: "My Renders", + description: "Your render jobs and finished videos.", + path: "/dashboard/renders", +}); + +export const dynamic = "force-dynamic"; + +export default async function MyRendersPage() { + const [allJobs, exports] = await Promise.all([listMyRenders(), listMyExports()]); + // Show active/failed jobs in the "processing" section; Done ones live as exports. + const jobs = allJobs.filter((j) => ACTIVE_STEPS.has(j.step) || j.step === "Failed"); + return ; +} diff --git a/src/app/api/exports/[exportId]/download/route.ts b/src/app/api/exports/[exportId]/download/route.ts new file mode 100644 index 0000000..d829af1 --- /dev/null +++ b/src/app/api/exports/[exportId]/download/route.ts @@ -0,0 +1,19 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { gatewayUrl } from "@/lib/api/gateway"; +import { getAccessToken } from "@/lib/auth/session"; + +export const runtime = "nodejs"; +interface Ctx { params: { exportId: string } } + +/** Return a short-lived presigned download URL for the caller's own export. */ +export async function GET(_req: NextRequest, { params }: Ctx) { + const token = await getAccessToken(); + if (!token) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + const res = await fetch(gatewayUrl(`/v1/exports/${params.exportId}/download-url`), { + cache: "no-store", + headers: { Authorization: `Bearer ${token}`, Accept: "application/json" }, + }); + const data = await res.json().catch(() => null); + if (!res.ok) return NextResponse.json({ error: data?.message ?? "Download failed" }, { status: res.status }); + return NextResponse.json(data ?? {}); +} diff --git a/src/app/api/renders/[jobId]/cancel/route.ts b/src/app/api/renders/[jobId]/cancel/route.ts new file mode 100644 index 0000000..41696f5 --- /dev/null +++ b/src/app/api/renders/[jobId]/cancel/route.ts @@ -0,0 +1,20 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { gatewayUrl } from "@/lib/api/gateway"; +import { getAccessToken } from "@/lib/auth/session"; + +export const runtime = "nodejs"; +interface Ctx { params: { jobId: string } } + +/** Cancel the caller's own render job (user-scoped). */ +export async function POST(_req: NextRequest, { params }: Ctx) { + const token = await getAccessToken(); + if (!token) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + const res = await fetch(gatewayUrl(`/v1/renders/${params.jobId}/cancel`), { + method: "POST", + cache: "no-store", + headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, + }); + const data = await res.json().catch(() => null); + if (!res.ok) return NextResponse.json({ error: data?.message ?? "Cancel failed" }, { status: res.status }); + return NextResponse.json(data ?? { ok: true }); +} diff --git a/src/components/dashboard/DashboardSidebarNav.tsx b/src/components/dashboard/DashboardSidebarNav.tsx index 08f6c8b..48da88e 100644 --- a/src/components/dashboard/DashboardSidebarNav.tsx +++ b/src/components/dashboard/DashboardSidebarNav.tsx @@ -7,6 +7,7 @@ import { FolderOpen, LayoutTemplate, Settings, + Video, Zap, } from "lucide-react"; @@ -14,6 +15,7 @@ import { cn } from "@/lib/utils"; const navItems = [ { labelKey: "myProjects", href: "/dashboard", icon: FolderOpen }, + { labelKey: "myRenders", href: "/dashboard/renders", icon: Video }, { labelKey: "templates", href: "/templates", icon: LayoutTemplate }, { labelKey: "upgrade", href: "/#pricing", icon: Zap }, { labelKey: "settings", href: "/dashboard/settings", icon: Settings }, diff --git a/src/components/dashboard/MyRenders.tsx b/src/components/dashboard/MyRenders.tsx new file mode 100644 index 0000000..6bdf75b --- /dev/null +++ b/src/components/dashboard/MyRenders.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { Download, Loader2, X } from "lucide-react"; + +import { apiFetch } from "@/lib/api/fetch"; +import type { MyExport, MyRenderJob } from "@/lib/api/my-renders"; + +function humanSize(n?: number | null): string { + if (!n) return "—"; + const u = ["B", "KB", "MB", "GB"]; let i = 0, v = n; + while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; } + return `${v.toFixed(v < 10 && i > 0 ? 1 : 0)} ${u[i]}`; +} + +export function MyRenders({ jobs, exports }: { jobs: MyRenderJob[]; exports: MyExport[] }) { + const t = useTranslations("auto.componentsDashboardMyRenders"); + const router = useRouter(); + const [busy, setBusy] = useState(null); + + const cancel = async (id: string) => { + if (!confirm(t("confirm"))) return; + setBusy(id); + try { await apiFetch(`/api/renders/${id}/cancel`, { method: "POST" }); router.refresh(); } + finally { setBusy(null); } + }; + + const download = async (id: string) => { + setBusy(id); + try { + const r = await apiFetch(`/api/exports/${id}/download`).then((x) => x.json()).catch(() => null); + if (r?.url) window.open(r.url, "_blank"); + } finally { setBusy(null); } + }; + + const card = "rounded-xl border border-gray-100 bg-white shadow-sm"; + + return ( +
+
+
+

{t("title")}

+

{t("subtitle")}

+
+ +
+ + {/* Processing */} +
+

{t("processing")}

+ {jobs.length === 0 ? ( +

{t("empty")}

+ ) : ( +
+ {jobs.map((j) => { + const failed = j.step === "Failed"; + const pct = Math.round(j.render_progress ?? 0); + return ( +
+
+

{j.title || j.project_name || j.name || j.id.slice(0, 8)}

+

{j.quality} · {j.resolution} · {failed ? t("failed") : j.step}

+ {!failed && ( +
+
+
+ )} + {failed && j.failed_message &&

{j.failed_message}

} +
+ {failed ? "" : `${pct}%`} + +
+ ); + })} +
+ )} +
+ + {/* Ready exports */} +
+

{t("ready")}

+ {exports.length === 0 ? ( +

{t("emptyReady")}

+ ) : ( +
+ {exports.map((e) => ( +
+
+ {e.image ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( + {e.file_extension ?? "video"} + )} +
+
+

+ {e.render_quality} · {e.width && e.height ? `${e.width}×${e.height}` : "—"} · {humanSize(e.size_bytes)} + {e.duration_sec ? ` · ${Math.round(e.duration_sec)}s` : ""} +

+ +
+
+ ))} +
+ )} +
+
+ ); +} diff --git a/src/lib/api/my-renders.ts b/src/lib/api/my-renders.ts new file mode 100644 index 0000000..6f32f91 --- /dev/null +++ b/src/lib/api/my-renders.ts @@ -0,0 +1,66 @@ +/** + * Server-side client for the current user's render jobs + finished exports. + * Calls the gateway with the caller's Identity access JWT (fr_access cookie). + */ +import { gatewayUrl } from "@/lib/api/gateway"; +import { getAccessToken } from "@/lib/auth/session"; + +export interface MyRenderJob { + id: string; + step: string; + render_progress?: number | null; + title?: string | null; + name?: string | null; + project_name?: string | null; + quality?: string | null; + resolution?: string | null; + image_preview_b64?: string | null; + export_id?: string | null; + failed_message?: string | null; + created_at: string; +} + +export interface MyExport { + id: string; + image?: string | null; + file_type?: string | null; + file_extension?: string | null; + render_quality?: string | null; + size_bytes?: number | null; + duration_sec?: number | null; + width?: number | null; + height?: number | null; + produce_date?: string | null; + auto_delete_date?: string | null; +} + +async function getJSON(path: string): Promise<{ data?: unknown[] } | null> { + const token = await getAccessToken(); + if (!token) return null; + try { + const res = await fetch(gatewayUrl(path), { + headers: { Authorization: `Bearer ${token}`, Accept: "application/json" }, + cache: "no-store", + }); + if (!res.ok) return null; + return (await res.json()) as { data?: unknown[] }; + } catch { + return null; + } +} + +export async function listMyRenders(): Promise { + const d = await getJSON("/v1/renders?page=1&page_size=50"); + return (d?.data as MyRenderJob[]) ?? []; +} + +export async function listMyExports(): Promise { + const d = await getJSON("/v1/exports?page=1&page_size=50"); + return (d?.data as MyExport[]) ?? []; +} + +/** Steps that mean the job is still running. */ +export const ACTIVE_STEPS = new Set([ + "Queued", "Preparing", "TemplateCache", "JsxGen", "Music", "Rendering", + "Validating", "Repairing", "Optimisation", "Video", "Mixing", "Final", "Uploading", +]);