mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Every bundle value that chained facts with " · " now reads as a phrase, a comma list or a parenthesis, in all seven locales, and the em-dash separators in the day, documents, correlation, sharing, environment, vaccination and Apple Health import strings become sentences or clauses. The day view's running list names the record first, with the dose in the name and a life event's category as a chip, and puts a quiet day count below: "Day 4, since 31 December" instead of a dot chain. Document meta, location periods, vaccination rows and mood crosstab rows drop their composed dots too. A guard walks every bundle and fails on any " · " outside a reasoned allowlist, and on an allowlist entry that no longer needs its place.
1203 lines
42 KiB
TypeScript
1203 lines
42 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* The Dokumente vault — one wide, calm, filing-cabinet-fast surface.
|
|
*
|
|
* Filter state lives in the URL (`?q&kind&episode&encounter&year`) so every view is
|
|
* shareable, back-button-safe, and deep-linkable from the illness page and
|
|
* labs. Search debounces 200 ms and `/` focuses it. The timeline below is
|
|
* virtualized; uploads appear optimistically above it in < 100 ms.
|
|
*
|
|
* Born-gated on the resolved `modules.inboundDocuments` flag from
|
|
* `GET /api/auth/me` (per-user opt-in AND the operator availability layer).
|
|
* An unauthenticated visitor bounces to login; an account without the
|
|
* module bounces home. Every `/api/documents/inbound/*` route re-enforces
|
|
* the gate server-side — this is a UX redirect, not the security boundary.
|
|
*/
|
|
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
|
|
import {
|
|
useInfiniteQuery,
|
|
useMutation,
|
|
useQuery,
|
|
useQueryClient,
|
|
} from "@tanstack/react-query";
|
|
import {
|
|
FolderOpen,
|
|
Loader2,
|
|
SearchX,
|
|
Upload,
|
|
UploadCloud,
|
|
Wrench,
|
|
} from "lucide-react";
|
|
import Link from "next/link";
|
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { toast } from "sonner";
|
|
|
|
import { useIllnessEpisodes } from "@/components/illness/use-illness";
|
|
import { Button } from "@/components/ui/button";
|
|
import { ConfirmDialog } from "@/components/ui/confirm-button";
|
|
import { EmptyState } from "@/components/ui/empty-state";
|
|
import { PageHeader } from "@/components/ui/page-header";
|
|
import { QueryErrorCard } from "@/components/ui/query-error-card";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { useAuth } from "@/hooks/use-auth";
|
|
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
|
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
|
|
import { useUrlFilterSync } from "@/hooks/use-url-filter-sync";
|
|
import { PullToRefreshIndicator } from "@/components/ui/pull-to-refresh-indicator";
|
|
import { apiGet, apiPost } from "@/lib/api/api-fetch";
|
|
import {
|
|
bodySiteText,
|
|
encounterKindText,
|
|
} from "@/components/encounters/encounter-labels";
|
|
import { useEncounters } from "@/hooks/use-encounters";
|
|
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
|
import type { EncounterKind } from "@/generated/prisma/client";
|
|
import {
|
|
type CoachCloseIntent,
|
|
useCoachLaunch,
|
|
} from "@/lib/insights/coach-launch-context";
|
|
import { invalidateKeys, queryKeys } from "@/lib/query-keys";
|
|
import { useDocumentsLayout } from "@/lib/queries/use-documents-layout";
|
|
import {
|
|
DOCUMENT_BULK_MAX_IDS,
|
|
type DocumentBulkAction,
|
|
type DocumentBulkResultDto,
|
|
type DocumentUsageDto,
|
|
type InboundDocumentDetailDto,
|
|
type InboundDocumentDto,
|
|
type InboundDocumentKindValue,
|
|
} from "@/lib/validations/inbound-documents";
|
|
import { DocumentBulkBar } from "./document-bulk-bar";
|
|
import { DocumentDetailSheet } from "./document-detail-sheet";
|
|
import { DocumentShareSheet } from "./document-share-sheet";
|
|
import { DocumentFilterBar, type ConditionChip } from "./document-filter-bar";
|
|
import { DocumentTimeline } from "./document-timeline";
|
|
import { UploadZone } from "./upload-zone";
|
|
import { ImportFromSourceButton } from "./sources/import-from-source-button";
|
|
import { useReindexAll } from "./use-content-index";
|
|
import { useDocumentUpload } from "./use-document-upload";
|
|
import { usePageFileDrop } from "./use-page-file-drop";
|
|
import {
|
|
buildProcedureChoices,
|
|
buildVaultListApiSearch,
|
|
countActiveFilters,
|
|
documentDateKey,
|
|
expandRangeSelection,
|
|
hasProcessingDocument,
|
|
parseVaultSearchParams,
|
|
type ProcedureFilterChoice,
|
|
resolveBulkShareDocuments,
|
|
SHARE_LINK_MAX_DOCUMENTS,
|
|
vaultFiltersToSearch,
|
|
} from "./vault-utils";
|
|
|
|
interface ListPage {
|
|
documents: InboundDocumentDto[];
|
|
nextCursor: string | null;
|
|
}
|
|
|
|
// Strict end-of-input: `$` also accepts a trailing newline in JavaScript.
|
|
/**
|
|
* How many visits the bulk bar offers. A menu is a picker, not a history: past
|
|
* the first screenful a person scrolls rather than reads, and filing against a
|
|
* visit older than this belongs on the visit's own sheet.
|
|
*/
|
|
const BULK_VISIT_OPTIONS = 12;
|
|
|
|
const DOCUMENT_QUERY_ID_PATTERN = /^[a-zA-Z0-9_-]{1,40}(?![\s\S])/;
|
|
const DOCUMENT_SELECTION_HISTORY_KEY = "__healthlogPushedDocumentSelection";
|
|
|
|
export function documentSelectionHistoryState(
|
|
documentId: string,
|
|
): Record<string, unknown> {
|
|
// Pass only application state here. Next's patched pushState copies its
|
|
// private router fields onto this object; copying history.state ourselves
|
|
// would include `__NA` and make that patch treat this as an internal write,
|
|
// skipping useSearchParams synchronization.
|
|
return { [DOCUMENT_SELECTION_HISTORY_KEY]: documentId };
|
|
}
|
|
|
|
export function documentSelectionHref(
|
|
pathname: string,
|
|
currentSearch: string,
|
|
documentId: string,
|
|
): string | null {
|
|
if (!DOCUMENT_QUERY_ID_PATTERN.test(documentId)) return null;
|
|
const next = new URLSearchParams(currentSearch);
|
|
next.set("doc", documentId);
|
|
const search = next.toString();
|
|
return search ? `${pathname}?${search}` : pathname;
|
|
}
|
|
|
|
export function withoutDocumentSelectionHref(
|
|
pathname: string,
|
|
currentSearch: string,
|
|
): string {
|
|
const next = new URLSearchParams(currentSearch);
|
|
next.delete("doc");
|
|
const search = next.toString();
|
|
return search ? `${pathname}?${search}` : pathname;
|
|
}
|
|
|
|
/**
|
|
* How long to wait before checking that `history.back()` actually happened.
|
|
*
|
|
* Long enough for a traversal to commit on a loaded machine, short enough that
|
|
* a reader who closed the sheet does not see the selection linger in the
|
|
* address bar. The window is bounded on the other side too: the effect that
|
|
* arms this cancels it as soon as the sheet re-opens.
|
|
*/
|
|
export const CLOSE_TRAVERSAL_GRACE_MS = 250;
|
|
|
|
/**
|
|
* The selection this close was meant to consume is still in the address bar.
|
|
*
|
|
* Matched on the id rather than on the presence of `doc`, so a traversal that
|
|
* landed somewhere carrying a DIFFERENT selection is not mistaken for a
|
|
* dropped one and stripped.
|
|
*/
|
|
export function documentSelectionSurvivedClose(
|
|
search: string,
|
|
documentId: string,
|
|
): boolean {
|
|
return new URLSearchParams(search).get("doc") === documentId;
|
|
}
|
|
|
|
export function closeDocumentSelectionHistoryEntry(
|
|
history: {
|
|
readonly state: unknown;
|
|
back: () => void;
|
|
replaceState: (
|
|
data: unknown,
|
|
unused: string,
|
|
url?: string | URL | null,
|
|
) => void;
|
|
},
|
|
pathname: string,
|
|
currentSearch: string,
|
|
documentId: string,
|
|
): void {
|
|
const ownsCurrentEntry =
|
|
history.state !== null &&
|
|
typeof history.state === "object" &&
|
|
(history.state as Record<string, unknown>)[
|
|
DOCUMENT_SELECTION_HISTORY_KEY
|
|
] === documentId;
|
|
if (ownsCurrentEntry) {
|
|
// Card selection owns the current pushed entry. Returning to its base
|
|
// consumes it, so the user's next Back reaches the prior distinct page.
|
|
history.back();
|
|
return;
|
|
}
|
|
|
|
// A deep link did not create a disposable entry on this page. Keep the user
|
|
// here and remove only the selection from the current URL.
|
|
history.replaceState(
|
|
null,
|
|
"",
|
|
withoutDocumentSelectionHref(pathname, currentSearch),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The selection is being KEPT on purpose, as the way back.
|
|
*
|
|
* Maximizing the Coach drawer hands the conversation to `/coach` and carries
|
|
* `?doc=` with it, so browser-Back can reconstruct the sheet. That is the one
|
|
* close-shaped transition that must leave the parameter exactly where it is.
|
|
*
|
|
* Named, and used by both the close and the check that follows it, because
|
|
* those two disagreeing is a bug with no symptom until it has one: the check
|
|
* would strip the return URL a few hundred milliseconds after the close
|
|
* deliberately preserved it, and whether it won that race was a question about
|
|
* how fast the machine navigated.
|
|
*/
|
|
export function coachHandoffRetainsSelection(
|
|
documentId: string,
|
|
handedOffDocumentId: string | null,
|
|
closeIntent: CoachCloseIntent | null | undefined,
|
|
): boolean {
|
|
return handedOffDocumentId === documentId && closeIntent === "navigate";
|
|
}
|
|
|
|
export function closeDocumentSelectionAfterCoachHandoff(
|
|
history: Parameters<typeof closeDocumentSelectionHistoryEntry>[0],
|
|
pathname: string,
|
|
currentSearch: string,
|
|
documentId: string,
|
|
handedOffDocumentId: string | null,
|
|
closeIntent: CoachCloseIntent | null | undefined,
|
|
): string | null {
|
|
if (
|
|
coachHandoffRetainsSelection(documentId, handedOffDocumentId, closeIntent)
|
|
) {
|
|
return handedOffDocumentId;
|
|
}
|
|
|
|
closeDocumentSelectionHistoryEntry(
|
|
history,
|
|
pathname,
|
|
currentSearch,
|
|
documentId,
|
|
);
|
|
return handedOffDocumentId === documentId ? null : handedOffDocumentId;
|
|
}
|
|
|
|
export function DocumentsView() {
|
|
const { t } = useTranslations();
|
|
const format = useFormatters();
|
|
const { user, isLoading: authLoading, isAuthenticated } = useAuth();
|
|
const timezone = user?.timezone || DEFAULT_TIMEZONE;
|
|
// v1.36.x — uploading a document, filing it, sharing it and asking the AI
|
|
// about it are none of them delegated verbs. Inside somebody else's record
|
|
// the vault reads and nothing more: no upload path, no selection, no bulk
|
|
// bar, no corpus backfill. `canManageDomain("documents")` says so from the
|
|
// server's table, and answers false under every grant today.
|
|
const { canManageDomain, inSharedRecord, sections } = useRecordCapabilities();
|
|
const canManageDocuments = canManageDomain("documents");
|
|
const router = useRouter();
|
|
const pathname = usePathname();
|
|
const searchParams = useSearchParams();
|
|
const queryClient = useQueryClient();
|
|
const coachLaunch = useCoachLaunch();
|
|
|
|
const moduleEnabled = user?.modules?.inboundDocuments === true;
|
|
|
|
// UX redirect only — the API routes enforce the gate server-side.
|
|
useEffect(() => {
|
|
if (authLoading) return;
|
|
if (!isAuthenticated) {
|
|
router.push("/auth/login");
|
|
} else if (!moduleEnabled) {
|
|
router.push("/");
|
|
}
|
|
}, [authLoading, isAuthenticated, moduleEnabled, router]);
|
|
|
|
// ── URL-owned filter state ────────────────────────────────────────────
|
|
// The parse/serialise round trip stays in `vault-utils.ts`; the router
|
|
// plumbing lives in the shared hook (which the measurements and mood
|
|
// lists reuse — this surface is the pattern's origin).
|
|
const { filters, applyFilters } = useUrlFilterSync({
|
|
parse: parseVaultSearchParams,
|
|
serialise: vaultFiltersToSearch,
|
|
});
|
|
|
|
// Search draft debounces into the URL (200 ms); an external URL change
|
|
// (back button, deep link) re-seeds the draft — render-phase derived-state
|
|
// adjustment, not an effect.
|
|
const [searchDraft, setSearchDraft] = useState(filters.q ?? "");
|
|
const [lastUrlQ, setLastUrlQ] = useState(filters.q ?? "");
|
|
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
|
if ((filters.q ?? "") !== lastUrlQ) {
|
|
setLastUrlQ(filters.q ?? "");
|
|
setSearchDraft(filters.q ?? "");
|
|
}
|
|
useEffect(() => {
|
|
const handle = setTimeout(() => {
|
|
const trimmed = searchDraft.trim();
|
|
if (trimmed === (filters.q ?? "")) return;
|
|
applyFilters(
|
|
{ ...filters, q: trimmed === "" ? undefined : trimmed },
|
|
"replace",
|
|
);
|
|
}, 200);
|
|
return () => clearTimeout(handle);
|
|
}, [searchDraft, filters, applyFilters]);
|
|
|
|
// `/` focuses the search from anywhere on the page.
|
|
useEffect(() => {
|
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
if (event.key !== "/" || event.metaKey || event.ctrlKey || event.altKey)
|
|
return;
|
|
const target = event.target as HTMLElement | null;
|
|
if (
|
|
target &&
|
|
(target.tagName === "INPUT" ||
|
|
target.tagName === "TEXTAREA" ||
|
|
target.tagName === "SELECT" ||
|
|
target.isContentEditable)
|
|
) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
searchInputRef.current?.focus();
|
|
};
|
|
window.addEventListener("keydown", onKeyDown);
|
|
return () => window.removeEventListener("keydown", onKeyDown);
|
|
}, []);
|
|
|
|
const toggleKind = (kind: InboundDocumentKindValue) => {
|
|
const current = new Set(filters.kinds ?? []);
|
|
if (current.has(kind)) {
|
|
current.delete(kind);
|
|
} else {
|
|
current.add(kind);
|
|
}
|
|
applyFilters(
|
|
{
|
|
...filters,
|
|
kinds: current.size > 0 ? [...current].sort() : undefined,
|
|
},
|
|
"push",
|
|
);
|
|
};
|
|
|
|
const toggleEpisode = (episodeId: string) => {
|
|
applyFilters(
|
|
{
|
|
...filters,
|
|
episodeId: filters.episodeId === episodeId ? undefined : episodeId,
|
|
},
|
|
"push",
|
|
);
|
|
};
|
|
|
|
const toggleEncounter = (encounterId: string) => {
|
|
applyFilters(
|
|
{
|
|
...filters,
|
|
encounterId:
|
|
filters.encounterId === encounterId ? undefined : encounterId,
|
|
},
|
|
"push",
|
|
);
|
|
};
|
|
|
|
const toggleYear = (year: number) => {
|
|
applyFilters(
|
|
{ ...filters, year: filters.year === year ? undefined : year },
|
|
"push",
|
|
);
|
|
};
|
|
|
|
const clearFilters = () => {
|
|
setSearchDraft("");
|
|
applyFilters({}, "push");
|
|
};
|
|
|
|
// ── Data ──────────────────────────────────────────────────────────────
|
|
const usage = useQuery({
|
|
queryKey: queryKeys.inboundDocumentUsage(),
|
|
enabled: moduleEnabled,
|
|
queryFn: () => apiGet<DocumentUsageDto>("/api/documents/inbound/usage"),
|
|
});
|
|
|
|
const list = useInfiniteQuery({
|
|
queryKey: queryKeys.inboundDocumentList(filters),
|
|
enabled: moduleEnabled,
|
|
queryFn: ({ pageParam }) =>
|
|
apiGet<ListPage>(
|
|
`/api/documents/inbound?${buildVaultListApiSearch(filters, pageParam)}`,
|
|
),
|
|
initialPageParam: null as string | null,
|
|
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
|
// v1.29.x — the auto-index (+ optional AI summary) job that lands right
|
|
// after upload is fire-and-forget server-side; nothing pushes its
|
|
// completion to the client. Poll while any loaded document is still
|
|
// inside its recent-upload processing window (see `hasProcessingDocument`
|
|
// — bounded, so an old/permanently-unindexed document never keeps this
|
|
// polling forever) so the card's "Processing…" chip clears on its own
|
|
// once the index lands, without a manual refresh.
|
|
refetchInterval: (query) => {
|
|
const pages = query.state.data?.pages ?? [];
|
|
const docs = pages.flatMap((p) => p.documents);
|
|
return hasProcessingDocument(docs) ? 4000 : false;
|
|
},
|
|
});
|
|
|
|
const documents = useMemo(
|
|
() => list.data?.pages.flatMap((p) => p.documents) ?? [],
|
|
[list.data],
|
|
);
|
|
|
|
// The reader's presentation (cards / list, stacked / flowing months),
|
|
// persisted per user and changed behind the wrench.
|
|
const { layout, isLayoutLoading } = useDocumentsLayout(moduleEnabled);
|
|
|
|
// v1.30.1 M12 — pull-to-refresh parity with labs/checkups/mood/
|
|
// measurements/medications. The vault is one of the sharpest PWA-resume-
|
|
// staleness surfaces named in the audit: a document indexed server-side
|
|
// while the tab was backgrounded (auto-index / AI summary) otherwise
|
|
// stays invisible until the bounded processing poll above happens to
|
|
// catch it or the user navigates away and back.
|
|
const refreshDocuments = useCallback(
|
|
() => Promise.all([list.refetch(), usage.refetch()]),
|
|
[list, usage],
|
|
);
|
|
const pull = usePullToRefresh({ onRefresh: refreshDocuments });
|
|
|
|
const upload = useDocumentUpload(
|
|
usage.data ? { maxFileBytes: usage.data.maxFileBytes } : undefined,
|
|
);
|
|
const uploadInputRef = useRef<HTMLInputElement | null>(null);
|
|
|
|
// One intake path for every source (picker, zone drop, page-wide drop,
|
|
// clipboard paste): pre-links to the active episode filter and feeds the
|
|
// sr-only live region — the drop overlay itself is decorative.
|
|
const [uploadAnnouncement, setUploadAnnouncement] = useState("");
|
|
const enqueueRef = useRef(upload.enqueue);
|
|
useEffect(() => {
|
|
enqueueRef.current = upload.enqueue;
|
|
}, [upload.enqueue]);
|
|
const episodeIdFilter = filters.episodeId;
|
|
const encounterIdFilter = filters.encounterId;
|
|
const enqueueFiles = useCallback(
|
|
(files: File[]) => {
|
|
if (files.length === 0) return;
|
|
enqueueRef.current(files, {
|
|
episodeId: episodeIdFilter,
|
|
encounterId: encounterIdFilter,
|
|
});
|
|
setUploadAnnouncement(
|
|
t("documents.upload.queuedAnnouncement", { count: files.length }),
|
|
);
|
|
},
|
|
[episodeIdFilter, encounterIdFilter, t],
|
|
);
|
|
const { dropActive } = usePageFileDrop(
|
|
canManageDocuments ? enqueueFiles : undefined,
|
|
);
|
|
|
|
const episodes = useIllnessEpisodes(
|
|
true,
|
|
!inSharedRecord || sections === null || sections.includes("illness"),
|
|
);
|
|
|
|
// Content-search coverage: fire the corpus backfill when indexing is
|
|
// available and some documents are not yet searchable. The list search
|
|
// already unions content matches server-side — this only surfaces the hint
|
|
// and the "index all" affordance from the usage gauge.
|
|
const reindexAll = useReindexAll();
|
|
const contentIndex = usage.data?.contentIndex;
|
|
const canIndexContent = contentIndex?.enabled ?? false;
|
|
const showIndexAll =
|
|
canManageDocuments &&
|
|
canIndexContent &&
|
|
contentIndex !== undefined &&
|
|
contentIndex.totalCount > 0 &&
|
|
contentIndex.indexedCount < contentIndex.totalCount;
|
|
const handleIndexAll = useCallback(() => {
|
|
reindexAll.mutate(undefined, {
|
|
onSuccess: (result) =>
|
|
toast.success(
|
|
result.enqueued > 0
|
|
? t("documents.contentIndex.indexAllQueued", {
|
|
count: result.enqueued,
|
|
})
|
|
: t("documents.contentIndex.indexAllNothing"),
|
|
),
|
|
onError: () => toast.error(t("documents.contentIndex.indexAllError")),
|
|
});
|
|
}, [reindexAll, t]);
|
|
|
|
// Condition chips: every episode carrying at least one live document
|
|
// link, served by the usage endpoint (NOT derived from the loaded corpus
|
|
// — an old linked document pages deep must still surface its chip), plus
|
|
// the actively filtered episode (a deep link must always show its own
|
|
// chip, even when its last link was just removed).
|
|
const conditionChips = useMemo<ConditionChip[]>(() => {
|
|
const byId = new Map<string, string>();
|
|
for (const link of usage.data?.linkedEpisodes ?? []) {
|
|
byId.set(link.episodeId, link.name);
|
|
}
|
|
if (filters.episodeId && !byId.has(filters.episodeId)) {
|
|
const episode = episodes.data?.find((e) => e.id === filters.episodeId);
|
|
byId.set(filters.episodeId, episode?.label ?? filters.episodeId);
|
|
}
|
|
return [...byId.entries()]
|
|
.map(([episodeId, name]) => ({ episodeId, name }))
|
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
}, [usage.data?.linkedEpisodes, filters.episodeId, episodes.data]);
|
|
|
|
// The visits the bulk bar can file a selection against: the account's own,
|
|
// read through the same list the checkups page shows. Labelled here rather
|
|
// than in the bar so the label uses the reader's locale for the kind and the
|
|
// reader's timezone for the date — both presentation, both this side's job.
|
|
const visits = useEncounters(
|
|
!inSharedRecord || sections === null || sections.includes("profile"),
|
|
);
|
|
const bulkEncounterOptions = useMemo(
|
|
() =>
|
|
[...(visits.data?.upcoming ?? []), ...(visits.data?.past ?? [])]
|
|
.slice(0, BULK_VISIT_OPTIONS)
|
|
.map((visit) => ({
|
|
id: visit.id,
|
|
label: `${
|
|
visit.practitioner?.name ??
|
|
encounterKindText(t, visit.kind as EncounterKind)
|
|
}, ${format.date(visit.occurredAt)}`,
|
|
})),
|
|
[visits.data, t, format],
|
|
);
|
|
|
|
// Procedure choices: every procedure carrying at least one live document
|
|
// link, served by the usage endpoint for the same reason as the condition
|
|
// chips, plus the actively filtered visit when it is a procedure (see
|
|
// `buildProcedureChoices`).
|
|
const procedureChips = useMemo<ProcedureFilterChoice[]>(() => {
|
|
const activeVisit = filters.encounterId
|
|
? [...(visits.data?.upcoming ?? []), ...(visits.data?.past ?? [])].find(
|
|
(v) => v.id === filters.encounterId && v.kind === "PROCEDURE",
|
|
)
|
|
: undefined;
|
|
return buildProcedureChoices(
|
|
usage.data?.linkedProcedures ?? [],
|
|
activeVisit
|
|
? {
|
|
encounterId: activeVisit.id,
|
|
occurredAt: activeVisit.occurredAt,
|
|
reason: activeVisit.reason,
|
|
bodySite: activeVisit.bodySite,
|
|
laterality: activeVisit.laterality,
|
|
practitionerName: activeVisit.practitioner?.name ?? null,
|
|
}
|
|
: null,
|
|
{
|
|
kindName: encounterKindText(t, "PROCEDURE"),
|
|
siteText: (site, laterality) => bodySiteText(t, site, laterality),
|
|
formatDate: (iso) => format.date(iso),
|
|
},
|
|
);
|
|
}, [
|
|
usage.data?.linkedProcedures,
|
|
filters.encounterId,
|
|
visits.data,
|
|
t,
|
|
format,
|
|
]);
|
|
|
|
// Year segmenter: years present in the loaded corpus (+ the active year).
|
|
const years = useMemo(() => {
|
|
const set = new Set<number>();
|
|
for (const doc of documents) {
|
|
set.add(Number(documentDateKey(doc, timezone).slice(0, 4)));
|
|
}
|
|
if (filters.year !== undefined) set.add(filters.year);
|
|
return [...set].sort((a, b) => b - a);
|
|
}, [documents, filters.year, timezone]);
|
|
|
|
// ── Selection + bulk actions ──────────────────────────────────────────
|
|
const [selectedIds, setSelectedIds] = useState<ReadonlySet<string>>(
|
|
new Set(),
|
|
);
|
|
// Anchor for shift-click ranges: the last plainly-toggled id.
|
|
const rangeAnchorRef = useRef<string | null>(null);
|
|
const toggleSelected = useCallback(
|
|
(id: string, range?: boolean) => {
|
|
setSelectedIds((prev) => {
|
|
if (range) {
|
|
return expandRangeSelection(
|
|
documents.map((d) => d.id),
|
|
prev,
|
|
rangeAnchorRef.current,
|
|
id,
|
|
);
|
|
}
|
|
const next = new Set(prev);
|
|
if (next.has(id)) {
|
|
next.delete(id);
|
|
} else {
|
|
next.add(id);
|
|
}
|
|
return next;
|
|
});
|
|
if (!range) rangeAnchorRef.current = id;
|
|
},
|
|
[documents],
|
|
);
|
|
const clearSelection = useCallback(() => {
|
|
setSelectedIds(new Set());
|
|
rangeAnchorRef.current = null;
|
|
}, []);
|
|
|
|
// Escape clears the selection — unless a dialog/popover owns the key.
|
|
useEffect(() => {
|
|
if (selectedIds.size === 0) return;
|
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
if (event.key !== "Escape") return;
|
|
const target = event.target as HTMLElement | null;
|
|
if (target?.closest('[role="dialog"],[role="menu"]')) return;
|
|
clearSelection();
|
|
};
|
|
window.addEventListener("keydown", onKeyDown);
|
|
return () => window.removeEventListener("keydown", onKeyDown);
|
|
}, [selectedIds.size, clearSelection]);
|
|
|
|
/**
|
|
* One bulk POST per ≤100-id chunk (the endpoint's cap); per-id results
|
|
* are merged so a partial failure in any chunk surfaces once. The
|
|
* endpoint is no-op-success on already-in-state rows, so an undo toast
|
|
* firing twice is safe by contract.
|
|
*/
|
|
const bulk = useMutation({
|
|
mutationFn: async (input: {
|
|
ids: string[];
|
|
action: DocumentBulkAction;
|
|
kind?: InboundDocumentKindValue;
|
|
episodeId?: string;
|
|
}) => {
|
|
const results: DocumentBulkResultDto[] = [];
|
|
for (let i = 0; i < input.ids.length; i += DOCUMENT_BULK_MAX_IDS) {
|
|
const chunk = input.ids.slice(i, i + DOCUMENT_BULK_MAX_IDS);
|
|
const page = await apiPost<{ results: DocumentBulkResultDto[] }>(
|
|
"/api/documents/inbound/bulk",
|
|
{
|
|
ids: chunk,
|
|
action: input.action,
|
|
...(input.kind !== undefined && { kind: input.kind }),
|
|
...(input.episodeId !== undefined && {
|
|
episodeId: input.episodeId,
|
|
}),
|
|
},
|
|
);
|
|
results.push(...page.results);
|
|
}
|
|
return results;
|
|
},
|
|
onSettled: () => {
|
|
void invalidateKeys(queryClient, [queryKeys.documents()]);
|
|
},
|
|
});
|
|
|
|
const reportBulkOutcome = useCallback(
|
|
(results: DocumentBulkResultDto[], successMessage: string) => {
|
|
const failed = results.filter((r) => !r.ok).length;
|
|
if (failed > 0) {
|
|
toast.error(
|
|
t("documents.bulk.partialFailure", {
|
|
failed,
|
|
total: results.length,
|
|
}),
|
|
);
|
|
} else {
|
|
toast.success(successMessage);
|
|
}
|
|
},
|
|
[t],
|
|
);
|
|
|
|
const runBulk = useCallback(
|
|
(
|
|
action: Exclude<DocumentBulkAction, "delete" | "restore">,
|
|
extra: {
|
|
kind?: InboundDocumentKindValue;
|
|
episodeId?: string;
|
|
encounterId?: string;
|
|
},
|
|
) => {
|
|
const ids = [...selectedIds];
|
|
bulk.mutate(
|
|
{ ids, action, ...extra },
|
|
{
|
|
onSuccess: (results) => {
|
|
reportBulkOutcome(
|
|
results,
|
|
t("documents.bulk.updated", {
|
|
count: results.filter((r) => r.ok).length,
|
|
}),
|
|
);
|
|
clearSelection();
|
|
},
|
|
onError: () => toast.error(t("documents.bulk.failed")),
|
|
},
|
|
);
|
|
},
|
|
[selectedIds, bulk, reportBulkOutcome, clearSelection, t],
|
|
);
|
|
|
|
const restoreBulk = useCallback(
|
|
(ids: string[]) => {
|
|
bulk.mutate(
|
|
{ ids, action: "restore" },
|
|
{
|
|
onSuccess: (results) =>
|
|
reportBulkOutcome(
|
|
results,
|
|
t("documents.bulk.restored", {
|
|
count: results.filter((r) => r.ok).length,
|
|
}),
|
|
),
|
|
onError: () => toast.error(t("documents.toast.restoreFailed")),
|
|
},
|
|
);
|
|
},
|
|
[bulk, reportBulkOutcome, t],
|
|
);
|
|
|
|
const deleteBulk = useCallback(
|
|
(ids: string[]) => {
|
|
bulk.mutate(
|
|
{ ids, action: "delete" },
|
|
{
|
|
onSuccess: (results) => {
|
|
const okIds = results.filter((r) => r.ok).map((r) => r.id);
|
|
const failed = results.length - okIds.length;
|
|
if (failed > 0) {
|
|
toast.error(
|
|
t("documents.bulk.partialFailure", {
|
|
failed,
|
|
total: results.length,
|
|
}),
|
|
);
|
|
}
|
|
if (okIds.length > 0) {
|
|
// ONE aggregate undo for the whole batch (bulk restore).
|
|
toast.success(
|
|
t("documents.bulk.deleted", { count: okIds.length }),
|
|
{
|
|
action: {
|
|
label: t("common.undo"),
|
|
onClick: () => restoreBulk(okIds),
|
|
},
|
|
},
|
|
);
|
|
}
|
|
clearSelection();
|
|
},
|
|
onError: () => toast.error(t("documents.bulk.failed")),
|
|
},
|
|
);
|
|
},
|
|
[bulk, clearSelection, restoreBulk, t],
|
|
);
|
|
|
|
// ── Delete confirmation ───────────────────────────────────────────────
|
|
// Every way of deleting from the vault page — the bulk bar's Delete and a
|
|
// card's Delete key — comes through here and asks first. Nothing on this
|
|
// page calls `deleteBulk` directly.
|
|
const [pendingDeleteIds, setPendingDeleteIds] = useState<string[] | null>(
|
|
null,
|
|
);
|
|
const requestDelete = useCallback((ids: string[]) => {
|
|
if (ids.length > 0) setPendingDeleteIds(ids);
|
|
}, []);
|
|
const confirmDelete = useCallback(() => {
|
|
if (pendingDeleteIds) deleteBulk(pendingDeleteIds);
|
|
setPendingDeleteIds(null);
|
|
}, [pendingDeleteIds, deleteBulk]);
|
|
|
|
// ── Bulk share ────────────────────────────────────────────────────────
|
|
// ONE documents-only link for the whole selection (the share model carries
|
|
// up to SHARE_MAX_DOCUMENTS docs per link). The titles come from the loaded
|
|
// corpus — a selected id is always a rendered row. Over the cap we surface a
|
|
// hint and refuse rather than silently dropping docs from the link.
|
|
const [bulkShareOpen, setBulkShareOpen] = useState(false);
|
|
const [bulkShareDocs, setBulkShareDocs] = useState<
|
|
{ id: string; title: string }[]
|
|
>([]);
|
|
const openBulkShare = useCallback(() => {
|
|
const resolved = resolveBulkShareDocuments(
|
|
documents,
|
|
selectedIds,
|
|
t("documents.card.untitled"),
|
|
);
|
|
if (resolved.overCap) {
|
|
toast.error(
|
|
t("documents.bulk.shareTooMany", { max: SHARE_LINK_MAX_DOCUMENTS }),
|
|
);
|
|
return;
|
|
}
|
|
if (resolved.documents.length === 0) return;
|
|
setBulkShareDocs(resolved.documents);
|
|
setBulkShareOpen(true);
|
|
}, [documents, selectedIds, t]);
|
|
|
|
// ── Detail sheet ──────────────────────────────────────────────────────
|
|
const [detailId, setDetailId] = useState<string | null>(null);
|
|
const [detailOpen, setDetailOpen] = useState(false);
|
|
const handedOffDocumentRef = useRef<string | null>(null);
|
|
|
|
const rawDocParam = searchParams.get("doc");
|
|
const docParam =
|
|
rawDocParam !== null && DOCUMENT_QUERY_ID_PATTERN.test(rawDocParam)
|
|
? rawDocParam
|
|
: null;
|
|
const [observedDocParam, setObservedDocParam] = useState<string | null>(null);
|
|
|
|
// The query is the navigation owner: direct links, browser back, and browser
|
|
// forward all drive the same detail selection without remounting the route.
|
|
// Wait for auth so a deep link cannot be consumed before the module gate is
|
|
// known, and never interpolate an unvalidated id into the detail API path.
|
|
if (!authLoading && moduleEnabled && rawDocParam !== observedDocParam) {
|
|
setObservedDocParam(rawDocParam);
|
|
setDetailId(docParam);
|
|
setDetailOpen(docParam !== null);
|
|
}
|
|
|
|
const openDetail = useCallback(
|
|
(id: string) => {
|
|
const href = documentSelectionHref(pathname, searchParams.toString(), id);
|
|
if (href === null) return;
|
|
handedOffDocumentRef.current = null;
|
|
setDetailId(id);
|
|
setDetailOpen(true);
|
|
window.history.pushState(documentSelectionHistoryState(id), "", href);
|
|
},
|
|
[pathname, searchParams],
|
|
);
|
|
|
|
const handleDetailOpenChange = useCallback((open: boolean) => {
|
|
setDetailOpen(open);
|
|
}, []);
|
|
|
|
const coachOwnsDocument =
|
|
docParam !== null &&
|
|
coachLaunch?.open === true &&
|
|
coachLaunch.documentId === docParam;
|
|
|
|
useEffect(() => {
|
|
if (authLoading || !moduleEnabled) return;
|
|
if (docParam === null) {
|
|
handedOffDocumentRef.current = null;
|
|
return;
|
|
}
|
|
if (detailOpen) return;
|
|
if (coachOwnsDocument) {
|
|
// "Ask Coach" intentionally closes the sheet while the drawer owns the
|
|
// same document. Keep the return URL intact so maximize → browser-back
|
|
// can reconstruct the sheet from `?doc=`.
|
|
handedOffDocumentRef.current = docParam;
|
|
return;
|
|
}
|
|
// Read before the call below reassigns the ref: the same inputs the close
|
|
// itself decides on.
|
|
const retainedAsReturnUrl = coachHandoffRetainsSelection(
|
|
docParam,
|
|
handedOffDocumentRef.current,
|
|
coachLaunch?.closeIntent,
|
|
);
|
|
|
|
handedOffDocumentRef.current = closeDocumentSelectionAfterCoachHandoff(
|
|
window.history,
|
|
pathname,
|
|
searchParams.toString(),
|
|
docParam,
|
|
handedOffDocumentRef.current,
|
|
coachLaunch?.closeIntent,
|
|
);
|
|
|
|
// Nothing was closed: the selection is the way back from `/coach` and is
|
|
// meant to stay. Checking for it here would delete it.
|
|
if (retainedAsReturnUrl) return;
|
|
|
|
// Consuming the pushed entry is a REQUEST to the browser, not a guarantee.
|
|
//
|
|
// `history.back()` is queued against the session history, and a traversal
|
|
// asked for while another is still settling can be dropped — closing the
|
|
// sheet immediately after pressing the browser's Forward button is exactly
|
|
// that shape. Nothing raises when it happens: the sheet is closed, React
|
|
// is consistent, and the URL still names the document. A reload or a
|
|
// shared link then re-opens the sheet the reader just closed.
|
|
//
|
|
// So the request is checked rather than trusted. If the selection is still
|
|
// in the address bar shortly afterwards, it is removed the synchronous
|
|
// way. The guard is the document id, so a traversal that landed on some
|
|
// other selection is left alone, and the cleanup below cancels the check
|
|
// the moment the sheet re-opens or the parameter changes — which is what
|
|
// makes re-opening within the window safe.
|
|
const verify = window.setTimeout(() => {
|
|
if (!documentSelectionSurvivedClose(window.location.search, docParam)) {
|
|
return;
|
|
}
|
|
window.history.replaceState(
|
|
null,
|
|
"",
|
|
withoutDocumentSelectionHref(pathname, window.location.search),
|
|
);
|
|
}, CLOSE_TRAVERSAL_GRACE_MS);
|
|
return () => window.clearTimeout(verify);
|
|
}, [
|
|
authLoading,
|
|
coachOwnsDocument,
|
|
coachLaunch?.closeIntent,
|
|
detailOpen,
|
|
docParam,
|
|
moduleEnabled,
|
|
pathname,
|
|
searchParams,
|
|
]);
|
|
|
|
// Hover/focus intent prefetches the detail METADATA (never the blob —
|
|
// the blob fetch starts when the sheet mounts its preview element).
|
|
const prefetchDetail = useCallback(
|
|
(id: string) => {
|
|
void queryClient.prefetchQuery({
|
|
queryKey: queryKeys.inboundDocument(id),
|
|
queryFn: () =>
|
|
apiGet<InboundDocumentDetailDto>(`/api/documents/inbound/${id}`),
|
|
staleTime: 30_000,
|
|
});
|
|
},
|
|
[queryClient],
|
|
);
|
|
|
|
if (authLoading || !isAuthenticated || !moduleEnabled) {
|
|
return (
|
|
<div className="flex h-64 items-center justify-center" role="status">
|
|
<Loader2
|
|
className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none"
|
|
aria-hidden
|
|
/>
|
|
<span className="sr-only">{t("nav.loadingScreen")}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const activeCount = countActiveFilters(filters);
|
|
const isFiltered = activeCount > 0;
|
|
const showEmpty =
|
|
list.isSuccess && documents.length === 0 && upload.items.length === 0;
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<PullToRefreshIndicator {...pull} />
|
|
<PageHeader
|
|
title={t("documents.title")}
|
|
description={t("documents.subtitle")}
|
|
actions={
|
|
canManageDocuments || !inSharedRecord ? (
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
{/* The wrench left of the primary action, as on the illness
|
|
journal: it opens this vault's display settings. `/settings`
|
|
is the caller's own, so it never shows inside somebody
|
|
else's record. */}
|
|
{!inSharedRecord ? (
|
|
<Button
|
|
asChild
|
|
variant="ghost"
|
|
size="icon"
|
|
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
|
>
|
|
<Link
|
|
href="/settings/layout/documents"
|
|
aria-label={t("documents.customize")}
|
|
title={t("documents.customize")}
|
|
data-slot="documents-customize"
|
|
>
|
|
<Wrench className="size-4" aria-hidden />
|
|
</Link>
|
|
</Button>
|
|
) : null}
|
|
{canManageDocuments ? (
|
|
<>
|
|
{/* Renders only with a connected Paperless-ngx or Papra. */}
|
|
<ImportFromSourceButton className="min-h-11 sm:min-h-9" />
|
|
{/* An empty vault's empty state carries the upload. */}
|
|
{showEmpty && !isFiltered ? null : (
|
|
<Button
|
|
className="min-h-11 sm:min-h-9"
|
|
onClick={() => uploadInputRef.current?.click()}
|
|
>
|
|
<Upload className="size-4" aria-hidden />
|
|
{t("documents.pageUpload")}
|
|
</Button>
|
|
)}
|
|
</>
|
|
) : null}
|
|
</div>
|
|
) : null
|
|
}
|
|
/>
|
|
|
|
{canManageDocuments && (
|
|
<UploadZone
|
|
usage={usage.data}
|
|
inputRef={uploadInputRef}
|
|
onFiles={enqueueFiles}
|
|
/>
|
|
)}
|
|
{inSharedRecord && !canManageDocuments && (
|
|
// Where the upload zone would be: the one empty space a person acting
|
|
// for somebody else would otherwise search a control in.
|
|
<p
|
|
className="text-muted-foreground text-sm"
|
|
data-slot="documents-owner-only"
|
|
>
|
|
{t("documents.ownerOnlyUpload")}
|
|
</p>
|
|
)}
|
|
|
|
<DocumentFilterBar
|
|
searchValue={searchDraft}
|
|
onSearchChange={setSearchDraft}
|
|
searchInputRef={searchInputRef}
|
|
activeKinds={new Set(filters.kinds ?? [])}
|
|
onToggleKind={toggleKind}
|
|
conditionChips={conditionChips}
|
|
activeEpisodeId={filters.episodeId}
|
|
onToggleEpisode={toggleEpisode}
|
|
procedureChips={procedureChips}
|
|
activeEncounterId={filters.encounterId}
|
|
onToggleEncounter={toggleEncounter}
|
|
years={years}
|
|
activeYear={filters.year}
|
|
onToggleYear={toggleYear}
|
|
activeCount={activeCount}
|
|
onClearAll={clearFilters}
|
|
showIndexAll={showIndexAll}
|
|
indexAllPending={reindexAll.isPending}
|
|
onIndexAll={handleIndexAll}
|
|
/>
|
|
|
|
{list.isPending || isLayoutLoading ? (
|
|
layout.view === "list" ? (
|
|
<div data-slot="documents-loading" className="space-y-4">
|
|
{Array.from({ length: 6 }, (_, i) => (
|
|
<Skeleton key={i} className="h-20 w-full rounded-xl" />
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div
|
|
data-slot="documents-loading"
|
|
// The archive's tile shape (a 4:3 preview over two text lines),
|
|
// in the timeline's column counts, so the grid does not jump when
|
|
// the first page lands.
|
|
className="grid grid-cols-2 gap-4 lg:grid-cols-3 xl:grid-cols-4"
|
|
>
|
|
{Array.from({ length: 8 }, (_, i) => (
|
|
<div key={i} className="space-y-2">
|
|
<Skeleton className="aspect-[4/3] w-full rounded-xl" />
|
|
<Skeleton className="h-4 w-3/4" />
|
|
<Skeleton className="h-3 w-1/2" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
)
|
|
) : list.isError ? (
|
|
<QueryErrorCard
|
|
title={t("documents.list.loadError")}
|
|
onRetry={() => void list.refetch()}
|
|
/>
|
|
) : showEmpty && !isFiltered ? (
|
|
<EmptyState
|
|
icon={<FolderOpen className="size-6" aria-hidden />}
|
|
title={t("documents.empty.title")}
|
|
description={t("documents.empty.description")}
|
|
action={
|
|
canManageDocuments ? (
|
|
<Button
|
|
className="min-h-11 sm:min-h-9"
|
|
onClick={() => uploadInputRef.current?.click()}
|
|
>
|
|
<Upload className="size-4" aria-hidden />
|
|
{t("documents.empty.action")}
|
|
</Button>
|
|
) : undefined
|
|
}
|
|
/>
|
|
) : showEmpty && isFiltered ? (
|
|
<EmptyState
|
|
icon={<SearchX className="size-6" aria-hidden />}
|
|
title={t("documents.empty.noMatchesTitle")}
|
|
description={t("documents.empty.noMatchesDescription")}
|
|
action={
|
|
<Button variant="outline" onClick={clearFilters}>
|
|
{t("documents.filter.clear")}
|
|
</Button>
|
|
}
|
|
/>
|
|
) : (
|
|
<DocumentTimeline
|
|
view={layout.view}
|
|
arrangement={layout.arrangement}
|
|
timezone={timezone}
|
|
documents={documents}
|
|
uploadItems={upload.items}
|
|
onDismissUpload={upload.dismiss}
|
|
hasNextPage={list.hasNextPage}
|
|
isFetchingNextPage={list.isFetchingNextPage}
|
|
onLoadMore={() => void list.fetchNextPage()}
|
|
selectedIds={selectedIds}
|
|
onToggleSelected={canManageDocuments ? toggleSelected : undefined}
|
|
onOpen={openDetail}
|
|
onDelete={
|
|
canManageDocuments ? (id) => requestDelete([id]) : undefined
|
|
}
|
|
highlightId={upload.highlightId}
|
|
onPrefetch={prefetchDetail}
|
|
/>
|
|
)}
|
|
|
|
<DocumentDetailSheet
|
|
documentId={detailId}
|
|
open={detailOpen}
|
|
onOpenChange={handleDetailOpenChange}
|
|
assistAvailable={usage.data?.assistAvailable}
|
|
contentIndexEnabled={usage.data?.contentIndex.enabled}
|
|
/>
|
|
|
|
{canManageDocuments && selectedIds.size > 0 ? (
|
|
<DocumentBulkBar
|
|
selectedCount={selectedIds.size}
|
|
episodes={(episodes.data ?? []).map((e) => ({
|
|
id: e.id,
|
|
label: e.label,
|
|
}))}
|
|
busy={bulk.isPending}
|
|
onSetKind={(kind) => runBulk("setKind", { kind })}
|
|
onLinkEpisode={(episodeId) => runBulk("linkEpisode", { episodeId })}
|
|
encounters={bulkEncounterOptions}
|
|
onLinkEncounter={(encounterId) =>
|
|
runBulk("linkEncounter", { encounterId })
|
|
}
|
|
onShare={openBulkShare}
|
|
onRequestDelete={() => requestDelete([...selectedIds])}
|
|
onClear={clearSelection}
|
|
/>
|
|
) : null}
|
|
|
|
<ConfirmDialog
|
|
open={pendingDeleteIds !== null}
|
|
onOpenChange={(open) => {
|
|
if (!open) setPendingDeleteIds(null);
|
|
}}
|
|
title={
|
|
pendingDeleteIds !== null && pendingDeleteIds.length > 1
|
|
? t("documents.deleteConfirm.titleMany", {
|
|
count: pendingDeleteIds.length,
|
|
})
|
|
: t("documents.deleteConfirm.titleOne")
|
|
}
|
|
body={t("documents.deleteConfirm.body")}
|
|
confirmLabel={t("documents.deleteConfirm.action")}
|
|
onConfirm={confirmDelete}
|
|
slot="documents-delete"
|
|
destructive
|
|
/>
|
|
|
|
<DocumentShareSheet
|
|
open={bulkShareOpen}
|
|
onOpenChange={setBulkShareOpen}
|
|
documents={bulkShareDocs}
|
|
/>
|
|
|
|
{/* Page-wide drop overlay — pure decoration (aria-hidden); the intake
|
|
itself announces through the live region below. */}
|
|
{dropActive ? (
|
|
<div
|
|
aria-hidden
|
|
data-slot="document-drop-overlay"
|
|
className="bg-background/80 fixed inset-0 z-50 p-4 backdrop-blur-sm md:p-8"
|
|
>
|
|
<div className="border-primary bg-primary/5 flex h-full items-center justify-center rounded-xl border-2 border-dashed">
|
|
<div className="flex flex-col items-center gap-2 px-4 text-center">
|
|
<UploadCloud className="text-primary size-8" aria-hidden />
|
|
<p className="text-base font-medium">
|
|
{t("documents.dropOverlay.title")}
|
|
</p>
|
|
<p className="text-muted-foreground text-xs">
|
|
{t("documents.dropOverlay.hint")}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
<p aria-live="polite" role="status" className="sr-only">
|
|
{uploadAnnouncement}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|