E32 S4 — the burn-down from 29 skipped functions to 1: - try/finally (26 sites): the cleanup runs after a try/catch; steps that return early moved into their own function (push, the setup wizard's next, the share page's unlock). - A conditional, loop or optional call inside a try skips the function too, and react-hooks/todo does not see it: the value is computed before the try, or the try lives in a module-level helper (config writes, clip queueing, clipboard, frame counts, seekable end, YouTube player times). - The two exhaustive-deps suppressions are gone. useYtProgress takes its per-item snapshot through one effect event, read once when the player is built; PlexPlayer's unmount relies on the leaving-the-item cleanup for the generation bump it duplicated. - What the skips hid: PlexPlayer's 17 render-phase ref writes (useLatest), its tracks popover alias, the loadSession self-reference (declared early, set after commit); PlayerModal's session-position read during render (now where the player starts), navRef/modeRef writes, and a description helper that ran a ref-carrying callback during render (now PlayerDescription); SavedViewsProvider's latest-cfg ref, and its setState in the seed effect (preferSticky now prefers the starred default among same-filter views, so the seed needs no sticky write — test seen red on the old helper). - A store's hook was called `use()`, which the compiler does not know as a hook: inside the newly compiled usePlaylistActions it was memoized, ran on the first render alone, and the playlist rail threw "rendered fewer hooks" on its second render. Renamed to `useValue()` everywhere; the old contract test claimed the call was safe inside any `use…` function, which was false — replaced by a render test that is red under the old name. - PlexPlayer's palette ref was written and never read since the Backspace guard moved to the layer stack: removed. react-hooks/refs is back at error with no exception. The ratchet lists only VirtualGrid (IncompatibleLibrary) and pins both players as compiled. No size or complexity ceiling was raised.
248 lines
11 KiB
TypeScript
248 lines
11 KiB
TypeScript
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 "./ui/Modal";
|
||
import { Button } from "./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 { navigateTo } from "../lib/nav";
|
||
import { formatBytes } from "../lib/format";
|
||
import { DISPLAY_NAME_MAX } from "../lib/api/downloads";
|
||
import { Pressable } from "./ui/Pressable";
|
||
import { Input } from "./ui/Input";
|
||
|
||
// Local-file ingest (E7 S5c · D5-A). 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, so we invalidate + point the user there.
|
||
export default function UploadDialog({ onClose }: { onClose: () => void }) {
|
||
const { t, i18n } = useTranslation();
|
||
const lang = i18n.language;
|
||
const qc = useQueryClient();
|
||
const [file, setFile] = useState<File | null>(null);
|
||
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.downloadUsage(), queryFn: api.downloadUsage }).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. `KeyGate`, `SetupWizard` and `Welcome` all 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 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;
|
||
try {
|
||
await api.uploadDownload(file, {
|
||
displayName,
|
||
onProgress: setPct,
|
||
signal: ac.signal,
|
||
});
|
||
invalidateDownloads(qc);
|
||
notify({
|
||
level: "success",
|
||
message: t("downloads.upload.added"),
|
||
action: { label: t("downloads.dialog.view"), onClick: () => navigateTo("downloads") },
|
||
});
|
||
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 (deep-review round 5).
|
||
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>
|
||
);
|
||
}
|