feat(web): add FileDropZone component with upload progress

New files:
- web/src/hooks/useFileUpload.ts: presigned upload hook that gets
  a presigned PUT URL from POST /api/uploads/presign then uploads
  via XMLHttpRequest for progress tracking
- web/src/components/items/FileDropZone.tsx: drag-and-drop file
  upload zone with file list, type-colored badges (CAD/PDF/IMG),
  progress bars, and remove buttons

Features:
- Dashed border drop zone with drag-over visual feedback
- Click to browse or drag files to add
- File type detection by extension with colored badges
- Upload progress bar (2px mauve) during active uploads
- Error state display per file
- Configurable accepted file types via accept prop

Closes #14
This commit is contained in:
Forbes
2026-02-07 10:13:18 -06:00
parent 6f357c2199
commit 3358e7dd1c
2 changed files with 333 additions and 0 deletions

View File

@@ -0,0 +1,246 @@
import { useState, useRef, useCallback } from "react";
import type { PendingAttachment } from "../../hooks/useFileUpload";
interface FileDropZoneProps {
files: PendingAttachment[];
onFilesAdded: (files: PendingAttachment[]) => void;
onFileRemoved: (index: number) => void;
accept?: string;
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function fileTypeInfo(name: string): { label: string; color: string } {
const ext = name.split(".").pop()?.toLowerCase() ?? "";
if (["fcstd", "step", "stp", "stl", "iges", "igs"].includes(ext))
return { label: "CAD", color: "var(--ctp-blue)" };
if (ext === "pdf") return { label: "PDF", color: "var(--ctp-red)" };
if (["png", "jpg", "jpeg", "gif", "svg", "webp"].includes(ext))
return { label: "IMG", color: "var(--ctp-green)" };
return { label: "FILE", color: "var(--ctp-overlay1)" };
}
export function FileDropZone({
files,
onFilesAdded,
onFileRemoved,
accept,
}: FileDropZoneProps) {
const [dragOver, setDragOver] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const handleFiles = useCallback(
(fileList: FileList) => {
const pending: PendingAttachment[] = Array.from(fileList).map((f) => ({
file: f,
objectKey: "",
uploadProgress: 0,
uploadStatus: "pending" as const,
}));
onFilesAdded(pending);
},
[onFilesAdded],
);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setDragOver(false);
if (e.dataTransfer.files.length > 0) handleFiles(e.dataTransfer.files);
},
[handleFiles],
);
return (
<div>
{/* Drop zone */}
<div
onDragOver={(e) => {
e.preventDefault();
setDragOver(true);
}}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
onClick={() => inputRef.current?.click()}
style={{
border: `2px dashed ${dragOver ? "var(--ctp-mauve)" : "var(--ctp-surface1)"}`,
borderRadius: "0.5rem",
padding: "1.25rem",
textAlign: "center",
cursor: "pointer",
backgroundColor: dragOver
? "rgba(203,166,247,0.05)"
: "transparent",
transition: "border-color 0.15s, background-color 0.15s",
}}
>
<div style={{ fontSize: "0.85rem", color: "var(--ctp-subtext1)" }}>
Drop files here or{" "}
<span style={{ color: "var(--ctp-mauve)", fontWeight: 600 }}>
browse
</span>
</div>
{accept && (
<div
style={{
fontSize: "0.7rem",
color: "var(--ctp-overlay0)",
marginTop: "0.25rem",
}}
>
{accept}
</div>
)}
<input
ref={inputRef}
type="file"
multiple
accept={accept}
onChange={(e) => {
if (e.target.files && e.target.files.length > 0)
handleFiles(e.target.files);
e.target.value = "";
}}
style={{ display: "none" }}
/>
</div>
{/* File list */}
{files.length > 0 && (
<div style={{ marginTop: "0.5rem" }}>
{files.map((att, i) => (
<FileRow key={i} attachment={att} onRemove={() => onFileRemoved(i)} />
))}
</div>
)}
</div>
);
}
function FileRow({
attachment,
onRemove,
}: {
attachment: PendingAttachment;
onRemove: () => void;
}) {
const [hovered, setHovered] = useState(false);
const { label, color } = fileTypeInfo(attachment.file.name);
return (
<div
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
display: "flex",
alignItems: "center",
gap: "0.5rem",
padding: "0.3rem 0.4rem",
borderRadius: "0.3rem",
position: "relative",
}}
>
{/* Type badge */}
<div
style={{
width: 28,
height: 28,
borderRadius: "0.3rem",
backgroundColor: color,
opacity: 0.8,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "0.6rem",
fontWeight: 700,
color: "var(--ctp-crust)",
flexShrink: 0,
}}
>
{label}
</div>
{/* Name + size */}
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: "0.8rem",
color: "var(--ctp-text)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{attachment.file.name}
</div>
<div style={{ fontSize: "0.7rem", color: "var(--ctp-overlay0)" }}>
{formatSize(attachment.file.size)}
{attachment.uploadStatus === "error" && (
<span style={{ color: "var(--ctp-red)", marginLeft: "0.5rem" }}>
{attachment.error ?? "Upload failed"}
</span>
)}
</div>
{/* Progress bar */}
{attachment.uploadStatus === "uploading" && (
<div
style={{
height: 2,
backgroundColor: "var(--ctp-surface1)",
borderRadius: 1,
marginTop: 2,
}}
>
<div
style={{
height: "100%",
width: `${attachment.uploadProgress}%`,
backgroundColor: "var(--ctp-mauve)",
borderRadius: 1,
transition: "width 0.15s",
}}
/>
</div>
)}
</div>
{/* Status / remove */}
{attachment.uploadStatus === "complete" ? (
<span
style={{
fontSize: "0.7rem",
color: "var(--ctp-green)",
flexShrink: 0,
}}
>
Done
</span>
) : null}
<button
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
style={{
background: "none",
border: "none",
cursor: "pointer",
fontSize: "0.8rem",
color: hovered ? "var(--ctp-red)" : "var(--ctp-overlay0)",
padding: "0 0.2rem",
flexShrink: 0,
transition: "color 0.15s",
}}
title="Remove"
>
x
</button>
</div>
);
}

View File

@@ -0,0 +1,87 @@
import { useState, useCallback } from "react";
import { post } from "../api/client";
export interface PendingAttachment {
file: File;
objectKey: string;
uploadProgress: number;
uploadStatus: "pending" | "uploading" | "complete" | "error";
error?: string;
}
interface PresignResponse {
object_key: string;
upload_url: string;
expires_at: string;
}
export function useFileUpload() {
const [uploading, setUploading] = useState(false);
const upload = useCallback(
(
file: File,
onProgress?: (progress: number) => void,
): Promise<PendingAttachment> => {
setUploading(true);
return (async () => {
try {
// Get presigned URL.
const presign = await post<PresignResponse>(
"/api/uploads/presign",
{
filename: file.name,
content_type: file.type || "application/octet-stream",
size: file.size,
},
);
// Upload via XMLHttpRequest for progress events.
await new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("PUT", presign.upload_url);
xhr.setRequestHeader(
"Content-Type",
file.type || "application/octet-stream",
);
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
onProgress?.(Math.round((e.loaded / e.total) * 100));
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) resolve();
else reject(new Error(`Upload failed: HTTP ${xhr.status}`));
};
xhr.onerror = () => reject(new Error("Upload failed"));
xhr.send(file);
});
return {
file,
objectKey: presign.object_key,
uploadProgress: 100,
uploadStatus: "complete" as const,
};
} catch (e) {
return {
file,
objectKey: "",
uploadProgress: 0,
uploadStatus: "error" as const,
error: e instanceof Error ? e.message : "Upload failed",
};
} finally {
setUploading(false);
}
})();
},
[],
);
return { upload, uploading };
}