feat(files): the Downloads page goes — the tray, a Storage tab and a Downloads settings tab take its place

- The Downloads module, its rail icon and badge are gone. A bookmark, the remembered page and an old
  history entry that name it land on Files (urlState.resolvePage); `g d` goes to Files too.
- Users page: a Storage tab — the disk in numbers, every user's use against their own limit with
  their file count, every user's downloads; aligned tables, one menu per row (edit / reset quota,
  open source, copy, the owner's quota). The admin storage rows carry quota + file count, the admin
  job list its owner.
- Settings: a Downloads tab — the download formats in one list (duplicate a built-in, edit or delete
  your own) and the site sign-ins, moved from Account.
- "View" on an added download, a clip or a converting upload opens the tray on that row (unfolded,
  back if closed; a finished one opens as a file; under a player it opens without taking focus).
- The download dialog opens on the format used last.
- DataTable: `pager={false}` for a short list, `cardRow` to give a lone "…" its own card line.
- Welcome and README speak of Files; a new Files screenshot.
This commit is contained in:
2026-10-11 19:37:13 +02:00
parent 1c4f0042d2
commit 078194731b
69 changed files with 1553 additions and 1147 deletions
+14 -1
View File
@@ -112,6 +112,8 @@ export interface DownloadJob {
expires_at: string | null;
asset: DownloadAsset | null;
shared?: boolean;
// Whose it is — only the admin Storage tab's listing of every user's downloads carries them.
user_id?: number;
user_email?: string;
// Where the viewer stopped in it (whole seconds; 0 = from the start). Only the two library listings
// (`downloads`, `downloadsShared`) carry it — a job a mutation returns does not.
@@ -147,12 +149,23 @@ export interface ShareLink {
// languages is exactly the drift that CLAUDE.md's comment-vs-code rule is about.
export const DISPLAY_NAME_MAX = 255;
/** One user's row on the admin Storage tab: their footprint against their own limit, and how many
* files their Files lists. */
export interface AdminStorageUser {
user_id: number;
email: string | null;
footprint_bytes: number;
files: number;
max_bytes: number;
unlimited: boolean;
}
export interface AdminStorage {
files_on_disk: number;
total_bytes: number;
total_assets: number;
total_cap_bytes: number;
per_user: { user_id: number; email: string | null; footprint_bytes: number }[];
per_user: AdminStorageUser[];
}
export interface AdminDownloadQuota {
+22 -3
View File
@@ -4,9 +4,9 @@ import { createStore } from "./store";
// The download queue as this tab has watched it: which jobs it has seen finish. The tray shows them
// ("Done · Open") until it is closed, and announces each once (the done notice).
//
// Watched where the queue is FETCHED, not in a component's effect: every reader of the queue (the
// tray, the Downloads page) fetches through `fetchQueue`, so a finish is seen once whichever of them
// polled, and nothing has to diff the previous render's data.
// Watched where the queue is FETCHED, not in a component's effect: every reader of the queue fetches
// through `fetchQueue`, so a finish is seen once whichever of them polled, and nothing has to diff the
// previous render's data.
/** The statuses a job leaves on its own: it is waiting, or working. */
export const IN_FLIGHT: ReadonlySet<string> = new Set(["queued", "running", "paused"]);
@@ -14,6 +14,13 @@ export const IN_FLIGHT: ReadonlySet<string> = new Set(["queued", "running", "pau
let lastSeen: Map<number, string> | null = null;
const finished = createStore<QueueJob[]>([]);
const unannounced = createStore<QueueJob[]>([]);
const revealing = createStore<Reveal | null>(null);
/** A job to show in the tray, asked for at `at` (ms). */
export interface Reveal {
id: number;
at: number;
}
/** Note a fresh queue: a job that is done now and was not on the last look has just finished. The
* first look only learns what is there — a file finished before this tab opened is old news. */
@@ -51,9 +58,21 @@ export function takeUnannounced(): QueueJob[] {
return list;
}
/** "View" on a job just started (a download, a clip, an upload still converting): the tray opens on
* its row — unfolded, back if it was closed, the keyboard on the row. One the tray no longer lists
* because it is done already opens as a file. */
export const showInTray = (id: number): void => revealing.set({ id, at: Date.now() });
/** The pending "View", until the tray has answered it. */
export const useRevealing = (): Reveal | null => revealing.useValue();
/** The tray answered the "View" (or the job is not in the queue at all). */
export const doneRevealing = (): void => revealing.set(null);
/** Forget what was watched (a test starts from a tab that has looked at nothing). */
export function resetQueueWatch(): void {
lastSeen = null;
finished.set([]);
unannounced.set([]);
revealing.set(null);
}
-6
View File
@@ -45,12 +45,6 @@ export function formatBytes(bytes: number | null | undefined, lang: string): str
return `${num} ${units[i]}`;
}
/** Download speed from bytes/sec, e.g. "2.4 MB/s". */
export function formatSpeed(bps: number | null | undefined, lang: string): string {
if (!bps) return "";
return `${formatBytes(bps, lang)}/s`;
}
/** Relative-time tiers as [seconds-per-unit, Intl unit]. The unit is what the divisor produces,
* and Intl.RelativeTimeFormat turns the pair into the localized words — so a divisor can no
* longer be paired with the wrong label, which is exactly what shifted every tier by one from
+1 -1
View File
@@ -84,7 +84,7 @@ describe("two-step sequences", () => {
dispatch(press({ key: "g" }), "page", pageHandlers(calls));
dispatch(press({ key: "Shift", shiftKey: true }), "page", pageHandlers(calls));
dispatch(press({ key: "d" }), "page", pageHandlers(calls));
expect(calls).toEqual(["goDownloads"]);
expect(calls).toEqual(["goFiles"]);
});
// No second step needs a modifier (see the page-level rows below), so a chord with one is the reader changing their mind: it
+2 -8
View File
@@ -117,14 +117,8 @@ export const SHORTCUTS = [
labelKey: "keys.goNotifications",
page: "notifications",
},
{ id: "goFiles", scope: "page", keys: ["g l"], labelKey: "keys.goFiles", page: "files" },
{
id: "goDownloads",
scope: "page",
keys: ["g d"],
labelKey: "keys.goDownloads",
page: "downloads",
},
// "g d" is the retired Downloads page's chord: what it held is in Files now, so it goes there too.
{ id: "goFiles", scope: "page", keys: ["g l", "g d"], labelKey: "keys.goFiles", page: "files" },
{ id: "goStats", scope: "page", keys: ["g s"], labelKey: "keys.goStats", page: "stats" },
{ id: "goSettings", scope: "page", keys: ["g ,"], labelKey: "keys.goSettings", page: "settings" },
-8
View File
@@ -3,7 +3,6 @@ import {
BarChart3,
Bell,
Clapperboard,
Download,
Home,
ListVideo,
ScrollText,
@@ -95,13 +94,6 @@ const MODULES: readonly ModuleDef[] = [
railKey: "nav.rail.files",
icon: FolderOpen,
},
{
id: "downloads",
labelKey: "downloads.navLabel",
railKey: "nav.rail.downloads",
icon: Download,
hasBadge: true,
},
{
id: "stats",
labelKey: "header.account.stats",
+1 -16
View File
@@ -1,21 +1,6 @@
import type { Page } from "./urlState";
// Tiny decoupled navigator so deep components (a feed card's download toast, the Download
// Center) can switch pages without threading setPage through the whole tree. NavigationProvider
// registers its setPage on mount; navigateTo is a no-op until then.
let _navigate: ((p: Page) => void) | null = null;
export function setNavigator(fn: (p: Page) => void): void {
_navigate = fn;
}
/** The "View" on a "Download added" toast is clicked INSIDE the player (its toast host). That is
* safe because `setPage` closes every open overlay before it pushes (see
* `lib/history.closeOverlaysThenNavigate`) — without it the player's Back-to-close cleanup popped the
* entry the navigation had just pushed. */
export function navigateTo(p: Page): void {
_navigate?.(p);
}
// Page links: a page's own address, and whether a click on one is the app's to handle.
/** A page's own address, for a link to it (the nav rail's rows). Only the page: the rest of a page's
* state — the feed's filters, Plex's library — a fresh tab restores from the account's own storage,
+10 -1
View File
@@ -22,7 +22,7 @@ afterEach(() => {
vi.useRealTimers();
});
const ALL: Page[] = ["home", "feed", "channels", "playlists", "plex", "downloads", "settings"];
const ALL: Page[] = ["home", "feed", "channels", "playlists", "plex", "files", "settings"];
function Host({
onNavigate,
@@ -61,6 +61,15 @@ describe("the g chords", () => {
expect(nav.mock.calls).toEqual([["feed"], ["settings"]]);
});
// The retired Downloads page's chord still answers — where its content went.
it("g d, the old Downloads chord, goes to Files", () => {
const nav = vi.fn();
render(<Host onNavigate={nav} />);
key({ key: "g" });
key({ key: "d" });
expect(nav.mock.calls).toEqual([["files"]]);
});
// `h` alone opens the sheet; the `h` of `g h` must not ALSO do that.
it("take the h of g h away from the help sheet", () => {
const nav = vi.fn();
-1
View File
@@ -12,7 +12,6 @@ const EXPECTED: Record<Page, string> = {
playlists: "header.account.playlists",
notifications: "inbox.navLabel",
files: "files.navLabel",
downloads: "downloads.navLabel",
stats: "header.usageStats",
plex: "plex.navLabel",
scheduler: "header.scheduler",
+3
View File
@@ -26,6 +26,9 @@ export const LS = {
// The queue the reader closed the tray on (job ids, a failure marked) — it stays shut until that
// changes. Per-account mirror of the server pref.
downloadTrayClosed: "siftlode.downloadTrayClosed",
// The download format used last — the download dialog opens on it. Per-account mirror of the
// server pref.
downloadProfile: "siftlode.downloadProfile",
hints: "siftlode.hints",
lang: "siftlode.lang",
filters: "siftlode.filters",
+3
View File
@@ -37,6 +37,9 @@ export interface Column<T> {
cardPrimary?: boolean;
hideInCard?: boolean;
cardLabel?: boolean;
// …and `cardRow` gives the value a line of its own: a row's lone "…" flowed in after the facts,
// at the end of one card's line and on the next line of another — never in the same place twice.
cardRow?: boolean;
// A column a table WITHOUT the card fallback (the playlist's drag-reorderable hybrid) leaves out
// while the TABLE is narrower than 42rem: its fixed widths alone overran the box and left the title
// no width. The table's own width, not the viewport's (an `@container` round it): beside the
+16 -4
View File
@@ -132,20 +132,32 @@ const PAGES = [
"logs",
"notifications",
"files",
"downloads",
"plex",
] as const;
export type Page = (typeof PAGES)[number];
/** Narrow an arbitrary string to a known Page (else null). */
export function isPage(p: string | null | undefined): p is Page {
function isPage(p: string | null | undefined): p is Page {
return !!p && (PAGES as readonly string[]).includes(p);
}
// A page this build no longer has, and the page that took its place. A bookmark, the remembered last
// page and an old history entry can still name one; all three resolve through `resolvePage`, so a
// retired page lands where its content went instead of on the feed.
const RETIRED: Readonly<Record<string, Page>> = {
// The download queue became the tray; what it downloaded lives in Files.
downloads: "files",
};
/** A page id as stored or linked: a live page, a retired page's successor, else null. */
export function resolvePage(p: string | null | undefined): Page | null {
if (isPage(p)) return p;
return p && Object.prototype.hasOwnProperty.call(RETIRED, p) ? RETIRED[p]! : null;
}
export function readPage(): Page {
const p = new URLSearchParams(window.location.search).get("page");
return isPage(p) ? p : "feed";
return resolvePage(new URLSearchParams(window.location.search).get("page")) ?? "feed";
}
/** Build a URL (path + query + fragment) from the CURRENT location with `updates` applied to the query —