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:
246
web/src/components/items/FileDropZone.tsx
Normal file
246
web/src/components/items/FileDropZone.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
web/src/hooks/useFileUpload.ts
Normal file
87
web/src/hooks/useFileUpload.ts
Normal 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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user