diff --git a/client/src/components/chat-media-selector/chat-media-selector.component.jsx b/client/src/components/chat-media-selector/chat-media-selector.component.jsx index 85ea7aa93..ff2336f69 100644 --- a/client/src/components/chat-media-selector/chat-media-selector.component.jsx +++ b/client/src/components/chat-media-selector/chat-media-selector.component.jsx @@ -40,7 +40,11 @@ export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, c variables: { jobId: conversation.job_conversations[0]?.jobid }, - skip: !open || !conversation.job_conversations || conversation.job_conversations.length === 0 + skip: + !open || + !conversation.job_conversations || + conversation.job_conversations.length === 0 || + bodyshop.uselocalmediaserver }); const handleVisibleChange = (change) => { @@ -48,7 +52,8 @@ export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, c }; useEffect(() => { - setSelectedMedia([]); + // Instead of wiping the array (which holds media objects), just clear selection flags + setSelectedMedia((prev) => prev.map((m) => ({ ...m, isSelected: false }))); }, [setSelectedMedia, conversation]); //Knowingly taking on the technical debt of poor implementation below. Done this way to avoid an edge case where no component may be displayed. @@ -75,6 +80,7 @@ export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, c )} @@ -90,6 +96,7 @@ export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, c )} @@ -110,6 +117,7 @@ export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, c trigger="click" open={open} onOpenChange={handleVisibleChange} + destroyOnHidden classNames={{ root: "media-selector-popover" }} > s.isSelected).length}> diff --git a/client/src/components/email-documents/email-documents.component.jsx b/client/src/components/email-documents/email-documents.component.jsx index 8882bd2da..1a1dc5e11 100644 --- a/client/src/components/email-documents/email-documents.component.jsx +++ b/client/src/components/email-documents/email-documents.component.jsx @@ -67,6 +67,7 @@ export function EmailDocumentsComponent({ emailConfig, form, selectedMediaState, )} @@ -82,6 +83,7 @@ export function EmailDocumentsComponent({ emailConfig, form, selectedMediaState, )} diff --git a/client/src/components/jobs-documents-local-gallery/jobs-documents-local-gallery.external.component.jsx b/client/src/components/jobs-documents-local-gallery/jobs-documents-local-gallery.external.component.jsx index 47f1ebaa5..88bfe0dab 100644 --- a/client/src/components/jobs-documents-local-gallery/jobs-documents-local-gallery.external.component.jsx +++ b/client/src/components/jobs-documents-local-gallery/jobs-documents-local-gallery.external.component.jsx @@ -1,11 +1,12 @@ -import { useEffect } from "react"; -import { Gallery } from "react-grid-gallery"; +import { useEffect, useMemo, useState, useCallback } from "react"; +import LocalMediaGrid from "./local-media-grid.component"; import { useTranslation } from "react-i18next"; import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; import { getJobMedia } from "../../redux/media/media.actions"; import { selectAllMedia } from "../../redux/media/media.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors"; +import LoadingSpinner from "../loading-spinner/loading-spinner.component"; const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop, @@ -18,41 +19,127 @@ const mapDispatchToProps = (dispatch) => ({ export default connect(mapStateToProps, mapDispatchToProps)(JobDocumentsLocalGalleryExternal); -function JobDocumentsLocalGalleryExternal({ jobId, externalMediaState, getJobMedia, allMedia }) { +/** + * JobDocumentsLocalGalleryExternal + * Fetches and displays job-related image media using the custom LocalMediaGrid. + * + * Props: + * - jobId: string | number (required to fetch media) + * - externalMediaState: [imagesArray, setImagesFn] (state lifted to parent for shared selection) + * - getJobMedia: dispatching function to retrieve media for a job + * - allMedia: redux slice keyed by jobId containing raw media records + * - context: "chat" | "email" | other string used to drive grid behavior + * + * Notes: + * - The previous third-party gallery required a remount key (openVersion); custom grid no longer does. + * - Selection flags are preserved when media refreshes. + * - Loading state ends after transformation regardless of whether any images were found. + */ +function JobDocumentsLocalGalleryExternal({ jobId, externalMediaState, getJobMedia, allMedia, context = "chat" }) { const [galleryImages, setgalleryImages] = externalMediaState; - const { t } = useTranslation(); + const [isLoading, setIsLoading] = useState(false); + const { t } = useTranslation(); // i18n hook retained if future translations are added + const DEBUG_LOCAL_GALLERY = false; // flip to true for verbose console logging + // Transform raw media record into a normalized image object consumed by the grid. + const transformMediaToImages = useCallback((raw) => { + return raw + .filter((m) => m.type?.mime?.startsWith("image")) + .map((m) => ({ + ...m, + src: m.thumbnail, + thumbnail: m.thumbnail, + fullsize: m.src, + width: 225, + height: 225, + thumbnailWidth: 225, + thumbnailHeight: 225, + caption: m.filename || m.key + })); + }, []); + + // Fetch media when jobId changes (network request triggers Redux update -> documents memo recalculates). useEffect(() => { - if (jobId) { - getJobMedia(jobId); - } + if (!jobId) return; + setIsLoading(true); + getJobMedia(jobId); }, [jobId, getJobMedia]); - useEffect(() => { - let documents = allMedia?.[jobId] - ? allMedia[jobId].reduce((acc, val) => { - if (val.type?.mime && val.type.mime.startsWith("image")) { - acc.push({ ...val, src: val.thumbnail, fullsize: val.src }); - } - return acc; - }, []) - : []; - console.log( - "🚀 ~ file: jobs-documents-local-gallery.external.component.jsx:48 ~ useEffect ~ documents:", - documents - ); + // Memo: transform raw redux media into gallery documents. + const documents = useMemo( + () => transformMediaToImages(allMedia?.[jobId] || []), + [allMedia, jobId, transformMediaToImages] + ); - setgalleryImages(documents); - }, [allMedia, jobId, setgalleryImages, t]); + // Sync transformed documents into external state while preserving selection flags. + 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 + if (DEBUG_LOCAL_GALLERY) { + console.log("[LocalGallery] documents unchanged", { jobId, count: documents.length }); + } + return; + } + } + setgalleryImages(nextImages); + setIsLoading(false); // stop loading after transform regardless of emptiness + if (DEBUG_LOCAL_GALLERY) { + console.log("[LocalGallery] documents transformed", { jobId, count: documents.length }); + } + }, [documents, setgalleryImages, galleryImages, jobId, DEBUG_LOCAL_GALLERY]); + + // Toggle handler (stable reference) + const handleToggle = useCallback( + (idx) => { + setgalleryImages((imgs) => imgs.map((g, gIdx) => (gIdx === idx ? { ...g, isSelected: !g.isSelected } : g))); + }, + [setgalleryImages] + ); + + const messageStyle = { textAlign: "center", padding: "1rem" }; // retained for potential future states + + 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/local-media-grid.component.jsx b/client/src/components/jobs-documents-local-gallery/local-media-grid.component.jsx new file mode 100644 index 000000000..277351296 --- /dev/null +++ b/client/src/components/jobs-documents-local-gallery/local-media-grid.component.jsx @@ -0,0 +1,207 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +/** + * LocalMediaGrid + * Lightweight replacement for react-grid-gallery inside the chat popover. + * Props: + * - images: Array<{ src, fullsize, filename?, isSelected? }> + * - onToggle(index) + */ +export function LocalMediaGrid({ + images, + onToggle, + thumbSize = 100, + gap = 8, + minColumns = 3, + maxColumns = 12, + context = "default" +}) { + const containerRef = useRef(null); + const [cols, setCols] = useState(() => { + // Pre-calc initial columns to stabilize layout before images render + const count = images.length; + if (count === 0) return minColumns; // reserve minimal structure + if (count === 1 && context === "chat") return 1; + return Math.min(maxColumns, Math.max(minColumns, count)); + }); + const [justifyMode, setJustifyMode] = useState("start"); + const [distributeExtra, setDistributeExtra] = useState(false); + const [loadedMap, setLoadedMap] = useState(() => new Map()); // filename -> boolean loaded + + const handleImageLoad = useCallback((key) => { + setLoadedMap((prev) => { + if (prev.get(key)) return prev; // already loaded + const next = new Map(prev); + next.set(key, true); + return next; + }); + }, []); + + // Dynamically compute columns for all contexts to avoid auto-fit stretching gaps in email overlay + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const compute = () => { + // For non-chat (email / default) we rely on CSS auto-fill; only chat needs explicit column calc & distribution logic. + if (context !== "chat") { + setCols(images.length || 0); // retain count for ARIA semantics; not used for template when non-chat. + setDistributeExtra(false); + return; + } + const width = el.clientWidth; + if (!width) return; + const perCol = thumbSize + gap; // track + gap space + const fitCols = Math.max(1, Math.floor((width + gap) / perCol)); + // base desired columns: up to how many images we have and how many fit + let finalCols = Math.min(images.length || 1, fitCols, maxColumns); + // enforce minimum columns to reserve layout skeleton (except when fewer images) + if (finalCols < minColumns && images.length >= minColumns) { + finalCols = Math.min(fitCols, minColumns); + } + // chat-specific clamp + if (context === "chat") { + finalCols = Math.min(finalCols, 4); + } + if (finalCols < 1) finalCols = 1; + setCols(finalCols); + setJustifyMode("start"); + + // Determine if there is leftover horizontal space that can't fit another column. + // Only distribute when we're at the maximum allowed columns for the context and images exceed or meet that count. + const contextMax = context === "chat" ? 4 : maxColumns; + const baseWidthNeeded = finalCols * thumbSize + (finalCols - 1) * gap; + const leftover = width - baseWidthNeeded; + const atMaxColumns = finalCols === contextMax && images.length >= finalCols; + // leftover must be positive but less than space needed for an additional column (perCol) + if (atMaxColumns && leftover > 0 && leftover < perCol) { + setDistributeExtra(true); + } else { + setDistributeExtra(false); + } + }; + compute(); + const ro = new ResizeObserver(() => compute()); + ro.observe(el); + return () => ro.disconnect(); + }, [images.length, thumbSize, gap, minColumns, maxColumns, context]); + + const gridTemplateColumns = useMemo(() => { + if (context === "chat") { + if (distributeExtra) { + return `repeat(${cols}, minmax(${thumbSize}px, 1fr))`; + } + return `repeat(${cols}, ${thumbSize}px)`; + } + // Non-chat contexts: allow browser to auto-fill columns; fixed min (thumbSize) ensures squares; tracks expand to distribute remaining space. + return `repeat(auto-fill, minmax(${thumbSize}px, 1fr))`; + }, [cols, thumbSize, distributeExtra, context]); + const stableWidth = undefined; // no fixed width + + const handleKeyDown = useCallback( + (e, idx) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onToggle(idx); + } + }, + [onToggle] + ); + + return ( +
+ {images.map((img, idx) => ( +
onToggle(idx)} + onKeyDown={(e) => handleKeyDown(e, idx)} + style={{ + position: "relative", + border: img.isSelected ? "2px solid #1890ff" : "1px solid #ccc", + outline: "none", + borderRadius: 4, + cursor: "pointer", + background: "#fafafa", + width: thumbSize, + height: thumbSize, + overflow: "hidden", + boxSizing: "border-box" + }} + > + {(() => { + const key = img.filename || idx; + const loaded = loadedMap.get(key) === true; + return ( + <> + {!loaded && ( + + )} + {img.filename handleImageLoad(key)} + style={{ + width: thumbSize, + height: thumbSize, + objectFit: "cover", + display: "block", + borderRadius: 4, + opacity: loaded ? 1 : 0, + transition: "opacity .25s ease" + }} + /> + + ); + })()} + {img.isSelected && ( + + ))} + {/* No placeholders needed; layout uses auto-fit for non-chat or fixed columns for chat */} +
+ ); +} + +export default LocalMediaGrid; diff --git a/client/src/redux/media/media.reducer.js b/client/src/redux/media/media.reducer.js index ace8cac46..7199fdff2 100644 --- a/client/src/redux/media/media.reducer.js +++ b/client/src/redux/media/media.reducer.js @@ -19,29 +19,19 @@ const mediaReducer = (state = INITIAL_STATE, action) => { case MediaActionTypes.TOGGLE_MEDIA_SELECTED: return { ...state, - [action.payload.jobid]: state[action.payload.jobid].map((p) => { - if (p.filename === action.payload.filename) { - p.isSelected = !p.isSelected; - } - return p; - }) + [action.payload.jobid]: state[action.payload.jobid].map((p) => + p.filename === action.payload.filename ? { ...p, isSelected: !p.isSelected } : p + ) }; case MediaActionTypes.SELECT_ALL_MEDIA_FOR_JOB: return { ...state, - [action.payload.jobid]: state[action.payload.jobid].map((p) => { - p.isSelected = true; - - return p; - }) + [action.payload.jobid]: state[action.payload.jobid].map((p) => ({ ...p, isSelected: true })) }; case MediaActionTypes.DESELECT_ALL_MEDIA_FOR_JOB: return { ...state, - [action.payload.jobid]: state[action.payload.jobid].map((p) => { - p.isSelected = false; - return p; - }) + [action.payload.jobid]: state[action.payload.jobid].map((p) => ({ ...p, isSelected: false })) }; default: return state; diff --git a/client/src/redux/media/media.selectors.js b/client/src/redux/media/media.selectors.js index e5c930e5c..8c1123bb1 100644 --- a/client/src/redux/media/media.selectors.js +++ b/client/src/redux/media/media.selectors.js @@ -2,4 +2,5 @@ import { createSelector } from "reselect"; const selectMedia = (state) => state.media; -export const selectAllMedia = createSelector([selectMedia], (media) => media); +// Return a shallow copy to avoid identity selector warning and allow memoization to detect actual changes. +export const selectAllMedia = createSelector([selectMedia], (media) => ({ ...media }));