src/components/certifications/certification-card-image.tsx
Rama main · réplica de lectura del repositorio original.
"use client";
import { useCallback, type ReactNode } from "react";
import { FileText, ImageOff } from "lucide-react";
import {
certificationsAPI,
CertificationFileInfo,
CertificationSide,
} from "@/lib/api/certifications";
import { useAuthedBlobUrl } from "@/hooks/useAuthedBlobUrl";
import { getApiErrorMessage } from "@/lib/api/error";
import { shrinkPicture } from "@/lib/shrink-picture";
import {
CERTIFICATION_CARD_ASPECT_CLASS,
certificationFileVersion,
} from "@/lib/certification";
import { cn } from "@/lib/utils";
import { ButtonSpinner } from "@/components/ui/button-spinner";
interface CertificationCardFrameProps {
/**
* Draws the frame at a thumbnail's width instead of filling its column. It
* changes the *width* only: the shape is the same everywhere, which is the
* whole point of the frame existing.
*/
compact?: boolean;
/** Dashed, for a slot with nothing in it. */
empty?: boolean;
className?: string;
/** Announces what the frame stands for when it holds no picture to describe. */
"aria-label"?: string;
title?: string;
children?: ReactNode;
}
/**
* The box a c-card is drawn in, wherever one is drawn: the standard card shape,
* the app's own corner radius, and `overflow-hidden` so a picture filling it is
* clipped to those corners rather than sitting square inside them.
*
* Exported because a card is not always a picture - a stored PDF, a slot the diver
* has not filled, a fetch that failed and the check-in sheet's own PDF note all
* occupy the same footprint, and one box is what keeps them to one size.
*/
export function CertificationCardFrame({
compact = false,
empty = false,
className,
children,
...rest
}: CertificationCardFrameProps) {
return (
<div
className={cn(
// `self-start` guards the aspect ratio rather than tidying anything:
// `aspect-ratio` applies only to a box whose height is auto, and a flex or
// grid item stretches to its line by default. Inert where the frame is an
// ordinary block child, which is every caller today.
"relative flex items-center justify-center self-start overflow-hidden rounded-lg border bg-muted",
// A picture fills the frame out of flow. As a flex item inside a table
// cell Safari draws it far larger than the frame, which then shows a
// close-up of its middle. `cover`, not `contain`: a
// card cropped on upload fills this exactly, and an uncropped one loses a
// few percent of its edge rather than sitting in a letterbox, which makes
// the same picture look a different size per page.
"[&>img]:absolute [&>img]:inset-0 [&>img]:size-full [&>img]:object-cover",
CERTIFICATION_CARD_ASPECT_CLASS,
compact ? "w-20" : "w-full",
empty && "border-dashed",
className,
)}
{...rest}
>
{children}
</div>
);
}
interface CertificationCardImageProps {
certificationUuid: string;
side: CertificationSide;
// The embedded metadata for this side, or undefined if it has no file. Passing
// it in (rather than probing the API) is what lets a list of cards render
// without a request per empty slot.
file?: CertificationFileInfo;
className?: string;
// Renders the "no image" state as a compact placeholder instead of a full
// dashed panel, for a small thumbnail such as the check-in sheet's.
compact?: boolean;
// Redraws the picture at no more than this many pixels on its long edge - see
// `shrinkPicture`. The stored file as it is when unset.
maxEdge?: number;
}
// Renders one side of a stored certification card.
//
// Images are fetched with the API client and shown from an object URL, because
// the endpoint is owner-only and an `<img src>` cannot carry an `Authorization`
// header - see `hooks/useAuthedBlobUrl.ts`.
//
// PDFs are deliberately *not* previewed. Rendering one inline would mean an
// `<object>` or `<iframe>`, and the app's CSP sets `object-src 'none'` with a
// narrow `frame-src`; loosening either to display user-uploaded documents is a
// bad trade for a preview. They show as a labelled file instead, and the detail
// view offers a download. In practice this is the rare case - c-cards get
// photographed far more often than scanned.
export function CertificationCardImage({
certificationUuid,
side,
file,
className,
compact = false,
maxEdge,
}: CertificationCardImageProps) {
const isPdf = file?.content_type === "application/pdf";
// Identifies this version of the file - see `certificationFileVersion`. Without
// it in the deps below, replacing a card image leaves the old one on screen.
const version = certificationFileVersion(file);
// Stable per file version so the hook doesn't refetch every render. Null when
// there's nothing to fetch, which also covers the PDF case.
const fetchBlob = useCallback(async () => {
const blob = await certificationsAPI.getCertificationFileBlob(
certificationUuid,
side,
version ?? undefined,
);
return maxEdge ? shrinkPicture(blob, maxEdge) : blob;
}, [certificationUuid, side, version, maxEdge]);
const { url, isLoading, hasError, error } = useAuthedBlobUrl(
file && !isPdf ? fetchBlob : null,
);
if (!file) {
// An empty slot is a normal state rather than a gap - most modern e-cards
// are one-sided, so a card with nothing on the back is the common case. The
// label names the side and the visible text does not, because a compact
// frame is drawn with no heading beside it to say which slot it is.
return (
<CertificationCardFrame
compact={compact}
empty
className={className}
aria-label={`No ${side} image uploaded`}
>
{!compact && (
<span className="text-xs text-muted-foreground">Not uploaded</span>
)}
</CertificationCardFrame>
);
}
if (isPdf) {
return (
<CertificationCardFrame
compact={compact}
className={cn("flex-col gap-1 text-muted-foreground", className)}
>
<FileText className={compact ? "h-4 w-4" : "h-6 w-6"} />
{!compact && <span className="text-xs">PDF</span>}
</CertificationCardFrame>
);
}
if (isLoading) {
return (
<CertificationCardFrame compact={compact} className={className}>
<ButtonSpinner className="text-muted-foreground" />
</CertificationCardFrame>
);
}
if (hasError || !url) {
// The API's own wording where it has one ("No front image for this
// certification"), which is far more actionable than "couldn't load" - the
// response interceptor unwraps the blob-wrapped error body so this reads as
// a normal JSON error. Falls back for the network-failure case, which has no
// body at all.
const message = getApiErrorMessage(error, "Couldn't load image");
return (
<CertificationCardFrame
compact={compact}
className={cn("flex-col gap-1 px-2 text-muted-foreground", className)}
title={message}
>
<ImageOff className={cn("shrink-0", compact ? "h-4 w-4" : "h-6 w-6")} />
{!compact && (
<span className="line-clamp-2 text-center text-xs">{message}</span>
)}
</CertificationCardFrame>
);
}
return (
<CertificationCardFrame compact={compact} className={className}>
{/* Deliberately a plain `<img>` rather than `next/image`: the source is a
runtime object URL for private bytes, which the image optimizer can
neither fetch nor cache. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={url} alt={`${side} of certification card`} />
</CertificationCardFrame>
);
}