IO-3428 Media Selector
Signed-off-by: Allan Carr <allan@imexsystems.ca>
This commit is contained in:
@@ -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
|
||||
<JobDocumentsLocalGalleryExternal
|
||||
externalMediaState={[selectedMedia, setSelectedMedia]}
|
||||
jobId={conversation.job_conversations[0]?.jobid}
|
||||
context="chat"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -90,6 +96,7 @@ export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, c
|
||||
<JobDocumentsLocalGalleryExternal
|
||||
externalMediaState={[selectedMedia, setSelectedMedia]}
|
||||
jobId={conversation.job_conversations[0]?.jobid}
|
||||
context="chat"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -110,6 +117,7 @@ export function ChatMediaSelector({ bodyshop, selectedMedia, setSelectedMedia, c
|
||||
trigger="click"
|
||||
open={open}
|
||||
onOpenChange={handleVisibleChange}
|
||||
destroyOnHidden
|
||||
classNames={{ root: "media-selector-popover" }}
|
||||
>
|
||||
<Badge count={selectedMedia.filter((s) => s.isSelected).length}>
|
||||
|
||||
@@ -67,6 +67,7 @@ export function EmailDocumentsComponent({ emailConfig, form, selectedMediaState,
|
||||
<JobsDocumentsLocalGalleryExternalComponent
|
||||
externalMediaState={[selectedMedia, setSelectedMedia]}
|
||||
jobId={emailConfig.jobid}
|
||||
context="email"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -82,6 +83,7 @@ export function EmailDocumentsComponent({ emailConfig, form, selectedMediaState,
|
||||
<JobsDocumentsLocalGalleryExternalComponent
|
||||
externalMediaState={[selectedMedia, setSelectedMedia]}
|
||||
jobId={emailConfig.jobid}
|
||||
context="email"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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 (
|
||||
<div aria-label="media gallery unavailable" style={{ position: "relative", minHeight: 80 }}>
|
||||
<div style={messageStyle}>No job selected.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="clearfix">
|
||||
<Gallery
|
||||
images={galleryImages}
|
||||
onSelect={(index) => {
|
||||
setgalleryImages(galleryImages.map((g, idx) => (index === idx ? { ...g, isSelected: !g.isSelected } : g)));
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="clearfix"
|
||||
style={{ position: "relative", minHeight: 80 }}
|
||||
data-jobid={jobId}
|
||||
aria-label={`media gallery for job ${jobId}`}
|
||||
>
|
||||
{isLoading && galleryImages.length === 0 && (
|
||||
<div className="local-gallery-loading" style={messageStyle} role="status" aria-live="polite">
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
)}
|
||||
{galleryImages.length > 0 && (
|
||||
<LocalMediaGrid images={galleryImages} minColumns={4} context={context} onToggle={handleToggle} />
|
||||
)}
|
||||
{galleryImages.length > 0 && (
|
||||
<div style={{ fontSize: 10, color: "#888", marginTop: 4 }} aria-live="off">
|
||||
{`${t("general.labels.media")}: ${galleryImages.length}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
className="local-media-grid"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns,
|
||||
gap,
|
||||
maxHeight: 420,
|
||||
overflowY: "auto",
|
||||
overflowX: "hidden",
|
||||
padding: 4,
|
||||
justifyContent: justifyMode,
|
||||
width: stableWidth
|
||||
}}
|
||||
ref={containerRef}
|
||||
role="list"
|
||||
aria-label="media thumbnails"
|
||||
>
|
||||
{images.map((img, idx) => (
|
||||
<div
|
||||
key={img.filename || idx}
|
||||
role="listitem"
|
||||
tabIndex={0}
|
||||
aria-label={img.filename || `image ${idx + 1}`}
|
||||
onClick={() => 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 && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
background: "#f0f0f0",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
fontSize: 10,
|
||||
color: "#bbb"
|
||||
}}
|
||||
>
|
||||
{/* simple skeleton; no shimmer to reduce cost */}…
|
||||
</div>
|
||||
)}
|
||||
<img
|
||||
src={img.src}
|
||||
alt={img.filename || img.caption || "thumbnail"}
|
||||
loading="lazy"
|
||||
onLoad={() => handleImageLoad(key)}
|
||||
style={{
|
||||
width: thumbSize,
|
||||
height: thumbSize,
|
||||
objectFit: "cover",
|
||||
display: "block",
|
||||
borderRadius: 4,
|
||||
opacity: loaded ? 1 : 0,
|
||||
transition: "opacity .25s ease"
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
{img.isSelected && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
background: "rgba(24,144,255,0.45)",
|
||||
borderRadius: 4
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{/* No placeholders needed; layout uses auto-fit for non-chat or fixed columns for chat */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LocalMediaGrid;
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
Reference in New Issue
Block a user