src/components/certifications/certification-card-files.tsx
Rama main · réplica de lectura del repositorio original.
"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<string>());
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<CertificationCardEdits[CertificationSide]>,
) => {
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 (
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
{CERTIFICATION_SIDES.map((side) => {
const edit = edits[side];
return (
<CertificationCardSlot
key={side}
side={side}
certificationUuid={certification?.uuid ?? ""}
stored={
certification
? certificationFile(certification, side)
: undefined
}
pending={edit?.kind === "replace" ? edit.image : null}
isRemoved={edit?.kind === "remove"}
disabled={disabled}
onPick={(file) => void handlePick(side, file)}
onRemove={() => setEdit(side, { kind: "remove" })}
onUndo={() => clearEdit(side)}
/>
);
})}
</div>
{/* 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. */}
<p className="text-xs text-muted-foreground">
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.
</p>
{cropping && (
<ImageCropDialog
imageSrc={cropping.url}
aspect={CERTIFICATION_CARD_ASPECT}
title="Frame Your Card"
description="Drag to move, pinch or use the slider to zoom. Everything inside the frame is saved, and that is the shape cards are shown in."
saveLabel="Use this crop"
savingLabel="Preparing..."
isSaving={false}
onCancel={() => setCropping(null)}
onSave={(area) => void handleCropped(area)}
/>
)}
</div>
);
}