Files
siftlode/frontend/src/modules/files/FilePreview.tsx
T
peter 84db570951 fix(player): marks give way to newer lists, Watch later items mark, and auto-advance has its own glyphs
A session's marks are weighed per field against when each page of a never-refetched list was read:
a page read before the mark (a Show more / Load more on its way) takes it, one read after keeps the
server's word. The live search lays them over its rows too, not only the related list.

Items of the built-in Watch later list ARE the bookmark, so adding, removing and moving them go
through features/video/videoWrites and mark the video; the contract test covers those writes and
catches a write that is named without being called on the spot.

The player's watched toggle follows a mark confirmed elsewhere since it opened (the related row's
menu, a notice's Undo); Restart on the related row after a description link goes back to it.
The file viewer tells the help sheet its keys wait under a layer; refusalReason replaces the last
two open-coded reads.

Auto-advance wears list glyphs of its own (the skip arrows are Previous / Next beside it), and the
auto-advance and loop cycles are `lit`, not toggles: no aria-pressed, the name says the mode. Both
places render from one list of controls, so the docked tooltips name the mode too. The English cap
message and admin hint quote "Show more"; the hint says refunds are limited.
2026-10-25 04:01:43 +01:00

319 lines
12 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<LibraryFile>({ 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 (
<Modal
initialFocus="close"
title={<span className="block truncate">{title}</span>}
onClose={onClose}
maxWidth="max-w-6xl"
headerActions={
<>
<IconButton
variant="ghost"
label={t("files.preview.prev")}
disabled={at <= 0}
onClick={() => step(-1)}
>
<ChevronLeft />
</IconButton>
<span className="text-xs tabular-nums text-muted">
{t("files.preview.position", { n: at + 1, total: files.length })}
</span>
<IconButton
variant="ghost"
label={t("files.preview.next")}
disabled={at >= files.length - 1}
onClick={() => step(1)}
>
<ChevronRight />
</IconButton>
{openable ? (
<IconLink
href={api.previewUrl(file.id)}
target="_blank"
rel="noopener"
label={t("files.preview.openInTab")}
>
<ExternalLink />
</IconLink>
) : (
// Its place kept, so the icons after it do not shift from one file to the next.
<span className="size-7 shrink-0" aria-hidden />
)}
<IconLink href={api.fileUrl(file.id)} label={t("downloads.actions.download")}>
<Download />
</IconLink>
<MoreButton
label={t("files.rowMenu")}
expanded={menu.open && menu.target?.id === file.id}
menuProps={menu.buttonProps(file)}
/>
<ContextMenu
menu={menu}
ariaLabel={t("files.rowMenu")}
items={menu.target ? menuItems(menu.target) : []}
/>
</>
}
>
{/* Keyed by file: a step starts the next one's own load, never a frame of the last one. */}
<Stage key={file.id} file={file} kind={kind} title={title} />
</Modal>
);
}
function Stage({
file,
kind,
title,
}: {
file: LibraryFile;
kind: FilePreviewKind | null;
title: string;
}) {
const { t } = useTranslation();
if (kind === "image")
return (
<div className={`${STAGE} flex items-center justify-center`}>
{/* A preview that fails is an answer (the file is not an image after all), not a hiccup:
said at once, no retries. */}
<RemoteImage
src={api.previewUrl(file.id)}
alt={title}
className="max-h-full max-w-full rounded-md object-contain"
retries={0}
fallback={<StateMessage tone="error">{t("common.loadError")}</StateMessage>}
/>
</div>
);
if (kind === "pdf")
return (
<iframe
src={api.previewUrl(file.id)}
title={title}
className={`${STAGE} rounded-md border border-border bg-white`}
/>
);
if (kind === "text") return <TextStage file={file} />;
return <NoPreview file={file} />;
}
function TextStage({ file }: { file: LibraryFile }) {
const { t, i18n } = useTranslation();
const [wrap, setWrap] = useState(true);
const q = useTextPreview(file.id, true);
if (q.isError)
return refused(q.error) ? (
<NoPreview file={file} />
) : (
<StateMessage
tone="error"
onRetry={() => void q.refetch()}
className={`${STAGE} justify-center`}
>
{t("common.loadError")}
</StateMessage>
);
if (!q.data) return <LoadingState className={`${STAGE} justify-center`} />;
// A file's last line ends in a newline; that is no line of its own. Only the first TEXT_LINES are
// laid out: every line is a row of the page, and a head of short lines (a log) runs to tens of
// thousands of them — a stall on open and on every wrap toggle. The rest is behind Download.
const all = q.data.text.replace(/\r?\n$/, "").split(/\r?\n/);
const lines = all.length > TEXT_LINES ? all.slice(0, TEXT_LINES) : all;
return (
<div className={`${STAGE} flex flex-col gap-2`}>
<div className="flex items-center gap-2 text-xs text-muted">
<span className="min-w-0 flex-1 truncate">
{q.data.truncated
? t("files.preview.truncated", {
size: formatBytes(file.asset?.size_bytes, i18n.language),
})
: t("files.preview.asText")}
</span>
<IconButton
variant="ghost"
label={t("files.preview.wrap")}
pressed={wrap}
onClick={() => setWrap(!wrap)}
>
<WrapText />
</IconButton>
</div>
<div
// Scrolls by keyboard too: a focusable region with a name — sideways as well, its ← / →
// taken from the viewer's paging while a line runs past the edge.
tabIndex={0}
data-scrolls-sideways
role="region"
aria-label={jobTitle(file)}
className="min-h-0 flex-1 overflow-auto rounded-md border border-border bg-bg font-mono text-xs leading-relaxed"
>
<ol className="m-0 list-none p-0">
{lines.map((line, i) => (
<li key={i} className="flex">
<span
aria-hidden
className="sticky left-0 w-12 shrink-0 select-none bg-bg pr-3 text-right text-muted"
>
{i + 1}
</span>
<span
className={wrap ? "min-w-0 whitespace-pre-wrap wrap-anywhere" : "whitespace-pre"}
>
{line || " "}
</span>
</li>
))}
</ol>
{lines.length < all.length && (
<p className="m-0 border-t border-border px-3 py-2 font-sans text-muted">
{t("files.preview.firstLines", { lines: TEXT_LINES })}{" "}
<LinkButton href={api.fileUrl(file.id)}>{t("files.preview.downloadWhole")}</LinkButton>
</p>
)}
</div>
</div>
);
}
function NoPreview({ file }: { file: LibraryFile }) {
const { t, i18n } = useTranslation();
const size = file.asset?.size_bytes;
return (
<div className={`${STAGE} flex flex-col items-center justify-center gap-3 text-sm text-muted`}>
<FileQuestion className="size-10" aria-hidden />
<span className="text-center">
{jobTitle(file)}
{file.asset?.container ? ` · ${file.asset.container.toUpperCase()}` : ""}
{size ? ` · ${formatBytes(size, i18n.language)}` : ""}
</span>
<span>{t("files.preview.none")}</span>
<LinkButton href={api.fileUrl(file.id)} icon={<Download className="size-4" />}>
{t("downloads.actions.download")}
</LinkButton>
</div>
);
}