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:
2026-10-11 05:34:25 +02:00
parent 1a1665b8ec
commit 85aa917b38
65 changed files with 2295 additions and 1044 deletions
+18 -5
View File
@@ -19,15 +19,24 @@ import { Input } from "./ui/Input";
// Local-file ingest. A general upload — ANY file type, no format list: media
// (video/audio) joins the library with the full function set, a generic file is download-only. The
// file is streamed to the server via XHR so this dialog can show real upload progress. On success it
// lands in the Library as an already-`done` job — or, for media no phone could play as it is (an
// lands in the library (Files) as an already-`done` job — or, for media no phone could play as it is (an
// iPhone's HEVC, a webm, an mkv), as a `queued` job the worker converts first — so we invalidate,
// say which, and point the user there.
// Out here, not inline in the upload handler: a `?:` inside its `try` makes the React Compiler
// skip the whole dialog.
const uploadedKey = (status: string) =>
status === "done" ? "downloads.upload.added" : "downloads.upload.addedConverting";
// A done file is in Files; one the worker still converts is in the download queue until it is.
const uploadedPage = (status: string) => (status === "done" ? "files" : "downloads");
export default function UploadDialog({ onClose }: { onClose: () => void }) {
export default function UploadDialog({
folderId = null,
onClose,
}: {
/** The library folder the file goes into (an own folder, or the Uploads folder); null = Uploads. */
folderId?: number | null;
onClose: () => void;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const qc = useQueryClient();
@@ -46,7 +55,7 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) {
// On a default install the one that answers is the edge ceiling (`bodylimit`), whose refusal
// carries no number, so without this the user would be told "too large" and not what the limit is.
// A cap that has not loaded yet blocks nothing — the server still refuses, honestly.
const cap = useQuery({ queryKey: qk.downloadUsage(), queryFn: api.downloadUsage }).data
const cap = useQuery({ queryKey: qk.storageUsage(), queryFn: api.filesUsage }).data
?.upload_max_bytes;
const tooBig = !!file && !!cap && file.size > cap;
const refusalId = useId();
@@ -80,8 +89,9 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) {
setPct(0);
const displayName = name.trim() || undefined;
try {
const job = await api.uploadDownload(file, {
const job = await api.uploadFile(file, {
displayName,
folderId,
onProgress: setPct,
signal: ac.signal,
});
@@ -89,7 +99,10 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) {
notify({
level: "success",
message: t(uploadedKey(job.status)),
action: { label: t("downloads.dialog.view"), onClick: () => navigateTo("downloads") },
action: {
label: t("downloads.dialog.view"),
onClick: () => navigateTo(uploadedPage(job.status)),
},
});
onClose();
} catch (e) {