import { useEffect, useEffectEvent, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { ChevronLeft, ChevronRight, Download, ExternalLink, FileQuestion, WrapText, } from "lucide-react"; import { api, HttpError, type FilePreviewKind, type LibraryFile } from "../../lib/api"; import { formatBytes } from "../../lib/format"; import { dispatch, setScopeCoveredProbe, useKeyScope } from "../../lib/keymap"; import { useLayer } from "../../lib/layerStack"; import { qk } from "../../lib/queryKeys"; import { jobTitle } from "../../features/downloads/downloadTitle"; import { type ActionMenuItem } from "../../components/ui/ActionMenu"; import { ContextMenu } from "../../components/ui/ContextMenu"; import { IconButton } from "../../components/ui/IconButton"; import { IconLink, LinkButton } from "../../components/ui/LinkButton"; import Modal from "../../components/ui/Modal"; import { MoreButton } from "../../components/ui/MoreButton"; import { LoadingState, StateMessage } from "../../components/ui/QueryState"; import { useContextMenu } from "../../components/ui/useContextMenu"; import RemoteImage from "../../components/ui/RemoteImage"; // The Files viewer (DESIGN.md "Files preview"): the library's files that are not media, one at a time, // in the shared dialog shell. What a file shows is the server's call (`asset.preview`, // backend downloads/preview.py): an image as an image, a PDF in the browser's own viewer, a text // format as plain text — HTML and SVG too, never rendered: on this origin a page would run with the // reader's session. The rest gets a card that offers the file itself. ← / → step through `files`, the // list's own order, so the viewer pages the way the list reads. // // Every kind fills the same stage, so a step never resizes the dialog: its ‹ › stay under the pointer. const STAGE = "h-[min(75dvh,56rem)] w-full"; // How many lines of a text file the viewer lays out (see TextStage). const TEXT_LINES = 5000; /** A text file's head (one read, shared by the viewer's header and its stage). */ function useTextPreview(id: number, enabled: boolean) { return useQuery({ queryKey: qk.filePreview(id), queryFn: () => api.previewText(id), enabled, // A refusal (the "text" turned out binary) is an answer, not a blip to retry. retry: (n, e) => !(e instanceof HttpError && e.status < 500) && n < 1, }); } /** The server's "no preview" for a file it listed as text: it was binary after all. */ const refused = (e: unknown) => e instanceof HttpError && e.status === 415; export default function FilePreview({ files, fileId, onFileChange, onClose, menuItems, }: { /** The files the viewer steps through, in the list's order. */ files: readonly LibraryFile[]; fileId: number; onFileChange: (id: number) => void; onClose: () => void; /** The file's row menu, behind the viewer's "…". */ menuItems: (file: LibraryFile) => ActionMenuItem[]; }) { const { t } = useTranslation(); const at = files.findIndex((f) => f.id === fileId); const file = at >= 0 ? files[at] : undefined; // Gone from the list under the viewer (deleted from its menu, or by another tab): nothing to show. useEffect(() => { if (!file) onClose(); }, [file, onClose]); const step = (by: number) => { const next = files[at + by]; if (next) onFileChange(next.id); }; useKeyScope("fileViewer"); // The viewer's own place on the layer stack: ← / → are its only while nothing is on top of it — its // "…" menu, a confirm, the Share or Details dialog opened from that menu. There an arrow moves // through the layer's own buttons; paging the viewer under it would show one file while the dialog // acts on another. It mounts with its dialog (both only while there is a file), and effects run // child first, so it sits just ABOVE the dialog's layer: the dialog is never topmost, and Escape is // this layer's to take — passive, so the dialog keeps its Tab trap. const layer = useLayer(!!file, onClose, { passive: true }); // …and the help sheet opened over that layer must not list them (keymap `setScopeCoveredProbe`). const coveredNow = useEffectEvent(() => layer.blockingAbove()); useEffect(() => { setScopeCoveredProbe("fileViewer", () => coveredNow()); return () => setScopeCoveredProbe("fileViewer", null); }, []); const onKey = useEffectEvent((e: KeyboardEvent) => { if (layer.blockingAbove()) return; // The text, scrolled sideways by its arrows: a line wider than the stage is read that way. const scroller = e.target instanceof Element ? e.target.closest("[data-scrolls-sideways]") : null; if (scroller && scroller.scrollWidth > scroller.clientWidth) return; dispatch(e, "fileViewer", { prevFile: (ev) => { ev.preventDefault(); step(-1); }, nextFile: (ev) => { ev.preventDefault(); step(1); }, }); }); useEffect(() => { const handler = (e: KeyboardEvent) => onKey(e); window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); }, []); const menu = useContextMenu({ keyOf: (f) => String(f.id) }); const text = useTextPreview(fileId, file?.asset?.preview === "text"); if (!file) return null; const kind = file.asset?.preview ?? null; // A new tab would only show the refusal. const openable = kind !== null && !refused(text.error); const title = jobTitle(file); return ( {title}} onClose={onClose} maxWidth="max-w-6xl" headerActions={ <> step(-1)} > {t("files.preview.position", { n: at + 1, total: files.length })} = files.length - 1} onClick={() => step(1)} > {openable ? ( ) : ( // Its place kept, so the icons after it do not shift from one file to the next. )} } > {/* Keyed by file: a step starts the next one's own load, never a frame of the last one. */} ); } function Stage({ file, kind, title, }: { file: LibraryFile; kind: FilePreviewKind | null; title: string; }) { const { t } = useTranslation(); if (kind === "image") return (
{/* A preview that fails is an answer (the file is not an image after all), not a hiccup: said at once, no retries. */} {t("common.loadError")}} />
); if (kind === "pdf") return (