diff --git a/package.json b/package.json index affd4a6..7b9e3d5 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@chakra-ui/react": "^3.20.0", + "@dnd-kit/core": "^6.3.1", "@emotion/react": "^11.14.0", "@reduxjs/toolkit": "^2.8.2", "axios": "^1.9.0", diff --git a/src/components/Catalogue/arrowOverlay.jsx b/src/components/Catalogue/arrowOverlay.jsx index 5afc9b7..662f549 100644 --- a/src/components/Catalogue/arrowOverlay.jsx +++ b/src/components/Catalogue/arrowOverlay.jsx @@ -4,46 +4,58 @@ import { motion, AnimatePresence } from "framer-motion"; const MotionBox = motion.create(Box); -const ArrowOverlay = ({ direction, isDisabled, onClick, isOverflowing }) => { +const ArrowOverlay = ({ direction, isDisabled, onClick, isOverflowing, smaller = false }) => { const isLeft = direction === "left"; + const commonProps = { + initial: { opacity: 0, x: isLeft ? -20 : 20 }, + animate: { opacity: 1, x: 0 }, + exit: { opacity: 0, x: isLeft ? -20 : 20 }, + transition: { duration: 0.3 }, + position: "absolute", + top: 0, + bottom: 0, + left: isLeft ? 0 : "auto", + right: isLeft ? "auto" : 0, + display: "flex", + alignItems: "center", + justifyContent: isLeft ? "flex-start" : "flex-end", + zIndex: 10, + cursor: isDisabled ? "not-allowed" : "pointer", + pointerEvents: isDisabled ? "none" : "auto", + opacity: isDisabled ? 0.3 : 1, + onClick, + bgGradient: isLeft ? "to-r" : "to-l", + gradientFrom: "rgb(255, 255, 255)", + gradientTo: "rgba(255, 255, 255, 0)", + }; + return ( {isOverflowing && ( - - - + smaller ? ( + + + + ) : ( + + + + ) )} ); diff --git a/src/components/Catalogue/cardComponent.jsx b/src/components/Catalogue/cardComponent.jsx index aa3b482..9a67ce8 100644 --- a/src/components/Catalogue/cardComponent.jsx +++ b/src/components/Catalogue/cardComponent.jsx @@ -1,13 +1,13 @@ import { Card, Image, Text, Skeleton } from "@chakra-ui/react"; import { useState } from "react"; -const CardComponent = ({ imageSrc, itemTitle, itemDescription, isSelected, expanded }) => { +const CardComponent = ({ imageSrc, itemTitle, itemDescription, expanded }) => { const [isImageLoaded, setIsImageLoaded] = useState(false); const isLoading = !isImageLoaded; return ( - + {expanded && ( - {itemDescription} + {itemDescription} )} diff --git a/src/components/Catalogue/cardItem.jsx b/src/components/Catalogue/cardItem.jsx index 8c61dc2..cb4426c 100644 --- a/src/components/Catalogue/cardItem.jsx +++ b/src/components/Catalogue/cardItem.jsx @@ -5,22 +5,15 @@ import CardComponent from "./cardComponent"; const MotionBox = motion.create(Box); -const CardItem = ({ itemTitle, itemDescription, selectedTitle, imageSrc }) => { +const CardItem = ({ itemTitle, itemDescription, imageSrc, expandable = true, descriptionDisplay = false }) => { const isMobile = useBreakpointValue({ base: true, md: false }); const [isHovered, setIsHovered] = useState(false); - const [isSelected, setIsSelected] = useState(selectedTitle === itemTitle); - const [isLoading, setIsLoading] = useState(true); - const cardRef = useRef(null); const [hoverPos, setHoverPos] = useState({ top: 0, left: 0 }); const scrollContainerRef = useRef(null); const hoverTimeoutRef = useRef(null); - useEffect(() => { - setIsSelected(selectedTitle === itemTitle); - }, [selectedTitle, itemTitle]); - const updateHoverPosition = () => { const rect = cardRef.current?.getBoundingClientRect(); if (rect) { @@ -44,7 +37,7 @@ const CardItem = ({ itemTitle, itemDescription, selectedTitle, imageSrc }) => { }; const handleMouseEnter = () => { - if (isMobile) return; + if (isMobile || !expandable) return; hoverTimeoutRef.current = setTimeout(() => { updateHoverPosition(); setIsHovered(true); @@ -99,24 +92,18 @@ const CardItem = ({ itemTitle, itemDescription, selectedTitle, imageSrc }) => { transition="all 0.1s ease-in-out" cursor="pointer" w="280px" - border={isSelected ? "1px solid" : "none"} - borderColor={isSelected ? "blue.600" : "transparent"} - bg={isSelected ? "gray.100" : "white"} borderRadius="md" - boxShadow={isSelected ? "lg" : "md"} - zIndex={isHovered ? 30 : "auto"} // Conditional z-index + zIndex={isHovered ? 30 : "auto"} > - {!isMobile && ( + {!isMobile && expandable && ( {isHovered && ( { position: "fixed", top: hoverPos.top, left: hoverPos.left + 15, - zIndex: 30, // Always high z-index for expanded card + zIndex: 30, pointerEvents: "auto", width: "280px", }} @@ -142,7 +129,6 @@ const CardItem = ({ itemTitle, itemDescription, selectedTitle, imageSrc }) => { imageSrc={imageSrc} itemTitle={itemTitle} itemDescription={itemDescription} - isSelected={isSelected} expanded={true} /> diff --git a/src/components/Catalogue/catalogueItemView.jsx b/src/components/Catalogue/catalogueItemView.jsx index 62025c0..60f08ab 100644 --- a/src/components/Catalogue/catalogueItemView.jsx +++ b/src/components/Catalogue/catalogueItemView.jsx @@ -1,7 +1,11 @@ -import { Portal, CloseButton, Image, Box, Text, Icon, useBreakpointValue } from "@chakra-ui/react"; +import { Portal, CloseButton, Image, Box, Text, Icon, Flex, useBreakpointValue } from "@chakra-ui/react"; import { useEffect, useState, useRef } from "react"; import { motion, AnimatePresence } from "framer-motion"; import { FaChevronLeft, FaChevronRight } from "react-icons/fa"; +import ItemViewToggle from "./itemViewToggle"; +import ItemViewMenu from "./itemViewMenu"; +import MetadataDisplay from "./metadataDisplay"; +import ItemChat from "./itemChat"; const MotionBox = motion.create(Box); const MotionChevron = motion.create(Box); @@ -10,11 +14,11 @@ const CatalogueItemView = ({ isOpen, onClose, title, items, setDialogTitle, imag const [isNavigating, setIsNavigating] = useState(false); const [direction, setDirection] = useState(0); const [isInitialRender, setIsInitialRender] = useState(true); - const [hasRendered, setHasRendered] = useState(false); const isMobile = useBreakpointValue({ base: true, md: false }); + const isTablet = useBreakpointValue({ base: false, md: true, lg: false }); + const [selectedSegment, setSelectedSegment] = useState("metadata"); const scrollRef = useRef(null); const modalRef = useRef(null); - const currentIndex = items.findIndex((item) => item.title === title); const prevItem = () => { @@ -52,7 +56,6 @@ const CatalogueItemView = ({ isOpen, onClose, title, items, setDialogTitle, imag // Reset flags when modal closes useEffect(() => { if (!isOpen) { - setHasRendered(false); setDirection(0); setIsInitialRender(true); } @@ -60,21 +63,59 @@ const CatalogueItemView = ({ isOpen, onClose, title, items, setDialogTitle, imag // Scroll to correct position on mobile when rendered useEffect(() => { - if (isMobile && isOpen && hasRendered && scrollRef.current) { + if (isMobile && isOpen && scrollRef.current) { scrollRef.current.scrollTo({ left: currentIndex * window.innerWidth, behavior: "instant", }); } - }, [isMobile, isOpen, hasRendered, currentIndex]); + }, [isMobile, isOpen, currentIndex]); + + useEffect(() => { + if (!isMobile || !isOpen || !scrollRef.current) return; + + let debounceTimeout = null; + let isScrolling = false; + + const handleScroll = () => { + // Set loading immediately when scroll starts + if (!isScrolling) { + isScrolling = true; + } + + if (debounceTimeout) clearTimeout(debounceTimeout); + + debounceTimeout = setTimeout(() => { + const scrollLeft = scrollRef.current.scrollLeft; + const newIndex = Math.round(scrollLeft / window.innerWidth); + + if (newIndex !== currentIndex && items[newIndex]) { + setDialogTitle(items[newIndex].title); + } + + // Reset scrolling flag after debounce + isScrolling = false; + }, 100); // Keep the debounce time reasonable (100ms) + }; + + const scrollEl = scrollRef.current; + scrollEl.addEventListener("scroll", handleScroll); + + return () => { + scrollEl.removeEventListener("scroll", handleScroll); + if (debounceTimeout) clearTimeout(debounceTimeout); + }; + }, [isMobile, isOpen, currentIndex, items, setDialogTitle]); // Keyboard navigation useEffect(() => { const handleKeyDown = (e) => { if (!isOpen || isNavigating) return; - if (e.key === "Escape") {onClose()} - else if (e.key === "ArrowLeft" && currentIndex > 0) prevItem(); - else if (e.key === "ArrowRight" && currentIndex < items.length - 1) nextItem(); + if (e.key === "Escape") { + onClose(); + } else if (e.key === "ArrowLeft" && currentIndex > 0) prevItem(); + else if (e.key === "ArrowRight" && currentIndex < items.length - 1) + nextItem(); }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); @@ -98,22 +139,36 @@ const CatalogueItemView = ({ isOpen, onClose, title, items, setDialogTitle, imag }; const modalSize = { - width: isMobile ? "100vw" : "80vw", - height: isMobile ? "100vh" : "80vh", + width: isMobile ? "100vw" : isTablet ? "90vw" : "80vw", + height: isMobile ? "100vh" : isTablet ? "90vh" : "80vh", }; const chevronPosition = { - left: isMobile ? "10px" : "calc(10vw - 60px)", - right: isMobile ? "10px" : "calc(10vw - 60px)", + left: isMobile + ? "10px" + : isTablet + ? "calc(5vw - 50px)" + : "calc(10vw - 60px)", + right: isMobile + ? "10px" + : isTablet + ? "calc(5vw - 50px)" + : "calc(10vw - 60px)", top: "50%", }; const chevronSize = isMobile ? "30px" : "40px"; const desktopVariants = { - enter: (direction) => ({ x: direction > 0 ? "100%" : "-100%", opacity: 0 }), + enter: (direction) => ({ + x: direction > 0 ? "100%" : "-100%", + opacity: 0, + }), center: { x: 0, opacity: 1 }, - exit: (direction) => ({ x: direction > 0 ? "-100%" : "100%", opacity: 0 }), + exit: (direction) => ({ + x: direction > 0 ? "-100%" : "100%", + opacity: 0, + }), }; return ( @@ -138,7 +193,7 @@ const CatalogueItemView = ({ isOpen, onClose, title, items, setDialogTitle, imag onClick={onClose} /> - {/* Desktop chevrons */} + {/* Desktop/Tablet chevrons */} {!isMobile && ( <> {currentIndex > 0 && ( @@ -164,7 +219,11 @@ const CatalogueItemView = ({ isOpen, onClose, title, items, setDialogTitle, imag _hover={{ transform: "translateY(-50%) scale(1.2)" }} onClick={prevItem} > - + )} @@ -191,7 +250,11 @@ const CatalogueItemView = ({ isOpen, onClose, title, items, setDialogTitle, imag _hover={{ transform: "translateY(-50%) scale(1.2)" }} onClick={nextItem} > - + )} @@ -237,7 +300,7 @@ const CatalogueItemView = ({ isOpen, onClose, title, items, setDialogTitle, imag scrollBehavior="smooth" sx={{ WebkitOverflowScrolling: "touch", - "&::-webkit-scrollbar": { display: "none" }, + "&::-webkit-scrollbar": { display: "none" } }} > {items.map((item) => ( @@ -249,7 +312,14 @@ const CatalogueItemView = ({ isOpen, onClose, title, items, setDialogTitle, imag display="flex" flexDirection="column" > - + {item.title} - - - {item.title} - + + + + {item.title} + + + + + + + + + + + + + {selectedSegment === + "metadata" ? ( + + ) : selectedSegment === + "chat" ? ( + + ) : null} + ))} - {!hasRendered && ( - setHasRendered(true)} /> - )} ) : ( - // Desktop: sliding animation - - + // Desktop/Tablet: sliding animation + + { - if (isInitialRender) setIsInitialRender(false); + if (isInitialRender) + setIsInitialRender(false); }} > - + {title} - - - {title} - + + + + + {title} + + + + + + + + + + {selectedSegment === + "metadata" ? ( + + ) : selectedSegment === + "chat" ? ( + + ) : null} + diff --git a/src/components/Catalogue/itemChat.jsx b/src/components/Catalogue/itemChat.jsx new file mode 100644 index 0000000..415be99 --- /dev/null +++ b/src/components/Catalogue/itemChat.jsx @@ -0,0 +1,5 @@ +function ItemChat() { + return
ItemChat
; +} + +export default ItemChat; diff --git a/src/components/Catalogue/itemViewMenu.jsx b/src/components/Catalogue/itemViewMenu.jsx new file mode 100644 index 0000000..4a63799 --- /dev/null +++ b/src/components/Catalogue/itemViewMenu.jsx @@ -0,0 +1,31 @@ +import { Box, Menu, Portal } from "@chakra-ui/react"; +import { CgMoreVerticalAlt } from "react-icons/cg"; + +const ItemViewMenu = () => { + return ( + + + + + + + + + + + Open in Data Studio + + + Metadata Version History + + + + + + ); +}; + +export default ItemViewMenu; \ No newline at end of file diff --git a/src/components/Catalogue/itemViewToggle.jsx b/src/components/Catalogue/itemViewToggle.jsx new file mode 100644 index 0000000..4d5e395 --- /dev/null +++ b/src/components/Catalogue/itemViewToggle.jsx @@ -0,0 +1,73 @@ +import { HStack, SegmentGroup, Text, useBreakpointValue } from "@chakra-ui/react"; +import { TbFileDatabase, TbMessageChatbot } from "react-icons/tb"; + +const ItemViewToggle = ({ value, onChange, size = "md" }) => { + const isMobile = useBreakpointValue({ base: true, md: false }); + + const items = [ + { + value: "metadata", + label: ( + + + + {isMobile ? "Info" : "Metadata"} + + + ), + }, + { + value: "chat", + label: ( + + + + Chat + + + ), + }, + ]; + + // Keep original styling but make responsive + 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" + > + + + + ); +}; + +export default ItemViewToggle; \ No newline at end of file diff --git a/src/components/Catalogue/metadataDisplay.jsx b/src/components/Catalogue/metadataDisplay.jsx new file mode 100644 index 0000000..9e1972a --- /dev/null +++ b/src/components/Catalogue/metadataDisplay.jsx @@ -0,0 +1,294 @@ +import { Box, Flex, Text, Image, SkeletonCircle, Spinner, Center } from "@chakra-ui/react"; +import { useEffect, useRef, useState } from "react"; +import ToastWizard from "../toastWizard"; +import TranscriptionToggle from "./transcriptionToggle"; +import server, { JSONResponse } from "../../networking"; + +// 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 MetadataDisplay({ currentItem, isOpen }) { + const [selectedTranscription, setSelectedTranscription] = useState("tradCN"); + const [hoveredIndex, setHoveredIndex] = useState(null); + const hoverTimeoutRef = useRef(null); + const [color, setColor] = useState('primaryColour'); + + // Metadata fetching states + const [metadata, setMetadata] = useState(null); + const [loading, setLoading] = useState(false); + + // Metadata fetching function + const fetchMetadata = async () => { + if (!currentItem?.id) return; + + setLoading(true); + + try { + const response = await server.get(`/cdn/artefactMetadata/${currentItem.id}`); + + if (response.data instanceof JSONResponse) { + if (response.data.isErrorStatus()) { + const errObject = { + response: { + data: response.data + } + }; + throw errObject; + } + + // Success case + setMetadata(response.data.raw.data); + } else { + throw new Error("Unexpected response format"); + } + } catch (err) { + if (err.response && err.response.data instanceof JSONResponse) { + if (err.response.data.userErrorType()) { + ToastWizard.standard( + "error", + "We could not load the details", + err.response.data.message || "There was a problem retrieving this item information. Please try again later." + ); + } else { + ToastWizard.standard( + "error", + "Something went wrong", + "We are having trouble loading the information. Please try again in a moment." + ); + } + } else { + ToastWizard.standard( + "error", + "Connection issue", + "We could not connect to the server. Please check your internet connection or try again later." + ); + } + setMetadata(null); + } finally { + setLoading(false); + } + }; + + // Fetch metadata when currentItem changes or modal opens + useEffect(() => { + if (isOpen) { + fetchMetadata(); + } + }, [currentItem?.id, isOpen]); + + // Toggle spinner color for visual interest while loading + useEffect(() => { + const timer = setInterval(() => { + setColor(prevColor => prevColor === 'primaryColour' ? 'sccciColour' : 'primaryColour'); + }, 500); + + return () => clearInterval(timer); + }, []); + + // Delay tooltip hover effect for entity labels + const handleMouseEnter = (index) => { + if (hoverTimeoutRef.current) { + clearTimeout(hoverTimeoutRef.current); + } + hoverTimeoutRef.current = setTimeout(() => { + setHoveredIndex(index); + }, 500); + }; + + // Show loading spinner while data is being fetched + if (loading) { + return ( +
+ +
+ ); + } + + // Display a fallback message if no metadata is available + if (!metadata) { + return ( + + No metadata available. + + ); + } + + // Determine which metadata types are available + const isOCRMetadata = metadata.english || metadata.tradCN || metadata.simplifiedCN; + + return ( + + {isOCRMetadata && ( + <> + {/* Transcription language switcher */} + + + + + {/* OCR text display with optional entity highlighting */} + + {selectedTranscription === "english" ? ( + renderHighlightedText( + metadata[selectedTranscription] || "", + metadata.nerLabels, + hoveredIndex, + handleMouseEnter + ) + ) : ( + + {metadata[selectedTranscription] || "N/A"} + + )} + + + )} + + {!isOCRMetadata && ( + <> + {/* Figure caption */} + Caption: + {metadata.caption || "N/A"} + + {/* Figure headshots (if available) */} + {Array.isArray(metadata.figureIDs) && metadata.figureIDs.length > 0 ? ( + + {metadata.figureIDs.map((id) => ( + + {`Headshot + } + loading="eager" + /> + + ))} + + ) : ( + N/A + )} + + {/* Additional figure metadata, if present */} + {metadata.addInfo && ( + <> + Additional Info: + {metadata.addInfo} + + )} + + )} + + ); +} + +export default MetadataDisplay; \ No newline at end of file diff --git a/src/components/Catalogue/mmSection.jsx b/src/components/Catalogue/mmSection.jsx index 548d8ca..392912c 100644 --- a/src/components/Catalogue/mmSection.jsx +++ b/src/components/Catalogue/mmSection.jsx @@ -4,7 +4,7 @@ import CardItem from "./cardItem.jsx"; import ArrowOverlay from "./arrowOverlay.jsx"; import placeholderImage from "../../assets/placeholderImage.png" -const MMSection = ({ books = [], onItemClick, selectedTitle }) => { +const MMSection = ({ books = [], onItemClick }) => { const isMobile = useBreakpointValue({ base: true, md: false }); const scrollRef = useRef(null); const [atStart, setAtStart] = useState(true); @@ -105,17 +105,9 @@ const MMSection = ({ books = [], onItemClick, selectedTitle }) => { borderRadius="md" onClick={() => handleClick(book.title)} > - {/* Hidden Image for preloading */} - -
diff --git a/src/components/Catalogue/section.jsx b/src/components/Catalogue/section.jsx index d221dc7..5f781b6 100644 --- a/src/components/Catalogue/section.jsx +++ b/src/components/Catalogue/section.jsx @@ -5,7 +5,7 @@ import CatalogueItemView from "./catalogueItemView.jsx"; import ArrowOverlay from "./arrowOverlay.jsx"; import placeholderImage from "../../assets/placeholderImage.png" -const Section = ({ sectionTitle, selectedTitle, onItemClick, artefacts = [] }) => { +const Section = ({ sectionTitle, onItemClick, artefacts = [] }) => { const isMobile = useBreakpointValue({ base: true, md: false }); const items = artefacts.map((art) => ({ @@ -122,16 +122,10 @@ const Section = ({ sectionTitle, selectedTitle, onItemClick, artefacts = [] }) = borderRadius="md" onClick={() => handleCardClick(item.title)} > -
)) diff --git a/src/components/Catalogue/transcriptionToggle.jsx b/src/components/Catalogue/transcriptionToggle.jsx new file mode 100644 index 0000000..adec14b --- /dev/null +++ b/src/components/Catalogue/transcriptionToggle.jsx @@ -0,0 +1,68 @@ +import { SegmentGroup, Text } from "@chakra-ui/react"; + +const TranscriptionToggle = ({ value, onChange }) => { + const items = [ + { + value: "tradCN", + label: ( + + Trad CN + + ), + }, + { + value: "simplifiedCN", + label: ( + + Simp CN + + ), + }, + { + value: "english", + label: ( + + ENG + + ), + }, + { + value: "summary", + label: ( + + SUM + + ), + }, + ]; + + return ( + onChange(value)} + size="sm" + bg="gray.100" + p="1" + rounded="lg" + boxShadow="sm" + gap="00" + > + + + + ); +}; + +export default TranscriptionToggle; diff --git a/src/components/DataStudio/ItemGrid.jsx b/src/components/DataStudio/ItemGrid.jsx new file mode 100644 index 0000000..8fcae2b --- /dev/null +++ b/src/components/DataStudio/ItemGrid.jsx @@ -0,0 +1,58 @@ +import { Box, Grid } from "@chakra-ui/react"; +import CardItem from "../Catalogue/cardItem.jsx"; +import placeholderImage from "../../assets/placeholderImage.png"; +import { useDraggable } from "@dnd-kit/core"; + +const dummyData = Array.from({ length: 24 }, (_, i) => ({ + id: `item-${i}`, + imageSrc: placeholderImage, + title: `Item ${i + 1}`, + description: `This is item ${i + 1}`, +})); + +function DraggableCard({ item }) { + const { attributes, listeners, setNodeRef, isDragging } = useDraggable({ + id: item.id, + data: item, // Pass full item data for DragOverlay + }); + + return ( + + + + ); +} + +function ItemGrid() { + return ( + + + {dummyData.map((item) => ( + + ))} + + + ); +} + +export default ItemGrid; \ No newline at end of file diff --git a/src/components/DataStudio/ItemGroups.jsx b/src/components/DataStudio/ItemGroups.jsx new file mode 100644 index 0000000..ae25348 --- /dev/null +++ b/src/components/DataStudio/ItemGroups.jsx @@ -0,0 +1,72 @@ +import { Box, Flex } from "@chakra-ui/react"; +import GroupToggle from "./groupToggle"; +import GroupCard from "./groupCard"; +import { useDroppable } from "@dnd-kit/core"; +import { useEffect, useState } from 'react' + +function DroppableGroupCard({ id, title, description, items }) { + const { setNodeRef, isOver } = useDroppable({ id }); + + return ( + + + + ); +} + +function ItemGroups({ droppedItems }) { + const [selectedGroup, setSelectedGroup] = useState("books"); + + const title = "Book of LIFE"; + const description = + "This book contains the life of Mr Joon (AKA B.A.S), a legend forged in caffeine, code, and sheer audacity."; + + return ( + + + + + + + {Array.from({ length: 10 }).map((_, i) => { + const groupId = `group-${i}`; + return ( + + ); + })} + + + ); +} + +export default ItemGroups; \ No newline at end of file diff --git a/src/components/DataStudio/groupCard.jsx b/src/components/DataStudio/groupCard.jsx new file mode 100644 index 0000000..51f5b0e --- /dev/null +++ b/src/components/DataStudio/groupCard.jsx @@ -0,0 +1,24 @@ +import { Card } from "@chakra-ui/react"; +import GroupItemsSection from "./groupItemsSection"; + +function GroupCard({ title, description }) { + const truncatedDesc = description.length > 50 ? description.slice(0, 50) + "..." : description; + + return ( + + + + {title} + + + {truncatedDesc} + + + + + + + ); +} + +export default GroupCard; \ No newline at end of file diff --git a/src/components/DataStudio/groupItemsSection.jsx b/src/components/DataStudio/groupItemsSection.jsx new file mode 100644 index 0000000..6643368 --- /dev/null +++ b/src/components/DataStudio/groupItemsSection.jsx @@ -0,0 +1,105 @@ +import { Box, Image, Flex } from "@chakra-ui/react"; +import { useRef, useState, useEffect } from "react"; +import ArrowOverlay from "../Catalogue/arrowOverlay"; +import placeholderImage from "../../assets/placeholderImage.png"; + +function GroupItemsSection() { + const scrollRef = useRef(null); + const [atStart, setAtStart] = useState(true); + const [atEnd, setAtEnd] = useState(false); + const [isOverflowing, setIsOverflowing] = useState(false); + + const items = Array.from({ length: 12 }, (_, i) => ({ + id: i + 1, + imageSrc: placeholderImage, + name: `Item ${i + 1}`, + })); + + const checkScrollEdges = () => { + const node = scrollRef.current; + if (!node) return; + + setAtStart(node.scrollLeft === 0); + setAtEnd( + Math.ceil(node.scrollLeft + node.clientWidth) >= node.scrollWidth + ); + + setIsOverflowing(node.scrollWidth > node.clientWidth); + }; + + const scroll = (direction) => { + if (!scrollRef.current) return; + const scrollAmount = 300; + scrollRef.current.scrollBy({ + left: direction === "left" ? -scrollAmount : scrollAmount, + behavior: "smooth", + }); + }; + + useEffect(() => { + const node = scrollRef.current; + if (!node) return; + + checkScrollEdges(); + node.addEventListener("scroll", checkScrollEdges); + window.addEventListener("resize", checkScrollEdges); + + return () => { + node.removeEventListener("scroll", checkScrollEdges); + window.removeEventListener("resize", checkScrollEdges); + }; + }, [items]); + + return ( + + {/* Arrow overlays with small size */} + scroll("left")} + isOverflowing={isOverflowing && !atStart} + smaller={true} + /> + scroll("right")} + isOverflowing={isOverflowing && !atEnd} + smaller={true} + /> + + {/* Scrollable items container */} + + {items.map((item) => ( + + {item.name} + + ))} + + + ); +} + +export default GroupItemsSection; diff --git a/src/components/DataStudio/groupToggle.jsx b/src/components/DataStudio/groupToggle.jsx new file mode 100644 index 0000000..9cd9a88 --- /dev/null +++ b/src/components/DataStudio/groupToggle.jsx @@ -0,0 +1,58 @@ +import { HStack, SegmentGroup, Text } from "@chakra-ui/react"; +import { FaBookOpen, FaImages, FaCubes } from "react-icons/fa"; + +const GroupToggle = ({ value, onChange }) => { + const items = [ + { + value: "books", + label: ( + + + + Books + + + ), + }, + { + value: "categories", + label: ( + + + + Categories + + + ), + }, + { + value: "batch", + label: ( + + + + Batch + + + ), + }, + ]; + + return ( + onChange(value)} + size="lg" + bg="gray.50" + p="1" + rounded="l" + boxShadow="md" + gap="1" + > + + + + ); +}; + +export default GroupToggle; diff --git a/src/main.jsx b/src/main.jsx index 66e63da..c37405d 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -20,6 +20,9 @@ import ProtectedLayout from './ProtectedLayout.jsx'; import AnimateIn from './AnimateIn.jsx'; import PublicGallery from './pages/PublicGallery.jsx'; import PublicProfile from './pages/PublicProfile.jsx'; +import DataStudio from './pages/DataStudio.jsx'; +import ArtefactEditor from './pages/ArtefactEditor.jsx'; +import GroupView from './pages/GroupView.jsx'; import Profile from './pages/Profile.jsx'; import AdminConsole from './pages/AdminConsole.jsx'; @@ -46,6 +49,14 @@ createRoot(document.getElementById('root')).render( } /> + + + } /> + + } /> + } /> + + {/* Protected Pages */} diff --git a/src/pages/ArtefactEditor.jsx b/src/pages/ArtefactEditor.jsx new file mode 100644 index 0000000..5a297fe --- /dev/null +++ b/src/pages/ArtefactEditor.jsx @@ -0,0 +1,11 @@ +import { Box, Flex } from "@chakra-ui/react"; +import { useParams } from 'react-router-dom'; + +function ArtefactEditor() { + const { artID } = useParams(); + return ( + ArtefactEditor for {artID} + ); +} + +export default ArtefactEditor; diff --git a/src/pages/Catalogue.jsx b/src/pages/Catalogue.jsx index 3a64487..3dd990c 100644 --- a/src/pages/Catalogue.jsx +++ b/src/pages/Catalogue.jsx @@ -1,9 +1,10 @@ import { useEffect, useState } from "react"; +import { useSelector } from "react-redux"; import { Box, Text, Center } from "@chakra-ui/react"; import { AnimatePresence, motion } from "framer-motion"; import MMSection from "../components/Catalogue/mmSection.jsx"; import Section from "../components/Catalogue/section.jsx"; -import CentredSpinner from "../components/centredSpinner.jsx"; +import CentredSpinner from "../components/CentredSpinner.jsx"; import server, { JSONResponse } from '../networking' import ToastWizard from "../components/toastWizard.js"; @@ -13,33 +14,64 @@ function Catalogue() { const [books, setBooks] = useState([]); const [categories, setCategories] = useState({}); const [loading, setLoading] = useState(true); + const { loaded } = useSelector(state => state.auth); async function fetchCatalogue() { try { - const res = await server.get("/cdn/catalogue"); - const data = res.data; + const response = await server.get("/cdn/catalogue"); - if (!(data instanceof JSONResponse) || data.isErrorStatus()) { - ToastWizard.standard("error", "An error occurred", "Please try again later"); - return; - } + if (response.data instanceof JSONResponse) { + if (response.data.isErrorStatus()) { + const errObject = { + response: { + data: response.data + } + }; + throw errObject; + } - const { categories = {}, books = [] } = data.raw.data || {}; - setCategories(categories); - setBooks(books.map(book => ({ - ...book, - artefacts: book.mmArtefacts || [], - }))); + // Success case + const { categories = {}, books = [] } = response.data.raw.data || {}; + setCategories(categories); + setBooks(books.map(book => ({ + ...book, + artefacts: book.mmArtefacts || [], + }))); + } else { + throw new Error("Unexpected response format"); + } } catch (err) { - ToastWizard.standard("error", "An error occurred", "Please try again later"); + if (err.response && err.response.data instanceof JSONResponse) { + if (err.response.data.userErrorType()) { + ToastWizard.standard( + "error", + "Could not load catalogue", + err.response.data.message || "There was a problem loading the catalogue. Please try again later." + ); + } else { + ToastWizard.standard( + "error", + "Something went wrong", + "We are having trouble loading the catalogue. Please try again in a moment." + ); + } + } else { + ToastWizard.standard( + "error", + "Connection issue", + "We could not connect to the server. Please check your internet connection or try again later." + ); + } } finally { setLoading(false); } } useEffect(() => { - fetchCatalogue(); - }, []); + if (loaded) { + fetchCatalogue(); + } + }, [loaded]); const handleItemClick = (title, source) => { if (source === "MM") { @@ -73,10 +105,21 @@ function Catalogue() { return ( <> + {/* Fixed Headers */} + + + Catalogue Browser + + + {hasBooks && ( handleItemClick(title, "MM")} - selectedTitle={selectedMMTitle} books={books} /> )} diff --git a/src/pages/DataStudio.jsx b/src/pages/DataStudio.jsx new file mode 100644 index 0000000..5efb649 --- /dev/null +++ b/src/pages/DataStudio.jsx @@ -0,0 +1,83 @@ +import { DndContext, DragOverlay } from "@dnd-kit/core"; +import { useState } from "react"; +import { Box, Flex, Text } from "@chakra-ui/react"; +import ItemGrid from "../components/DataStudio/ItemGrid.jsx"; +import ItemGroups from "../components/DataStudio/ItemGroups.jsx"; +import CardItem from "../components/Catalogue/cardItem.jsx"; + +function DataStudio() { + const [droppedItems, setDroppedItems] = useState({}); + const [activeItem, setActiveItem] = useState(null); + + const handleDragStart = (event) => { + setActiveItem(event.active.data.current); + }; + + const handleDragEnd = (event) => { + const { active, over } = event; + setActiveItem(null); + + if (over && active) { + const groupId = over.id; + const itemId = active.id; + + setDroppedItems((prev) => { + // Avoid duplicates by checking if item already exists in the group + const existingItems = prev[groupId] || []; + if (existingItems.some((item) => item.id === itemId)) { + return prev; + } + + return { + ...prev, + [groupId]: [...existingItems, active.data.current], + }; + }); + } + }; + + return ( + + + {/* Left Panel (Draggable Items) */} + + + Data Studio + Batch + + + + + + + {/* Right Panel (Droppable Groups) */} + + + + + + {/* Drag Preview */} + + {activeItem ? ( + + + + ) : null} + + + ); +} + +export default DataStudio; \ No newline at end of file diff --git a/src/pages/GroupView.jsx b/src/pages/GroupView.jsx new file mode 100644 index 0000000..575240f --- /dev/null +++ b/src/pages/GroupView.jsx @@ -0,0 +1,9 @@ +import { useParams } from 'react-router-dom'; + +function GroupView() { + const { artID, colID } = useParams(); + + return
GroupView for collection {colID}, {artID}
; +} + +export default GroupView; diff --git a/src/pages/PublicGallery.jsx b/src/pages/PublicGallery.jsx index de291d6..99d438c 100644 --- a/src/pages/PublicGallery.jsx +++ b/src/pages/PublicGallery.jsx @@ -1,42 +1,48 @@ import { Box, Text, Center } from "@chakra-ui/react"; import { useEffect, useState } from "react"; -import server, { JSONResponse } from '../networking' +import server, { JSONResponse } from "../networking"; import Section from "../components/Catalogue/section.jsx"; import ProfileSection from "../components/Catalogue/ProfileSection.jsx"; -import CentredSpinner from "../components/centredSpinner.jsx"; +import CentredSpinner from "../components/CentredSpinner.jsx"; +import ToastWizard from "../components/toastWizard.js"; function PublicGallery() { const [categories, setCategories] = useState({}); const [loading, setLoading] = useState(true); - async function fetchCatalogue() { + const fetchCatalogue = async () => { try { const res = await server.get("/cdn/catalogue"); const data = res.data; if (!(data instanceof JSONResponse) || data.isErrorStatus()) { - ToastWizard.standard("error", "An error occurred", "Please try again later"); + ToastWizard.standard( + "error", + "Couldn’t load gallery", + "Please try again later." + ); return; } - const { categories = {}, books = [] } = data.raw.data || {}; + const { categories = {} } = data.raw.data || {}; setCategories(categories); - setBooks(books.map(book => ({ - ...book, - artefacts: book.mmArtefacts || [], - }))); } catch (err) { - ToastWizard.standard("error", "An error occurred", "Please try again later"); + ToastWizard.standard( + "error", + "Connection issue", + "We couldn’t load the gallery. Please check your connection." + ); } finally { setLoading(false); } - } + }; useEffect(() => { fetchCatalogue(); }, []); - const hasCategories = Object.keys(categories).length > 0; + const hasCategories = + categories && Object.values(categories).some(cat => Array.isArray(cat) && cat.length > 0); if (loading) { return ; @@ -46,7 +52,7 @@ function PublicGallery() { return (
- No Items Uploaded + No items uploaded yet.
); @@ -60,7 +66,7 @@ function PublicGallery() {
))}