feat(files): folders managed in place, a grid per folder, drop to upload, open on one file

- Own folders are made, renamed and deleted from the rail (right click, long press,
  Shift+F10) or the "…" beside an open folder's name; any folder's retention is a
  submenu (7/30/90 days, until deleted). Deleting confirms where its files go.
- List or grid per folder, sharing one sort; the grid is the one media card.
- A file dropped anywhere on the page opens Upload with it, bound for the folder
  on screen.
- A link (?item=) or the upload notice opens Files on the file: its page turned
  to, the keyboard on it, a ring round it.
- An in-place name field left by Enter or Escape hands the focus back to what
  replaced it (the row, the entry, the "…") instead of dropping it on <body>.
- The table's saved page size is per account.
This commit is contained in:
2026-10-11 15:59:12 +02:00
parent 85aa917b38
commit d22c64f4dd
23 changed files with 1569 additions and 175 deletions
+14 -5
View File
@@ -11,6 +11,8 @@ import { notifyActionError } from "../lib/actionErrors";
import { invalidateDownloads } from "../lib/downloads";
import { notify } from "../lib/notifications";
import { navigateTo } from "../lib/nav";
import type { DownloadJob } from "../lib/api";
import { useFilesActions } from "./filesContext";
import { formatBytes } from "../lib/format";
import { DISPLAY_NAME_MAX } from "../lib/api/downloads";
import { Pressable } from "./ui/Pressable";
@@ -26,21 +28,21 @@ import { Input } from "./ui/Input";
// 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({
folderId = null,
initialFile = null,
onClose,
}: {
/** The library folder the file goes into (an own folder, or the Uploads folder); null = Uploads. */
folderId?: number | null;
/** A file already chosen — dropped on the Files page — so the dialog opens ready to send it. */
initialFile?: File | null;
onClose: () => void;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
const qc = useQueryClient();
const [file, setFile] = useState<File | null>(null);
const [file, setFile] = useState<File | null>(initialFile);
const [name, setName] = useState("");
const [dragOver, setDragOver] = useState(false);
const [pct, setPct] = useState<number | null>(null); // null = idle; 0–100 while uploading
@@ -65,6 +67,13 @@ export default function UploadDialog({
// silent to a screen reader. `SetupWizard` and `Welcome` both key their alerts for the
// same reason, and `formAlerts.dom.test.tsx` is the test that exists because of it.
const [pickSeq, setPickSeq] = useState(0);
const { openFile } = useFilesActions();
// A done file is in Files, opened on it; one the worker still converts is in the download queue
// until it is.
const openUploaded = (job: DownloadJob) => {
if (job.status === "done") openFile(job.id, job.folder_id ?? null);
else navigateTo("downloads");
};
const pick_ = (f: File) => {
setFile(f);
setName("");
@@ -101,7 +110,7 @@ export default function UploadDialog({
message: t(uploadedKey(job.status)),
action: {
label: t("downloads.dialog.view"),
onClick: () => navigateTo(uploadedPage(job.status)),
onClick: () => openUploaded(job),
},
});
onClose();