From 4a22aeca4659aed7d9c8beaa41d7c31a7da48733 Mon Sep 17 00:00:00 2001 From: Allan Carr Date: Fri, 2 Jan 2026 14:51:13 -0800 Subject: [PATCH] IO-3431 Jobs Image Gallery Signed-off-by: Allan Carr --- ...s-documents-imgproxy-gallery.component.jsx | 38 ++---- ...ts-imgproxy-gallery.external.component.jsx | 125 +++++++++++++++--- ...jobs-documents-local-gallery.container.jsx | 52 +++----- .../local-media-grid.component.jsx | 27 +++- 4 files changed, 154 insertions(+), 88 deletions(-) diff --git a/client/src/components/jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.component.jsx b/client/src/components/jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.component.jsx index 1c14eb486..f2f5fbfd5 100644 --- a/client/src/components/jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.component.jsx +++ b/client/src/components/jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.component.jsx @@ -1,10 +1,9 @@ -import { EditFilled, FileExcelFilled, SyncOutlined } from "@ant-design/icons"; +import { EditFilled, SyncOutlined } from "@ant-design/icons"; import { Button, Card, Col, Row, Space } from "antd"; import axios from "axios"; import i18n from "i18next"; import { isFunction } from "lodash"; import { useCallback, useEffect, useState } from "react"; -import { Gallery } from "react-grid-gallery"; import { useTranslation } from "react-i18next"; import Lightbox from "react-image-lightbox"; import "react-image-lightbox/style.css"; @@ -18,19 +17,13 @@ import JobsDocumentsDownloadButton from "./jobs-document-imgproxy-gallery.downlo import JobsDocumentsGalleryReassign from "./jobs-document-imgproxy-gallery.reassign.component"; import JobsDocumentsDeleteButton from "./jobs-documents-imgproxy-gallery.delete.component"; import JobsDocumentsGallerySelectAllComponent from "./jobs-documents-imgproxy-gallery.selectall.component"; +import LocalMediaGrid from "../jobs-documents-local-gallery/local-media-grid.component"; const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop }); const mapDispatchToProps = () => ({}); -/* -################################################################################################ - Developer Note: - Known Technical Debt Item - Modifications to this code requires complementary changes to the Cloudinary code. Cloudinary code will be removed upon completed migration. -################################################################################################ -*/ function JobsDocumentsImgproxyComponent({ bodyshop, data, @@ -119,17 +112,12 @@ function JobsDocumentsImgproxyComponent({ )} - { setModalState({ open: true, index: index }); - // window.open( - // item.fullsize, - // "_blank", - // "toolbar=0,location=0,menubar=0" - // ); }} - onSelect={(index) => { + onToggle={(index) => { setGalleryImages({ ...galleryImages, images: galleryImages.images.map((g, idx) => @@ -137,30 +125,26 @@ function JobsDocumentsImgproxyComponent({ ) }); }} + minColumns={4} + expandHeight={true} /> - { - return { - backgroundImage: , - height: "100%", - width: "100%", - cursor: "pointer" - }; - }} onClick={(index) => { window.open(galleryImages.other[index].source, "_blank", "toolbar=0,location=0,menubar=0"); }} - onSelect={(index) => { + onToggle={(index) => { setGalleryImages({ ...galleryImages, other: galleryImages.other.map((g, idx) => (index === idx ? { ...g, isSelected: !g.isSelected } : g)) }); }} + minColumns={4} + expandHeight={true} /> @@ -221,6 +205,7 @@ export const fetchImgproxyThumbnails = async ({ setStateCallback, jobId, billId, width: 225, isSelected: false, key: value.key, + filename: value.key, extension: value.extension, id: value.id, type: value.type, @@ -259,6 +244,7 @@ export const fetchImgproxyThumbnails = async ({ setStateCallback, jobId, billId, isSelected: false, extension: value.extension, key: value.key, + filename: value.key, id: value.id, type: value.type, size: value.size diff --git a/client/src/components/jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.external.component.jsx b/client/src/components/jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.external.component.jsx index 0458d3aac..884ac5ac3 100644 --- a/client/src/components/jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.external.component.jsx +++ b/client/src/components/jobs-documents-imgproxy-gallery/jobs-documents-imgproxy-gallery.external.component.jsx @@ -1,31 +1,112 @@ -import { useEffect } from "react"; -import { Gallery } from "react-grid-gallery"; -import { fetchImgproxyThumbnails } from "./jobs-documents-imgproxy-gallery.component"; +import { useEffect, useMemo, useState, useCallback } from "react"; +import axios from "axios"; +import { useTranslation } from "react-i18next"; +import LocalMediaGrid from "../jobs-documents-local-gallery/local-media-grid.component"; +import LoadingSpinner from "../loading-spinner/loading-spinner.component"; -/* -################################################################################################ - Developer Note: - Known Technical Debt Item - Modifications to this code requires complementary changes to the Cloudinary code. Cloudinary code will be removed upon completed migration. -################################################################################################ -*/ - -function JobsDocumentImgproxyGalleryExternal({ jobId, externalMediaState }) { +function JobsDocumentImgproxyGalleryExternal({ jobId, externalMediaState, context = "chat" }) { const [galleryImages, setgalleryImages] = externalMediaState; + const [rawMedia, setRawMedia] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const { t } = useTranslation(); + + const fetchThumbnails = useCallback(async () => { + const result = await axios.post("/media/imgproxy/thumbnails", { jobid: jobId }); + return result.data; + }, [jobId]); useEffect(() => { - if (jobId) fetchImgproxyThumbnails({ setStateCallback: setgalleryImages, jobId, imagesOnly: true }); - }, [jobId, setgalleryImages]); + if (!jobId) return; + setIsLoading(true); + fetchThumbnails() + .then(setRawMedia) + .catch(console.error) + .finally(() => setIsLoading(false)); + }, [jobId, fetchThumbnails]); + + const documents = useMemo(() => { + return rawMedia + .filter((v) => v.type?.startsWith("image")) + .map((v) => ({ + src: v.thumbnailUrl, + thumbnail: v.thumbnailUrl, + fullsize: v.originalUrl, + width: 225, + height: 225, + thumbnailWidth: 225, + thumbnailHeight: 225, + caption: v.key, + filename: v.key, + // additional properties if needed + key: v.key, + id: v.id, + type: v.type, + size: v.size, + extension: v.extension + })); + }, [rawMedia]); + + useEffect(() => { + const prevSelection = new Map(galleryImages.map((p) => [p.filename, p.isSelected])); + const nextImages = documents.map((d) => ({ ...d, isSelected: prevSelection.get(d.filename) || false })); + // Micro-optimization: if array length and each filename + selection flag match, skip creating a new array. + if (galleryImages.length === nextImages.length) { + let identical = true; + for (let i = 0; i < nextImages.length; i++) { + if ( + galleryImages[i].filename !== nextImages[i].filename || + galleryImages[i].isSelected !== nextImages[i].isSelected + ) { + identical = false; + break; + } + } + if (identical) { + setIsLoading(false); // ensure loading stops even on no-change + return; + } + } + setgalleryImages(nextImages); + setIsLoading(false); // stop loading after transform regardless of emptiness + }, [documents, setgalleryImages, galleryImages, jobId]); + + const handleToggle = useCallback( + (idx) => { + setgalleryImages((imgs) => imgs.map((g, gIdx) => (gIdx === idx ? { ...g, isSelected: !g.isSelected } : g))); + }, + [setgalleryImages] + ); + + const messageStyle = { textAlign: "center", padding: "1rem" }; + + if (!jobId) { + return ( +
+
No job selected.
+
+ ); + } return ( -
- { - setgalleryImages(galleryImages.map((g, idx) => (index === idx ? { ...g, isSelected: !g.isSelected } : g))); - }} - /> +
+ {isLoading && galleryImages.length === 0 && ( +
+ +
+ )} + {galleryImages.length > 0 && ( + + )} + {galleryImages.length > 0 && ( +
+ {`${t("general.labels.media")}: ${galleryImages.length}`} +
+ )}
); } diff --git a/client/src/components/jobs-documents-local-gallery/jobs-documents-local-gallery.container.jsx b/client/src/components/jobs-documents-local-gallery/jobs-documents-local-gallery.container.jsx index da94678a8..4ef9cb881 100644 --- a/client/src/components/jobs-documents-local-gallery/jobs-documents-local-gallery.container.jsx +++ b/client/src/components/jobs-documents-local-gallery/jobs-documents-local-gallery.container.jsx @@ -1,7 +1,6 @@ -import { EditFilled, FileExcelFilled, SyncOutlined } from "@ant-design/icons"; +import { EditFilled, SyncOutlined } from "@ant-design/icons"; import { Alert, Button, Card, Col, Row, Space } from "antd"; import { useEffect, useState } from "react"; -import { Gallery } from "react-grid-gallery"; import { useTranslation } from "react-i18next"; import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; @@ -14,6 +13,7 @@ import JobsDocumentsLocalDeleteButton from "./jobs-documents-local-gallery.delet import JobsLocalGalleryDownloadButton from "./jobs-documents-local-gallery.download"; import JobsDocumentsLocalGalleryReassign from "./jobs-documents-local-gallery.reassign.component"; import JobsDocumentsLocalGallerySelectAllComponent from "./jobs-documents-local-gallery.selectall.component"; +import LocalMediaGrid from "./local-media-grid.component"; import Lightbox from "react-image-lightbox"; import "react-image-lightbox/style.css"; @@ -132,54 +132,34 @@ export function JobsDocumentsLocalGallery({ - + )} + { - toggleMediaSelected({ jobid: job.id, filename: image.filename }); - }} - {...(optimized && { - customControls: [ - - ] - })} onClick={(index) => { setModalState({ open: true, index: index }); - // const media = allMedia[job.id].find( - // (m) => m.optimized === item.src - // ); - - // window.open( - // media ? media.fullsize : item.fullsize, - // "_blank", - // "toolbar=0,location=0,menubar=0" - // ); }} + onToggle={(index) => { + toggleMediaSelected({ jobid: job.id, filename: jobMedia.images[index].filename }); + }} + minColumns={4} + expandHeight={true} /> - { - return { - backgroundImage: , - height: "100%", - width: "100%", - cursor: "pointer" - }; - }} onClick={(index) => { window.open(jobMedia.other[index].fullsize, "_blank", "toolbar=0,location=0,menubar=0"); }} - onSelect={(index, image) => { - toggleMediaSelected({ jobid: job.id, filename: image.filename }); + onToggle={(index) => { + toggleMediaSelected({ jobid: job.id, filename: jobMedia.other[index].filename }); }} + minColumns={4} + expandHeight={true} /> diff --git a/client/src/components/jobs-documents-local-gallery/local-media-grid.component.jsx b/client/src/components/jobs-documents-local-gallery/local-media-grid.component.jsx index 277351296..f4c671f4f 100644 --- a/client/src/components/jobs-documents-local-gallery/local-media-grid.component.jsx +++ b/client/src/components/jobs-documents-local-gallery/local-media-grid.component.jsx @@ -6,15 +6,18 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; * Props: * - images: Array<{ src, fullsize, filename?, isSelected? }> * - onToggle(index) + * - onClick(index) optional for viewing */ export function LocalMediaGrid({ images, onToggle, + onClick, thumbSize = 100, gap = 8, minColumns = 3, maxColumns = 12, - context = "default" + context = "default", + expandHeight = false }) { const containerRef = useRef(null); const [cols, setCols] = useState(() => { @@ -114,8 +117,7 @@ export function LocalMediaGrid({ display: "grid", gridTemplateColumns, gap, - maxHeight: 420, - overflowY: "auto", + ...(expandHeight ? {} : { maxHeight: 420, overflowY: "auto" }), overflowX: "hidden", padding: 4, justifyContent: justifyMode, @@ -131,7 +133,7 @@ export function LocalMediaGrid({ role="listitem" tabIndex={0} aria-label={img.filename || `image ${idx + 1}`} - onClick={() => onToggle(idx)} + onClick={() => onClick ? onClick(idx) : onToggle(idx)} onKeyDown={(e) => handleKeyDown(e, idx)} style={{ position: "relative", @@ -197,6 +199,23 @@ export function LocalMediaGrid({ }} /> )} + {onClick && ( + { + e.stopPropagation(); + onToggle(idx); + }} + onClick={(e) => e.stopPropagation()} + style={{ + position: 'absolute', + top: 5, + right: 5, + zIndex: 2 + }} + /> + )}
))} {/* No placeholders needed; layout uses auto-fit for non-chat or fixed columns for chat */}