"use client"; import { useEffect, useRef, useState } from "react"; import type { Area } from "react-easy-crop"; import { certificationFile, Certification, CertificationSide, CERTIFICATION_SIDES, MAX_CERTIFICATION_FILE_SIZE, } from "@/lib/api/certifications"; import type { CertificationCardEdits } from "@/lib/certification-card-edits"; import { CERTIFICATION_CARD_ASPECT, CERTIFICATION_CARD_EXPORT_WIDTH, } from "@/lib/certification"; import { getApiErrorMessage } from "@/lib/api/error"; import { cropToBlob, croppedFilename, decodeImage, ImageCropError, } from "@/lib/image-crop"; import { ImageCropDialog } from "@/components/ui/image-crop-dialog"; import { useToast } from "@/components/ui/use-toast"; import { CertificationCardSlot, PDF_CONTENT_TYPE, } from "./certification-card-slot"; interface CertificationCardFilesProps { // The certification being edited, or undefined on the create form - where there // is nothing stored yet and every slot starts empty. certification?: Certification | null; edits: CertificationCardEdits; onChange: (edits: CertificationCardEdits) => void; disabled?: boolean; } /** * The card-images half of the certification form: what each side holds now, and * what saving the form will do to it. * * **Nothing is uploaded from here.** Picking an image crops it and parks the bytes * in `edits`; the Trash marks a stored file and turns into an Undo. The form sends * the lot after the details save (`applyCertificationCardEdits`), so Cancel leaves * the stored cards exactly as it found them - the same contract as the dive form's * file list, and the reason this is a section of the form rather than a dialog of * its own reached from a second button. * * Both slots are always shown, empty or not, and neither is required - no card * image reaches `certificationSchema` at all. The second is the one most divers * leave empty, modern e-cards being usually one-sided. Keeping it on screen is what * lets the diver holding a two-sided card - TDI/SDI still prints one - find * somewhere to put the back without hunting for a control that only appears once it * is needed. */ export function CertificationCardFiles({ certification, edits, onChange, disabled = false, }: CertificationCardFilesProps) { const { toast } = useToast(); // The side whose picked image is being cropped, and the object URL it decodes // from. Held together because one without the other is not a state this has. const [cropping, setCropping] = useState<{ side: CertificationSide; url: string; } | null>(null); useEffect(() => { if (!cropping) return; return () => URL.revokeObjectURL(cropping.url); }, [cropping]); // Every preview URL this section has handed out, so the ones still outstanding // can be released when it goes. An object URL is held by the document until it // is revoked explicitly, and the edits themselves outlive this component - the // form holds them. const previewUrls = useRef(new Set()); useEffect(() => { const urls = previewUrls.current; return () => { for (const url of urls) URL.revokeObjectURL(url); urls.clear(); }; }, []); // Releases whatever the edit being replaced was previewing. A slot the diver // re-picks twice would otherwise leak the first two images for the life of the // page. const releasePreview = (side: CertificationSide) => { const previous = edits[side]; const url = previous?.kind === "replace" ? previous.image.previewUrl : null; if (!url) return; URL.revokeObjectURL(url); previewUrls.current.delete(url); }; const setEdit = ( side: CertificationSide, edit: NonNullable, ) => { releasePreview(side); if (edit.kind === "replace" && edit.image.previewUrl) { previewUrls.current.add(edit.image.previewUrl); } onChange({ ...edits, [side]: edit }); }; const clearEdit = (side: CertificationSide) => { releasePreview(side); const next = { ...edits }; delete next[side]; onChange(next); }; const showError = (error: unknown, fallback: string) => toast({ title: "Error", // A failure raised in this browser carries its own message and has no // response for `getApiErrorMessage` to read, which would swap it for the // fallback. description: error instanceof ImageCropError ? error.message : getApiErrorMessage(error, fallback), variant: "destructive", }); const handlePick = async (side: CertificationSide, file: File) => { // Checked here as well as by the API so a diver on a slow connection isn't // made to upload 40 MB before being told no. The API re-checks regardless, // and its check is the one that counts. if (file.size > MAX_CERTIFICATION_FILE_SIZE) { toast({ title: "File too large", description: "Card images must be 10 MB or smaller.", variant: "destructive", }); return; } // A PDF has no crop step: it is stored whole and never rendered, so there is // nothing to frame and no canvas that could read it. if (file.type === PDF_CONTENT_TYPE) { setEdit(side, { kind: "replace", image: { blob: file, filename: file.name }, }); return; } const url = URL.createObjectURL(file); // Decode before opening the cropper rather than discovering the problem // inside it: `react-easy-crop` has no failure callback, so bytes it cannot // read leave an empty frame and a Save button that never enables. The live // case is a HEIC picked through the iOS Files app, which walks past `accept`. try { await decodeImage(url); } catch (error) { URL.revokeObjectURL(url); showError(error, "That file could not be read as an image."); return; } setCropping({ side, url }); }; const handleCropped = async (area: Area) => { if (!cropping) return; const { side, url } = cropping; try { // WebP, not PNG: this API stores what it is given rather than re-encoding // it, so the encoding chosen here is the one the diver's card lives in // forever. A browser with no WebP encoder falls back to PNG on its own - // the spec says so - which is why the name comes off the blob's own type. const blob = await cropToBlob(url, area, { maxWidth: CERTIFICATION_CARD_EXPORT_WIDTH, type: "image/webp", quality: 0.9, }); setEdit(side, { kind: "replace", image: { blob, filename: croppedFilename(`card-${side}`, blob), previewUrl: URL.createObjectURL(blob), }, }); setCropping(null); } catch (error) { showError(error, "That image could not be prepared."); } }; return (
{CERTIFICATION_SIDES.map((side) => { const edit = edits[side]; return ( void handlePick(side, file)} onRemove={() => setEdit(side, { kind: "remove" })} onUndo={() => clearEdit(side)} /> ); })}
{/* Said before the upload rather than after it: RAID hands out a PNG and a PDF of the same card, so the choice is the diver's to make here. The formats named are every non-PDF entry in `CERTIFICATION_FILE_ACCEPT`. Why a PDF cannot be shown is `CertificationCardImage`'s to explain. */}

PNG, JPEG and WEBP images are shown here. A PDF is stored and can be downloaded, but not displayed — if your agency offers both, pick the image.

{cropping && ( setCropping(null)} onSave={(area) => void handleCropped(area)} /> )}
); }