Three quick fixes off first-deploy testing: 1. /api/staff/file 404'd for valid files. Refactor fileBelongsToOrg to SELECT the org's and activities' file columns and JS-compare instead of WHERE ... OR with custom field refs (APIv4 fragility around nested OR + dotted custom fields). Same ownership probe, same shape /api/staff/report itself uses to read file values. 2. Lightbox ballooned to full report height because the staff iframe auto-grows to fit content (often 3000+ px). Cap to a fixed 640px x min(92vw, 900px) box so it stays a reasonable preview regardless of iframe document size. 3. Production frame-ancestors blocked crm.fci.coop from iframing survey.fci.coop -- the CSP only included the dev Civi origin derived from CIVI_BASE_URL. Add CIVI_FRAME_ALLOWED_ORIGINS (comma-separated) so one app deploy can be embedded by both dev and prod Civi. Falls back to CIVI_BASE_URL for single-Civi compatibility. PRODUCTION_CUTOVER.md updated inline and in the change log.
129 lines
4.6 KiB
TypeScript
129 lines
4.6 KiB
TypeScript
"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 (
|
||
// Sizing note: this dialog opens inside the staff-report iframe, which
|
||
// auto-grows to fit content (often 2000–4000 px tall). "h-full"/"vh"
|
||
// values inside that iframe resolve to the full iframe document, so the
|
||
// dialog would balloon. Cap to a fixed pixel box that fits comfortably
|
||
// on a typical laptop and still gives PDFs/images enough room.
|
||
<dialog
|
||
ref={ref}
|
||
onClick={onDialogClick}
|
||
aria-label={`Preview: ${filename}`}
|
||
className="m-auto w-[min(92vw,900px)] rounded-md bg-transparent p-0 shadow-2xl backdrop:bg-ink/70"
|
||
>
|
||
<div className="flex h-[640px] max-h-[85vh] flex-col overflow-hidden rounded-md">
|
||
<header className="flex items-center justify-between gap-4 bg-paper px-4 py-2.5 sm:px-5">
|
||
<p className="min-w-0 truncate font-display text-sm text-ink">
|
||
{filename}
|
||
</p>
|
||
<div className="flex flex-shrink-0 items-center gap-3">
|
||
<a
|
||
href={downloadHref}
|
||
className="text-xs 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-xs 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-3">
|
||
{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 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>
|
||
);
|
||
}
|