Files
HealthLog/src/components/documents/document-card.tsx
T
Marc-André Bombeck ee76672798 feat(documents): preview thumbnails in the vault overview
Render a small JPEG preview for each stored document and show it at the
card's leading edge, falling back to the kind icon when none exists.

- Generate previews in the background on upload (new document-thumbnail
  pg-boss queue) plus a boot-time corpus backfill for existing documents;
  upload never blocks on or fails because of it. Images are downscaled via
  @napi-rs/canvas, a PDF's first page is rasterised then downscaled, every
  other type is skipped. Generation never throws.
- Store the preview encrypted at rest in a 1:1 DocumentThumbnail side table
  (migration 0236) so the list/detail queries that omit the original blob
  never drag the preview either. Same AES-256-GCM string codec + fail-closed
  decrypt as the content index; wired into the key-rotation walk.
- Serve it through an owner-scoped GET /api/documents/inbound/[id]/thumbnail
  (auth + module gate + userId-scoped query + fail-closed decrypt), always
  image/jpeg + nosniff + private, no-store; 404 on miss. The card renders an
  authed lazy <img>, onError falls back to the icon.
- Add hasThumbnail to the document DTO so the card only fetches a preview
  that exists.

Also drop the "search looks inside your documents" caption from the overview
(the index-all prompt stays) and the per-card searchable marker; both were
noise. Remove the now-unused i18n keys across all six locales.
2026-07-10 01:15:32 +02:00

347 lines
12 KiB
TypeScript

"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), one muted meta line (date · size · filename),
* 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 { Download, X } from "lucide-react";
import { 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 { 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 { documentDateKey, formatBytes } from "./vault-utils";
/** 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). */
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);
// Touch long-press selects instead of opening; the click that the
// browser fires after the release is swallowed once.
const longPressTimer = useRef<ReturnType<typeof setTimeout> | 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;
return (
<Card
data-slot="document-card"
data-document-id={document.id}
className={cn(
"group relative h-full gap-2 py-3 transition-shadow md:py-4",
"hover:shadow-sm",
highlighted && "ring-primary ring-2",
)}
>
<CardContent className="flex h-full flex-col gap-2 px-4">
<div className="flex items-start gap-2">
{document.hasThumbnail && !thumbFailed ? (
// Leading-edge preview tile. `loading="lazy"` + the timeline's
// virtualization means only cards near the viewport fetch their
// thumbnail. Decorative (alt="") — the title beside it names the
// document; an authed same-origin subresource, never cached
// cross-user. onError falls back to the kind icon.
<span
data-slot="document-thumbnail"
className="bg-muted mt-0.5 block size-12 shrink-0 overflow-hidden rounded-md"
>
{/* Authed, private (no-store) same-origin subresource — next/image
would try to proxy/optimize it, which is wrong for a PHI blob
we deliberately never cache. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`/api/documents/inbound/${document.id}/thumbnail`}
alt=""
loading="lazy"
decoding="async"
onError={() => setThumbFailed(true)}
className="size-full object-cover"
/>
</span>
) : (
<Icon
className="text-foreground mt-0.5 size-5 shrink-0"
aria-hidden
/>
)}
<p className="min-w-0 flex-1 truncate text-sm font-medium">{title}</p>
<Checkbox
checked={selected}
// Explicit click handling instead of onCheckedChange: the mouse
// event carries `shiftKey` for file-manager range selection.
// preventDefault stops Radix's internal toggle (state is fully
// controlled by the page's selection set anyway).
onClick={(e) => {
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",
)}
/>
</div>
<p className="text-muted-foreground truncate text-xs">
{date} · {size}
{showFilename ? ` · ${document.filename}` : ""}
</p>
{document.conditionLinks.length > 0 ||
document.servingClass === "attachment" ? (
<div className="mt-auto flex flex-wrap items-center gap-1.5 pt-1">
{document.conditionLinks.map((link) => (
<span
key={link.episodeId}
className="bg-muted text-foreground inline-flex max-w-40 items-center rounded-full px-2 py-0.5 text-xs"
>
<span className="truncate">{link.name}</span>
</span>
))}
{document.servingClass === "attachment" ? (
<Badge
variant="outline"
className="text-muted-foreground gap-1 text-xs font-normal"
>
<Download className="size-3" aria-hidden />
{t("documents.card.attachmentBadge")}
</Badge>
) : null}
</div>
) : null}
</CardContent>
{/* 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. */}
<button
type="button"
data-slot="document-open"
tabIndex={tabIndex}
onClick={() => {
if (longPressFired.current) {
longPressFired.current = false;
return;
}
onOpen(document.id);
}}
onKeyDown={(e) => {
if (e.key === " ") {
e.preventDefault();
onToggleSelected(document.id, e.shiftKey);
} else if (e.key === "Delete" || e.key === "Backspace") {
e.preventDefault();
onDelete?.(document.id);
}
}}
onTouchStart={() => {
longPressFired.current = false;
cancelLongPress();
longPressTimer.current = setTimeout(() => {
longPressFired.current = true;
onToggleSelected(document.id);
}, LONG_PRESS_MS);
}}
onTouchMove={cancelLongPress}
onTouchEnd={cancelLongPress}
onTouchCancel={cancelLongPress}
onMouseEnter={() => onPrefetch?.(document.id)}
onFocus={() => {
onCardFocus?.(document.id);
onPrefetch?.(document.id);
}}
aria-label={t("documents.card.openLabel", { title })}
className="focus-visible:ring-ring/50 absolute inset-0 rounded-xl focus-visible:ring-[3px] focus-visible:outline-none"
/>
</Card>
);
}
/** SVG progress ring for the in-flight upload card (0..1). */
function ProgressRing({ fraction }: { fraction: number }) {
const clamped = Math.min(1, Math.max(0, fraction));
const r = 13;
const circumference = 2 * Math.PI * r;
return (
<svg
viewBox="0 0 32 32"
className="size-8 shrink-0 -rotate-90"
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(clamped * 100)}
>
<circle
cx="16"
cy="16"
r={r}
fill="none"
strokeWidth="3"
className="stroke-muted"
/>
<circle
cx="16"
cy="16"
r={r}
fill="none"
strokeWidth="3"
strokeLinecap="round"
className="stroke-primary transition-[stroke-dashoffset] duration-200"
strokeDasharray={circumference}
strokeDashoffset={circumference * (1 - clamped)}
/>
</svg>
);
}
/**
* Transient card for a queue entry: uploading (progress ring) or failed
* (translated reason + dismiss). Shares the DocumentCard footprint.
*/
export function UploadStateCard({
item,
onDismiss,
}: {
item: UploadQueueItem;
onDismiss: (localId: string) => void;
}) {
const { t, locale } = useTranslations();
const failure = item.failure;
let failureCopy: string | null = null;
if (failure) {
switch (failure.reason) {
case "fileTooLarge":
failureCopy = t("documents.error.fileTooLarge", {
maxSize: formatBytes(failure.maxFileBytes ?? 0, locale),
});
break;
case "quotaExceeded":
failureCopy = t("documents.error.quotaExceeded", {
used: formatBytes(failure.usedBytes ?? 0, locale),
quota: formatBytes(failure.quotaBytes ?? 0, locale),
});
break;
case "unsupportedType":
failureCopy = t("documents.error.unsupportedType");
break;
case "purged":
failureCopy = t("documents.error.purged");
break;
case "duplicateExists":
failureCopy = t("documents.error.duplicateExists");
break;
case "rateLimited":
failureCopy = t("documents.error.rateLimited");
break;
default:
failureCopy = t("documents.error.generic");
}
}
return (
<Card
data-slot="document-upload-card"
className={cn(
"h-full gap-2 border-dashed py-3 md:py-4",
item.status === "error" && "border-destructive/50",
)}
>
<CardContent className="flex h-full flex-col gap-2 px-4">
<div className="flex items-center gap-3">
{item.status === "uploading" ? (
<ProgressRing fraction={item.progress} />
) : null}
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{item.fileName}</p>
<p className="text-muted-foreground text-xs">
{item.status === "uploading"
? t("documents.card.uploading")
: formatBytes(item.byteSize, locale)}
</p>
</div>
{item.status === "error" ? (
<Button
variant="ghost"
size="icon"
className="size-8 shrink-0"
onClick={() => onDismiss(item.localId)}
aria-label={t("documents.card.dismissFailed")}
>
<X className="size-4" aria-hidden />
</Button>
) : null}
</div>
{failureCopy ? (
<p role="alert" className="text-destructive text-xs">
{failureCopy}
</p>
) : null}
</CardContent>
</Card>
);
}