5e29c3b88d
Deploy / deploy (push) Successful in 3m16s
A search box above the exports table matches any part of the shot code or client code, case-insensitively, so "UNG_114" lists a whole episode. It is applied server-side (the table is paged there), debounced, and scopes the summary counts too; combines with the project and status filters. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
544 lines
23 KiB
TypeScript
544 lines
23 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState } from "react";
|
||
import Link from "next/link";
|
||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import { useSession } from "next-auth/react";
|
||
import { RefreshCw, RotateCcw, XCircle, CheckCircle2, Server, Package, ChevronLeft, ChevronRight, Search, X } from "lucide-react";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Progress } from "@/components/ui/progress";
|
||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/components/ui/select";
|
||
import { useToast } from "@/components/ui/use-toast";
|
||
import { cn } from "@/lib/utils";
|
||
import {
|
||
EXPORT_STATUS_STYLES,
|
||
FAILED_STATUSES,
|
||
ACTIVE_STATUSES,
|
||
statusLabel,
|
||
formatEta,
|
||
} from "./status-colors";
|
||
|
||
interface QueueExport {
|
||
id: string;
|
||
shotCode: string;
|
||
episode: string | null;
|
||
projectName: string;
|
||
projectCode: string;
|
||
versionString: string;
|
||
status: string;
|
||
statusChangedAt: string;
|
||
createdAt: string;
|
||
outputDir: string;
|
||
job: {
|
||
id: string;
|
||
attempt: number;
|
||
status: string;
|
||
progress: number;
|
||
currentFrame: number | null;
|
||
totalFrames: number | null;
|
||
etaSeconds: number | null;
|
||
priority: number;
|
||
machineName: string | null;
|
||
errorMessage: string | null;
|
||
} | null;
|
||
}
|
||
|
||
interface Props {
|
||
projects: { id: string; name: string; code: string }[];
|
||
/** Status to open filtered to, from `?status=` (see page.tsx). */
|
||
initialStatus?: string;
|
||
}
|
||
|
||
const PAGE_SIZE = 25;
|
||
|
||
const BLENDER_ACTIVE = ["QUEUED", "CLAIMED", "RUNNING"];
|
||
|
||
export function PipelineQueueClient({ projects, initialStatus = "all" }: Props) {
|
||
const { data: session } = useSession();
|
||
const { toast } = useToast();
|
||
const queryClient = useQueryClient();
|
||
const [projectId, setProjectId] = useState<string>("all");
|
||
const [statusFilter, setStatusFilter] = useState<string>(initialStatus);
|
||
const [page, setPage] = useState(1);
|
||
const [searchInput, setSearchInput] = useState("");
|
||
const [search, setSearch] = useState("");
|
||
const isAdmin = ["ADMIN", "PRODUCER", "SUPERVISOR"].includes(session?.user?.role ?? "");
|
||
|
||
// Searched on the server (the table is paged there), so typing is debounced
|
||
// rather than firing a request per keystroke.
|
||
useEffect(() => {
|
||
const next = searchInput.trim();
|
||
if (next === search) return;
|
||
const t = setTimeout(() => {
|
||
setSearch(next);
|
||
setPage(1);
|
||
}, 300);
|
||
return () => clearTimeout(t);
|
||
}, [searchInput, search]);
|
||
|
||
const { data, isLoading, refetch, isFetching } = useQuery({
|
||
queryKey: ["pipeline-queue", projectId, statusFilter, search, page],
|
||
queryFn: async () => {
|
||
const params = new URLSearchParams({ limit: String(PAGE_SIZE), page: String(page) });
|
||
if (projectId !== "all") params.set("projectId", projectId);
|
||
if (statusFilter !== "all") params.set("status", statusFilter);
|
||
if (search) params.set("q", search);
|
||
const res = await fetch(`/api/render/queue?${params}`);
|
||
if (!res.ok) throw new Error("Failed to load queue");
|
||
return res.json() as Promise<{
|
||
exports: QueueExport[];
|
||
pagination: { page: number; total: number; pages: number };
|
||
statusCounts: Record<string, number>;
|
||
}>;
|
||
},
|
||
refetchInterval: 5000,
|
||
// Keep the current page on screen while the next one loads, instead of
|
||
// flashing "Loading…" on every page turn.
|
||
placeholderData: (previous) => previous,
|
||
});
|
||
|
||
// Fetched here rather than in the tab so the tab label can say how many are
|
||
// running: a Blender render must not become invisible by sitting in a tab.
|
||
const { data: blenderData } = useQuery({
|
||
queryKey: ["blender-jobs", projectId],
|
||
queryFn: async () => {
|
||
const qs = projectId !== "all" ? `?projectId=${encodeURIComponent(projectId)}` : "";
|
||
const res = await fetch(`/api/render/blender${qs}`);
|
||
if (!res.ok) throw new Error("Failed to load Blender jobs");
|
||
return res.json() as Promise<{ jobs: BlenderJobRow[] }>;
|
||
},
|
||
refetchInterval: 5000,
|
||
});
|
||
|
||
const exports = data?.exports ?? [];
|
||
const pagination = data?.pagination;
|
||
const statusCounts = data?.statusCounts ?? {};
|
||
const countOf = (statuses: string[]) => statuses.reduce((n, s) => n + (statusCounts[s] ?? 0), 0);
|
||
const totalExports = Object.values(statusCounts).reduce((n, c) => n + c, 0);
|
||
const blenderJobs = blenderData?.jobs ?? [];
|
||
const blenderActive = blenderJobs.filter((j) => BLENDER_ACTIVE.includes(j.status)).length;
|
||
const blenderFailed = blenderJobs.filter((j) => ["FAILED", "EXPIRED"].includes(j.status)).length;
|
||
|
||
async function action(exportId: string, verb: "retry" | "cancel" | "mark-done") {
|
||
const res = await fetch(`/api/render/exports/${exportId}/${verb}`, { method: "POST" });
|
||
const body = await res.json().catch(() => ({}));
|
||
if (!res.ok) {
|
||
toast({ title: `${verb} failed`, description: body.error ?? res.statusText, variant: "destructive" });
|
||
} else {
|
||
toast({ title: `Export ${verb === "mark-done" ? "marked done" : verb === "retry" ? "requeued" : "cancelled"}` });
|
||
}
|
||
queryClient.invalidateQueries({ queryKey: ["pipeline-queue"] });
|
||
}
|
||
|
||
const cards: { label: string; value: number; status?: string; className?: string }[] = [
|
||
{ label: "Queued", value: countOf(["QUEUED"]), status: "QUEUED" },
|
||
{ label: "Rendering", value: countOf(["RENDERING"]), status: "RENDERING", className: "text-blue-400" },
|
||
// Several statuses count as failed and the filter takes one, so this card
|
||
// is a figure only.
|
||
{ label: "Failed", value: countOf(FAILED_STATUSES), className: "text-red-400" },
|
||
{ label: "Ready for QC", value: countOf(["READY_FOR_QC"]), status: "READY_FOR_QC", className: "text-amber-400" },
|
||
{
|
||
label: "Ready for Delivery",
|
||
value: countOf(["READY_FOR_DELIVERY"]),
|
||
status: "READY_FOR_DELIVERY",
|
||
className: "text-green-400",
|
||
},
|
||
];
|
||
|
||
const changeProject = (id: string) => {
|
||
setProjectId(id);
|
||
setPage(1);
|
||
};
|
||
const changeStatus = (status: string) => {
|
||
setStatusFilter(status);
|
||
setPage(1);
|
||
};
|
||
|
||
const firstRow = pagination && pagination.total > 0 ? (pagination.page - 1) * PAGE_SIZE + 1 : 0;
|
||
const lastRow = pagination ? Math.min(pagination.page * PAGE_SIZE, pagination.total) : 0;
|
||
|
||
return (
|
||
<div className="p-4 md:p-6 space-y-6">
|
||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||
<div>
|
||
<h1 className="text-2xl font-semibold text-white">Render Queue</h1>
|
||
<p className="text-sm text-zinc-400 mt-1">
|
||
After Effects exports and Blender renders — live status, retries and history
|
||
</p>
|
||
</div>
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<Select value={projectId} onValueChange={changeProject}>
|
||
<SelectTrigger className="w-56">
|
||
<SelectValue placeholder="All projects" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="all">All projects</SelectItem>
|
||
{projects.map((p) => (
|
||
<SelectItem key={p.id} value={p.id}>
|
||
{p.name}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<Link href="/pipeline/deliveries">
|
||
<Button variant="outline" size="sm">
|
||
<Package className="h-4 w-4 mr-2" />
|
||
Deliveries
|
||
</Button>
|
||
</Link>
|
||
<Link href="/pipeline/machines">
|
||
<Button variant="outline" size="sm">
|
||
<Server className="h-4 w-4 mr-2" />
|
||
Machines
|
||
</Button>
|
||
</Link>
|
||
<Button variant="outline" size="sm" onClick={() => refetch()} disabled={isFetching}>
|
||
<RefreshCw className={cn("h-4 w-4", isFetching && "animate-spin")} />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
<Tabs defaultValue="exports" className="space-y-4">
|
||
<TabsList className="bg-zinc-900 border border-zinc-800">
|
||
<TabsTrigger value="exports">
|
||
After Effects exports
|
||
<span className="ml-2 text-xs text-zinc-500">{totalExports}</span>
|
||
</TabsTrigger>
|
||
<TabsTrigger value="blender">
|
||
Blender renders
|
||
{blenderActive > 0 ? (
|
||
<span className="ml-2 rounded-full bg-blue-500/20 px-1.5 text-xs text-blue-400">
|
||
{blenderActive} active
|
||
</span>
|
||
) : blenderFailed > 0 ? (
|
||
<span className="ml-2 rounded-full bg-red-500/20 px-1.5 text-xs text-red-400">
|
||
{blenderFailed} failed
|
||
</span>
|
||
) : (
|
||
<span className="ml-2 text-xs text-zinc-500">{blenderJobs.length}</span>
|
||
)}
|
||
</TabsTrigger>
|
||
</TabsList>
|
||
|
||
<TabsContent value="exports" className="space-y-4 mt-0">
|
||
{/* Summary cards — each one with a status filters the table to what it counts */}
|
||
<div className="grid grid-cols-2 sm:grid-cols-5 gap-3">
|
||
{cards.map((c) => {
|
||
const selected = c.status !== undefined && statusFilter === c.status;
|
||
const body = (
|
||
<>
|
||
<div className={cn("text-2xl font-semibold text-white", c.className)}>{c.value}</div>
|
||
<div className="text-xs text-zinc-400 mt-0.5">{c.label}</div>
|
||
</>
|
||
);
|
||
return c.status ? (
|
||
<button
|
||
key={c.label}
|
||
type="button"
|
||
onClick={() => changeStatus(selected ? "all" : c.status!)}
|
||
className={cn(
|
||
"rounded-lg border bg-zinc-900 px-4 py-3 text-left transition-colors hover:border-zinc-600",
|
||
selected ? "border-zinc-500" : "border-zinc-800"
|
||
)}
|
||
>
|
||
{body}
|
||
</button>
|
||
) : (
|
||
<div key={c.label} className="rounded-lg border border-zinc-800 bg-zinc-900 px-4 py-3">
|
||
{body}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<div className="relative w-full sm:w-72">
|
||
<Search className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-500" />
|
||
<Input
|
||
value={searchInput}
|
||
onChange={(e) => setSearchInput(e.target.value)}
|
||
onKeyDown={(e) => e.key === "Escape" && setSearchInput("")}
|
||
placeholder="Search shots, e.g. UNG_114"
|
||
aria-label="Search exports by shot code"
|
||
className="pl-8 pr-8"
|
||
/>
|
||
{searchInput && (
|
||
<button
|
||
type="button"
|
||
onClick={() => setSearchInput("")}
|
||
className="absolute right-2 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300"
|
||
aria-label="Clear search"
|
||
>
|
||
<X className="h-4 w-4" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
<Select value={statusFilter} onValueChange={changeStatus}>
|
||
<SelectTrigger className="w-56">
|
||
<SelectValue placeholder="All statuses" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="all">All statuses</SelectItem>
|
||
{Object.keys(EXPORT_STATUS_STYLES).map((s) => (
|
||
<SelectItem key={s} value={s}>
|
||
{statusLabel(s)}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<div className="rounded-lg border border-zinc-800 overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="border-b border-zinc-800 bg-zinc-900/60 text-left text-xs text-zinc-400">
|
||
<th className="px-4 py-2.5 font-medium">Shot</th>
|
||
<th className="px-4 py-2.5 font-medium">Version</th>
|
||
<th className="px-4 py-2.5 font-medium">Status</th>
|
||
<th className="px-4 py-2.5 font-medium w-48">Progress</th>
|
||
<th className="px-4 py-2.5 font-medium">Machine</th>
|
||
<th className="px-4 py-2.5 font-medium">Attempt</th>
|
||
<th className="px-4 py-2.5 font-medium">Queued</th>
|
||
<th className="px-4 py-2.5 font-medium text-right">Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{isLoading ? (
|
||
<tr>
|
||
<td colSpan={8} className="px-4 py-10 text-center text-zinc-500">
|
||
Loading…
|
||
</td>
|
||
</tr>
|
||
) : exports.length === 0 ? (
|
||
<tr>
|
||
<td colSpan={8} className="px-4 py-10 text-center text-zinc-500">
|
||
{search
|
||
? `No exports match “${search}”`
|
||
: statusFilter === "all"
|
||
? "No exports yet — queue one from the After Effects panel"
|
||
: `No exports are ${statusLabel(statusFilter).toLowerCase()}`}
|
||
</td>
|
||
</tr>
|
||
) : (
|
||
exports.map((e) => (
|
||
<tr key={e.id} className="border-b border-zinc-800/60 hover:bg-zinc-900/40">
|
||
<td className="px-4 py-2.5">
|
||
<Link href={`/pipeline/exports/${e.id}`} className="text-white hover:text-amber-400 font-medium">
|
||
{e.shotCode}
|
||
</Link>
|
||
<div className="text-xs text-zinc-500">
|
||
{e.projectCode}
|
||
{e.episode ? ` · ep ${e.episode}` : ""}
|
||
</div>
|
||
</td>
|
||
<td className="px-4 py-2.5 text-zinc-300">{e.versionString}</td>
|
||
<td className="px-4 py-2.5">
|
||
<span
|
||
className={cn(
|
||
"inline-block rounded-full border px-2 py-0.5 text-xs whitespace-nowrap",
|
||
EXPORT_STATUS_STYLES[e.status] ?? "bg-zinc-500/15 text-zinc-300 border-zinc-500/30"
|
||
)}
|
||
title={e.job?.errorMessage ?? undefined}
|
||
>
|
||
{statusLabel(e.status)}
|
||
</span>
|
||
</td>
|
||
<td className="px-4 py-2.5">
|
||
{e.status === "RENDERING" && e.job ? (
|
||
<div className="space-y-1">
|
||
<Progress value={e.job.progress * 100} className="h-1.5" />
|
||
<div className="text-xs text-zinc-500">
|
||
{e.job.currentFrame != null && e.job.totalFrames != null
|
||
? `frame ${e.job.currentFrame}/${e.job.totalFrames} · `
|
||
: ""}
|
||
ETA {formatEta(e.job.etaSeconds)}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<span className="text-zinc-600 text-xs">—</span>
|
||
)}
|
||
</td>
|
||
<td className="px-4 py-2.5 text-zinc-400">{e.job?.machineName ?? "—"}</td>
|
||
<td className="px-4 py-2.5 text-zinc-400">{e.job ? `${e.job.attempt}` : "—"}</td>
|
||
<td className="px-4 py-2.5 text-zinc-500 text-xs whitespace-nowrap">
|
||
{new Date(e.createdAt).toLocaleString()}
|
||
</td>
|
||
<td className="px-4 py-2.5">
|
||
<div className="flex items-center justify-end gap-1">
|
||
{FAILED_STATUSES.includes(e.status) && (
|
||
<Button variant="ghost" size="icon-sm" title="Retry" onClick={() => action(e.id, "retry")}>
|
||
<RotateCcw className="h-4 w-4 text-amber-400" />
|
||
</Button>
|
||
)}
|
||
{ACTIVE_STATUSES.includes(e.status) && (
|
||
<Button variant="ghost" size="icon-sm" title="Cancel" onClick={() => action(e.id, "cancel")}>
|
||
<XCircle className="h-4 w-4 text-red-400" />
|
||
</Button>
|
||
)}
|
||
{isAdmin && ["QUEUED", "RENDERING"].includes(e.status) && (
|
||
<Button
|
||
variant="ghost"
|
||
size="icon-sm"
|
||
title="Mark done manually (rendered outside the pipeline)"
|
||
onClick={() => action(e.id, "mark-done")}
|
||
>
|
||
<CheckCircle2 className="h-4 w-4 text-green-400" />
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{pagination && pagination.pages > 1 && (
|
||
<div className="flex items-center justify-between text-sm text-zinc-400">
|
||
<span>
|
||
{firstRow}–{lastRow} of {pagination.total}
|
||
</span>
|
||
<div className="flex items-center gap-2">
|
||
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
|
||
<ChevronLeft className="h-4 w-4" />
|
||
</Button>
|
||
<span className="text-xs">
|
||
Page {pagination.page} of {pagination.pages}
|
||
</span>
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
disabled={page >= pagination.pages}
|
||
onClick={() => setPage((p) => p + 1)}
|
||
>
|
||
<ChevronRight className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</TabsContent>
|
||
|
||
<TabsContent value="blender" className="mt-0">
|
||
<BlenderQueue jobs={blenderJobs} loaded={blenderData !== undefined} />
|
||
</TabsContent>
|
||
</Tabs>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
interface BlenderJobRow {
|
||
id: string;
|
||
label: string | null;
|
||
status: string;
|
||
attempt: number;
|
||
maxAttempts: number;
|
||
progress: number;
|
||
currentFrame: number | null;
|
||
totalFrames: number | null;
|
||
etaSeconds: number | null;
|
||
errorMessage: string | null;
|
||
machineName: string | null;
|
||
submittedBy: string | null;
|
||
projectCode: string | null;
|
||
blendFile: string | null;
|
||
frameStart: number | null;
|
||
frameEnd: number | null;
|
||
createdAt: string;
|
||
}
|
||
|
||
const BLENDER_STATUS_STYLES: Record<string, string> = {
|
||
QUEUED: "bg-zinc-500/15 text-zinc-300 border-zinc-500/30",
|
||
CLAIMED: "bg-blue-500/15 text-blue-400 border-blue-500/30",
|
||
RUNNING: "bg-blue-500/15 text-blue-400 border-blue-500/30",
|
||
COMPLETED: "bg-emerald-500/15 text-emerald-400 border-emerald-500/30",
|
||
FAILED: "bg-red-500/15 text-red-400 border-red-500/30",
|
||
CANCELLED: "bg-zinc-500/10 text-zinc-500 border-zinc-600/30",
|
||
EXPIRED: "bg-red-500/15 text-red-400 border-red-500/30",
|
||
};
|
||
|
||
/**
|
||
* Standalone Blender renders. They have no Export behind them, so they cannot
|
||
* appear in the exports table — but they share the queue, and a render nobody
|
||
* can see is a render nobody can retry.
|
||
*/
|
||
function BlenderQueue({ jobs, loaded }: { jobs: BlenderJobRow[]; loaded: boolean }) {
|
||
const { toast } = useToast();
|
||
const queryClient = useQueryClient();
|
||
|
||
async function jobAction(id: string, verb: "cancel" | "retry") {
|
||
const res = await fetch(`/api/render/blender/${id}?action=${verb}`, { method: "POST" });
|
||
if (!res.ok) {
|
||
const body = await res.json().catch(() => ({}));
|
||
toast({ title: `${verb} failed`, description: body.error ?? res.statusText, variant: "destructive" });
|
||
}
|
||
queryClient.invalidateQueries({ queryKey: ["blender-jobs"] });
|
||
}
|
||
|
||
if (jobs.length === 0) {
|
||
return (
|
||
<div className="rounded-lg border border-zinc-800 px-4 py-10 text-center text-sm text-zinc-500">
|
||
{loaded ? "No Blender renders yet — submit one from the Blender panel" : "Loading…"}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40">
|
||
<div className="divide-y divide-zinc-800/60">
|
||
{jobs.map((j) => (
|
||
<div key={j.id} className="px-4 py-3 text-sm">
|
||
<div className="flex items-center gap-3 flex-wrap">
|
||
<span className="text-zinc-200">{j.label ?? "(untitled)"}</span>
|
||
<span className={cn("rounded-full border px-2 py-0.5 text-xs", BLENDER_STATUS_STYLES[j.status] ?? "")}>
|
||
{j.status.toLowerCase()}
|
||
</span>
|
||
{j.projectCode && <span className="text-xs text-zinc-500">{j.projectCode}</span>}
|
||
{j.frameStart != null && (
|
||
<span className="text-xs text-zinc-500">
|
||
frames {j.frameStart}–{j.frameEnd}
|
||
</span>
|
||
)}
|
||
{j.status === "RUNNING" && (
|
||
<span className="text-xs text-blue-400">
|
||
{Math.round(j.progress * 100)}%
|
||
{j.currentFrame != null ? ` · frame ${j.currentFrame}` : ""}
|
||
{j.etaSeconds ? ` · ETA ${formatEta(j.etaSeconds)}` : ""}
|
||
</span>
|
||
)}
|
||
{j.machineName && <span className="text-xs text-zinc-500">{j.machineName}</span>}
|
||
{j.attempt > 1 && (
|
||
<span className="text-xs text-zinc-500">
|
||
attempt {j.attempt}/{j.maxAttempts}
|
||
</span>
|
||
)}
|
||
<span className="ml-auto flex items-center gap-1">
|
||
{["QUEUED", "CLAIMED", "RUNNING"].includes(j.status) && (
|
||
<Button variant="ghost" size="icon-sm" title="Cancel" onClick={() => jobAction(j.id, "cancel")}>
|
||
<XCircle className="h-4 w-4 text-red-400" />
|
||
</Button>
|
||
)}
|
||
{["FAILED", "CANCELLED", "EXPIRED"].includes(j.status) && (
|
||
<Button variant="ghost" size="icon-sm" title="Requeue" onClick={() => jobAction(j.id, "retry")}>
|
||
<RotateCcw className="h-4 w-4 text-amber-400" />
|
||
</Button>
|
||
)}
|
||
</span>
|
||
</div>
|
||
{j.blendFile && <div className="mt-1 text-xs text-zinc-600 break-all">{j.blendFile}</div>}
|
||
{j.errorMessage && <div className="mt-1 text-xs text-red-400">{j.errorMessage}</div>}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|