Rewrite project file drop zone.

Folder drops still skip KiCad backups/pretty/3dmodels; dashed upload chrome is unchanged.
This commit is contained in:
2026-09-20 20:11:48 +02:00
parent c5cd98587f
commit 7d82358e19
2 changed files with 199 additions and 0 deletions
@@ -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