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