chore(release): v1.28.15 — document thumbnails; admin-shared AI access

Document vault shows a per-file thumbnail: a server-rendered preview,
downscaled and re-encoded so location and camera metadata are dropped,
served only to the file's owner over an authenticated request. Files that
cannot be previewed keep their type icon. Generation is bounded — a header
sniff refuses an over-large source before decode, so a crafted image cannot
exhaust the preview worker.

Also folds in admin-managed central AI access — an administrator connects
one access on the server and each user opts in or keeps their own provider,
off until an admin turns it on — and trims the redundant documents-search
caption and per-file searchable/read badges.
This commit is contained in:
Marc-André Bombeck committed 2026-07-10 01:32:28 +02:00
commit eb102ac50c
37 files changed
+1490 -163

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}