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.
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
"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}`;
|
||||
}
|
||||
Reference in New Issue
Block a user