A "YouTube history" section under YouTube access: the watch history and the Watch later csv are dropped or picked (one request each, each file's outcome said on its own), and the section then says what the import did - only the counts it produced - or that it is running, failed or was cut off by a restart. While it runs it asks again until it ends; a failed re-read shows the failure, not the previous answer. The drop zone the Files upload had inline is now ui/DropZone, used by both. The e2e seed clears the account's kept Takeout videos.
98 lines
3.8 KiB
TypeScript
98 lines
3.8 KiB
TypeScript
import { useRef, useState, type ReactNode } from "react";
|
|
import clsx from "clsx";
|
|
import { Pressable } from "./Pressable";
|
|
|
|
// A place to drop files onto, which is also the button that opens the native file dialog: the
|
|
// dashed surface an upload starts from. It owns what each such surface needed and the first one
|
|
// got right only after a flicker: dragenter/dragleave fire as the cursor crosses the zone's OWN
|
|
// children (an icon, a line of text), so a plain boolean flickers — enter and leave are counted
|
|
// and the highlight clears at depth 0. The caller draws the inside, told whether a drag is over it.
|
|
export function DropZone({
|
|
onFiles,
|
|
multiple = false,
|
|
accept,
|
|
disabled = false,
|
|
invalid = false,
|
|
describedBy,
|
|
className,
|
|
children,
|
|
}: {
|
|
/** The files dropped or picked, never empty. One unless `multiple`. */
|
|
onFiles: (files: [File, ...File[]]) => void;
|
|
multiple?: boolean;
|
|
/** The picker's filter (`<input accept>`); a drop is not filtered by it. */
|
|
accept?: string;
|
|
disabled?: boolean;
|
|
/** What the zone holds is refused: the border turns danger and `describedBy` says why. */
|
|
invalid?: boolean;
|
|
describedBy?: string;
|
|
/** Lays the zone out (its padding); the border and the drag paint are the zone's. */
|
|
className?: string;
|
|
children: (dragOver: boolean) => ReactNode;
|
|
}) {
|
|
const [dragOver, setDragOver] = useState(false);
|
|
const depth = useRef(0);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const take = (list: FileList | null | undefined) => {
|
|
const [first, ...rest] = [...(list ?? [])];
|
|
if (first) onFiles(multiple ? [first, ...rest] : [first]);
|
|
};
|
|
return (
|
|
<>
|
|
<Pressable
|
|
align="center"
|
|
onClick={() => inputRef.current?.click()}
|
|
onDragEnter={(e) => {
|
|
e.preventDefault();
|
|
depth.current += 1;
|
|
setDragOver(true);
|
|
}}
|
|
onDragOver={(e) => e.preventDefault()}
|
|
onDragLeave={() => {
|
|
depth.current = Math.max(0, depth.current - 1);
|
|
if (depth.current === 0) setDragOver(false);
|
|
}}
|
|
onDrop={(e) => {
|
|
e.preventDefault();
|
|
depth.current = 0;
|
|
setDragOver(false);
|
|
if (!disabled) take(e.dataTransfer.files);
|
|
}}
|
|
disabled={disabled}
|
|
// The refusal is tied to the control it invalidates, not left to a one-shot announcement:
|
|
// the 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 it.) The
|
|
// signal that is NOT colour is the caller's sentence — the border only reinforces it.
|
|
aria-invalid={invalid}
|
|
aria-describedby={describedBy}
|
|
// `dragOver` outranks `invalid`: 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.
|
|
className={clsx(
|
|
"w-full flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed transition disabled:opacity-60",
|
|
dragOver
|
|
? "border-accent bg-accent/5"
|
|
: invalid
|
|
? "border-danger"
|
|
: "border-border hover:border-fg/40",
|
|
className,
|
|
)}
|
|
>
|
|
{children(dragOver)}
|
|
</Pressable>
|
|
<input
|
|
ref={inputRef}
|
|
type="file"
|
|
className="hidden"
|
|
multiple={multiple}
|
|
accept={accept}
|
|
onChange={(e) => {
|
|
take(e.target.files);
|
|
e.target.value = ""; // let re-picking the same file fire onChange again
|
|
}}
|
|
/>
|
|
</>
|
|
);
|
|
}
|