Skip to content

Commit c1763a3

Browse files
ralyodioclaude
andcommitted
feat: Download PDF + Copy Markdown on the scan-run page
Two new actions in the scan-run header, rendered as soon as any engine reaches complete (no point hitting them while everything is still running). - PdfButton hits /api/audits/<first-audit>/pdf, which the recent consolidated-PDF change already makes scan-run-aware: returns the exec summary + every engine's full report stitched into one PDF. - New CopyMarkdownButton fetches a fresh markdown endpoint and writes the body to the clipboard. Designed for pasting the full consolidated report into another LLM. - New /api/projects/[id]/runs/[runId]/markdown route — owner-only, returns the same consolidated Markdown the email-summary PDF and /r/<token>/report.md serve, via the shared buildScanRunMarkdown helper. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7786a69 commit c1763a3

3 files changed

Lines changed: 124 additions & 0 deletions

File tree

‎app/(app)/projects/[id]/runs/[runId]/page.tsx‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { createClient } from "@/lib/supabase/server";
33
import { type Engine } from "@/lib/credits";
44
import { ScanRunResults, type RunAudit } from "@/components/scan-run-results";
55
import { ScanRunRefresh } from "@/components/scan-run-refresh";
6+
import { PdfButton } from "@/components/pdf-button";
7+
import { CopyMarkdownButton } from "@/components/copy-markdown-button";
68

79
export const dynamic = "force-dynamic";
810

@@ -48,13 +50,25 @@ export default async function ScanRunPage({
4850
summary: r.summary,
4951
}));
5052

53+
const anyComplete = typedRows.some((r) => r.status === "complete");
54+
5155
return (
5256
<>
5357
<ScanRunResults
5458
rows={typedRows}
5559
targetUrl={rows[0].target_url}
5660
backHref={`/projects/${projectId}`}
5761
backLabel={project.name}
62+
ownerActions={
63+
anyComplete ? (
64+
<div className="flex flex-col gap-2 sm:flex-row">
65+
<PdfButton auditId={rows[0].id} />
66+
<CopyMarkdownButton
67+
href={`/api/projects/${projectId}/runs/${runId}/markdown`}
68+
/>
69+
</div>
70+
) : undefined
71+
}
5872
/>
5973
<ScanRunRefresh projectId={projectId} runId={runId} done={allDone} />
6074
</>
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { NextResponse } from "next/server";
2+
import { createClient } from "@/lib/supabase/server";
3+
import {
4+
buildScanRunMarkdown,
5+
type SummaryRow,
6+
} from "@/lib/audit/summary-markdown";
7+
8+
export const runtime = "nodejs";
9+
10+
// Owner-only consolidated Markdown for a scan run — executive summary
11+
// followed by each engine's full report. Used by the Copy Markdown
12+
// button on the scan-run page; safe to paste into another LLM.
13+
export async function GET(
14+
_req: Request,
15+
{ params }: { params: Promise<{ id: string; runId: string }> },
16+
) {
17+
const { id: projectId, runId } = await params;
18+
const supabase = await createClient();
19+
const {
20+
data: { user },
21+
} = await supabase.auth.getUser();
22+
if (!user) return new Response("Not authenticated", { status: 401 });
23+
24+
const { data: project } = await supabase
25+
.from("projects")
26+
.select("id, owner_id")
27+
.eq("id", projectId)
28+
.maybeSingle();
29+
if (!project || project.owner_id !== user.id) {
30+
return new Response("Not found", { status: 404 });
31+
}
32+
33+
const { data: audits } = await supabase
34+
.from("audits")
35+
.select(
36+
"id, engine, status, score, share_token, summary, report_markdown, failed_reason, created_at, target_url",
37+
)
38+
.eq("scan_run_id", runId)
39+
.eq("project_id", projectId)
40+
.order("created_at", { ascending: true });
41+
const rows = (audits ?? []) as (SummaryRow & { target_url: string })[];
42+
if (rows.length === 0) {
43+
return new Response("Not found", { status: 404 });
44+
}
45+
46+
const md = buildScanRunMarkdown({
47+
targetUrl: rows[0].target_url,
48+
rows,
49+
});
50+
return new Response(md, {
51+
headers: {
52+
"content-type": "text/markdown; charset=utf-8",
53+
"cache-control": "no-store",
54+
},
55+
});
56+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"use client";
2+
3+
import { useState } from "react";
4+
5+
// Fetches a Markdown endpoint and copies the response body to the
6+
// clipboard. Used on the scan-run page to grab the consolidated
7+
// "executive summary + per-engine reports" document for pasting into
8+
// another LLM.
9+
export function CopyMarkdownButton({
10+
href,
11+
label = "Copy Markdown",
12+
}: {
13+
href: string;
14+
label?: string;
15+
}) {
16+
const [state, setState] = useState<"idle" | "loading" | "copied" | "error">(
17+
"idle",
18+
);
19+
20+
async function onClick() {
21+
setState("loading");
22+
try {
23+
const res = await fetch(href, { cache: "no-store" });
24+
if (!res.ok) {
25+
setState("error");
26+
return;
27+
}
28+
const md = await res.text();
29+
await navigator.clipboard.writeText(md);
30+
setState("copied");
31+
setTimeout(() => setState("idle"), 2000);
32+
} catch {
33+
setState("error");
34+
setTimeout(() => setState("idle"), 3000);
35+
}
36+
}
37+
38+
return (
39+
<button
40+
className="btn"
41+
onClick={onClick}
42+
disabled={state === "loading"}
43+
title="Copy the full report as Markdown — paste into another LLM"
44+
>
45+
{state === "loading"
46+
? "Copying…"
47+
: state === "copied"
48+
? "Copied ✓"
49+
: state === "error"
50+
? "Failed — retry"
51+
: label}
52+
</button>
53+
);
54+
}

0 commit comments

Comments
 (0)