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:
Joel Brock
2026-06-15 11:56:45 -07:00
parent 5203dabeac
commit 6850ff9dee
8 changed files with 648 additions and 29 deletions
+31 -22
View File
@@ -19,6 +19,7 @@ import {
computeDateRange,
} from "./report/FieldHistory";
import { LoadingState, EmptyState, ErrorState } from "./report/ReportStates";
import { FileLink } from "./report/FileLink";
interface StaffReportViewProps {
org: number;
@@ -207,6 +208,8 @@ export function StaffReportView({
section={section}
options={data.options}
civiBaseUrl={civiBaseUrl}
org={org}
authKey={authKey}
/>
))}
@@ -271,10 +274,14 @@ function StaffSection({
section,
options,
civiBaseUrl,
org,
authKey,
}: {
section: StaffReportSection;
options: Record<number, SelectOption[]>;
civiBaseUrl: string;
org: number;
authKey: string;
}) {
const filled = section.fields.filter((f) => f.history.length > 0);
const empty = section.fields.filter((f) => f.history.length === 0);
@@ -328,6 +335,8 @@ function StaffSection({
field={f}
options={options}
civiBaseUrl={civiBaseUrl}
org={org}
authKey={authKey}
/>
))}
</ul>
@@ -374,10 +383,14 @@ function CompactFieldRow({
field,
options,
civiBaseUrl,
org,
authKey,
}: {
field: StaffReportField;
options: Record<number, SelectOption[]>;
civiBaseUrl: string;
org: number;
authKey: string;
}) {
const [open, setOpen] = useState(false);
const latest = field.history[0];
@@ -394,6 +407,8 @@ function CompactFieldRow({
entry={latest}
options={options}
civiBaseUrl={civiBaseUrl}
org={org}
authKey={authKey}
/>
</span>
{latest.date ? (
@@ -431,6 +446,8 @@ function CompactFieldRow({
entry={e}
options={options}
civiBaseUrl={civiBaseUrl}
org={org}
authKey={authKey}
/>
</span>
</li>
@@ -446,41 +463,33 @@ function FieldValue({
entry,
options,
civiBaseUrl,
org,
authKey,
}: {
field: StaffReportField;
entry: FieldHistoryEntry;
options: Record<number, SelectOption[]>;
civiBaseUrl: string;
org: number;
authKey: string;
}) {
if (field.descriptor.render === "file") {
const v = entry.value as
| { id?: number | string; file_name?: string; url?: string }
| { id?: number | string; file_name?: string; url?: string; mime?: string }
| null;
if (!v || v.id === undefined) return <span></span>;
const id = String(v.id);
const name = v.file_name ?? `file-${id}`;
// Prefer the Civi-signed URL (carries the fcs JWT) returned by
// Attachment.get. If absent, fall back to the WebForm-mw Civi extension's
// file-redirect route — it mints the fcs server-side and 302s to the
// real /civicrm/file URL. (Hitting /civicrm/file?id=X bare crashes Civi
// on a null fcs JWT decode.)
let href = "#";
if (v.url) {
href = v.url.startsWith("http")
? v.url
: `${civiBaseUrl}${v.url.startsWith("/") ? "" : "/"}${v.url}`;
} else if (civiBaseUrl) {
href = `${civiBaseUrl}/civicrm/webform-mw/file?id=${encodeURIComponent(id)}`;
}
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-ink underline decoration-rule underline-offset-4 hover:decoration-ink"
>
{name}
</a>
<FileLink
fileId={id}
fileName={name}
civiSignedUrl={v.url}
mime={v.mime}
org={org}
authKey={authKey}
civiBaseUrl={civiBaseUrl}
/>
);
}
return (