fix(files): review round 1, the page — honest states, the keyboard, the tray's words
- A library past the list's cap is searched and sorted on the server; the count says "the newest". - Each folder and each search opens on its first page (DataTable pageScope, useCardPager scope). - A file dropped where the page takes none never reaches the browser (which would unload the app). - The viewer's arrows wait while a layer opened from it is on top, and scroll a wide text line; a text shows its first 5000 lines and says where the rest is. - Confirms name the file (no more "your downloads"); the asking menu rows end in "…". - The tray names a running step beside its bar, shows a pause mark for paused work, polls slowly while only paused work waits, and forgets a finished file that was deleted. - An unchanged rename sends nothing; a paste opens the download dialog only with nothing on top; the link box never replaces what was typed. - Honest states: folders that failed to load, a file no longer there, the storage figure; the rail says "nothing downloaded" only when there is nothing. - Focus after deleting the open folder and when the format form opens; the format name's label. - ui/Meter (one danger line for both quota bars) and ui/RenameField in the platform layer. - Copy fixes, stale comments and the speculative hub key; a contract test for the list's parameters; e2e: the Files page and the paste intake.
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
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 "./downloadTitle";
|
||||
import { type ActionMenuItem } from "./ui/ActionMenu";
|
||||
@@ -32,6 +33,8 @@ import { useContextMenu } from "./ui/useContextMenu";
|
||||
//
|
||||
// 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) {
|
||||
@@ -74,7 +77,17 @@ export default function FilePreview({
|
||||
if (next) onFileChange(next.id);
|
||||
};
|
||||
useKeyScope("fileViewer");
|
||||
// The viewer's own place on the layer stack (above its dialog): ← / → 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.
|
||||
const layer = useLayer(true, undefined, { 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();
|
||||
@@ -217,8 +230,11 @@ function TextStage({ file }: { file: LibraryFile }) {
|
||||
</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.
|
||||
const lines = q.data.text.replace(/\r?\n$/, "").split(/\r?\n/);
|
||||
// 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">
|
||||
@@ -239,8 +255,10 @@ function TextStage({ file }: { file: LibraryFile }) {
|
||||
</IconButton>
|
||||
</div>
|
||||
<div
|
||||
// Scrolls by keyboard too: a focusable region with a name.
|
||||
// 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"
|
||||
@@ -262,6 +280,12 @@ function TextStage({ file }: { file: LibraryFile }) {
|
||||
</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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user