diff --git a/src/components/Catalogue/createGroup.jsx b/src/components/Catalogue/createGroup.jsx new file mode 100644 index 0000000..194f5b9 --- /dev/null +++ b/src/components/Catalogue/createGroup.jsx @@ -0,0 +1,199 @@ +import { useState } from "react"; +import { Button, CloseButton, Dialog, Portal, Field, Input, Text, Box, useBreakpointValue } from "@chakra-ui/react"; +import { Tooltip } from "../../components/ui/tooltip" +import { HiInformationCircle, HiPlus } from "react-icons/hi2"; +import server, { JSONResponse } from "../../networking"; +import ToastWizard from "../toastWizard"; +import GroupTypeToggle from "./groupTypeToggle"; + +function CreateGroup({ fetchCatalogue }) { + const [groupType, setGroupType] = useState("book"); + const [title, setTitle] = useState(""); + const [subtitle, setSubtitle] = useState(""); + const [isCreating, setIsCreating] = useState(false); + const [isOpen, setIsOpen] = useState(false); + const isMobile = useBreakpointValue({ base: true, md: false }); + + const validateForm = () => { + const errors = []; + + if (!title.trim()) { + errors.push("Title is required"); + } else if (title.trim().length > 25) { + errors.push("Title must be 25 characters or less"); + } + + if (!subtitle.trim()) { + errors.push("Subtitle is required"); + } else if (subtitle.trim().length > 100) { + errors.push("Subtitle must be 100 characters or less"); + } + + return errors; + }; + + const handleCreateGroup = async () => { + const validationErrors = validateForm(); + if (validationErrors.length > 0) { + ToastWizard.standard( + "error", + "All fields must be filled!", + validationErrors.join(". ") + ); + return; + } + + setIsCreating(true); + try { + const payload = { + title: title.trim(), + subtitle: subtitle.trim(), + type: groupType, + }; + + const response = await server.post("/studio/group/create", payload); + + if (response.data instanceof JSONResponse) { + if (response.data.isErrorStatus()) { + const errObject = { + response: { + data: response.data + } + }; + throw new Error(errObject); + } + + // Success case + ToastWizard.standard( + "success", + "Group created successfully" + ); + + setTitle(""); + setSubtitle(""); + setGroupType("book"); + setIsOpen(false); + fetchCatalogue() + } else { + throw new Error("Unexpected response format"); + } + } catch (err) { + if (err.response && err.response.data instanceof JSONResponse) { + console.log("Error response in login request:", err.response.data.fullMessage()); + if (err.response.data.userErrorType()) { + ToastWizard.standard("error", "Group creation failed", err.response.data.message); + } else { + ToastWizard.standard("error", "Group creation failed", "Something went wrong while creating your group. Please try again later.", 3000, true, handleCreateGroup, 'Retry'); + } + } else { + console.log("Unexpected error in login request:", err); + ToastWizard.standard("error", "Group creation failed", "Something went wrong while creating your group. Please try again later.", 3000, true, handleCreateGroup, 'Retry'); + } + setIsCreating(false); + } + }; + + const isTitleValid = title.trim().length > 0 && title.trim().length <= 25; + const isSubtitleValid = subtitle.trim().length > 0 && subtitle.trim().length <= 100; + const isFormValid = isTitleValid && isSubtitleValid; + + return ( + setIsOpen(e.open)} + > + + + + + + + + + + Create New Group + + + + + + + + + + + + + + + + {groupType === "book" + ? "Book Title" + : "Category Name"} + + setTitle(e.target.value)} + maxLength={25} + /> + + {title.length}/25 characters + + + + + + {groupType === "book" + ? "Subtitle" + : "Description"} + + + setSubtitle(e.target.value) + } + maxLength={100} + /> + + {subtitle.length}/100 characters + + + + + + + + + + + + + + ); +} + +export default CreateGroup; \ No newline at end of file diff --git a/src/components/Catalogue/groupTypeToggle.jsx b/src/components/Catalogue/groupTypeToggle.jsx new file mode 100644 index 0000000..d5d8206 --- /dev/null +++ b/src/components/Catalogue/groupTypeToggle.jsx @@ -0,0 +1,73 @@ +import { HStack, SegmentGroup, Text, useBreakpointValue } from "@chakra-ui/react"; +import { LuBook, LuTag } from "react-icons/lu"; + +const GroupTypeToggle = ({ value, onChange, size = "md" }) => { + const isMobile = useBreakpointValue({ base: true, md: false }); + + const items = [ + { + value: "book", + label: ( + + + + Book + + + ), + }, + { + value: "category", + label: ( + + + + Category + + + ), + }, + ]; + + const segmentProps = { + sm: { + p: "1", + rounded: "lg", + width: "180px" + }, + md: { + p: "1", + rounded: "lg", + width: "210px" + }, + lg: { + p: "1.5", + rounded: "lg", + width: "240px" + } + }; + + const currentProps = segmentProps[size] || segmentProps.md; + + return ( + onChange(value)} + size={size} + bg="gray.50" + p={currentProps.p} + rounded={currentProps.rounded} + boxShadow="md" + gap="1" + mb={6} + > + + + + ); +}; + +export default GroupTypeToggle; \ No newline at end of file diff --git a/src/components/Catalogue/metadataDisplay.jsx b/src/components/Catalogue/metadataDisplay.jsx index 0c33bc3..3d77a96 100644 --- a/src/components/Catalogue/metadataDisplay.jsx +++ b/src/components/Catalogue/metadataDisplay.jsx @@ -112,8 +112,11 @@ function MetadataDisplay({ currentItem, isOpen }) { throw errObject; } - // Success case - setMetadata(response.data.raw.data.metadata); + setMetadata({ + ...response.data.raw.data.metadata, + figureInfo: response.data.raw.figureInfo || {} + }); + } else { throw new Error("Unexpected response format"); } @@ -279,6 +282,14 @@ function MetadataDisplay({ currentItem, isOpen }) { } loading="eager" /> + + {metadata.figureInfo[id] || "No Label"} + ))} diff --git a/src/components/DataStudio/deleteGroup.jsx b/src/components/DataStudio/deleteGroup.jsx new file mode 100644 index 0000000..e6662fb --- /dev/null +++ b/src/components/DataStudio/deleteGroup.jsx @@ -0,0 +1,104 @@ +import { Button, CloseButton, Dialog, Field, Input, Portal, Text } from '@chakra-ui/react'; +import ToastWizard from '../toastWizard'; +import { useState } from 'react'; +import { MdDeleteForever } from 'react-icons/md'; +import { useNavigate } from 'react-router-dom'; +import server, { JSONResponse } from '../../networking'; + +function DeleteGroup({ isMobile, colID, colName }) { + const navigate = useNavigate() + const [dialogOpen, setDialogOpen] = useState(false); + const [deletingGroup, setDeletingGroup] = useState(false); + const [confirmGroupName, setConfirmGroupName] = useState(''); + + const handleDelete = async () => { + setDeletingGroup(true) + try { + const response = await server.post('/studio/group/delete', {groupID: colID}) + + if (response.data instanceof JSONResponse) { + if (response.data.isErrorStatus()) { + const errObject = { + response: { + data: response.data + } + }; + throw new Error(errObject); + } + + // Success case + navigate(-1) + ToastWizard.standard("success", "Group Deleted", "Group deleted successfully."); + } else { + throw new Error("Unexpected response format"); + } + } catch (err) { + if (err.response && err.response.data instanceof JSONResponse) { + console.log("Error response in delete group request:", err.response.data.fullMessage()); + if (err.response.data.userErrorType()) { + ToastWizard.standard("error", "Failed to delte group", err.response.data.message); + } else { + ToastWizard.standard("error", "Failed to delete group", "Unable to delete group. Please try again later.", 3000, true, handleDelete, 'Retry') + } + } else { + console.log("Unexpected error in deleting group:", err); + ToastWizard.standard("error", "Failed to delete group", "Unable to delete group. Please try again later.", 3000, true, handleDelete, 'Retry') + } + } + setDeletingGroup(false) + } + + return ( + setDialogOpen(e.open)} placement={'center'}> + + + + + + + + + Delete group? + + + This is a destructive action that cannot be undone. + The group and all associated data will be permanently deleted. + Please re-enter the group's name to proceed with deletion: + + Group Name + setConfirmGroupName(e.target.value)} + /> + Type in: {colName} + + + + + + + + + + + + + + + + ) +} + +export default DeleteGroup \ No newline at end of file diff --git a/src/components/DataStudio/editorCard.jsx b/src/components/DataStudio/editorCard.jsx index 013277d..c845cd8 100644 --- a/src/components/DataStudio/editorCard.jsx +++ b/src/components/DataStudio/editorCard.jsx @@ -87,7 +87,7 @@ function renderHighlightedText(text, labels, hoveredIndex, setHoveredIndex) { return {chunks}; } -function EditorCard({ metadata, artefactId, refreshArtefactData }) { +function EditorCard({ metadata = { metadata: {} }, artefactId, refreshArtefactData }) { const [selectedTranscription, setSelectedTranscription] = useState("tradCN"); const [isEditing, setIsEditing] = useState(false); const [hoveredIndex, setHoveredIndex] = useState(null); @@ -155,7 +155,7 @@ function EditorCard({ metadata, artefactId, refreshArtefactData }) { }); } - const isMMArtefact = (metadata.metadata.english || metadata.metadata.tradCN || metadata.metadata.simplifiedCN || metadata.metadata.summary) ? true : false; + const isMMArtefact = (metadata?.metadata?.english || metadata?.metadata?.tradCN || metadata?.metadata?.simplifiedCN || metadata?.metadata?.summary) ? true : false; return ( <> @@ -346,7 +346,7 @@ function EditorCard({ metadata, artefactId, refreshArtefactData }) { diff --git a/src/components/DataStudio/figureDisplay.jsx b/src/components/DataStudio/figureDisplay.jsx index d47d465..df6a7d7 100644 --- a/src/components/DataStudio/figureDisplay.jsx +++ b/src/components/DataStudio/figureDisplay.jsx @@ -1,24 +1,28 @@ -import { Box, Flex, Text, Image, SkeletonCircle, Portal, Dialog, Button } from '@chakra-ui/react'; -import { MdOutlineDeleteForever } from 'react-icons/md'; -import { useState } from 'react'; -import server, { JSONResponse } from '../../networking'; -import ToastWizard from '../toastWizard'; +import { Box, Flex, Text, Image, SkeletonCircle, Portal, Dialog, Button } from "@chakra-ui/react"; +import { MdOutlineDeleteForever } from "react-icons/md"; +import { useState } from "react"; +import server, { JSONResponse } from "../../networking"; +import ToastWizard from "../toastWizard"; -function FigureDisplaySection({ artefactId, figureIds, getArtefactData, isEditing }) { +function FigureDisplaySection({ artefactId, figureInfo, getArtefactData, isEditing }) { const [deletingFigure, setDeletingFigure] = useState(null); const [removing, setRemoving] = useState(false); const [isConfirmDeleteOpen, setIsConfirmDeleteOpen] = useState(false); const [selectedFigureId, setSelectedFigureId] = useState(null); + const figureIds = Object.keys(figureInfo || {}); const handleRemoveFigure = async (figureID) => { setDeletingFigure(figureID); setRemoving(true); - + try { - const response = await server.post('/studio/artefact/removeFigure', { - artefactID: artefactId, - figureID: figureID - }); + const response = await server.post( + "/studio/artefact/removeFigure", + { + artefactID: artefactId, + figureID: figureID, + } + ); if (response.data instanceof JSONResponse) { if (response.data.isErrorStatus()) { @@ -31,24 +35,33 @@ function FigureDisplaySection({ artefactId, figureIds, getArtefactData, isEditin throw new Error("Unexpected response format"); } } catch (err) { - console.error("Error removing figure:", err); - - let errorMessage = "Failed to remove figure. Please try again."; if (err.response && err.response.data instanceof JSONResponse) { - errorMessage = err.response.data.message; - } else if (err.message) { - errorMessage = err.message; + console.log("Error response in login request:", err.response.data.fullMessage()); + if (err.response.data.userErrorType()) { + ToastWizard.standard("error", "Failed to remove figure.", err.response.data.message); + } else { + ToastWizard.standard( + "error", + "Failed to remove figure. Please try again later.", + errorMessage, + 3000, + true, + handleRemoveFigure, + "Retry" + ); + } + } else { + console.log("Unexpected error in removing figure:", err); + ToastWizard.standard( + "error", + "Failed to remove figure. Please try again later.", + errorMessage, + 3000, + true, + handleRemoveFigure, + "Retry" + ); } - - ToastWizard.standard( - "error", - "Remove failed", - errorMessage, - 3000, - true, - handleRemoveFigure, - 'Retry' - ); } finally { setDeletingFigure(null); setRemoving(false); @@ -63,77 +76,125 @@ function FigureDisplaySection({ artefactId, figureIds, getArtefactData, isEditin {figureIds.length > 0 ? ( - {figureIds.map(id => ( - ( + - {`Headshot} - loading="eager" - /> - - {/* Delete Overlay - only show when editing */} - {isEditing && ( - { - setSelectedFigureId(id); - setIsConfirmDeleteOpen(true); - }} - cursor={deletingFigure === id ? "not-allowed" : "pointer"} - pointerEvents={deletingFigure === id ? "none" : "auto"} - > - + { + } + loading="eager" + /> + + {/* Delete Overlay */} + {isEditing && ( + { + setSelectedFigureId(id); + setIsConfirmDeleteOpen(true); }} - /> - - )} - + cursor={ + deletingFigure === id + ? "not-allowed" + : "pointer" + } + pointerEvents={ + deletingFigure === id + ? "none" + : "auto" + } + > + + + )} + + + {/* Label under image */} + + {figureInfo[id] || "No Label"} + + ))} ) : ( - No associated figures + + No associated figures + )} - setIsConfirmDeleteOpen(e.open)}> + setIsConfirmDeleteOpen(e.open)} + > @@ -143,14 +204,29 @@ function FigureDisplaySection({ artefactId, figureIds, getArtefactData, isEditin - Are you sure you'd like to remove this figure? This action cannot be undone. + Are you sure you'd like to remove this + figure? This action cannot be undone. - - @@ -162,4 +238,4 @@ function FigureDisplaySection({ artefactId, figureIds, getArtefactData, isEditin ); } -export default FigureDisplaySection; \ No newline at end of file +export default FigureDisplaySection; diff --git a/src/components/DataStudio/groupViewActionBar.jsx b/src/components/DataStudio/groupViewActionBar.jsx new file mode 100644 index 0000000..1816383 --- /dev/null +++ b/src/components/DataStudio/groupViewActionBar.jsx @@ -0,0 +1,171 @@ +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 GroupViewActionBar({ itemData, originalData, setItemData, refreshData, itemId, isEditing, setIsEditing }) { + const [changesMade, setChangesMade] = useState(false); + const [isConfirmSaveOpen, setIsConfirmSaveOpen] = useState(false); + const [saveLoading, setSaveLoading] = useState(false); + + useEffect(() => { + setChangesMade(!isEqual(itemData, originalData) && isEditing); + }, [itemData, originalData, 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 'description': + return typeof value === 'string' && value.trim().length <= 200; + default: + return true; + } + }; + + const validateUpdateDict = (updateDict) => { + const errors = []; + + if (!validateField('name', updateDict.name)) { + errors.push('Name must be between 1-25 characters'); + } + + if (!validateField('description', updateDict.description)) { + errors.push('Description must be 200 characters or less'); + } + + return errors; + }; + + const handleCancelChanges = () => { + setItemData(originalData); + ToastWizard.standard("warning", "Changes Reset", "All changes reverted.", 3000); + }; + + const handleSaveChanges = async () => { + setSaveLoading(true); + + const updateDict = { + collectionID: itemId, + name: itemData.name || '', + description: itemData.description || '' + }; + + // 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/group/updateDetails', updateDict); + + if (response.data instanceof JSONResponse) { + if (response.data.isErrorStatus()) { + const errObject = { + response: { + data: response.data + } + }; + throw new Error(errObject); + } + + // Success case + refreshData(); + setIsConfirmSaveOpen(false); + setIsEditing(false); + + ToastWizard.standard("success", "Collection 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 collection data request:", err.response.data.fullMessage()); + if (err.response.data.userErrorType()) { + ToastWizard.standard("error", "Collection update failed.", err.response.data.message); + } else { + ToastWizard.standard( + "error", + "Collection update failed.", + err.response.data.message, + 3000, + true, + handleSaveChanges, + "Retry" + ); + } + } else { + console.log("Unexpected error in collection update request:", err); + ToastWizard.standard( + "error", + "Collection update failed.", + "Failed to update your collection information. Please try again.", + 3000, + true, + handleSaveChanges, + "Retry" + ); + } + } + setSaveLoading(false); + }; + + return ( + + + + + + + setIsConfirmSaveOpen(e.open)}> + + + + + + + + + Update Collection + + + + Are you sure you'd like to save these changes? This action cannot be undone. + + + + + + + + + + + + + + + ); +} + +export default GroupViewActionBar; \ No newline at end of file diff --git a/src/components/DataStudio/groupViewDetails.jsx b/src/components/DataStudio/groupViewDetails.jsx new file mode 100644 index 0000000..bac0590 --- /dev/null +++ b/src/components/DataStudio/groupViewDetails.jsx @@ -0,0 +1,116 @@ +import { Box, Text, Input, Textarea, Flex, Button } from "@chakra-ui/react"; +import { useState, useEffect } from "react"; +import { MdOutlineEdit, MdOutlineCancel } from "react-icons/md"; +import ToastWizard from "../toastWizard"; +import GroupViewActionBar from "./GroupViewActionBar"; + +const GroupViewDetails = ({ isMobile, collection, colID, refreshCollectionData }) => { + const [isEditing, setIsEditing] = useState(false); + const [originalCollectionData, setOriginalCollectionData] = useState({}); + const [collectionData, setCollectionData] = useState({}); + + // Initialize data when collection prop changes + useEffect(() => { + if (collection) { + const processedData = { + name: collection.name || '', + description: collection.description || '' + }; + setOriginalCollectionData(processedData); + setCollectionData(processedData); + } + }, [collection]); + + const handleEditClick = () => { + setIsEditing(true); + ToastWizard.standard("info", "Editor Mode Enabled", "You are currently in editor mode. Remember to save your changes!", 3000); + }; + + const handleCancelClick = () => { + setCollectionData(originalCollectionData); + setIsEditing(false); + ToastWizard.standard("warning", "Editor Mode Disabled", "All changes reverted.", 3000); + }; + + const handleNameChange = (e) => { + setCollectionData({ + ...collectionData, + name: e.target.value + }); + }; + + const handleDescriptionChange = (e) => { + setCollectionData({ + ...collectionData, + description: e.target.value + }); + }; + + return ( + <> + + + {isEditing ? ( + + ) : ( + + {collectionData.name || "No Name"} + + )} + + + + + {isEditing ? ( +