refactor(frontend): the flat components/ folder moves into shell/, modules/<page>/ and features/<name>/
A pure move (git mv, history kept): 333 files leave components/, which now holds only the platform layer (ui/) and the legal pages. The shell is the frame (App, header, nav rail, palette, boot); each page lives in modules/<page>/ with its rail, store and tests; what several pages share sits in features/<name>/; the shared-link page is watch/. Domain-free helpers went straight to ui/ (ViewSwitcher, FilterRail, the confirm dialog, the language switcher, the copy rows). Every relative specifier was rewritten (imports, dynamic imports, vi.mock, new URL), and the contract tests that scan sources now scan the whole app source, not only components/ — each glob was counted before and after so none went green over fewer files. The locale-format rule matched `/src/components/` only, so the move would have taken ~300 files out from under its i18n-singleton check; it now covers every folder that holds components, with a test per folder.
This commit is contained in:
@@ -0,0 +1,312 @@
|
||||
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, 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 });
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user