Files
siftlode/frontend/src/components/ui/DropZone.tsx
T
peter 3553eac8c4 feat(settings): upload a Google Takeout export in Settings › Account
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.
2026-10-23 18:51:05 +02:00

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
}}
/>
</>
);
}