Ctrl K
ComunidadGuías…

src/components/certifications/certification-card-slot.tsx

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"use client";

import { useRef } from "react";
import { FileText, Replace, Trash2, Undo2, Upload } from "lucide-react";
import {
  CertificationSide,
  CertificationFileInfo,
  CERTIFICATION_FILE_ACCEPT,
  CERTIFICATION_SIDE_LABELS,
} from "@/lib/api/certifications";
import type { PendingCardImage } from "@/lib/certification-card-edits";
import { formatFileSize } from "@/lib/format";
import { Button } from "@/components/ui/button";
import { IconTooltip } from "@/components/ui/tooltip";
import {
  CertificationCardFrame,
  CertificationCardImage,
} from "./certification-card-image";

/** A PDF is stored whole and never rendered - see `CertificationCardImage`. */
export const PDF_CONTENT_TYPE = "application/pdf";

interface CertificationCardSlotProps {
  side: CertificationSide;
  /** The uuid to fetch a stored image from, or "" on the create form. */
  certificationUuid: string;
  /** What the API holds for this side today. */
  stored?: CertificationFileInfo;
  /** Bytes the diver has picked, which the stored image gives way to. */
  pending: PendingCardImage | null;
  /** True once the stored image is struck off, pending the save. */
  isRemoved: boolean;
  disabled: boolean;
  onPick: (file: File) => void;
  onRemove: () => void;
  /** Takes back a removal, or drops a picked image. */
  onUndo: () => void;
}

/**
 * One side of a card in the certification form: what the slot holds, what saving
 * will do to it, and the two controls that change either.
 *
 * Presentation only - it neither uploads nor crops. `CertificationCardFiles` owns
 * the picked bytes and the edit list, which is what makes "nothing is sent until
 * the form is saved" a property of one place rather than of two.
 */
export function CertificationCardSlot({
  side,
  certificationUuid,
  stored,
  pending,
  isRemoved,
  disabled,
  onPick,
  onRemove,
  onUndo,
}: CertificationCardSlotProps) {
  // Its own input, now that a slot is its own component: the picker has to be
  // opened from a Button, which cannot be a file input.
  const inputRef = useRef<HTMLInputElement>(null);
  const label = CERTIFICATION_SIDE_LABELS[side].toLowerCase();
  const showsSomething = !!pending || (!!stored && !isRemoved);

  return (
    <div className="space-y-2">
      <p className="text-sm font-medium">{CERTIFICATION_SIDE_LABELS[side]}</p>

      {pending ? (
        <PendingCardPreview image={pending} side={side} />
      ) : (
        <CertificationCardImage
          certificationUuid={certificationUuid}
          side={side}
          file={isRemoved ? undefined : stored}
        />
      )}

      <input
        type="file"
        accept={CERTIFICATION_FILE_ACCEPT}
        className="hidden"
        aria-label={`Choose a ${label} card image`}
        onChange={(event) => {
          const file = event.target.files?.[0];
          // Cleared unconditionally, so picking the *same* file again after a
          // cancelled crop still fires a change event.
          event.target.value = "";
          if (file) onPick(file);
        }}
        ref={inputRef}
      />

      <div className="flex items-center gap-2">
        <Button
          type="button"
          variant="outline"
          size="sm"
          disabled={disabled}
          onClick={() => inputRef.current?.click()}
        >
          {showsSomething ? (
            <Replace className="h-4 w-4 mr-2" />
          ) : (
            <Upload className="h-4 w-4 mr-2" />
          )}
          {/* What the button does to what the slot is showing, not to what is
              stored: a picked image is as replaceable as a saved one. */}
          {showsSomething ? "Replace" : "Upload"}
        </Button>

        {isRemoved ? (
          <IconTooltip label={`Keep the ${side} image`}>
            <Button
              type="button"
              variant="ghost"
              size="sm"
              disabled={disabled}
              onClick={onUndo}
            >
              <Undo2 className="h-4 w-4" />
            </Button>
          </IconTooltip>
        ) : (
          showsSomething && (
            <IconTooltip
              label={
                pending
                  ? `Discard the new ${side} image`
                  : `Remove the ${side} image`
              }
            >
              <Button
                type="button"
                variant="ghost"
                size="sm"
                disabled={disabled}
                // A picked image is dropped rather than marked: there is nothing
                // stored yet for a save to delete, so the edit simply goes.
                onClick={pending ? onUndo : onRemove}
              >
                <Trash2 className="h-4 w-4" />
              </Button>
            </IconTooltip>
          )
        )}
      </div>

      {/* Every deferred state says so in words. The preview alone cannot: a slot
          showing a new picture and a slot showing the stored one look the same
          until the diver is told which is which. */}
      <SlotCaption
        pending={pending}
        isRemoved={isRemoved}
        stored={isRemoved ? undefined : stored}
        replacesStored={!!stored}
      />
    </div>
  );
}

/**
 * The picked bytes, drawn in the same frame the stored card will be.
 *
 * A PDF gets the labelled box `CertificationCardImage` gives a stored one, for the
 * reason that component explains: the app's CSP has no way to render one inline.
 * It carries no `previewUrl` for the same reason.
 */
function PendingCardPreview({
  image,
  side,
}: {
  image: PendingCardImage;
  side: CertificationSide;
}) {
  if (image.blob.type === PDF_CONTENT_TYPE) {
    return (
      <CertificationCardFrame className="flex-col gap-1 text-muted-foreground">
        <FileText className="h-6 w-6" />
        <span className="text-xs">PDF</span>
      </CertificationCardFrame>
    );
  }

  return (
    <CertificationCardFrame>
      {image.previewUrl && (
        // eslint-disable-next-line @next/next/no-img-element
        <img src={image.previewUrl} alt={`New ${side} of certification card`} />
      )}
    </CertificationCardFrame>
  );
}

// What this slot will hold once the form is saved, in words.
function SlotCaption({
  pending,
  isRemoved,
  replacesStored,
  stored,
}: {
  pending: PendingCardImage | null;
  isRemoved: boolean;
  replacesStored: boolean;
  stored?: CertificationFileInfo;
}) {
  if (pending) {
    return (
      <p className="text-xs text-muted-foreground">
        {replacesStored ? "Replaces the stored image" : "Added"} when you save ·{" "}
        {formatFileSize(pending.blob.size)}
      </p>
    );
  }

  if (isRemoved) {
    return (
      <p className="text-xs text-muted-foreground">Deleted when you save</p>
    );
  }

  if (stored) {
    return (
      <p className="text-xs text-muted-foreground truncate">
        {stored.original_filename} · {formatFileSize(stored.byte_size)}
      </p>
    );
  }

  return null;
}