diff --git a/web/src/components/items/FileDropZone.tsx b/web/src/components/items/FileDropZone.tsx new file mode 100644 index 0000000..93455a8 --- /dev/null +++ b/web/src/components/items/FileDropZone.tsx @@ -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(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 ( +
+ {/* Drop zone */} +
{ + 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", + }} + > +
+ Drop files here or{" "} + + browse + +
+ {accept && ( +
+ {accept} +
+ )} + { + if (e.target.files && e.target.files.length > 0) + handleFiles(e.target.files); + e.target.value = ""; + }} + style={{ display: "none" }} + /> +
+ + {/* File list */} + {files.length > 0 && ( +
+ {files.map((att, i) => ( + onFileRemoved(i)} /> + ))} +
+ )} +
+ ); +} + +function FileRow({ + attachment, + onRemove, +}: { + attachment: PendingAttachment; + onRemove: () => void; +}) { + const [hovered, setHovered] = useState(false); + const { label, color } = fileTypeInfo(attachment.file.name); + + return ( +
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 */} +
+ {label} +
+ + {/* Name + size */} +
+
+ {attachment.file.name} +
+
+ {formatSize(attachment.file.size)} + {attachment.uploadStatus === "error" && ( + + {attachment.error ?? "Upload failed"} + + )} +
+ + {/* Progress bar */} + {attachment.uploadStatus === "uploading" && ( +
+
+
+ )} +
+ + {/* Status / remove */} + {attachment.uploadStatus === "complete" ? ( + + Done + + ) : null} + + +
+ ); +} diff --git a/web/src/hooks/useFileUpload.ts b/web/src/hooks/useFileUpload.ts new file mode 100644 index 0000000..7647b2a --- /dev/null +++ b/web/src/hooks/useFileUpload.ts @@ -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 => { + setUploading(true); + + return (async () => { + try { + // Get presigned URL. + const presign = await post( + "/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((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 }; +}