refactor(frontend): the flat components/ folder moves into shell/, modules/<page>/ and features/<name>/

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.
This commit is contained in:
2026-10-22 23:12:45 +02:00
parent fdf8076013
commit 63a0fecc8b
356 changed files with 2433 additions and 2332 deletions
+282
View File
@@ -0,0 +1,282 @@
import { useEffect, useId, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { UploadCloud, File as FileIcon } from "lucide-react";
import Modal, { ModalFooter } from "../../components/ui/Modal";
import { Button } from "../../components/ui/Button";
import { api } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
import { isAborted } from "../../lib/api/core";
import { notifyActionError } from "../../lib/actionErrors";
import { invalidateDownloads } from "../../lib/downloads";
import { notify } from "../../lib/notifications";
import { showInTray, uploadEnded, uploadStarted } from "../../lib/downloadQueue";
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 "../../components/ui/Pressable";
import { Input } from "../../components/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 (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";
// The job the upload's own notice announces (a done one), for the queue not to say it again.
const announces = (job: { id: number; status: string }) => (job.status === "done" ? job.id : null);
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>(initialFile);
const [name, setName] = useState("");
const [dragOver, setDragOver] = useState(false);
const [pct, setPct] = useState<number | null>(null); // null = idle; 0–100 while uploading
const inputRef = useRef<HTMLInputElement>(null);
const abortRef = useRef<AbortController | null>(null);
// dragenter/dragleave fire as the cursor crosses the drop zone's OWN children (icon/text), so a
// plain boolean flickers. Count enter vs leave and only clear the highlight at depth 0.
const dragDepth = useRef(0);
const busy = pct !== null;
// The per-file cap, so an over-cap file is refused HERE instead of after the user has pushed it up
// the wire. Both server-side caps are still the boundary — this is only the pleasant half of it.
// 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.storageUsage(), queryFn: api.filesUsage }).data
?.upload_max_bytes;
const tooBig = !!file && !!cap && file.size > cap;
const refusalId = useId();
// Every pick gets a new number, so the refusal below REMOUNTS rather than being reconciled. Two
// over-cap picks in a row leave the node and its text identical — the message names the cap, not
// the file — and a live region that is not touched is not announced, so the second refusal was
// 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 tray until it is.
const openUploaded = (job: DownloadJob) => {
if (job.status === "done") openFile(job.id, job.folder_id ?? null);
else showInTray(job.id);
};
const pick_ = (f: File) => {
setFile(f);
setName("");
setPickSeq((n) => n + 1);
};
// Closing the dialog mid-upload (X / ESC / backdrop / browser Back all unmount us) must abort the
// in-flight XHR — otherwise it keeps streaming in the background and still toasts success. Only the
// footer Cancel wired the abort; this covers every other dismissal.
useEffect(() => () => abortRef.current?.abort(), []);
const pick = () => inputRef.current?.click();
const submit = async () => {
// `tooBig` here is belt and braces, NOT the mechanism: `ui/Button` is focus-safe, so while the
// button holds focus it swallows the activation itself, and while it does not it is natively
// disabled. Measured — deleting this term breaks nothing, which is exactly why it must not be
// described as the guard. It is here for the next caller of `submit`, not for the only one.
if (!file || busy || tooBig) return;
const ac = new AbortController();
abortRef.current = ac;
setPct(0);
const displayName = name.trim() || undefined;
uploadStarted();
try {
const job = await api.uploadFile(file, {
displayName,
folderId,
onProgress: setPct,
signal: ac.signal,
});
// Said here, so the queue does not say it again; one the worker still converts is the tray's
// to announce when it is done.
uploadEnded(announces(job));
invalidateDownloads(qc);
notify({
level: "success",
message: t(uploadedKey(job.status)),
action: {
label: t("downloads.dialog.view"),
onClick: () => openUploaded(job),
},
});
onClose();
} catch (e) {
// Everything except the user's own Cancel is said out loud. `uploadReq` shares `reportFailure`
// with `req`, which deliberately leaves 403/404/429 to the caller and answers a 502/503/504
// with only the transient "connection lost" notice — so this empty catch meant a proxy 502 on
// a long upload, or an nginx 413, snapped the dialog back to its pre-upload state with nothing
// named and no job in the list. The comment here claimed the global error modal had it; it
// had not since the shared `reportFailure` landed.
uploadEnded(null);
if (!isAborted(e)) notifyActionError(e, t("downloads.upload.failed"));
// Re-arm the form so the user can retry or pick again.
setPct(null);
abortRef.current = null;
}
};
return (
<Modal initialFocus="field" title={t("downloads.upload.title")} onClose={onClose}>
<p className="text-sm text-muted mb-4">{t("downloads.upload.hint")}</p>
{/* Drop zone doubles as the picker trigger — a click anywhere opens the native file dialog. */}
<Pressable
align="center"
onClick={pick}
onDragEnter={(e) => {
e.preventDefault();
dragDepth.current += 1;
setDragOver(true);
}}
onDragOver={(e) => e.preventDefault()}
onDragLeave={() => {
dragDepth.current = Math.max(0, dragDepth.current - 1);
if (dragDepth.current === 0) setDragOver(false);
}}
onDrop={(e) => {
e.preventDefault();
dragDepth.current = 0;
setDragOver(false);
const f = e.dataTransfer.files?.[0];
if (f) pick_(f);
}}
disabled={busy}
// The refusal is tied to the control it invalidates, not left to a one-shot announcement:
// the drop zone reads as invalid and points at the sentence, so a reader who tabs back to
// it hears WHY. (Measured, because `aria-invalid` is not in `button`'s ARIA-supported
// list: Chromium's accessibility node for a native button does carry `invalid` from this
// attribute.) The signal that is NOT colour is the sentence below, visible to everyone —
// the border only reinforces it.
aria-invalid={tooBig}
aria-describedby={tooBig ? refusalId : undefined}
// `dragOver` outranks `tooBig`: a drag is transient and the refusal repaints the instant
// the drop lands, whereas a zone that stays red under the pointer tells the user their
// drop target is dead — in the one state where they are trying to replace the file. With
// a file picked, the label below shows its name rather than `dropActive`, so this border
// is the ONLY feedback that the zone will accept the drop.
className={`w-full flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed px-4 py-8 text-center transition disabled:opacity-60 ${
dragOver
? "border-accent bg-accent/5"
: tooBig
? "border-danger"
: "border-border hover:border-fg/40"
}`}
>
{file ? (
<>
<FileIcon className="w-6 h-6 text-muted" />
<span className="text-sm font-medium break-all">{file.name}</span>
<span className="text-xs text-muted">{formatBytes(file.size, lang)}</span>
</>
) : (
<>
<UploadCloud className="w-7 h-7 text-muted" />
<span className="text-sm text-muted">
{dragOver ? t("downloads.upload.dropActive") : t("downloads.upload.drop")}
</span>
</>
)}
</Pressable>
<input
ref={inputRef}
type="file"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) pick_(f);
e.target.value = ""; // let re-picking the same file fire onChange again
}}
/>
{tooBig && (
<p key={pickSeq} id={refusalId} className="text-xs text-danger mt-2" role="alert">
{t("downloads.errors.upload.tooLarge", { size: formatBytes(cap ?? 0, lang) })}
</p>
)}
<label htmlFor="upload-display-name" className="block text-sm font-medium mt-4 mb-1">
{t("downloads.upload.nameLabel")}
</label>
<Input
className="w-full"
id="upload-display-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("downloads.upload.namePlaceholder")}
disabled={busy}
// The column behind it is varchar(255) and the name rides in a REQUEST HEADER, where
// nothing else bounds it; the server truncates to the same number. The number is not a
// third copy: `downloads.contract.test.ts` holds DISPLAY_NAME_MAX against the backend's
// own DISPLAY_TEXT_MAX.
maxLength={DISPLAY_NAME_MAX}
/>
{busy && (
<div className="mt-5" aria-live="polite">
<div
className="h-2 rounded-full bg-surface overflow-hidden"
role="progressbar"
aria-valuenow={pct ?? 0}
aria-valuemin={0}
aria-valuemax={100}
// A STABLE descriptor as the name; the live percentage rides aria-valuenow (and the visible
// <p> below re-announces it via the aria-live wrapper) — don't bake the value into the name.
aria-label={t("downloads.upload.uploadProgress")}
>
<div
className="h-full bg-accent transition-[width] duration-150"
style={{ width: `${pct ?? 0}%` }}
/>
</div>
<p className="text-xs text-muted mt-1.5 text-center">
{t("downloads.upload.uploading", { pct: pct ?? 0 })}
</p>
</div>
)}
<ModalFooter>
<Button
variant="ghost"
size="sm"
onClick={() => (busy ? abortRef.current?.abort() : onClose())}
>
{t("downloads.actions.cancel")}
</Button>
{/* Plain `disabled` — `ui/Button` is a FocusSafeButton, which already expresses it as
`aria-disabled` + a click guard for as long as the button HOLDS focus, so this cannot
strand a keyboard user on <body> when the cap arrives late or a second file is dropped.
`submit` refuses independently, which is what that soft state relies on. The REASON is
reachable from the drop zone (aria-invalid + aria-describedby above) rather than from
here, because a natively disabled button is not in the tab order to be asked. */}
<Button
variant="primary"
size="sm"
onClick={submit}
disabled={!file || busy || tooBig}
aria-describedby={tooBig ? refusalId : undefined}
>
{t("downloads.upload.submit")}
</Button>
</ModalFooter>
</Modal>
);
}