diff --git a/periscope/src/frontend/src/components/upload/file-upload-zone.tsx b/periscope/src/frontend/src/components/upload/file-upload-zone.tsx new file mode 100644 index 0000000..c01bc9a --- /dev/null +++ b/periscope/src/frontend/src/components/upload/file-upload-zone.tsx @@ -0,0 +1,180 @@ +"use client"; + +import { useCallback, useState, type ChangeEvent, type DragEvent } from "react"; +import { FileCheck, Upload } from "lucide-react"; +import { cn } from "@/lib/utils"; + +export type FileUploadZoneProps = { + label: string; + accept: string; + multiple?: boolean; + files: File[]; + onFilesChange: (files: File[]) => void; + preloaded?: string[]; +}; + +const SKIP_DIR = /(-backups|\.pretty|3dmodels|__macosx)$/i; + +function relativePath(file: File): string { + return (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name; +} + +function stampRelativePath(file: File, rel: string): File { + if ((file as File & { webkitRelativePath?: string }).webkitRelativePath === rel) { + return file; + } + try { + Object.defineProperty(file, "webkitRelativePath", { value: rel, configurable: true }); + } catch { + /* File may be non-extensible */ + } + return file; +} + +function readAllEntries(reader: FileSystemDirectoryReader): Promise { + return new Promise((resolve, reject) => { + const collected: FileSystemEntry[] = []; + const next = () => { + reader.readEntries((batch) => { + if (batch.length === 0) { + resolve(collected); + return; + } + collected.push(...batch); + next(); + }, reject); + }; + next(); + }); +} + +function fileFromEntry(entry: FileSystemFileEntry): Promise { + return new Promise((resolve, reject) => entry.file(resolve, reject)); +} + +async function flattenEntry(root: FileSystemEntry): Promise { + const out: File[] = []; + const stack: { entry: FileSystemEntry; prefix: string }[] = [{ entry: root, prefix: "" }]; + while (stack.length > 0) { + const { entry, prefix } = stack.pop()!; + if (entry.isFile) { + const file = await fileFromEntry(entry as FileSystemFileEntry); + out.push(stampRelativePath(file, prefix + file.name)); + continue; + } + if (!entry.isDirectory) continue; + if (SKIP_DIR.test(entry.name)) continue; + const kids = await readAllEntries((entry as FileSystemDirectoryEntry).createReader()); + const nestedPrefix = prefix + entry.name + "/"; + for (const child of kids) stack.push({ entry: child, prefix: nestedPrefix }); + } + return out; +} + +async function filesFromDropEvent(event: DragEvent): Promise { + const items = event.dataTransfer?.items; + if (items && items.length > 0) { + const fromEntries: File[] = []; + for (const item of Array.from(items)) { + const entry = item.webkitGetAsEntry?.(); + if (entry) fromEntries.push(...(await flattenEntry(entry))); + else { + const fallback = item.getAsFile(); + if (fallback) fromEntries.push(fallback); + } + } + if (fromEntries.length > 0) return fromEntries; + } + return Array.from(event.dataTransfer?.files ?? []); +} + +function mergeSelection(current: File[], incoming: File[], multiple: boolean): File[] { + if (!multiple) return incoming.slice(0, 1); + return [...current, ...incoming]; +} + +export function FileUploadZone({ + label, + accept, + multiple = false, + files, + onFilesChange, + preloaded, +}: FileUploadZoneProps) { + const [hover, setHover] = useState(false); + const filled = files.length > 0 || Boolean(preloaded && preloaded.length > 0); + + const onDrop = useCallback( + (event: DragEvent) => { + event.preventDefault(); + setHover(false); + void filesFromDropEvent(event).then((dropped) => { + onFilesChange(mergeSelection(files, dropped, multiple)); + }); + }, + [files, multiple, onFilesChange], + ); + + const onPick = useCallback( + (event: ChangeEvent) => { + const picked = Array.from(event.target.files ?? []); + onFilesChange(mergeSelection(files, picked, multiple)); + }, + [files, multiple, onFilesChange], + ); + + return ( + + ); +} diff --git a/tests/test_periscope_frontend_upload_rewrite.py b/tests/test_periscope_frontend_upload_rewrite.py new file mode 100644 index 0000000..315ea26 --- /dev/null +++ b/tests/test_periscope_frontend_upload_rewrite.py @@ -0,0 +1,19 @@ +"""Project file drop zone lives under periscope/src.""" + +from __future__ import annotations + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SRC = ROOT / "periscope" / "src" / "frontend" / "src" / "components/upload/file-upload-zone.tsx" + + +def test_upload_zone_is_src(): + text = SRC.read_text(encoding="utf-8") + assert "Native Periscope overlay" not in text[:400] + assert "export function FileUploadZone" in text + assert "webkitGetAsEntry" in text + assert "webkitRelativePath" in text + assert "-backups" in text + assert "border-dashed" in text + assert "border-emerald-500/40" in text