Files
flatrender/src/components/dashboard/MyRenders.tsx
T
soroush.asadi 1cd1e504d9 feat(dashboard): "My Renders" page for users
- /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 <noreply@anthropic.com>
2026-06-03 17:22:38 +03:30

129 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { 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<string | null>(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 (
<div className="mx-auto w-full max-w-5xl space-y-8">
<div className="flex items-center justify-between">
<div>
<h1 className="font-heading text-2xl font-bold text-neutral-900">{t("title")}</h1>
<p className="mt-1 text-sm text-neutral-600">{t("subtitle")}</p>
</div>
<button onClick={() => router.refresh()} className="rounded-lg border border-gray-200 px-3 py-1.5 text-sm text-neutral-600 hover:bg-neutral-50">
{t("refresh")}
</button>
</div>
{/* Processing */}
<section>
<h2 className="mb-3 text-sm font-semibold text-neutral-700">{t("processing")}</h2>
{jobs.length === 0 ? (
<p className={`${card} px-4 py-8 text-center text-sm text-neutral-400`}>{t("empty")}</p>
) : (
<div className="space-y-3">
{jobs.map((j) => {
const failed = j.step === "Failed";
const pct = Math.round(j.render_progress ?? 0);
return (
<div key={j.id} className={`${card} flex items-center gap-4 p-4`}>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-neutral-900">{j.title || j.project_name || j.name || j.id.slice(0, 8)}</p>
<p className="mt-0.5 text-xs text-neutral-500">{j.quality} · {j.resolution} · {failed ? t("failed") : j.step}</p>
{!failed && (
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-neutral-100">
<div className="h-full rounded-full bg-primary-600 transition-all" style={{ width: `${pct}%` }} />
</div>
)}
{failed && j.failed_message && <p className="mt-1 text-xs text-red-600">{j.failed_message}</p>}
</div>
<span className="text-xs tabular-nums text-neutral-500">{failed ? "" : `${pct}%`}</span>
<button
onClick={() => cancel(j.id)}
disabled={busy === j.id}
className="flex items-center gap-1 rounded-lg border border-red-200 px-3 py-1.5 text-xs text-red-600 hover:bg-red-50 disabled:opacity-50"
>
{busy === j.id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <X className="h-3.5 w-3.5" />}
{t("cancel")}
</button>
</div>
);
})}
</div>
)}
</section>
{/* Ready exports */}
<section>
<h2 className="mb-3 text-sm font-semibold text-neutral-700">{t("ready")}</h2>
{exports.length === 0 ? (
<p className={`${card} px-4 py-8 text-center text-sm text-neutral-400`}>{t("emptyReady")}</p>
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{exports.map((e) => (
<div key={e.id} className={`${card} overflow-hidden`}>
<div className="flex aspect-video items-center justify-center bg-neutral-100">
{e.image ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={e.image} alt="" className="h-full w-full object-cover" />
) : (
<span className="text-xs uppercase text-neutral-400">{e.file_extension ?? "video"}</span>
)}
</div>
<div className="p-3">
<p className="text-xs text-neutral-500">
{e.render_quality} · {e.width && e.height ? `${e.width}×${e.height}` : "—"} · {humanSize(e.size_bytes)}
{e.duration_sec ? ` · ${Math.round(e.duration_sec)}s` : ""}
</p>
<button
onClick={() => download(e.id)}
disabled={busy === e.id}
className="mt-2 flex w-full items-center justify-center gap-1.5 rounded-lg bg-primary-600 px-3 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50"
>
{busy === e.id ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />}
{t("download")}
</button>
</div>
</div>
))}
</div>
)}
</section>
</div>
);
}