feat(files): the Files page — the library in one aligned list, its folders in a rail
Files is the one library: every done file, the ones shared with me, or one folder's, picked in a side rail (sources, own folders, the storage meter) and mirrored to ?folder=. One table with every kind of value in its own column; Copy link, Share and Save in sight, every action on a right click: play, cut, rename in the row, move to an own folder or back home, keep, details, re-download, delete. The Downloads page keeps only the queue until the tray replaces it. The old /api/downloads library routes are gone: list shared, upload, the details PATCH, delete, the file and the usage now live under /api/files, and a listed file says which source it came from. The job's "expired" flag is gone with the state it described.
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
// The Files library: a user's done files, the folders they sit in, and the file itself
|
||||
// (backend `routes/files.py`). A library file is a done download job, so it is typed as one.
|
||||
import { getActiveAccount, req, uploadReq } from "./core";
|
||||
import type { DownloadJob } from "./downloads";
|
||||
|
||||
// "source": filled by where a file came from (YouTube, Plex, a site, uploads); "own": made by the user.
|
||||
export type FolderKind = "source" | "own";
|
||||
|
||||
export interface FileFolder {
|
||||
id: number;
|
||||
kind: FolderKind;
|
||||
// A source folder's origin — `youtube`, `plex`, `upload`, `site:<host>` — which the client names
|
||||
// in the reader's language; null on an own folder.
|
||||
source_key: string | null;
|
||||
name: string | null; // an own folder's name; null on a source folder
|
||||
retention_days: number | null; // null = kept until deleted
|
||||
count: number; // its done files
|
||||
size_bytes: number;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export type FileSort = "added" | "name" | "size";
|
||||
export type FileOrder = "asc" | "desc";
|
||||
|
||||
export interface FileListParams {
|
||||
folderId?: number | null; // one folder's files; absent = every file
|
||||
q?: string;
|
||||
sort?: FileSort;
|
||||
order?: FileOrder;
|
||||
shared?: boolean; // the files others shared with me instead of mine
|
||||
}
|
||||
|
||||
export interface LibraryFile extends DownloadJob {
|
||||
// When it was shared with me (the "added" of a shared file); null on my own.
|
||||
shared_at?: string | null;
|
||||
// Where it came from — its source folder's key (`youtube`, `site:<host>`, …), wherever it sits now.
|
||||
source_key: string;
|
||||
}
|
||||
|
||||
// What a PATCH may change. Presence decides: a null / "" text falls back to the system value.
|
||||
export interface FilePatch {
|
||||
name?: string | null;
|
||||
uploader?: string | null;
|
||||
uploader_url?: string | null;
|
||||
extra_links?: string[];
|
||||
folder_id?: number;
|
||||
kept?: boolean;
|
||||
}
|
||||
|
||||
export interface StorageUsage {
|
||||
footprint_bytes: number;
|
||||
active_jobs: number;
|
||||
running_jobs: number;
|
||||
max_bytes: number;
|
||||
max_concurrent: number;
|
||||
max_jobs: number;
|
||||
unlimited: boolean;
|
||||
// The per-file cap on an upload, so the dialog can refuse an over-cap file before spending the
|
||||
// user's bandwidth on it. Not the same thing as `max_bytes`, which is the whole library
|
||||
// footprint: this one is a single request's ceiling and is enforced twice server-side anyway.
|
||||
upload_max_bytes: number;
|
||||
}
|
||||
|
||||
function listQuery(p: FileListParams): string {
|
||||
const q = new URLSearchParams();
|
||||
if (p.shared) q.set("scope", "shared");
|
||||
else if (p.folderId != null) q.set("folder_id", String(p.folderId));
|
||||
if (p.q?.trim()) q.set("q", p.q.trim());
|
||||
if (p.sort) q.set("sort", p.sort);
|
||||
if (p.order) q.set("order", p.order);
|
||||
const s = q.toString();
|
||||
return s ? `?${s}` : "";
|
||||
}
|
||||
|
||||
export const filesApi = {
|
||||
fileFolders: (): Promise<FileFolder[]> => req("/api/files/folders"),
|
||||
files: (p: FileListParams = {}): Promise<LibraryFile[]> => req(`/api/files${listQuery(p)}`),
|
||||
filesUsage: (): Promise<StorageUsage> => req("/api/files/usage"),
|
||||
updateFile: (id: number, patch: FilePatch): Promise<DownloadJob> =>
|
||||
req(`/api/files/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
||||
deleteFile: (id: number): Promise<{ ok: true; deleted: number }> =>
|
||||
req(`/api/files/${id}`, { method: "DELETE" }),
|
||||
// Drop a file someone shared with me from my list (only my grant goes, not their file).
|
||||
removeSharedFile: (id: number): Promise<{ ok: true; removed: number }> =>
|
||||
req(`/api/files/shared/${id}`, { method: "DELETE" }),
|
||||
// Put a local file in the library. Sent via XHR rather than `req` so the dialog can show upload
|
||||
// progress; any file type is accepted (media gets the full function set, a generic file is
|
||||
// download-only). Returns the job the ingest creates: `done`, or `queued` while the worker
|
||||
// converts media no phone could play as it is (`downloads/upload.py`).
|
||||
// The body IS the file, and the names ride as headers. Not a style choice: a multipart part is a
|
||||
// DECLARED body, which FastAPI reads and spools BEFORE the route's `current_user` dependency runs
|
||||
// — so with that shape an unauthenticated caller could make the server write the whole per-file
|
||||
// allowance to temp. Headers are latin-1 on the wire, hence `encodeURIComponent`; the server
|
||||
// percent-decodes them. Content-Type is left to the browser (the File's own type). No folder =
|
||||
// the Uploads folder.
|
||||
uploadFile: (
|
||||
file: File,
|
||||
opts: {
|
||||
displayName?: string;
|
||||
folderId?: number | null;
|
||||
onProgress?: (pct: number) => void;
|
||||
signal?: AbortSignal;
|
||||
} = {},
|
||||
): Promise<DownloadJob> => {
|
||||
const headers: Record<string, string> = {
|
||||
"X-Upload-Filename": encodeURIComponent(file.name || "upload"),
|
||||
};
|
||||
if (opts.displayName) headers["X-Upload-Display-Name"] = encodeURIComponent(opts.displayName);
|
||||
if (opts.folderId != null) headers["X-Upload-Folder"] = String(opts.folderId);
|
||||
return uploadReq<DownloadJob>("/api/files/upload", file, {
|
||||
onProgress: opts.onProgress,
|
||||
signal: opts.signal,
|
||||
headers,
|
||||
});
|
||||
},
|
||||
// A plain <a> navigation can't send the X-Siftlode-Account header, so pass the active account via
|
||||
// ?account= (the server honours it only for an account in this browser's wallet) — otherwise the
|
||||
// file resolves to the session-default account and 404s for a per-tab account's file.
|
||||
fileUrl: (id: number): string => {
|
||||
const a = getActiveAccount();
|
||||
return a != null ? `/api/files/${id}/file?account=${a}` : `/api/files/${id}/file`;
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user