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.
55 lines
2.7 KiB
TypeScript
55 lines
2.7 KiB
TypeScript
import { ensureNavId, peekNavId } from "./history";
|
|
import { anyBlockingLayer } from "./layerStack";
|
|
import { createStore } from "./store";
|
|
|
|
// Downloading a link is an action you start anywhere: the Files page's link box, a link pasted onto
|
|
// any page, the command palette. They all hand the link to ONE app-wide download dialog
|
|
// (features/downloads/DownloadIntake), so none of them mounts a dialog of its own.
|
|
|
|
const pending = createStore<string | null>(null);
|
|
|
|
/** An http(s) link, alone — what a paste or the link box may download; anything else is not one. */
|
|
export function asDownloadLink(text: string | null | undefined): string | null {
|
|
const s = text?.trim() ?? "";
|
|
return /^https?:\/\/\S+$/i.test(s) ? s : null;
|
|
}
|
|
|
|
/** Whether a paste here is the page's to take: not typed into a field, and nothing on top that owns
|
|
* the keyboard — a dialog, a player, an open menu (the reader is busy with that, and a link pasted
|
|
* at it is not a request to download). The layer stack knows them all; a player marks no
|
|
* `aria-modal`, so the markup alone would miss it. */
|
|
export function pasteIsForThePage(target: EventTarget | null): boolean {
|
|
const el = target instanceof Element ? target : null;
|
|
if (el?.closest("input, textarea, select, [contenteditable]:not([contenteditable='false'])"))
|
|
return false;
|
|
return !anyBlockingLayer() && !document.querySelector("[aria-modal='true']");
|
|
}
|
|
|
|
/** Ask to download `url`: the app's download dialog opens on it. */
|
|
export const requestDownload = (url: string): void => pending.set(url);
|
|
|
|
/** The link the dialog is open on (null = closed). DownloadIntake only. */
|
|
export const usePendingDownload = (): string | null => pending.useValue();
|
|
|
|
/** The dialog closed, downloading or not. */
|
|
export const clearPendingDownload = (): void => pending.set(null);
|
|
|
|
// The palette's "Download from link…" takes the reader to the Files page's link box. A token for the
|
|
// history entry the navigation made (as lib/homeIntent's): Files is a lazy module, often not mounted
|
|
// when it is asked, and a request that outlived its entry must not grab the focus on a later visit.
|
|
const linkBox = createStore<number | null>(null);
|
|
|
|
/** Ask the Files page to focus its link box on THIS entry — `setPage("files", requestLinkBox)`. */
|
|
export const requestLinkBox = (): void => linkBox.set(ensureNavId());
|
|
|
|
/** The pending request (null = none) — the render subscription that wakes the link box. */
|
|
export const useLinkBoxRequest = (): number | null => linkBox.useValue();
|
|
|
|
/** Take the request, clearing it; true only on the entry it was made for. */
|
|
export const takeLinkBox = (): boolean => {
|
|
const entry = linkBox.get();
|
|
if (entry === null) return false;
|
|
linkBox.set(null);
|
|
return entry === peekNavId();
|
|
};
|