"use client"; /** * One document in the vault timeline (compact-density Card) plus the two * transient upload-state cards (in-flight with a live progress ring, failed * with the translated §3.2 reason). All three share the card footprint so an * optimistic entry morphs into the stored row without the grid jumping. * * Anatomy per the design standards: a leading edge that shows a small preview * thumbnail tile when one has been rendered (`hasThumbnail`) and otherwise the * kind icon `text-foreground size-5`, title in foreground (`text-sm * font-medium`, truncated), a muted meta line (date · size) with the real * filename on its own muted line below (full name on hover via `title`), * condition-tag pills, an attachment-class badge for download-only formats, * and a selection checkbox that appears on hover / focus / while selected. The * whole card is clickable through an invisible overlay button; the checkbox * floats above it. */ import { Check, Download, Loader2, X } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Checkbox } from "@/components/ui/checkbox"; import { useFencedObjectUrl } from "@/hooks/use-fenced-object-url"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { cn } from "@/lib/utils"; import type { InboundDocumentDto } from "@/lib/validations/inbound-documents"; import { DOCUMENT_KIND_ICONS } from "./document-kind-meta"; import type { UploadQueueItem } from "./use-document-upload"; import { documentCardKeyAction, documentDateKey, formatBytes, isDocumentProcessing, } from "./vault-utils"; /** How long the "Ready" chip stays up after a card's index lands. */ const READY_FLASH_MS = 3000; /** Press-and-hold duration that flips a touch press into "select" mode. */ const LONG_PRESS_MS = 500; export function DocumentCard({ document, selected, onToggleSelected, onOpen, onDelete, highlighted, tabIndex = 0, onCardFocus, onPrefetch, }: { document: InboundDocumentDto; selected: boolean; /** `range` = extend the selection from the last anchor (shift-click). */ /** Omitted inside somebody else's record: selection feeds bulk edits. */ onToggleSelected?: (id: string, range?: boolean) => void; onOpen: (id: string) => void; /** Delete key on the focused card — undo-able delete owned by the page. */ onDelete?: (id: string) => void; /** Brief ring after a duplicate upload resolved to this existing row. */ highlighted: boolean; /** Roving-tabindex slot from the timeline (0 = the one tabbable card). */ tabIndex?: number; /** The open-button gained focus (keyboard/roving bookkeeping). */ onCardFocus?: (id: string) => void; /** Hover/focus intent — prefetches the detail metadata (never the blob). */ onPrefetch?: (id: string) => void; }) { const { t, locale } = useTranslations(); const format = useFormatters(); // A preview thumbnail that fails to load (still rendering, decrypt error, // 404) falls back to the kind icon — never a broken image. const [thumbFailed, setThumbFailed] = useState(false); // v1.37.0 — fetched through the app transport rather than issued by the // browser as a subresource, so it carries the record-session assertion. The // null path means "this card has no thumbnail, or one already failed", which // is what keeps the timeline from firing a request per card for documents // that have nothing to show. const { url: thumbnailUrl, failed: thumbnailFailed } = useFencedObjectUrl( document.hasThumbnail && !thumbFailed ? `/api/documents/inbound/${document.id}/thumbnail` : null, ); // Touch long-press selects instead of opening; the click that the // browser fires after the release is swallowed once. const longPressTimer = useRef | null>(null); const longPressFired = useRef(false); const cancelLongPress = () => { if (longPressTimer.current) { clearTimeout(longPressTimer.current); longPressTimer.current = null; } }; const title = document.title ?? document.filename ?? t("documents.card.untitled"); const Icon = DOCUMENT_KIND_ICONS[document.kind]; const date = format.date(`${documentDateKey(document)}T12:00:00.000Z`); const size = formatBytes(document.byteSize, locale); const showFilename = document.filename !== null && document.filename !== title; // v1.29.x — processing/ready feedback for the fire-and-forget auto-index // (+ optional AI summary) job that starts right after upload. `processing` // is derived straight from the DTO (bounded to the recent-upload window, so // an old/never-indexed document never shows a stuck spinner). `justIndexed` // is local: it flashes once when a card that WAS processing on this page // transitions to indexed (the poll in `documents-view.tsx` is what drives // the transition), then clears on its own — a one-time "it landed" cue, // not a permanent tint. const processing = isDocumentProcessing(document); const wasProcessingRef = useRef(processing); const [justIndexed, setJustIndexed] = useState(false); useEffect(() => { if (wasProcessingRef.current && !processing) { setJustIndexed(true); const timer = setTimeout(() => setJustIndexed(false), READY_FLASH_MS); wasProcessingRef.current = processing; return () => clearTimeout(timer); } wasProcessingRef.current = processing; }, [processing]); return (
{thumbnailUrl && !thumbnailFailed ? ( // Leading-edge preview tile. Decorative (alt="") — the title // beside it names the document. The laziness that `loading="lazy"` // used to provide now lives in the hook's null path: a card with no // thumbnail, or one whose thumbnail already failed, issues no // request at all. onError falls back to the kind icon. {/* An object URL over bytes the app transport already fetched, not a `/api/…` subresource: the browser issues a subresource itself and so cannot carry the record-session assertion, which the fence refuses on any session that has been inside a shared record. `next/image` is wrong here for the older reason too — it would proxy a PHI blob we deliberately never cache. */} {/* eslint-disable-next-line @next/next/no-img-element */} setThumbFailed(true)} className="size-full object-cover" /> ) : ( )}

{title}

{date} · {size}

{showFilename ? ( // Filename on its OWN muted line so it never clips the date/size // meta; the full name stays reachable on hover via `title`.

{document.filename}

) : null}
{onToggleSelected && ( { e.preventDefault(); onToggleSelected(document.id, e.shiftKey); }} aria-label={t("documents.card.selectLabel", { title })} className={cn( "relative z-10 shrink-0 transition-opacity", "opacity-0 group-focus-within:opacity-100 group-hover:opacity-100", selected && "opacity-100", )} /> )}
{document.conditionLinks.length > 0 || document.servingClass === "attachment" || processing || justIndexed ? (
{document.conditionLinks.map((link) => ( {link.name} ))} {document.servingClass === "attachment" ? ( {t("documents.card.attachmentBadge")} ) : null} {processing ? ( {t("documents.card.processing")} ) : justIndexed ? ( {t("documents.card.ready")} ) : null}
) : null}
{/* Whole-card click target. Painted last so it sits above the content (the checkbox stays reachable via its own z-10). Keyboard contract (roving tabindex owned by the timeline): Enter opens (native click), Space toggles selection, Delete removes with undo. Touch: press-and-hold selects instead of opening. Backspace is deliberately NOT a delete key here. It is the reflex "go back" key, the roving tabindex hands a card focus the moment someone tabs into the grid, and a card carries no other delete control — so the reflex press destroyed a medical document with nothing but a transient undo toast to catch it. Delete alone, announced through `aria-keyshortcuts` and shown as a `` chip while the card holds focus. */} ) : null} {failureCopy ? (

{failureCopy}

) : null}
); }