mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +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,
|
||||
|
||||
Reference in new issue
Block a user