Files
bodyshop/client/src/components/jobs-documents-local-gallery/jobs-documents-local-gallery.external.component.jsx
Allan Carr fbaf47b89b IO-3428 Media Selector
Signed-off-by: Allan Carr <allan@imexsystems.ca>
2025-10-31 15:13:29 -07:00

146 lines
5.5 KiB
JavaScript

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,
allMedia: selectAllMedia
});
const mapDispatchToProps = (dispatch) => ({
getJobMedia: (id) => dispatch(getJobMedia(id))
});
export default connect(mapStateToProps, mapDispatchToProps)(JobDocumentsLocalGalleryExternal);
/**
* 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 [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) return;
setIsLoading(true);
getJobMedia(jobId);
}, [jobId, getJobMedia]);
// Memo: transform raw redux media into gallery documents.
const documents = useMemo(
() => transformMediaToImages(allMedia?.[jobId] || []),
[allMedia, jobId, transformMediaToImages]
);
// 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"
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>
);
}