Files
WebForm-mw/components/report/FileLink.tsx
T
Joel Brock 6850ff9dee Staff report: inline lightbox for image/PDF attachments
Adds a /api/staff/file proxy that re-streams Civi attachments with
Content-Disposition: inline so a native <dialog> lightbox can preview
images and PDFs in place. Office docs keep their plain download link
and gain a "View in Google Docs" secondary link (uses the Civi-signed
URL so Google can fetch without our staff key).

Also threads mime through /api/staff/report (Attachment.get mime_type)
so the dispatcher picks the right affordance without relying solely on
filename inference.
2026-06-15 11:56:45 -07:00

134 lines
4.0 KiB
TypeScript

"use client";
import { useState } from "react";
import { AttachmentLightbox } from "./AttachmentLightbox";
import { categoryFromMime, resolveMime } from "@/lib/mime.mjs";
/**
* Render an attachment row in the staff report with the right affordance
* for its type:
*
* - image / pdf -> button opens the inline lightbox
* - office -> download link + "View in Google Docs" secondary link
* - other -> plain download link
*
* The proxy URL is `/api/staff/file?id=&org=&key=` (re-streams with
* Content-Disposition: inline for previews, or `&dl=1` for downloads). The
* Civi-signed URL (carrying its short-lived fcs JWT) is passed straight to
* Google's Docs Viewer for office files; we deliberately don't proxy that
* one because Google's servers must fetch it without our staff key.
*/
export function FileLink({
fileId,
fileName,
civiSignedUrl,
mime: explicitMime,
org,
authKey,
civiBaseUrl,
}: {
fileId: number | string;
fileName: string;
/** From Attachment.get (includes fcs). Used for office Google Viewer + download fallback. */
civiSignedUrl?: string;
mime?: string;
org: number;
authKey: string;
civiBaseUrl: string;
}) {
const [open, setOpen] = useState(false);
const mime = resolveMime(explicitMime, fileName);
const category = categoryFromMime(mime);
const proxyBase =
`/api/staff/file?id=${encodeURIComponent(String(fileId))}` +
`&org=${encodeURIComponent(String(org))}` +
`&key=${encodeURIComponent(authKey)}`;
const previewSrc = proxyBase;
// Prefer Civi's signed URL for downloads when present (one fewer hop
// through our Lambda); the proxy is the fallback.
const downloadHref = civiSignedUrl
? absUrl(civiSignedUrl, civiBaseUrl)
: `${proxyBase}&dl=1`;
const linkClass =
"text-ink underline decoration-rule underline-offset-4 hover:decoration-ink";
if (category === "image" || category === "pdf") {
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className={
"bg-transparent p-0 text-left " +
linkClass +
" focus:outline-none focus-visible:ring-2 focus-visible:ring-leaf-700"
}
>
{fileName}
</button>
<AttachmentLightbox
open={open}
onClose={() => setOpen(false)}
previewSrc={previewSrc}
downloadHref={downloadHref}
filename={fileName}
mime={mime}
/>
</>
);
}
if (category === "office") {
// Google Docs Viewer renders DOC/DOCX/XLS/XLSX in a new tab. It fetches
// the source URL server-side, so the URL must be reachable without our
// staff key — that's why we pass the Civi-signed URL straight through.
const gview = civiSignedUrl
? `https://docs.google.com/viewer?url=${encodeURIComponent(absUrl(civiSignedUrl, civiBaseUrl))}`
: null;
return (
<span className="inline-flex flex-wrap items-baseline gap-x-2 gap-y-1">
<a
href={downloadHref}
target="_blank"
rel="noopener noreferrer"
className={linkClass}
>
{fileName}
</a>
{gview && (
<a
href={gview}
target="_blank"
rel="noopener noreferrer"
title="Opens in Google Docs Viewer (file bytes are sent to Google to render)"
className="text-xs font-medium text-leaf-700 underline decoration-rule underline-offset-4 hover:decoration-ink hover:text-leaf-800"
>
View in Google Docs
</a>
)}
</span>
);
}
// "other" — unknown types: just a download link.
return (
<a
href={downloadHref}
target="_blank"
rel="noopener noreferrer"
className={linkClass}
>
{fileName}
</a>
);
}
function absUrl(u: string, base: string): string {
if (u.startsWith("http")) return u;
if (!base) return u;
return `${base.replace(/\/+$/, "")}${u.startsWith("/") ? "" : "/"}${u}`;
}