Rewrite project file drop zone.
Folder drops still skip KiCad backups/pretty/3dmodels; dashed upload chrome is unchanged.
This commit is contained in:
@@ -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<FileSystemEntry[]> {
|
||||
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<File> {
|
||||
return new Promise((resolve, reject) => entry.file(resolve, reject));
|
||||
}
|
||||
|
||||
async function flattenEntry(root: FileSystemEntry): Promise<File[]> {
|
||||
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<File[]> {
|
||||
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<HTMLInputElement>) => {
|
||||
const picked = Array.from(event.target.files ?? []);
|
||||
onFilesChange(mergeSelection(files, picked, multiple));
|
||||
},
|
||||
[files, multiple, onFilesChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<label
|
||||
className={cn(
|
||||
"flex flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed p-6 cursor-pointer transition-colors",
|
||||
hover ? "border-blue-500 bg-blue-500/5" : "border-border hover:border-foreground/20",
|
||||
filled && "border-emerald-500/40 bg-emerald-500/5",
|
||||
)}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
setHover(true);
|
||||
}}
|
||||
onDragLeave={() => setHover(false)}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
{filled ? (
|
||||
<FileCheck className="h-6 w-6 text-emerald-600 dark:text-emerald-400" />
|
||||
) : (
|
||||
<Upload className="h-6 w-6 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-sm font-medium">{label}</span>
|
||||
{preloaded && preloaded.length > 0 ? (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{preloaded.map((name) => (
|
||||
<span key={name} className="font-mono block">
|
||||
{name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{files.length > 0 ? (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{files.length <= 2 ? (
|
||||
files.map((file) => {
|
||||
const rel = relativePath(file);
|
||||
return (
|
||||
<span key={rel} className="font-mono block">
|
||||
{rel.split("/").pop()}
|
||||
</span>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<span>{files.length} files</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
<input
|
||||
type="file"
|
||||
accept={accept}
|
||||
multiple={multiple}
|
||||
className="hidden"
|
||||
onChange={onPick}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user