mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(documents): virtualized month timeline and document cards
The timeline windows a flat item list (month labels + chunked card rows) with @tanstack/react-virtual over the shell's scroll container — single scroll owner, bounded DOM at four-digit corpus sizes. Columns are measured by a ResizeObserver so the row model and the painted grid never disagree. Cards are compact-density: kind glyph, foreground title, one muted meta line, condition pills, a download-only badge for attachment- class formats, and a hover/focus selection checkbox above the whole-card click target. In-flight and failed uploads share the same footprint with a live progress ring and translated failure copy. Plan-Task: W2-T2, W2-T4 (upload cards)
This commit is contained in:
1 parent
5536749985
commit
53d0d8cc33
5 files changed
+648
No files matched your search
@@ -64,6 +64,7 @@
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@simplewebauthn/server": "^13.3.1",
|
||||
"@tanstack/react-query": "5.101.2",
|
||||
"@tanstack/react-virtual": "^3.14.5",
|
||||
"@types/sax": "^1.2.7",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
Generated
+20
@@ -51,6 +51,9 @@ importers:
|
||||
'@tanstack/react-query':
|
||||
specifier: 5.101.2
|
||||
version: 5.101.2(react@19.2.7)
|
||||
'@tanstack/react-virtual':
|
||||
specifier: ^3.14.5
|
||||
version: 3.14.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
||||
'@types/sax':
|
||||
specifier: ^1.2.7
|
||||
version: 1.2.7
|
||||
@@ -2868,6 +2871,15 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^18 || ^19
|
||||
|
||||
'@tanstack/react-virtual@3.14.5':
|
||||
resolution: {integrity: sha512-4EKRXh7zBLkbKbFmG3AUVkircuHd+7OdT1pocJSepxtfBd3qnrJgJ5rtPkRYyo9fmyVb2+pI2xPy5oYvMLQy6A==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
'@tanstack/virtual-core@3.17.3':
|
||||
resolution: {integrity: sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw==}
|
||||
|
||||
'@testcontainers/postgresql@12.0.3':
|
||||
resolution: {integrity: sha512-y0l/ItWxNfdMU783EOZ66lMq90uuuXPqE+fo1jNSRfMbtfUq/9Ez/wluDxxaS+D/i3I0juep+jmz/WVuip8i9Q==}
|
||||
|
||||
@@ -9450,6 +9462,14 @@ snapshots:
|
||||
'@tanstack/query-core': 5.101.2
|
||||
react: 19.2.7
|
||||
|
||||
'@tanstack/react-virtual@3.14.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7)':
|
||||
dependencies:
|
||||
'@tanstack/virtual-core': 3.17.3
|
||||
react: 19.2.7
|
||||
react-dom: 19.2.7(react@19.2.7)
|
||||
|
||||
'@tanstack/virtual-core@3.17.3': {}
|
||||
|
||||
'@testcontainers/postgresql@12.0.3':
|
||||
dependencies:
|
||||
testcontainers: 12.0.3
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
|
||||
import { DocumentCard, UploadStateCard } from "../document-card";
|
||||
import type { UploadQueueItem } from "../use-document-upload";
|
||||
|
||||
/**
|
||||
* The vault card's render contract: title falls back filename → "untitled",
|
||||
* the meta line stays muted, the attachment-class badge appears ONLY for
|
||||
* download-only formats, condition links paint as pills, and the two
|
||||
* transient upload cards (in-flight ring / translated failure) render from
|
||||
* the same footprint.
|
||||
*/
|
||||
|
||||
function doc(overrides: Partial<InboundDocumentDto> = {}): InboundDocumentDto {
|
||||
return {
|
||||
id: "doc-1",
|
||||
kind: "IMAGING",
|
||||
title: "MRT Knie",
|
||||
filename: "mrt-knie.pdf",
|
||||
mimeType: "application/pdf",
|
||||
byteSize: 2_500_000,
|
||||
status: "STORED",
|
||||
providerType: null,
|
||||
reportDate: null,
|
||||
documentDate: "2025-10-04",
|
||||
errorReason: null,
|
||||
factCount: 0,
|
||||
pendingCount: 0,
|
||||
conditionLinks: [{ episodeId: "ep-knee", name: "Knie" }],
|
||||
servingClass: "inline",
|
||||
createdAt: "2025-10-05T08:00:00.000Z",
|
||||
updatedAt: "2025-10-05T08:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function render(node: React.ReactNode) {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
describe("<DocumentCard>", () => {
|
||||
it("renders title, condition pill, and NO attachment badge for inline docs", () => {
|
||||
const html = render(
|
||||
<DocumentCard
|
||||
document={doc()}
|
||||
selected={false}
|
||||
onToggleSelected={noop}
|
||||
onOpen={noop}
|
||||
highlighted={false}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("MRT Knie");
|
||||
expect(html).toContain("Knie");
|
||||
expect(html).toContain('data-document-id="doc-1"');
|
||||
expect(html).not.toContain("Download only");
|
||||
});
|
||||
|
||||
it("shows the download-only badge for attachment-class documents", () => {
|
||||
const html = render(
|
||||
<DocumentCard
|
||||
document={doc({
|
||||
servingClass: "attachment",
|
||||
mimeType: "application/octet-stream",
|
||||
filename: "befund.docx",
|
||||
title: null,
|
||||
})}
|
||||
selected={false}
|
||||
onToggleSelected={noop}
|
||||
onOpen={noop}
|
||||
highlighted={false}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Download only");
|
||||
// Title falls back to the filename.
|
||||
expect(html).toContain("befund.docx");
|
||||
});
|
||||
|
||||
it("falls back to the untitled label and rings when highlighted", () => {
|
||||
const html = render(
|
||||
<DocumentCard
|
||||
document={doc({ title: null, filename: null })}
|
||||
selected={false}
|
||||
onToggleSelected={noop}
|
||||
onOpen={noop}
|
||||
highlighted
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Untitled document");
|
||||
expect(html).toContain("ring-2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("<UploadStateCard>", () => {
|
||||
const base: UploadQueueItem = {
|
||||
localId: "u1",
|
||||
fileName: "scan.jpg",
|
||||
byteSize: 4_000_000,
|
||||
status: "uploading",
|
||||
progress: 0.4,
|
||||
};
|
||||
|
||||
it("paints the progress ring while uploading", () => {
|
||||
const html = render(<UploadStateCard item={base} onDismiss={noop} />);
|
||||
expect(html).toContain('role="progressbar"');
|
||||
expect(html).toContain('aria-valuenow="40"');
|
||||
expect(html).toContain("scan.jpg");
|
||||
});
|
||||
|
||||
it("renders the translated over-limit reason with the configured cap", () => {
|
||||
const html = render(
|
||||
<UploadStateCard
|
||||
item={{
|
||||
...base,
|
||||
status: "error",
|
||||
failure: {
|
||||
ok: false,
|
||||
reason: "fileTooLarge",
|
||||
maxFileBytes: 26_214_400,
|
||||
},
|
||||
}}
|
||||
onDismiss={noop}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('role="alert"');
|
||||
expect(html).toContain("25 MB");
|
||||
expect(html).not.toContain('role="progressbar"');
|
||||
});
|
||||
|
||||
it("renders the quota reason with used/quota figures", () => {
|
||||
const html = render(
|
||||
<UploadStateCard
|
||||
item={{
|
||||
...base,
|
||||
status: "error",
|
||||
failure: {
|
||||
ok: false,
|
||||
reason: "quotaExceeded",
|
||||
quotaBytes: 1_073_741_824,
|
||||
usedBytes: 1_020_054_732,
|
||||
},
|
||||
}}
|
||||
onDismiss={noop}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain("Storage is full");
|
||||
expect(html).toContain("1 GB");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
"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: 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 { 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";
|
||||
|
||||
export function DocumentCard({
|
||||
document,
|
||||
selected,
|
||||
onToggleSelected,
|
||||
onOpen,
|
||||
highlighted,
|
||||
onPrefetch,
|
||||
}: {
|
||||
document: InboundDocumentDto;
|
||||
selected: boolean;
|
||||
onToggleSelected: (id: string) => void;
|
||||
onOpen: (id: string) => void;
|
||||
/** Brief ring after a duplicate upload resolved to this existing row. */
|
||||
highlighted: boolean;
|
||||
/** Hover/focus intent — prefetches the detail metadata (never the blob). */
|
||||
onPrefetch?: (id: string) => void;
|
||||
}) {
|
||||
const { t, locale } = useTranslations();
|
||||
const format = useFormatters();
|
||||
|
||||
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">
|
||||
<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}
|
||||
onCheckedChange={() => onToggleSelected(document.id)}
|
||||
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). */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpen(document.id)}
|
||||
onMouseEnter={() => onPrefetch?.(document.id)}
|
||||
onFocus={() => 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The vault timeline: a virtualized, month-sectioned card grid windowed
|
||||
* with `@tanstack/react-virtual` over the shell's scroll container
|
||||
* (`#main-content` — single scroll owner per the design standards; the
|
||||
* timeline never brings its own scrollport). The flat item list (month
|
||||
* labels + chunked card rows) comes from `buildTimelineItems`, so the
|
||||
* mounted DOM stays bounded (< ~400 nodes) regardless of corpus size.
|
||||
*
|
||||
* Columns are measured, not breakpoint-classed: a ResizeObserver on the
|
||||
* grid container drives the per-row chunking (4 / 3 / 2 / 1), which keeps
|
||||
* the virtualizer's row model and the painted grid in lockstep.
|
||||
*
|
||||
* In-flight / failed uploads render as a small non-virtualized grid above
|
||||
* the timeline — they are few (client concurrency 3) and must appear
|
||||
* < 100 ms after file selection, before any query settles.
|
||||
*/
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
|
||||
import { DocumentCard, UploadStateCard } from "./document-card";
|
||||
import type { UploadQueueItem } from "./use-document-upload";
|
||||
import { buildTimelineItems, formatMonthLabel } from "./vault-utils";
|
||||
|
||||
const SCROLL_CONTAINER_ID = "main-content";
|
||||
|
||||
/** Measured-width → column count (desktop 4/3, tablet 2, phone 1). */
|
||||
function columnsForWidth(width: number): number {
|
||||
if (width >= 1200) return 4;
|
||||
if (width >= 900) return 3;
|
||||
if (width >= 600) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
export function DocumentTimeline({
|
||||
documents,
|
||||
uploadItems,
|
||||
onDismissUpload,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
onLoadMore,
|
||||
selectedIds,
|
||||
onToggleSelected,
|
||||
onOpen,
|
||||
highlightId,
|
||||
onPrefetch,
|
||||
}: {
|
||||
documents: InboundDocumentDto[];
|
||||
uploadItems: UploadQueueItem[];
|
||||
onDismissUpload: (localId: string) => void;
|
||||
hasNextPage: boolean;
|
||||
isFetchingNextPage: boolean;
|
||||
onLoadMore: () => void;
|
||||
selectedIds: ReadonlySet<string>;
|
||||
onToggleSelected: (id: string) => void;
|
||||
onOpen: (id: string) => void;
|
||||
highlightId: string | null;
|
||||
onPrefetch?: (id: string) => void;
|
||||
}) {
|
||||
const { t, locale } = useTranslations();
|
||||
const listRef = useRef<HTMLDivElement | null>(null);
|
||||
const [columns, setColumns] = useState(1);
|
||||
const [scrollMargin, setScrollMargin] = useState(0);
|
||||
|
||||
// Measured columns — the ResizeObserver drives the row chunking.
|
||||
useEffect(() => {
|
||||
const el = listRef.current;
|
||||
if (!el) return;
|
||||
const update = () => setColumns(columnsForWidth(el.clientWidth));
|
||||
update();
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
const items = useMemo(
|
||||
() => buildTimelineItems(documents, columns),
|
||||
[documents, columns],
|
||||
);
|
||||
|
||||
// The timeline does not start at the scrollport's top edge (page header,
|
||||
// filter bar, upload row sit above it) — feed the offset to the
|
||||
// virtualizer so window positions line up. Re-measured when the content
|
||||
// above changes height (upload cards appearing/leaving) and on resize.
|
||||
useEffect(() => {
|
||||
const scrollEl = document.getElementById(SCROLL_CONTAINER_ID);
|
||||
const listEl = listRef.current;
|
||||
if (!scrollEl || !listEl) return;
|
||||
const measure = () => {
|
||||
const margin =
|
||||
listEl.getBoundingClientRect().top -
|
||||
scrollEl.getBoundingClientRect().top +
|
||||
scrollEl.scrollTop;
|
||||
setScrollMargin(margin);
|
||||
};
|
||||
measure();
|
||||
window.addEventListener("resize", measure);
|
||||
return () => window.removeEventListener("resize", measure);
|
||||
}, [uploadItems.length, columns]);
|
||||
|
||||
// The React Compiler cannot memoize across TanStack Virtual's instance
|
||||
// API (library-level opt-out, not a fixable call-site problem) — the
|
||||
// windowing still works, the compiler just skips this component.
|
||||
// eslint-disable-next-line react-hooks/incompatible-library
|
||||
const virtualizer = useVirtualizer({
|
||||
count: items.length,
|
||||
getScrollElement: () => document.getElementById(SCROLL_CONTAINER_ID),
|
||||
estimateSize: (index) => (items[index].type === "month" ? 40 : 140),
|
||||
getItemKey: (index) => items[index].key,
|
||||
overscan: 6,
|
||||
scrollMargin,
|
||||
});
|
||||
|
||||
const virtualItems = virtualizer.getVirtualItems();
|
||||
|
||||
// Keyset infinite feed: pull the next page when the window nears the end.
|
||||
const lastIndex = virtualItems[virtualItems.length - 1]?.index ?? -1;
|
||||
useEffect(() => {
|
||||
if (
|
||||
hasNextPage &&
|
||||
!isFetchingNextPage &&
|
||||
lastIndex >= items.length - columns * 3 - 1
|
||||
) {
|
||||
onLoadMore();
|
||||
}
|
||||
}, [
|
||||
lastIndex,
|
||||
items.length,
|
||||
columns,
|
||||
hasNextPage,
|
||||
isFetchingNextPage,
|
||||
onLoadMore,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div data-slot="document-timeline" className="space-y-4">
|
||||
{uploadItems.length > 0 ? (
|
||||
<div
|
||||
data-slot="document-upload-queue"
|
||||
aria-live="polite"
|
||||
className="grid gap-4"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{uploadItems.map((item) => (
|
||||
<UploadStateCard
|
||||
key={item.localId}
|
||||
item={item}
|
||||
onDismiss={onDismissUpload}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div ref={listRef}>
|
||||
<div
|
||||
className="relative"
|
||||
style={{ height: virtualizer.getTotalSize() }}
|
||||
>
|
||||
{virtualItems.map((virtualItem) => {
|
||||
const item = items[virtualItem.index];
|
||||
return (
|
||||
<div
|
||||
key={virtualItem.key}
|
||||
ref={virtualizer.measureElement}
|
||||
data-index={virtualItem.index}
|
||||
className="absolute inset-x-0 top-0"
|
||||
style={{
|
||||
transform: `translateY(${
|
||||
virtualItem.start - scrollMargin
|
||||
}px)`,
|
||||
}}
|
||||
>
|
||||
{item.type === "month" ? (
|
||||
<h2 className="text-muted-foreground pt-2 pb-3 text-xs font-medium tracking-wide uppercase">
|
||||
{formatMonthLabel(item.key, locale)}
|
||||
</h2>
|
||||
) : (
|
||||
<div
|
||||
className="grid gap-4 pb-4"
|
||||
style={{
|
||||
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{item.documents.map((doc) => (
|
||||
<DocumentCard
|
||||
key={doc.id}
|
||||
document={doc}
|
||||
selected={selectedIds.has(doc.id)}
|
||||
onToggleSelected={onToggleSelected}
|
||||
onOpen={onOpen}
|
||||
highlighted={highlightId === doc.id}
|
||||
onPrefetch={onPrefetch}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isFetchingNextPage ? (
|
||||
<div
|
||||
className="text-muted-foreground flex items-center justify-center gap-2 py-4 text-sm"
|
||||
role="status"
|
||||
>
|
||||
<Loader2
|
||||
className="size-4 animate-spin motion-reduce:animate-none"
|
||||
aria-hidden
|
||||
/>
|
||||
{t("documents.timeline.loadingMore")}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user