From 8bd6f30973d5885b59340bd88c3352a78ddfcb38 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sun, 22 Mar 2026 14:36:36 +0100 Subject: [PATCH] feat: add drag and drop uploads and profile workspace polish --- job-tracker-ui/src/components/Attachments.tsx | 146 ++++++++++++------ job-tracker-ui/src/pages/ProfilePage.tsx | 106 +++++-------- 2 files changed, 134 insertions(+), 118 deletions(-) diff --git a/job-tracker-ui/src/components/Attachments.tsx b/job-tracker-ui/src/components/Attachments.tsx index b4a645a..ab3d300 100644 --- a/job-tracker-ui/src/components/Attachments.tsx +++ b/job-tracker-ui/src/components/Attachments.tsx @@ -1,13 +1,16 @@ -import React, { useCallback, useEffect, useMemo, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { + Alert, Box, Button, + Chip, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, + LinearProgress, Table, TableBody, TableCell, @@ -21,6 +24,7 @@ import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; import DriveFileRenameOutlineIcon from "@mui/icons-material/DriveFileRenameOutline"; import DownloadIcon from "@mui/icons-material/Download"; import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined"; +import CloudUploadOutlinedIcon from "@mui/icons-material/CloudUploadOutlined"; import { api } from "../api"; import { useToast } from "../toast"; @@ -63,6 +67,9 @@ export default function Attachments({ jobId }: { jobId: number }) { const [items, setItems] = useState([]); const [previewOpen, setPreviewOpen] = useState(false); const [preview, setPreview] = useState<{ url: string; type: string; name: string } | null>(null); + const [dragActive, setDragActive] = useState(false); + const [uploading, setUploading] = useState(false); + const fileInputRef = useRef(null); const load = useCallback(async () => { try { @@ -83,6 +90,30 @@ export default function Attachments({ jobId }: { jobId: number }) { }; }, [preview?.url]); + const uploadFiles = useCallback( + async (files: File[]) => { + if (!files.length) return; + + const data = new FormData(); + files.forEach((f) => data.append("files", f)); + data.append("jobId", jobId.toString()); + + setUploading(true); + try { + await api.post("/attachments", data, { + headers: { "Content-Type": "multipart/form-data" }, + }); + toast(files.length === 1 ? "File uploaded." : `${files.length} files uploaded.`, "success"); + await load(); + } catch { + toast("Upload failed.", "error"); + } finally { + setUploading(false); + } + }, + [jobId, load, toast], + ); + const rename = async (a: AttachmentItem) => { const next = await promptForValue("Rename attachment to:", a.fileName, { title: "Rename attachment", confirmLabel: "Rename" }); if (!next || next.trim() === a.fileName) return; @@ -106,23 +137,10 @@ export default function Attachments({ jobId }: { jobId: number }) { } }; - const upload = async (e: any) => { - if (!e.target.files) return; - - const data = new FormData(); - const files = Array.from(e.target.files) as File[]; - files.forEach((f) => data.append("files", f)); - data.append("jobId", jobId.toString()); - - try { - await api.post("/attachments", data, { - headers: { "Content-Type": "multipart/form-data" }, - }); - toast("Files uploaded.", "success"); - await load(); - } catch { - toast("Upload failed.", "error"); - } + const upload = async (e: React.ChangeEvent) => { + const files = e.target.files ? Array.from(e.target.files) : []; + await uploadFiles(files); + e.target.value = ""; }; const getBlobUrl = async (id: number) => { @@ -158,17 +176,65 @@ export default function Attachments({ jobId }: { jobId: number }) { }; const count = useMemo(() => items.length, [items.length]); + const imageCount = useMemo(() => items.filter((x) => isImageType(x.fileType)).length, [items]); + const pdfCount = useMemo(() => items.filter((x) => isPdfType(x.fileType)).length, [items]); return ( - - Attachments ({count}) - + { + e.preventDefault(); + setDragActive(true); + }} + onDragEnter={(e) => { + e.preventDefault(); + setDragActive(true); + }} + onDragLeave={(e) => { + e.preventDefault(); + setDragActive(false); + }} + onDrop={(e) => { + e.preventDefault(); + setDragActive(false); + void uploadFiles(Array.from(e.dataTransfer.files || [])); + }} + sx={{ + mb: 2, + p: 2, + borderRadius: 3, + border: "1px dashed", + borderColor: dragActive ? "primary.main" : "divider", + backgroundColor: dragActive ? "action.hover" : "background.paper", + transition: "all 0.2s ease", + textAlign: "center", + }} + > + Drag and drop files here + + or use the upload button to choose documents from your device. + + {uploading ? : null} + + @@ -188,7 +254,7 @@ export default function Attachments({ jobId }: { jobId: number }) { return ( - @@ -221,7 +287,9 @@ export default function Attachments({ jobId }: { jobId: number }) { {items.length === 0 ? ( - No attachments yet. + + No attachments yet. Upload a resume, cover letter, or portfolio to keep everything tied to this job. + ) : null} @@ -231,9 +299,11 @@ export default function Attachments({ jobId }: { jobId: number }) { setPreviewOpen(false)} fullWidth maxWidth="md"> Preview{preview?.name ? `: ${preview.name}` : ""} - + {!preview ? null : isImageType(preview.type) ? ( - {preview.name} + + {preview.name} + ) : isPdfType(preview.type) ? (