205 lines
9.1 KiB
TypeScript
205 lines
9.1 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
import * as React from "react";
|
||
|
|
import { useParams } from "next/navigation";
|
||
|
|
import { ExternalLink, ImageOff, Loader2 } from "lucide-react";
|
||
|
|
|
||
|
|
import { Badge } from "@/components/ui/badge";
|
||
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||
|
|
import { api } from "@/lib/trpc";
|
||
|
|
|
||
|
|
export default function ArtifactsPage() {
|
||
|
|
const params = useParams();
|
||
|
|
const workspaceSlug =
|
||
|
|
typeof params?.workspaceSlug === "string" ? params.workspaceSlug : undefined;
|
||
|
|
const [selectedId, setSelectedId] = React.useState<string | null>(null);
|
||
|
|
|
||
|
|
const artifactsQuery = api.convoys.listArtifacts.useQuery(
|
||
|
|
{ workspace: workspaceSlug! },
|
||
|
|
{ enabled: Boolean(workspaceSlug) },
|
||
|
|
);
|
||
|
|
|
||
|
|
const artifacts = artifactsQuery.data ?? [];
|
||
|
|
const selected = artifacts.find((a) => a.id === selectedId) ?? null;
|
||
|
|
|
||
|
|
if (!workspaceSlug) return null;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="flex h-full min-h-0 flex-col">
|
||
|
|
<header className="shrink-0 border-b border-border px-6 py-4">
|
||
|
|
<h1 className="text-lg font-semibold">Completion artifacts</h1>
|
||
|
|
<p className="text-xs text-muted-foreground">
|
||
|
|
Gallery of released convoys — summary + PR link + timeline
|
||
|
|
</p>
|
||
|
|
</header>
|
||
|
|
|
||
|
|
<div className="grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[1fr_380px]">
|
||
|
|
<section className="min-h-0 overflow-y-auto p-6">
|
||
|
|
{artifactsQuery.isLoading ? (
|
||
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||
|
|
<Loader2 className="size-4 animate-spin" />
|
||
|
|
Loading artifacts…
|
||
|
|
</div>
|
||
|
|
) : artifacts.length === 0 ? (
|
||
|
|
<div className="rounded-md border border-dashed border-border p-8 text-center">
|
||
|
|
<p className="text-sm font-medium">No completion artifacts yet</p>
|
||
|
|
<p className="mt-1 text-xs text-muted-foreground">
|
||
|
|
Complete a convoy from merged or published via human-gate-3 to generate one.
|
||
|
|
</p>
|
||
|
|
<div className="mt-4 inline-flex items-center gap-2 rounded-md border border-border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
||
|
|
<ImageOff className="size-4" />
|
||
|
|
Screenshot upload placeholder — shows when no image is attached
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||
|
|
{artifacts.map((artifact) => {
|
||
|
|
const isSelected = artifact.id === selectedId;
|
||
|
|
return (
|
||
|
|
<button
|
||
|
|
key={artifact.id}
|
||
|
|
type="button"
|
||
|
|
onClick={() => setSelectedId(artifact.id)}
|
||
|
|
className={`text-left ${isSelected ? "ring-2 ring-primary" : ""} rounded-lg`}
|
||
|
|
>
|
||
|
|
<Card className={`h-full ${isSelected ? "border-primary" : ""}`}>
|
||
|
|
<CardHeader className="pb-2">
|
||
|
|
<div className="flex items-center justify-between gap-2">
|
||
|
|
<CardTitle className="truncate text-sm">{artifact.convoySlug}</CardTitle>
|
||
|
|
<Badge variant="secondary" className="shrink-0 text-[10px]">
|
||
|
|
{artifact.convoyStatus}
|
||
|
|
</Badge>
|
||
|
|
</div>
|
||
|
|
<p className="text-xs text-muted-foreground">
|
||
|
|
{new Date(artifact.createdAt).toLocaleString()}
|
||
|
|
</p>
|
||
|
|
</CardHeader>
|
||
|
|
<CardContent className="space-y-3">
|
||
|
|
{artifact.screenshotUrl ? (
|
||
|
|
// eslint-disable-next-line @next/next/no-img-element
|
||
|
|
<img
|
||
|
|
src={artifact.screenshotUrl}
|
||
|
|
alt="screenshot"
|
||
|
|
className="h-32 w-full rounded-md object-cover"
|
||
|
|
/>
|
||
|
|
) : (
|
||
|
|
<div className="flex h-32 w-full items-center justify-center rounded-md border border-dashed border-border bg-muted/20 p-3 text-xs text-muted-foreground">
|
||
|
|
<span className="inline-flex items-center gap-1.5">
|
||
|
|
<ImageOff className="size-4" />
|
||
|
|
Screenshot upload placeholder
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
<pre className="line-clamp-3 whitespace-pre-wrap break-words rounded bg-muted/30 p-2 text-xs">
|
||
|
|
{artifact.summaryMd.slice(0, 180)}
|
||
|
|
{artifact.summaryMd.length > 180 ? "…" : ""}
|
||
|
|
</pre>
|
||
|
|
{artifact.pr.prUrl && (
|
||
|
|
<a
|
||
|
|
href={artifact.pr.prUrl}
|
||
|
|
target="_blank"
|
||
|
|
rel="noreferrer"
|
||
|
|
onClick={(e) => e.stopPropagation()}
|
||
|
|
className="inline-flex items-center gap-1 text-xs text-primary underline"
|
||
|
|
>
|
||
|
|
PR <ExternalLink className="size-3" />
|
||
|
|
</a>
|
||
|
|
)}
|
||
|
|
</CardContent>
|
||
|
|
</Card>
|
||
|
|
</button>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</section>
|
||
|
|
|
||
|
|
<aside className="min-h-0 overflow-y-auto border-l border-border bg-background p-4">
|
||
|
|
{!selected ? (
|
||
|
|
<p className="py-8 text-center text-sm text-muted-foreground">
|
||
|
|
Select an artifact to see detail summary + PR link + convoy timeline.
|
||
|
|
</p>
|
||
|
|
) : (
|
||
|
|
<div className="space-y-4">
|
||
|
|
<div>
|
||
|
|
<h2 className="text-sm font-semibold">{selected.convoySlug}</h2>
|
||
|
|
<p className="text-xs text-muted-foreground">{selected.convoyTitle}</p>
|
||
|
|
<p className="text-xs text-muted-foreground">{selected.convoyStatus}</p>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="rounded-md border border-border bg-muted/30 p-3">
|
||
|
|
<p className="mb-1 text-xs font-medium uppercase text-muted-foreground">Summary</p>
|
||
|
|
<pre className="whitespace-pre-wrap break-words text-sm">
|
||
|
|
{selected.summaryMd}
|
||
|
|
</pre>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="rounded-md border border-border p-3">
|
||
|
|
<p className="mb-1 text-xs font-medium uppercase text-muted-foreground">PR link</p>
|
||
|
|
{selected.pr.prUrl ? (
|
||
|
|
<a
|
||
|
|
href={selected.pr.prUrl}
|
||
|
|
target="_blank"
|
||
|
|
rel="noreferrer"
|
||
|
|
className="break-all text-sm text-primary underline"
|
||
|
|
>
|
||
|
|
{selected.pr.prUrl}
|
||
|
|
{selected.pr.prNumber ? ` (#${selected.pr.prNumber})` : ""}
|
||
|
|
</a>
|
||
|
|
) : (
|
||
|
|
<p className="text-xs text-muted-foreground">No PR linked</p>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{selected.screenshotUrl ? (
|
||
|
|
// eslint-disable-next-line @next/next/no-img-element
|
||
|
|
<img
|
||
|
|
src={selected.screenshotUrl}
|
||
|
|
alt="screenshot"
|
||
|
|
className="w-full rounded-md border border-border object-cover"
|
||
|
|
/>
|
||
|
|
) : (
|
||
|
|
<div className="flex h-32 w-full items-center justify-center rounded-md border border-dashed border-border bg-muted/20 p-3 text-xs text-muted-foreground">
|
||
|
|
<span className="inline-flex items-center gap-1.5">
|
||
|
|
<ImageOff className="size-4" />
|
||
|
|
Screenshot upload placeholder — no screenshot yet
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<div>
|
||
|
|
<p className="mb-2 text-xs font-medium uppercase text-muted-foreground">
|
||
|
|
Convoy timeline ({selected.timeline.length})
|
||
|
|
</p>
|
||
|
|
{selected.timeline.length === 0 ? (
|
||
|
|
<p className="text-xs text-muted-foreground">No events</p>
|
||
|
|
) : (
|
||
|
|
<ul className="space-y-1">
|
||
|
|
{selected.timeline.map((ev) => (
|
||
|
|
<li
|
||
|
|
key={ev.id}
|
||
|
|
className="rounded border border-border bg-muted/10 px-2 py-1.5 text-xs"
|
||
|
|
>
|
||
|
|
<div className="flex items-center justify-between gap-2">
|
||
|
|
<span className="font-medium">{ev.eventType}</span>
|
||
|
|
<span className="text-muted-foreground">
|
||
|
|
{new Date(ev.occurredAt).toLocaleString()}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
<p className="text-muted-foreground">
|
||
|
|
{ev.role}
|
||
|
|
{ev.actor ? ` · ${ev.actor}` : ""}
|
||
|
|
</p>
|
||
|
|
</li>
|
||
|
|
))}
|
||
|
|
</ul>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</aside>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|