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,174 @@
|
|||||||
|
/**
|
||||||
|
* GET /api/staff/file?id=<fileId>&org=<orgId>&key=<STAFF_REPORT_KEY>
|
||||||
|
*
|
||||||
|
* Server-side proxy that fetches a CiviCRM attachment and re-streams it
|
||||||
|
* with `Content-Disposition: inline`, so the staff-report lightbox can
|
||||||
|
* preview images and PDFs in-place. Civi's own `/civicrm/file` handler
|
||||||
|
* always sends `attachment`, which forces a download — that's correct for
|
||||||
|
* its UI but wrong for an embedded preview.
|
||||||
|
*
|
||||||
|
* Authorization layers:
|
||||||
|
* 1. STAFF_REPORT_KEY query param (same gate as /api/staff/report).
|
||||||
|
* 2. Server-side check that the requested file is actually linked to the
|
||||||
|
* `org`. This prevents the staff key from being used to pull arbitrary
|
||||||
|
* file ids out of CiviCRM — a file is reachable only if its
|
||||||
|
* entity_table/entity_id ties back to the org (directly, for org
|
||||||
|
* custom-field files; or via an activity's target_contact_id, for
|
||||||
|
* activity custom-field files).
|
||||||
|
*
|
||||||
|
* The upstream fetch uses the URL Civi returns from `Attachment.get`,
|
||||||
|
* which includes a freshly-minted `fcs` JWT. We don't carry any user
|
||||||
|
* session cookies — that JWT is the auth for `/civicrm/file`.
|
||||||
|
*
|
||||||
|
* STUB MODE: if Civi env vars are unset, 404. Stub-mode previews aren't
|
||||||
|
* meaningful (there are no real bytes to serve).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { isStaffKeyValid } from "@/lib/staff-auth";
|
||||||
|
import { civi3 } from "@/lib/civicrm";
|
||||||
|
import { resolveMime } from "@/lib/mime.mjs";
|
||||||
|
|
||||||
|
const MAX_BYTES = 10 * 1024 * 1024;
|
||||||
|
|
||||||
|
function isCiviStubMode(): boolean {
|
||||||
|
return !(
|
||||||
|
process.env.CIVI_BASE_URL &&
|
||||||
|
process.env.CIVI_API_KEY &&
|
||||||
|
process.env.CIVI_SITE_KEY
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AttachmentRow {
|
||||||
|
id: string | number;
|
||||||
|
url?: string;
|
||||||
|
mime_type?: string;
|
||||||
|
name?: string;
|
||||||
|
entity_table?: string;
|
||||||
|
entity_id?: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAttachment(fileId: number): Promise<AttachmentRow | null> {
|
||||||
|
const res = await civi3<AttachmentRow>("Attachment", "get", {
|
||||||
|
id: fileId,
|
||||||
|
return: "id,url,mime_type,name,entity_table,entity_id",
|
||||||
|
sequential: 1,
|
||||||
|
});
|
||||||
|
return res.values?.[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Confirm a file is reachable from `orgId`. Returns false on any uncertainty. */
|
||||||
|
async function fileBelongsToOrg(row: AttachmentRow, orgId: number): Promise<boolean> {
|
||||||
|
const entityId = Number(row.entity_id);
|
||||||
|
if (!Number.isFinite(entityId) || entityId <= 0) return false;
|
||||||
|
const entityTable = String(row.entity_table ?? "");
|
||||||
|
|
||||||
|
if (entityTable === "civicrm_contact") {
|
||||||
|
return entityId === orgId;
|
||||||
|
}
|
||||||
|
if (entityTable === "civicrm_activity") {
|
||||||
|
// The activity must have orgId in its target_contact_id list. APIv4
|
||||||
|
// exposes this as `target_contact_id` array; we just need a hit-check.
|
||||||
|
try {
|
||||||
|
const probe = await civi3<{ id: string | number }>("Activity", "get", {
|
||||||
|
id: entityId,
|
||||||
|
target_contact_id: orgId,
|
||||||
|
return: "id",
|
||||||
|
sequential: 1,
|
||||||
|
});
|
||||||
|
return Array.isArray(probe.values) && probe.values.length > 0;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const key = url.searchParams.get("key");
|
||||||
|
const idStr = url.searchParams.get("id");
|
||||||
|
const orgStr = url.searchParams.get("org");
|
||||||
|
const wantsDownload = url.searchParams.get("dl") === "1";
|
||||||
|
|
||||||
|
if (!isStaffKeyValid(key)) {
|
||||||
|
return new NextResponse("Not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileId = Number(idStr);
|
||||||
|
const orgId = Number(orgStr);
|
||||||
|
if (!idStr || !Number.isFinite(fileId) || fileId <= 0) {
|
||||||
|
return NextResponse.json({ error: "Missing or invalid file id." }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (!orgStr || !Number.isFinite(orgId) || orgId <= 0) {
|
||||||
|
return NextResponse.json({ error: "Missing or invalid org id." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCiviStubMode()) {
|
||||||
|
return new NextResponse("Not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let row: AttachmentRow | null;
|
||||||
|
try {
|
||||||
|
row = await fetchAttachment(fileId);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
console.error("[staff/file] Attachment.get failed:", msg);
|
||||||
|
return NextResponse.json({ error: "Could not look up the file." }, { status: 502 });
|
||||||
|
}
|
||||||
|
if (!row || !row.url) {
|
||||||
|
return new NextResponse("Not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const belongs = await fileBelongsToOrg(row, orgId);
|
||||||
|
if (!belongs) {
|
||||||
|
// Don't differentiate from "not found" — leaking link existence to a
|
||||||
|
// probe-with-wrong-org gives no useful info to a legit caller and a
|
||||||
|
// little to an attacker.
|
||||||
|
return new NextResponse("Not found", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// The signed URL Civi returns is sometimes a relative path (depends on
|
||||||
|
// Civi config). Normalise against CIVI_BASE_URL so fetch() has an
|
||||||
|
// absolute URL.
|
||||||
|
const base = (process.env.CIVI_BASE_URL ?? "").replace(/\/+$/, "");
|
||||||
|
const upstream = row.url.startsWith("http")
|
||||||
|
? row.url
|
||||||
|
: `${base}${row.url.startsWith("/") ? "" : "/"}${row.url}`;
|
||||||
|
|
||||||
|
let upstreamRes: Response;
|
||||||
|
try {
|
||||||
|
upstreamRes = await fetch(upstream, { cache: "no-store" });
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : String(e);
|
||||||
|
console.error("[staff/file] upstream fetch failed:", msg);
|
||||||
|
return NextResponse.json({ error: "Upstream fetch failed." }, { status: 502 });
|
||||||
|
}
|
||||||
|
if (!upstreamRes.ok || !upstreamRes.body) {
|
||||||
|
return new NextResponse("Not found", { status: upstreamRes.status === 404 ? 404 : 502 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentLengthRaw = upstreamRes.headers.get("content-length");
|
||||||
|
const contentLength = contentLengthRaw ? Number(contentLengthRaw) : NaN;
|
||||||
|
if (Number.isFinite(contentLength) && contentLength > MAX_BYTES) {
|
||||||
|
return NextResponse.json({ error: "File too large for inline preview." }, { status: 413 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const mime = resolveMime(
|
||||||
|
row.mime_type ?? upstreamRes.headers.get("content-type"),
|
||||||
|
row.name,
|
||||||
|
);
|
||||||
|
const safeName = (row.name ?? `file-${fileId}`).replace(/[\r\n"\\]/g, "_");
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Content-Type": mime,
|
||||||
|
"Content-Disposition": `${wantsDownload ? "attachment" : "inline"}; filename="${safeName}"`,
|
||||||
|
"Cache-Control": "private, no-store",
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
};
|
||||||
|
if (Number.isFinite(contentLength)) {
|
||||||
|
headers["Content-Length"] = String(contentLength);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new NextResponse(upstreamRes.body, { status: 200, headers });
|
||||||
|
}
|
||||||
@@ -193,7 +193,7 @@ function buildStubPayload(orgId: number): StaffReportPayload {
|
|||||||
{
|
{
|
||||||
activityId: 9012,
|
activityId: 9012,
|
||||||
date: daysAgo(3),
|
date: daysAgo(3),
|
||||||
value: { id: 4242, file_name: "co-op-vision.pdf" },
|
value: { id: 4242, file_name: "co-op-vision.pdf", mime: "application/pdf" },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -373,6 +373,7 @@ async function buildLivePayload(orgId: number): Promise<StaffReportPayload> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const urlByFileId = new Map<number, string>();
|
const urlByFileId = new Map<number, string>();
|
||||||
|
const mimeByFileId = new Map<number, string>();
|
||||||
if (fileIds.size > 0) {
|
if (fileIds.size > 0) {
|
||||||
// APIv3 Attachment.get doesn't accept an IN-clause cleanly on this Civi
|
// APIv3 Attachment.get doesn't accept an IN-clause cleanly on this Civi
|
||||||
// install — passing {IN: [...]} for `id` crashes Civi's error renderer
|
// install — passing {IN: [...]} for `id` crashes Civi's error renderer
|
||||||
@@ -382,11 +383,15 @@ async function buildLivePayload(orgId: number): Promise<StaffReportPayload> {
|
|||||||
// small. Each request is independent; we issue them in parallel.
|
// small. Each request is independent; we issue them in parallel.
|
||||||
const lookups = await Promise.allSettled(
|
const lookups = await Promise.allSettled(
|
||||||
Array.from(fileIds).map((fid) =>
|
Array.from(fileIds).map((fid) =>
|
||||||
civi3<{ id: string | number; url?: string }>("Attachment", "get", {
|
civi3<{ id: string | number; url?: string; mime_type?: string }>(
|
||||||
|
"Attachment",
|
||||||
|
"get",
|
||||||
|
{
|
||||||
id: fid,
|
id: fid,
|
||||||
return: "id,url",
|
return: "id,url,mime_type",
|
||||||
sequential: 1,
|
sequential: 1,
|
||||||
}).then((r) => ({ fid, row: r.values?.[0] })),
|
},
|
||||||
|
).then((r) => ({ fid, row: r.values?.[0] })),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
for (const result of lookups) {
|
for (const result of lookups) {
|
||||||
@@ -401,6 +406,9 @@ async function buildLivePayload(orgId: number): Promise<StaffReportPayload> {
|
|||||||
if (row && typeof row.url === "string" && row.url.length > 0) {
|
if (row && typeof row.url === "string" && row.url.length > 0) {
|
||||||
urlByFileId.set(fid, row.url);
|
urlByFileId.set(fid, row.url);
|
||||||
}
|
}
|
||||||
|
if (row && typeof row.mime_type === "string" && row.mime_type.length > 0) {
|
||||||
|
mimeByFileId.set(fid, row.mime_type);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -447,6 +455,7 @@ async function buildLivePayload(orgId: number): Promise<StaffReportPayload> {
|
|||||||
id: raw,
|
id: raw,
|
||||||
file_name: typeof fname === "string" ? fname : undefined,
|
file_name: typeof fname === "string" ? fname : undefined,
|
||||||
url: Number.isFinite(fid) ? urlByFileId.get(fid) : undefined,
|
url: Number.isFinite(fid) ? urlByFileId.get(fid) : undefined,
|
||||||
|
mime: Number.isFinite(fid) ? mimeByFileId.get(fid) : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -480,6 +489,7 @@ async function buildLivePayload(orgId: number): Promise<StaffReportPayload> {
|
|||||||
id: v,
|
id: v,
|
||||||
file_name: typeof fname === "string" ? fname : undefined,
|
file_name: typeof fname === "string" ? fname : undefined,
|
||||||
url: Number.isFinite(fid) ? urlByFileId.get(fid) : undefined,
|
url: Number.isFinite(fid) ? urlByFileId.get(fid) : undefined,
|
||||||
|
mime: Number.isFinite(fid) ? mimeByFileId.get(fid) : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
entries.push({ activityId: row.id, date: row.activity_date_time, value });
|
entries.push({ activityId: row.id, date: row.activity_date_time, value });
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
computeDateRange,
|
computeDateRange,
|
||||||
} from "./report/FieldHistory";
|
} from "./report/FieldHistory";
|
||||||
import { LoadingState, EmptyState, ErrorState } from "./report/ReportStates";
|
import { LoadingState, EmptyState, ErrorState } from "./report/ReportStates";
|
||||||
|
import { FileLink } from "./report/FileLink";
|
||||||
|
|
||||||
interface StaffReportViewProps {
|
interface StaffReportViewProps {
|
||||||
org: number;
|
org: number;
|
||||||
@@ -207,6 +208,8 @@ export function StaffReportView({
|
|||||||
section={section}
|
section={section}
|
||||||
options={data.options}
|
options={data.options}
|
||||||
civiBaseUrl={civiBaseUrl}
|
civiBaseUrl={civiBaseUrl}
|
||||||
|
org={org}
|
||||||
|
authKey={authKey}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -271,10 +274,14 @@ function StaffSection({
|
|||||||
section,
|
section,
|
||||||
options,
|
options,
|
||||||
civiBaseUrl,
|
civiBaseUrl,
|
||||||
|
org,
|
||||||
|
authKey,
|
||||||
}: {
|
}: {
|
||||||
section: StaffReportSection;
|
section: StaffReportSection;
|
||||||
options: Record<number, SelectOption[]>;
|
options: Record<number, SelectOption[]>;
|
||||||
civiBaseUrl: string;
|
civiBaseUrl: string;
|
||||||
|
org: number;
|
||||||
|
authKey: string;
|
||||||
}) {
|
}) {
|
||||||
const filled = section.fields.filter((f) => f.history.length > 0);
|
const filled = section.fields.filter((f) => f.history.length > 0);
|
||||||
const empty = 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}
|
field={f}
|
||||||
options={options}
|
options={options}
|
||||||
civiBaseUrl={civiBaseUrl}
|
civiBaseUrl={civiBaseUrl}
|
||||||
|
org={org}
|
||||||
|
authKey={authKey}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -374,10 +383,14 @@ function CompactFieldRow({
|
|||||||
field,
|
field,
|
||||||
options,
|
options,
|
||||||
civiBaseUrl,
|
civiBaseUrl,
|
||||||
|
org,
|
||||||
|
authKey,
|
||||||
}: {
|
}: {
|
||||||
field: StaffReportField;
|
field: StaffReportField;
|
||||||
options: Record<number, SelectOption[]>;
|
options: Record<number, SelectOption[]>;
|
||||||
civiBaseUrl: string;
|
civiBaseUrl: string;
|
||||||
|
org: number;
|
||||||
|
authKey: string;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const latest = field.history[0];
|
const latest = field.history[0];
|
||||||
@@ -394,6 +407,8 @@ function CompactFieldRow({
|
|||||||
entry={latest}
|
entry={latest}
|
||||||
options={options}
|
options={options}
|
||||||
civiBaseUrl={civiBaseUrl}
|
civiBaseUrl={civiBaseUrl}
|
||||||
|
org={org}
|
||||||
|
authKey={authKey}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
{latest.date ? (
|
{latest.date ? (
|
||||||
@@ -431,6 +446,8 @@ function CompactFieldRow({
|
|||||||
entry={e}
|
entry={e}
|
||||||
options={options}
|
options={options}
|
||||||
civiBaseUrl={civiBaseUrl}
|
civiBaseUrl={civiBaseUrl}
|
||||||
|
org={org}
|
||||||
|
authKey={authKey}
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
@@ -446,41 +463,33 @@ function FieldValue({
|
|||||||
entry,
|
entry,
|
||||||
options,
|
options,
|
||||||
civiBaseUrl,
|
civiBaseUrl,
|
||||||
|
org,
|
||||||
|
authKey,
|
||||||
}: {
|
}: {
|
||||||
field: StaffReportField;
|
field: StaffReportField;
|
||||||
entry: FieldHistoryEntry;
|
entry: FieldHistoryEntry;
|
||||||
options: Record<number, SelectOption[]>;
|
options: Record<number, SelectOption[]>;
|
||||||
civiBaseUrl: string;
|
civiBaseUrl: string;
|
||||||
|
org: number;
|
||||||
|
authKey: string;
|
||||||
}) {
|
}) {
|
||||||
if (field.descriptor.render === "file") {
|
if (field.descriptor.render === "file") {
|
||||||
const v = entry.value as
|
const v = entry.value as
|
||||||
| { id?: number | string; file_name?: string; url?: string }
|
| { id?: number | string; file_name?: string; url?: string; mime?: string }
|
||||||
| null;
|
| null;
|
||||||
if (!v || v.id === undefined) return <span>—</span>;
|
if (!v || v.id === undefined) return <span>—</span>;
|
||||||
const id = String(v.id);
|
const id = String(v.id);
|
||||||
const name = v.file_name ?? `file-${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 (
|
return (
|
||||||
<a
|
<FileLink
|
||||||
href={href}
|
fileId={id}
|
||||||
target="_blank"
|
fileName={name}
|
||||||
rel="noopener noreferrer"
|
civiSignedUrl={v.url}
|
||||||
className="text-ink underline decoration-rule underline-offset-4 hover:decoration-ink"
|
mime={v.mime}
|
||||||
>
|
org={org}
|
||||||
{name}
|
authKey={authKey}
|
||||||
</a>
|
civiBaseUrl={civiBaseUrl}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Modal preview for image and PDF attachments.
|
||||||
|
*
|
||||||
|
* Uses the native <dialog> element for focus trap, Esc-to-close, and
|
||||||
|
* inert-background semantics — saves ~100 lines of bespoke a11y wiring
|
||||||
|
* that we'd otherwise have to maintain.
|
||||||
|
*
|
||||||
|
* For an `image/*` mime, renders an <img>. For `application/pdf`, an
|
||||||
|
* <iframe>. Anything else should not reach this component — FileLink is
|
||||||
|
* responsible for branching office/other types to plain download links.
|
||||||
|
*/
|
||||||
|
export function AttachmentLightbox({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
previewSrc,
|
||||||
|
downloadHref,
|
||||||
|
filename,
|
||||||
|
mime,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
/** URL the <img>/<iframe> loads from. Should serve with Content-Disposition: inline. */
|
||||||
|
previewSrc: string;
|
||||||
|
/** Anchor target for the Download button. Serves with Content-Disposition: attachment. */
|
||||||
|
downloadHref: string;
|
||||||
|
filename: string;
|
||||||
|
/** Resolved mime; used to pick between <img> and <iframe>. */
|
||||||
|
mime: string;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLDialogElement | null>(null);
|
||||||
|
|
||||||
|
// Drive the native <dialog>'s open state from our prop.
|
||||||
|
useEffect(() => {
|
||||||
|
const dlg = ref.current;
|
||||||
|
if (!dlg) return;
|
||||||
|
if (open && !dlg.open) {
|
||||||
|
dlg.showModal();
|
||||||
|
} else if (!open && dlg.open) {
|
||||||
|
dlg.close();
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// Native <dialog> fires a 'close' event on Esc and on form-method=dialog
|
||||||
|
// submit. Mirror that back into React state so the parent stays in sync.
|
||||||
|
useEffect(() => {
|
||||||
|
const dlg = ref.current;
|
||||||
|
if (!dlg) return;
|
||||||
|
const handle = () => onClose();
|
||||||
|
dlg.addEventListener("close", handle);
|
||||||
|
return () => dlg.removeEventListener("close", handle);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
// Close when the user clicks the backdrop (everything outside the inner
|
||||||
|
// panel). The dialog itself receives the click event when the backdrop
|
||||||
|
// is hit because the panel uses pointer-events the same way.
|
||||||
|
const onDialogClick = (e: React.MouseEvent<HTMLDialogElement>) => {
|
||||||
|
if (e.target === ref.current) onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const isImage = mime.startsWith("image/");
|
||||||
|
const isPdf = mime === "application/pdf";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<dialog
|
||||||
|
ref={ref}
|
||||||
|
onClick={onDialogClick}
|
||||||
|
aria-label={`Preview: ${filename}`}
|
||||||
|
className="m-0 h-full max-h-screen w-full max-w-screen-2xl rounded-none bg-transparent p-0 backdrop:bg-ink/70"
|
||||||
|
>
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<header className="flex items-center justify-between gap-4 bg-paper px-4 py-3 shadow-sm sm:px-6">
|
||||||
|
<p className="min-w-0 truncate font-display text-base text-ink">
|
||||||
|
{filename}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-shrink-0 items-center gap-3">
|
||||||
|
<a
|
||||||
|
href={downloadHref}
|
||||||
|
className="text-sm font-medium text-leaf-700 underline decoration-rule underline-offset-4 hover:decoration-ink hover:text-leaf-800"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="rounded px-2 py-1 text-sm font-medium text-ink-soft hover:bg-rule-soft/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-leaf-700"
|
||||||
|
aria-label="Close preview"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="flex flex-1 items-center justify-center overflow-hidden bg-ink/90 p-4">
|
||||||
|
{isImage ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={previewSrc}
|
||||||
|
alt={filename}
|
||||||
|
className="max-h-full max-w-full object-contain"
|
||||||
|
/>
|
||||||
|
) : isPdf ? (
|
||||||
|
<iframe
|
||||||
|
src={previewSrc}
|
||||||
|
title={filename}
|
||||||
|
className="h-full w-full max-w-screen-lg border-0 bg-paper"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
// Defensive: FileLink shouldn't open the lightbox for non-previewable
|
||||||
|
// types, but if it does, surface a clear message instead of an empty box.
|
||||||
|
<p className="px-6 text-paper">
|
||||||
|
Preview not available. Use Download above to open the file.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// @ts-check
|
||||||
|
/**
|
||||||
|
* Mime helpers for staff-report attachment rendering.
|
||||||
|
*
|
||||||
|
* The staff report shows file attachments with three different affordances:
|
||||||
|
* - images / PDF -> inline lightbox preview
|
||||||
|
* - office docs -> plain download + "View in Google Docs" link
|
||||||
|
* - anything else -> plain download
|
||||||
|
*
|
||||||
|
* Civi can serve a mime via `Attachment.get`, but historical uploads may
|
||||||
|
* have a stale or missing `mime_type` column. Fall back to extension-based
|
||||||
|
* inference so we always reach a stable category.
|
||||||
|
*
|
||||||
|
* Written as JS+JSDoc rather than TS so Node's built-in --test runner can
|
||||||
|
* import this file directly without any tooling — matches the pattern set
|
||||||
|
* by lib/staff-field-mapping.mjs.
|
||||||
|
*
|
||||||
|
* @typedef {"image" | "pdf" | "office" | "other"} AttachmentCategory
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {Record<string, string>} */
|
||||||
|
const EXT_TO_MIME = {
|
||||||
|
pdf: "application/pdf",
|
||||||
|
png: "image/png",
|
||||||
|
jpg: "image/jpeg",
|
||||||
|
jpeg: "image/jpeg",
|
||||||
|
gif: "image/gif",
|
||||||
|
webp: "image/webp",
|
||||||
|
doc: "application/msword",
|
||||||
|
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
xls: "application/vnd.ms-excel",
|
||||||
|
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
};
|
||||||
|
|
||||||
|
const OFFICE_MIMES = new Set([
|
||||||
|
"application/msword",
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
"application/vnd.ms-excel",
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull the lowercased extension from a filename, or "" if absent.
|
||||||
|
* @param {string | undefined | null} filename
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function extOf(filename) {
|
||||||
|
if (!filename) return "";
|
||||||
|
const dot = filename.lastIndexOf(".");
|
||||||
|
if (dot < 0 || dot === filename.length - 1) return "";
|
||||||
|
return filename.slice(dot + 1).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map a filename's extension to a known mime, or null if unrecognised.
|
||||||
|
* @param {string | undefined | null} filename
|
||||||
|
* @returns {string | null}
|
||||||
|
*/
|
||||||
|
export function mimeFromFilename(filename) {
|
||||||
|
const ext = extOf(filename);
|
||||||
|
return EXT_TO_MIME[ext] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a mime by trusting the explicit value first, then falling back to
|
||||||
|
* filename inference. Returns "application/octet-stream" if nothing matches.
|
||||||
|
* @param {string | undefined | null} explicit
|
||||||
|
* @param {string | undefined | null} filename
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function resolveMime(explicit, filename) {
|
||||||
|
if (explicit && explicit !== "application/octet-stream") return explicit;
|
||||||
|
return mimeFromFilename(filename) ?? explicit ?? "application/octet-stream";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Categorise a mime for UI dispatch.
|
||||||
|
* @param {string} mime
|
||||||
|
* @returns {AttachmentCategory}
|
||||||
|
*/
|
||||||
|
export function categoryFromMime(mime) {
|
||||||
|
if (mime.startsWith("image/")) return "image";
|
||||||
|
if (mime === "application/pdf") return "pdf";
|
||||||
|
if (OFFICE_MIMES.has(mime)) return "office";
|
||||||
|
return "other";
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// Run with: npm run test:mime
|
||||||
|
import test from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import {
|
||||||
|
extOf,
|
||||||
|
mimeFromFilename,
|
||||||
|
resolveMime,
|
||||||
|
categoryFromMime,
|
||||||
|
} from "./mime.mjs";
|
||||||
|
|
||||||
|
test("extOf returns lowercased extension", () => {
|
||||||
|
assert.equal(extOf("Photo.JPG"), "jpg");
|
||||||
|
assert.equal(extOf("doc.tar.gz"), "gz");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("extOf handles missing or trailing-dot names", () => {
|
||||||
|
assert.equal(extOf(""), "");
|
||||||
|
assert.equal(extOf(null), "");
|
||||||
|
assert.equal(extOf("no-extension"), "");
|
||||||
|
assert.equal(extOf("trailing."), "");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mimeFromFilename recognises known types", () => {
|
||||||
|
assert.equal(mimeFromFilename("vision.pdf"), "application/pdf");
|
||||||
|
assert.equal(mimeFromFilename("budget.XLSX"),
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||||
|
assert.equal(mimeFromFilename("logo.PNG"), "image/png");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mimeFromFilename returns null for unknown extensions", () => {
|
||||||
|
assert.equal(mimeFromFilename("archive.zip"), null);
|
||||||
|
assert.equal(mimeFromFilename("readme"), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveMime trusts an explicit non-octet mime", () => {
|
||||||
|
assert.equal(resolveMime("application/pdf", "anything.txt"), "application/pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveMime falls back to filename when explicit is missing", () => {
|
||||||
|
assert.equal(resolveMime(undefined, "report.pdf"), "application/pdf");
|
||||||
|
assert.equal(resolveMime(null, "image.gif"), "image/gif");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveMime falls back to filename when explicit is octet-stream", () => {
|
||||||
|
assert.equal(
|
||||||
|
resolveMime("application/octet-stream", "spreadsheet.xlsx"),
|
||||||
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolveMime returns octet-stream when nothing is known", () => {
|
||||||
|
assert.equal(resolveMime(null, null), "application/octet-stream");
|
||||||
|
assert.equal(resolveMime(undefined, "unknownfile"), "application/octet-stream");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("categoryFromMime: image", () => {
|
||||||
|
assert.equal(categoryFromMime("image/png"), "image");
|
||||||
|
assert.equal(categoryFromMime("image/webp"), "image");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("categoryFromMime: pdf", () => {
|
||||||
|
assert.equal(categoryFromMime("application/pdf"), "pdf");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("categoryFromMime: office variants", () => {
|
||||||
|
assert.equal(categoryFromMime("application/msword"), "office");
|
||||||
|
assert.equal(
|
||||||
|
categoryFromMime("application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
|
||||||
|
"office",
|
||||||
|
);
|
||||||
|
assert.equal(categoryFromMime("application/vnd.ms-excel"), "office");
|
||||||
|
assert.equal(
|
||||||
|
categoryFromMime("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"),
|
||||||
|
"office",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("categoryFromMime: other", () => {
|
||||||
|
assert.equal(categoryFromMime("application/zip"), "other");
|
||||||
|
assert.equal(categoryFromMime("text/plain"), "other");
|
||||||
|
assert.equal(categoryFromMime("application/octet-stream"), "other");
|
||||||
|
});
|
||||||
+3
-1
@@ -8,7 +8,9 @@
|
|||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
"sync-help": "node --env-file=.env.local scripts/sync-help-from-civi.mjs",
|
"sync-help": "node --env-file=.env.local scripts/sync-help-from-civi.mjs",
|
||||||
"test:mapping": "node --test lib/staff-field-mapping.test.mjs"
|
"test:mapping": "node --test lib/staff-field-mapping.test.mjs",
|
||||||
|
"test:mime": "node --test lib/mime.test.mjs",
|
||||||
|
"test": "node --test lib/staff-field-mapping.test.mjs lib/mime.test.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "16.2.6",
|
"next": "16.2.6",
|
||||||
|
|||||||
Reference in New Issue
Block a user