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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-10 01:15:32 +02:00
1 parent 54c78b527e
commit ee76672798
34 files changed
+1342 -160

No files matched your search

@@ -33,6 +33,7 @@ function doc(overrides: Partial<InboundDocumentDto> = {}): InboundDocumentDto {
servingClass: "inline",
hasContentIndex: false,
contentIndexSource: null,
hasThumbnail: false,
createdAt: "2025-10-05T08:00:00.000Z",
updatedAt: "2025-10-05T08:00:00.000Z",
...overrides,
@@ -85,47 +86,33 @@ describe("<DocumentCard>", () => {
expect(html).toContain("befund.docx");
});
it("marks a content-indexed document as searchable", () => {
it("renders the preview thumbnail image when hasThumbnail is set", () => {
const html = render(
<DocumentCard
document={doc({ hasContentIndex: true })}
document={doc({ hasThumbnail: true })}
selected={false}
onToggleSelected={noop}
onOpen={noop}
highlighted={false}
/>,
);
expect(html).toContain('data-slot="document-searchable"');
expect(html).toContain('data-source="local"');
expect(html).toContain("Contents searchable");
expect(html).toContain('data-slot="document-thumbnail"');
expect(html).toContain('src="/api/documents/inbound/doc-1/thumbnail"');
expect(html).toContain('loading="lazy"');
});
it("marks an AI-read document with the highlighted read-by-AI badge", () => {
it("shows the kind icon (no thumbnail image) when hasThumbnail is false", () => {
const html = render(
<DocumentCard
document={doc({ hasContentIndex: true, contentIndexSource: "vision" })}
document={doc({ hasThumbnail: false })}
selected={false}
onToggleSelected={noop}
onOpen={noop}
highlighted={false}
/>,
);
expect(html).toContain('data-source="ai-read"');
expect(html).toContain("Read by AI");
expect(html).toContain("text-primary");
});
it("shows no searchable marker when the document is not indexed", () => {
const html = render(
<DocumentCard
document={doc({ hasContentIndex: false, conditionLinks: [] })}
selected={false}
onToggleSelected={noop}
onOpen={noop}
highlighted={false}
/>,
);
expect(html).not.toContain('data-slot="document-searchable"');
expect(html).not.toContain('data-slot="document-thumbnail"');
expect(html).not.toContain("/thumbnail");
});
it("falls back to the untitled label and rings when highlighted", () => {
@@ -53,6 +53,7 @@ function doc(id: string, title: string): InboundDocumentDto {
servingClass: "inline",
hasContentIndex: false,
contentIndexSource: null,
hasThumbnail: false,
createdAt: "2025-10-05T08:00:00.000Z",
updatedAt: "2025-10-05T08:00:00.000Z",
};
@@ -34,6 +34,7 @@ function doc(overrides: Partial<InboundDocumentDto> = {}): InboundDocumentDto {
servingClass: "inline",
hasContentIndex: false,
contentIndexSource: null,
hasThumbnail: false,
createdAt: "2026-03-11T08:00:00.000Z",
updatedAt: "2026-03-11T08:00:00.000Z",
...overrides,
+45 -49
View File
@@ -6,15 +6,17 @@
* 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: 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.
* 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, ScanSearch, Sparkles, X } from "lucide-react";
import { useRef } from "react";
import { Download, X } from "lucide-react";
import { useRef, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -22,10 +24,7 @@ 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 {
isAiReadSource,
type InboundDocumentDto,
} from "@/lib/validations/inbound-documents";
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";
@@ -63,6 +62,10 @@ export function DocumentCard({
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);
@@ -94,10 +97,35 @@ export function DocumentCard({
>
<CardContent className="flex h-full flex-col gap-2 px-4">
<div className="flex items-start gap-2">
<Icon
className="text-foreground mt-0.5 size-5 shrink-0"
aria-hidden
/>
{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}
@@ -122,8 +150,7 @@ export function DocumentCard({
{showFilename ? ` · ${document.filename}` : ""}
</p>
{document.conditionLinks.length > 0 ||
document.servingClass === "attachment" ||
document.hasContentIndex ? (
document.servingClass === "attachment" ? (
<div className="mt-auto flex flex-wrap items-center gap-1.5 pt-1">
{document.conditionLinks.map((link) => (
<span
@@ -142,37 +169,6 @@ export function DocumentCard({
{t("documents.card.attachmentBadge")}
</Badge>
) : null}
{document.hasContentIndex ? (
// Subtle searchable marker — icon-only to keep the row calm; the
// label rides an accessible name. An AI-read document gets the
// highlighted Sparkles (primary) so the richer read is legible at
// a glance; a locally-indexed one keeps the quiet muted glass.
isAiReadSource(document.contentIndexSource) ? (
<span
data-slot="document-searchable"
data-source="ai-read"
className="text-primary inline-flex items-center"
title={t("documents.card.aiReadBadge")}
>
<Sparkles className="size-3.5" aria-hidden />
<span className="sr-only">
{t("documents.card.aiReadBadge")}
</span>
</span>
) : (
<span
data-slot="document-searchable"
data-source="local"
className="text-muted-foreground inline-flex items-center"
title={t("documents.card.searchableBadge")}
>
<ScanSearch className="size-3.5" aria-hidden />
<span className="sr-only">
{t("documents.card.searchableBadge")}
</span>
</span>
)
) : null}
</div>
) : null}
</CardContent>
@@ -10,9 +10,8 @@
* The controls sit on ONE row at every width: the search flexes and can
* shrink to nothing while each dropdown trigger stays a fixed compact
* control, so the bar never wraps to a second line and never grows a
* horizontal chip scroller. The content-search hint (indexed-content match
* + corpus backfill) rides its own helper line below, only when indexing is
* live.
* horizontal chip scroller. The corpus-backfill prompt rides its own helper
* line below, only while some documents remain un-indexed.
*
* Purely presentational — the filter state lives in the page URL and is
* owned by `documents-view.tsx`; this bar only renders the controls. It
@@ -82,7 +81,6 @@ export function DocumentFilterBar({
onToggleYear,
activeCount,
onClearAll,
contentSearchActive = false,
showIndexAll = false,
indexAllPending = false,
onIndexAll,
@@ -101,8 +99,6 @@ export function DocumentFilterBar({
onToggleYear: (year: number) => void;
activeCount: number;
onClearAll: () => void;
/** Search already matches indexed document CONTENT (whole words). */
contentSearchActive?: boolean;
/** Some documents are not yet indexed — offer the corpus backfill. */
showIndexAll?: boolean;
indexAllPending?: boolean;
@@ -323,32 +319,28 @@ export function DocumentFilterBar({
) : null}
</div>
{contentSearchActive || showIndexAll ? (
{showIndexAll ? (
<div
data-slot="content-search-hint"
className="text-muted-foreground mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs"
>
<span className="inline-flex items-center gap-1.5">
<ScanSearch className="size-3.5 shrink-0" aria-hidden />
{contentSearchActive
? t("documents.contentIndex.searchHint")
: t("documents.contentIndex.indexPrompt")}
{t("documents.contentIndex.indexPrompt")}
</span>
{showIndexAll ? (
<Button
type="button"
variant="link"
size="sm"
data-slot="content-index-all"
onClick={onIndexAll}
disabled={indexAllPending}
className="text-primary h-auto p-0 text-xs"
>
{indexAllPending
? t("documents.contentIndex.indexAllPending")
: t("documents.contentIndex.indexAll")}
</Button>
) : null}
<Button
type="button"
variant="link"
size="sm"
data-slot="content-index-all"
onClick={onIndexAll}
disabled={indexAllPending}
className="text-primary h-auto p-0 text-xs"
>
{indexAllPending
? t("documents.contentIndex.indexAllPending")
: t("documents.contentIndex.indexAll")}
</Button>
</div>
) : null}
</div>
@@ -257,8 +257,6 @@ export function DocumentsView() {
contentIndex !== undefined &&
contentIndex.totalCount > 0 &&
contentIndex.indexedCount < contentIndex.totalCount;
const contentSearchActive =
canIndexContent && (contentIndex?.indexedCount ?? 0) > 0;
const handleIndexAll = useCallback(() => {
reindexAll.mutate(undefined, {
onSuccess: (result) =>
@@ -586,7 +584,6 @@ export function DocumentsView() {
onToggleYear={toggleYear}
activeCount={activeCount}
onClearAll={clearFilters}
contentSearchActive={contentSearchActive}
showIndexAll={showIndexAll}
indexAllPending={reindexAll.isPending}
onIndexAll={handleIndexAll}