"use client"; /** * Document detail on `ResponsiveSheet` (bottom sheet on phones, dialog on * desktop). Inline-class documents (PDF / browser-native images) preview * inline against the owner-scoped decrypt-and-serve route — skeleton first, * content on load, internal scroll only (`overscroll-contain`). Attachment- * class documents get NO fake preview: type glyph, filename, size, and a * prominent Download button. * * Metadata edits save on commit (title on Enter/blur, kind and filing date * on change, condition links on toggle — replace-set PATCH). Mutation * errors surface inline (`role="alert"`), never as a QueryErrorCard. * Delete soft-deletes with an Undo toast (restore endpoint); a restore * refusal maps `meta.reason` (`purged`, `duplicateExists`) to translated * copy. A Share action in the footer opens the clinician share-link create * flow (shared `ShareLinkCreateForm`) with this document pre-attached. */ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowRight, Check, Download, FlaskConical, Pencil, Plus, Share2, Sparkles, Trash2, X, } from "lucide-react"; import Link from "next/link"; import { useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { useIllnessEpisodes } from "@/components/illness/use-illness"; import { useAuth } from "@/hooks/use-auth"; import { Button } from "@/components/ui/button"; import { DateField } from "@/components/ui/date-field"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { ResponsiveSheet } from "@/components/ui/responsive-sheet"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Skeleton } from "@/components/ui/skeleton"; import { apiDelete, ApiError, apiGet, apiPatch, apiPost, } from "@/lib/api/api-fetch"; import { useFormatters, useTranslations } from "@/lib/i18n/context"; import { useCoachLaunch } from "@/lib/insights/coach-launch-context"; import { invalidateKeys, queryKeys } from "@/lib/query-keys"; import { cn } from "@/lib/utils"; import { INBOUND_DOCUMENT_KINDS, type DocumentSuggestionDto, type DocumentSummaryMode, type InboundDocumentDetailDto, type InboundDocumentKindValue, } from "@/lib/validations/inbound-documents"; import { DocumentAiSection } from "./document-ai-section"; import { DocumentSummaryBlock } from "./document-summary-block"; import type { DocumentAiTarget } from "./document-ai-transport"; import { DocumentShareSheet } from "./document-share-sheet"; import { DOCUMENT_KIND_ICONS } from "./document-kind-meta"; import { useIndexDocument } from "./use-content-index"; import { documentAiErrorKey, useDocumentAiCapability, useDocumentsAutoAiRead, useDocumentSummary, useSuggestDetails, } from "./use-document-assist"; import { formatBytes } from "./vault-utils"; type PatchInput = { title?: string | null; kind?: InboundDocumentKindValue; documentDate?: string | null; episodeIds?: string[]; }; /** Inline preview for Class A documents; skeleton until the blob paints. */ function InlinePreview({ documentId, mimeType, title, }: { documentId: string; mimeType: string; title: string; }) { const [loaded, setLoaded] = useState(false); const src = `/api/documents/inbound/${documentId}/original`; const isPdf = mimeType === "application/pdf"; // Chromium's PDF viewer grabs focus while it initialises — BEFORE the // frame's `load` event — stranding keyboard events inside the embedded // document, so Escape stops closing the sheet the moment a PDF preview // opens. While the guard window is open (armed at mount, extended at // load, ended early by a deliberate pointer interaction with the // preview), any focus landing on the frame bounces straight back to the // dialog container; a user who clicks into the PDF is left alone. const frameRef = useRef(null); const stealGuardUntil = useRef(0); const reclaimFocus = (frame: HTMLIFrameElement) => { if ( Date.now() < stealGuardUntil.current && document.activeElement === frame ) { frame.closest('[role="dialog"]')?.focus(); } }; useEffect(() => { // Arm at mount, then run a frame-by-frame sweep for the steal variants // that never dispatch a focus event on the frame element. // Self-terminates with the guard. stealGuardUntil.current = Date.now() + 3_000; let raf = 0; const sweep = () => { const frame = frameRef.current; if (!frame || Date.now() >= stealGuardUntil.current) return; if (document.activeElement === frame) { frame.closest('[role="dialog"]')?.focus(); } raf = requestAnimationFrame(sweep); }; raf = requestAnimationFrame(sweep); return () => cancelAnimationFrame(raf); }, []); return (
{!loaded ? ( ) : null} {isPdf ? ( // No `sandbox` attribute by design: Chromium force-downloads PDFs // in sandboxed frames instead of rendering them. The serve // response itself carries `default-src 'none'` + // `frame-ancestors 'self'` + true Content-Type + nosniff, and the // upload policy denies every format whose inline render could // execute script.