mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
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:
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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user