diff --git a/src/components/Catalogue/metadataDisplay.jsx b/src/components/Catalogue/metadataDisplay.jsx index 9e1972a..5ab3368 100644 --- a/src/components/Catalogue/metadataDisplay.jsx +++ b/src/components/Catalogue/metadataDisplay.jsx @@ -113,7 +113,7 @@ function MetadataDisplay({ currentItem, isOpen }) { } // Success case - setMetadata(response.data.raw.data); + setMetadata(response.data.raw.data.metadata); } else { throw new Error("Unexpected response format"); } @@ -245,6 +245,14 @@ function MetadataDisplay({ currentItem, isOpen }) { Caption: {metadata.caption || "N/A"} + {/* Additional figure metadata, if present */} + {metadata.addInfo && ( + <> + Additional Info: + {metadata.addInfo} + + )} + {/* Figure headshots (if available) */} {Array.isArray(metadata.figureIDs) && metadata.figureIDs.length > 0 ? ( @@ -277,14 +285,6 @@ function MetadataDisplay({ currentItem, isOpen }) { ) : ( N/A )} - - {/* Additional figure metadata, if present */} - {metadata.addInfo && ( - <> - Additional Info: - {metadata.addInfo} - - )} )} diff --git a/src/components/Catalogue/transcriptionToggle.jsx b/src/components/Catalogue/transcriptionToggle.jsx index adec14b..b1ec21d 100644 --- a/src/components/Catalogue/transcriptionToggle.jsx +++ b/src/components/Catalogue/transcriptionToggle.jsx @@ -57,7 +57,7 @@ const TranscriptionToggle = ({ value, onChange }) => { p="1" rounded="lg" boxShadow="sm" - gap="00" + gap="0" > diff --git a/src/components/DataImport/ArtefactUploadView.jsx b/src/components/DataImport/ArtefactUploadView.jsx new file mode 100644 index 0000000..e41d223 --- /dev/null +++ b/src/components/DataImport/ArtefactUploadView.jsx @@ -0,0 +1,224 @@ +import { Button, Flex, Box, Text } from "@chakra-ui/react"; +import { useEffect, useRef, useState } from "react"; +import { HiUpload } from "react-icons/hi"; +import server from "../../networking"; +import ToastWizard from "../toastWizard"; +import FileItem from "./FileItem"; +import { IoArrowBackCircleSharp } from "react-icons/io5"; +import PendingBatchCard from "./PendingBatchCard"; +import hp1 from "../../assets/hp1.png"; + +function ArtefactUploadView({ batch, setBatchID, onClose, fetchBatches, onBackToPending }) { + const batchID = batch?.id; + const fileInputRef = useRef(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const [selectedFiles, setSelectedFiles] = useState([]); + const [fileStatuses, setFileStatuses] = useState({}); + + const handleButtonClick = () => { + fileInputRef.current?.click(); + }; + + const handleFileChange = (event) => { + const files = Array.from(event.target.files); + if (files.length > 0) { + setSelectedFiles((prev) => [...prev, ...files]); + // Clear statuses for newly added files + const newStatuses = { ...fileStatuses }; + files.forEach((file) => { + delete newStatuses[file.name]; + }); + setFileStatuses(newStatuses); + } + }; + + const removeFile = (fileToRemove) => { + setSelectedFiles((prev) => prev.filter((file) => file !== fileToRemove)); + setFileStatuses((prev) => { + const updated = { ...prev }; + delete updated[fileToRemove.name]; + return updated; + }); + }; + + const clearAll = () => { + setSelectedFiles([]); + setFileStatuses({}); + }; + + const hasPreviousStatus = selectedFiles.some(file => fileStatuses[file.name]); + + const isDisabled = + selectedFiles.length === 0 || + isSubmitting || + hasPreviousStatus; + + const handleUpload = async () => { + if (selectedFiles.length === 0 || isSubmitting) { + ToastWizard.standard("warning", "No Files Selected", "Please choose at least one file before uploading."); + return; + } + + const formData = new FormData(); + selectedFiles.forEach((file) => { + formData.append("file", file); + }); + + if (batchID) { + formData.append("batchID", batchID); + } + + setIsSubmitting(true); + + try { + const res = await server.post("/dataImport/upload", formData, { + headers: { "Content-Type": "multipart/form-data" }, + transformRequest: formData => formData + }); + + const data = res.data; + const updates = data.raw?.updates || {}; + const returnedBatchID = data.raw?.batchID; + + if (returnedBatchID) { + setBatchID(returnedBatchID); + } + setFileStatuses(updates); + fetchBatches(); + + ToastWizard.standard( + "success", + "Upload Complete", + `Uploaded files to Batch ${returnedBatchID || ''}. Ready for processing.` + ); + } catch (err) { + console.log(`Non-success response in file upload to batch ${batchID || 'New Batch'}`, err) + const updates = err?.response?.data?.raw?.updates; + if (updates) { + setFileStatuses(updates); + } else { + // If no detailed errors, mark all files as failed + const fallbackStatus = {}; + selectedFiles.forEach((file) => { + fallbackStatus[file.name] = "ERROR: Failed to upload."; + }); + setFileStatuses(fallbackStatus); + } + + ToastWizard.standard("error", "Upload Failed", "Some or all files failed to upload."); + } finally { + setIsSubmitting(false); + } + }; + + useEffect(() => { + return () => { + clearAll(); + } + }, []); + + return ( + <> + + + + + + + + + + + + + + { + selectedFiles.length > 0 && ( + + + + {selectedFiles.length} file(s) selected + + + + + + {selectedFiles.map((file, index) => ( + + ))} + + + ) + } + + { + hasPreviousStatus && ( + + Click "Remove All" to upload new files. + + ) + } + + + + + + + + ); +} + +export default ArtefactUploadView; \ No newline at end of file diff --git a/src/components/DataImport/DialogUpload.jsx b/src/components/DataImport/DialogUpload.jsx deleted file mode 100644 index ff7c556..0000000 --- a/src/components/DataImport/DialogUpload.jsx +++ /dev/null @@ -1,256 +0,0 @@ -import { Button, FileUpload, VStack, Text, Box, CloseButton, Dialog, Portal, Badge } from "@chakra-ui/react"; -import { HiUpload } from "react-icons/hi"; -import { useState, useRef } from "react"; -import server from "../../networking"; -import ToastWizard from "../../components/toastWizard"; -import { FaPlus } from "react-icons/fa"; - -function DialogUpload() { - const [files, setFiles] = useState([]); - const [fileStatuses, setFileStatuses] = useState({}); - const [isSubmitting, setIsSubmitting] = useState(false); - const [batchID, setBatchID] = useState(null); - const [isConfirmed, setIsConfirmed] = useState(false); - const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false); - const inputRef = useRef(); - const isUploading = useRef(false); - - // Handle file upload - const handleFileChange = async (e) => { - if (isUploading.current) return; - isUploading.current = true; - - const selected = Array.from(e.target.files); - setFiles(selected); - setFileStatuses({}); - if (selected.length === 0) return; - - const formData = new FormData(); - selected.forEach((file) => formData.append("file", file)); - - setIsSubmitting(true); - try { - const res = await server.post("/dataImport/upload", formData, { - headers: { "Content-Type": "multipart/form-data" }, - }); - - const data = res.data; - const updates = data.raw?.updates || {}; - const newBatchID = data.raw?.batchID; - - setFileStatuses(updates); - setBatchID(newBatchID); - setIsConfirmed(false); - - ToastWizard.standard("success", "Upload Complete", `Batch ID: ${newBatchID}. Ready for confirmation.`); - } catch (err) { - const updates = err?.response?.data?.raw?.updates; - if (updates) { - setFileStatuses(updates); - } - ToastWizard.standard("error", "Upload Failed", "Failed to upload files."); - } finally { - setIsSubmitting(false); - isUploading.current = false; - } - }; - - const handleConfirmBatch = async () => { - if (!batchID) return; - - setIsSubmitting(true); - try { - const res = await server.post("/dataImport/confirm", { batchID }); - - if (res.status === 200 || res.data.raw.success !== false) { - setIsConfirmed(true); - ToastWizard.standard("success", "Processing Started", `Batch ${batchID} is now being processed.`); - setIsConfirmDialogOpen(false); - - } else { - ToastWizard.standard("error", "Failed to Confirm", res.data.message || "Unknown error."); - } - } catch (err) { - const errorMsg = err?.response?.data?.message || "Failed to confirm batch."; - ToastWizard.standard("error", "Confirm Error", errorMsg); - - } finally { - setIsSubmitting(false); - } - }; - - return ( - - - - - - - - - - - Upload Files - - - - - - - - - - - - - {/* Custom file list with status and remove button */} - {files.length > 0 && ( - - {files.map((file) => { - const status = fileStatuses[file.name]; - return ( - - - - {file.name} - - - {(file.size / 1024).toFixed(1)} KB - - - - {status && ( - - {status.startsWith("ERROR") && ( - - Error - - )} - - {status} - - - { - setFiles((prev) => - prev.filter((f) => f.name !== file.name) - ); - setFileStatuses((prev) => { - const updated = { ...prev }; - delete updated[file.name]; - return updated; - }); - }} - /> - - )} - - ); - })} - - )} - - - - - - - {/* Only show Confirm button after upload */} - {batchID && !isConfirmed && ( - - )} - - {isConfirmed && ( - - Processing started for Batch {batchID} - - )} - - - - - - setIsConfirmDialogOpen(open)}> - - - - - Confirm Processing - - - Are you sure you want to process batch{" "} - - {batchID} - - ? This will start processing the artefacts in the batch. - - - - - - - - - - - - ); -} - -export default DialogUpload; \ No newline at end of file diff --git a/src/components/DataImport/FileItem.jsx b/src/components/DataImport/FileItem.jsx new file mode 100644 index 0000000..1946c35 --- /dev/null +++ b/src/components/DataImport/FileItem.jsx @@ -0,0 +1,45 @@ +import { Flex, Box, Text, CloseButton } from "@chakra-ui/react"; + +function FileItem({ file, statusMsg, onRemove }) { + const isError = statusMsg && statusMsg.startsWith("ERROR"); + const isSuccess = statusMsg && statusMsg.startsWith("SUCCESS"); + + return ( + + + + {file.name} + + {(file.size / 1024).toFixed(1)} KB + + + onRemove(file)} + aria-label={`Remove ${file.name}`} + /> + + {statusMsg && ( + + {statusMsg.replace(/^(SUCCESS|ERROR):\s*/, "")} + + )} + + ); +} + +export default FileItem; \ No newline at end of file diff --git a/src/components/DataImport/ImportMenuDialog.jsx b/src/components/DataImport/ImportMenuDialog.jsx new file mode 100644 index 0000000..65be9a4 --- /dev/null +++ b/src/components/DataImport/ImportMenuDialog.jsx @@ -0,0 +1,99 @@ +import { Button, Dialog, Portal, Flex, Box, Text, VStack, CloseButton } from "@chakra-ui/react"; +import ArtefactUploadView from "./ArtefactUploadView"; +import hp1 from "../../assets/hp1.png"; +import PendingBatchCard from "./PendingBatchCard"; +import { useState } from "react"; + +function ImportMenuDialog({ pendingBatches, fetchBatches }) { + const [isOpen, setIsOpen] = useState(false); + const [view, setView] = useState('pendingList'); + const [targetBatchID, setTargetBatchID] = useState(null); + + const handleClose = () => { + setIsOpen(false); + setTimeout(() => { + setView('pendingList'); + setTargetBatchID(null); + }, 0); + } + + return ( + setIsOpen(e.open)} + unmountOnExit + > + + + + + + + + + Upload Artefacts + + + {view === 'uploadExisting' && + b.id === targetBatchID) || null} + setBatchID={(id) => setTargetBatchID(id)} + onClose={handleClose} + fetchBatches={fetchBatches} + onBackToPending={() => setView("pendingList")} />} + + {view === "pendingList" && ( + + + + + + + + + + Pending Batches + + {pendingBatches.length === 0 ? ( + + No pending batches. + + ) : ( + + {pendingBatches.map((batch) => ( + { + setTargetBatchID(batch.id); + setView("uploadExisting"); + }} + /> + ))} + + )} + + + )} + + + + + + + + + + + ) +} + +export default ImportMenuDialog; \ No newline at end of file diff --git a/src/components/DataImport/PendingBatchCard.jsx b/src/components/DataImport/PendingBatchCard.jsx new file mode 100644 index 0000000..52a2e24 --- /dev/null +++ b/src/components/DataImport/PendingBatchCard.jsx @@ -0,0 +1,43 @@ +import { Box, Flex, Image, Text } from "@chakra-ui/react"; + +const PendingBatchCard = ({ batchName, artefactCount, timestamp, thumbnail, onClick }) => { + return ( + + + {batchName} + + + {batchName} + + + {artefactCount != null + ? `${artefactCount} Artefact${artefactCount === 1 ? '' : 's'}` + : "No artefacts"} + + + {timestamp} + + + + + ); +}; + +export default PendingBatchCard; \ No newline at end of file diff --git a/src/components/DataStudio/artefactEditorActionBar.jsx b/src/components/DataStudio/artefactEditorActionBar.jsx new file mode 100644 index 0000000..0188d80 --- /dev/null +++ b/src/components/DataStudio/artefactEditorActionBar.jsx @@ -0,0 +1,222 @@ +import { ActionBar, Button, Dialog, Portal } from '@chakra-ui/react' +import { isEqual } from 'lodash'; +import { useEffect, useState } from 'react' +import ToastWizard from '../toastWizard'; +import server, { JSONResponse } from '../../networking'; + +function ArtefactEditorActionBar({ artefactData, originalArtefactData, setArtefactData, getArtefactData, artefactId, isEditing, setIsEditing, isMMArtefact }) { + const [changesMade, setChangesMade] = useState(false); + const [isConfirmSaveOpen, setIsConfirmSaveOpen] = useState(false); + const [saveLoading, setSaveLoading] = useState(false); + + useEffect(() => { + setChangesMade(!isEqual(artefactData, originalArtefactData) && isEditing); + }, [artefactData, originalArtefactData, isEditing]) + + // Validation functions + const validateField = (fieldName, value) => { + if (value === null || value === undefined) return true; + + switch (fieldName) { + case 'name': + return typeof value === 'string' && value.trim().length >= 1 && value.trim().length <= 25; + case 'tradCN': + case 'simplifiedCN': + return typeof value === 'string' && value.trim().length >= 1 && value.trim().length <= 1000; + case 'caption': + return typeof value === 'string' && value.trim().length >= 1 && value.trim().length <= 200; + case 'english': + case 'summary': + return typeof value === 'string' && value.trim().length >= 1 && value.trim().length <= 2000; + case 'addInfo': + return typeof value === 'string' && value.trim().length <= 3000; + default: + return true; + } + }; + + const validateUpdateDict = (updateDict) => { + const errors = []; + + // Common validation + if (!validateField('name', updateDict.name)) { + errors.push('Name must be between 1-25 characters'); + } + + if (isMMArtefact) { + // MM-type validation + if (updateDict.tradCN && !validateField('tradCN', updateDict.tradCN)) errors.push('Traditional Chinese must be between 1-1000 characters') + if (updateDict.simplifiedCN && !validateField('simplifiedCN', updateDict.simplifiedCN)) errors.push('Simplified Chinese must be between 1-1000 characters') + if (updateDict.english && !validateField('english', updateDict.english)) errors.push('English must be between 1-2000 characters') + if (updateDict.summary && !validateField('summary', updateDict.summary)) errors.push('Summary must be between 1-2000 characters') + } else { + // HF-type validation + if (updateDict.caption && !validateField('caption', updateDict.caption)) errors.push('Caption must be between 1-200 characters') + if (updateDict.addInfo && !validateField('addInfo', updateDict.addInfo)) errors.push('Additional Info must be 3000 characters or less') + } + + return errors; + }; + + const handleCancelChanges = () => { + setArtefactData(originalArtefactData); + ToastWizard.standard("warning", "Changes Reset", "All changes reverted.", 3000); + } + + const handleSaveChanges = async () => { + setSaveLoading(true); + + var updateDict = { + artefactID: artefactId, + name: artefactData.name || '', + }; + + // Add fields based on artefact type + if (isMMArtefact) { + // MM-type metadata fields + if (artefactData.metadata.tradCN != originalArtefactData.tradCN) updateDict.tradCN = artefactData.metadata.tradCN + if (artefactData.metadata.simplifiedCN != originalArtefactData.simplifiedCN) updateDict.simplifiedCN = artefactData.metadata.simplifiedCN + if (artefactData.metadata.english != originalArtefactData.english) updateDict.english = artefactData.metadata.english + if (artefactData.metadata.summary != originalArtefactData.summary) updateDict.summary = artefactData.metadata.summary + } else { + // HF-type metadata fields + if (artefactData.metadata.caption != originalArtefactData.caption) updateDict.caption = artefactData.metadata.caption + if (artefactData.metadata.addInfo != originalArtefactData.addInfo) updateDict.addInfo = artefactData.metadata.addInfo + } + + // Validate the update dictionary + const validationErrors = validateUpdateDict(updateDict); + if (validationErrors.length > 0) { + setSaveLoading(false); + ToastWizard.standard( + "error", + "Validation Error", + validationErrors.join('. '), + 5000 + ); + return; + } + + try { + const response = await server.post('/studio/artefact/update', updateDict); + + if (response.data instanceof JSONResponse) { + if (response.data.isErrorStatus()) { + const errObject = { + response: { + data: response.data + } + }; + throw new Error(errObject); + } + + // Success case - handle both full success (200) and partial success (207) + getArtefactData(); + setIsConfirmSaveOpen(false); + setIsEditing(false); // Exit editing mode after successful save + + if (response.data.status === 207) { + // Partial success - metadata updated but NER labelling failed + ToastWizard.standard( + "warning", + "Artefact partially updated.", + 5000, + true, + handleSaveChanges, + "Retry" + ); + } else { + // Full success + ToastWizard.standard("success", "Artefact updated.", response.data.message); + } + } else { + throw new Error("Unexpected response format"); + } + } catch (err) { + if (err.response && err.response.data instanceof JSONResponse) { + console.log("Error response in update artefact data request:", err.response.data.fullMessage()); + if (err.response.data.userErrorType()) { + ToastWizard.standard( + "error", + "Artefact update failed.", + err.response.data.message, + 3000, + true, + handleSaveChanges, + "Retry" + ); + } else { + ToastWizard.standard( + "error", + "Artefact update failed.", + "Failed to update your artefact information. Please try again.", + 3000, + true, + handleSaveChanges, + "Retry" + ); + } + } else { + console.log("Unexpected error in artefact update request:", err); + ToastWizard.standard( + "error", + "Artefact update failed.", + "Failed to update your artefact information. Please try again.", + 3000, + true, + handleSaveChanges, + "Retry" + ); + } + } + + setSaveLoading(false); + } + + return ( + + + + + + + setIsConfirmSaveOpen(e.open)}> + + + + + + + + + Update Artefact + + + + Are you sure you'd like to save these changes? This action cannot be undone. + + + + + + + + + + + + + + + ) +} + +export default ArtefactEditorActionBar \ No newline at end of file diff --git a/src/components/DataStudio/editorCard.jsx b/src/components/DataStudio/editorCard.jsx new file mode 100644 index 0000000..29dbc71 --- /dev/null +++ b/src/components/DataStudio/editorCard.jsx @@ -0,0 +1,367 @@ +import { Card, Button, Flex, Text, Field, Input, Textarea, Box } from "@chakra-ui/react"; +import { useState, useEffect, useRef } from "react"; +import { IoInformationCircleOutline } from "react-icons/io5"; +import { MdOutlineEdit, MdOutlineCancel } from "react-icons/md"; +import MetadataToggle from "./metadataToggle"; +import ArtefactEditorActionBar from "./artefactEditorActionBar"; +import ToastWizard from "../toastWizard"; +import FigureDisplaySection from "./figureDisplay"; + +// Helper function to render text with labeled entities highlighted +function renderHighlightedText(text, labels, hoveredIndex, setHoveredIndex) { + if (!labels || !Array.isArray(labels)) return {text}; + + // Define background colors for each entity type + const entityColors = { + PERSON: "yellow.200", + ORGANIZATION: "blue.200", + LOCATION: "green.200", + DATE: "orange.200", + GPE: "purple.200", + EVENT: "pink.200", + }; + + const chunks = []; + let remainingText = text; + + // Iterate through each labeled entity and highlight it + labels.forEach(([entity, label], i) => { + const index = remainingText.indexOf(entity); + if (index === -1) return; + + const before = remainingText.slice(0, index); + const matched = remainingText.slice(index, index + entity.length); + const after = remainingText.slice(index + entity.length); + + if (before) { + chunks.push({before}); + } + + // Render the matched entity with a tooltip for the label + chunks.push( + setHoveredIndex(i)} + onMouseLeave={() => setHoveredIndex(null)} + display="inline-block" + bg={entityColors[label] || "gray.200"} + borderRadius="md" + px={1} + mx="0.5px" + > + {matched} + + {label} + + + ); + + remainingText = after; + }); + + if (remainingText) { + chunks.push({remainingText}); + } + + return {chunks}; +} + +function EditorCard({ metadata, artefactId, refreshArtefactData }) { + const [selectedTranscription, setSelectedTranscription] = useState("tradCN"); + const [isEditing, setIsEditing] = useState(false); + const [hoveredIndex, setHoveredIndex] = useState(null); + const hoverTimeoutRef = useRef(null); + + // Change detection states + const [originalArtefactData, setOriginalArtefactData] = useState({}); + const [artefactData, setArtefactData] = useState({}); + + // Initialize data when metadata prop changes + useEffect(() => { + if (metadata) { + const processedData = { + name: metadata.name || '', + metadata: metadata.metadata || {} + }; + + setOriginalArtefactData(processedData); + setArtefactData(processedData); + } + }, [metadata]); + + // Delay tooltip hover effect for entity labels + const handleMouseEnter = (index) => { + if (hoverTimeoutRef.current) { + clearTimeout(hoverTimeoutRef.current); + } + hoverTimeoutRef.current = setTimeout(() => { + setHoveredIndex(index); + }, 500); + }; + + // Handle edit/cancel button click + const handleEditClick = () => { + setIsEditing(true); + ToastWizard.standard("info", "Editor Mode Enabled", "You are currently in editor mode. Remember to save your changes!", 3000); + } + + const handleCancelClick = () => { + // Reset data to original state + setArtefactData(originalArtefactData); + setIsEditing(false); + ToastWizard.standard("warning", "Editor Mode Disabled", "All changes reverted.", 3000); + } + + // Handle input changes + const handleNameChange = (e) => { + setArtefactData({ + ...artefactData, + name: e.target.value + }); + } + + const handleTranscriptionChange = (e) => { + setArtefactData({ + ...artefactData, + metadata: { + ...artefactData.metadata, + [selectedTranscription]: e.target.value + } + }); + } + + const isMMArtefact = (metadata.metadata.english || metadata.metadata.tradCN || metadata.metadata.simplifiedCN || metadata.metadata.summary) ? true : false; + + return ( + <> + + + + + Artefact Details + + + + + + + + + {isMMArtefact ? ( + <> + + Name: + {isEditing ? ( + + ) : ( + + {artefactData.name || 'No name set'} + + )} + + + {/* MM Artefact Specific Fields */} + + Transcription: + + + + {isEditing ? ( +