IO-3431 Jobs Image Gallery

Signed-off-by: Allan Carr <allan@imexsystems.ca>
This commit is contained in:
Allan Carr
2026-01-02 14:51:13 -08:00
parent 2eca085284
commit 4a22aeca46
4 changed files with 154 additions and 88 deletions

View File

@@ -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 { Button, Card, Col, Row, Space } from "antd";
import axios from "axios"; import axios from "axios";
import i18n from "i18next"; import i18n from "i18next";
import { isFunction } from "lodash"; import { isFunction } from "lodash";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { Gallery } from "react-grid-gallery";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import Lightbox from "react-image-lightbox"; import Lightbox from "react-image-lightbox";
import "react-image-lightbox/style.css"; 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 JobsDocumentsGalleryReassign from "./jobs-document-imgproxy-gallery.reassign.component";
import JobsDocumentsDeleteButton from "./jobs-documents-imgproxy-gallery.delete.component"; import JobsDocumentsDeleteButton from "./jobs-documents-imgproxy-gallery.delete.component";
import JobsDocumentsGallerySelectAllComponent from "./jobs-documents-imgproxy-gallery.selectall.component"; import JobsDocumentsGallerySelectAllComponent from "./jobs-documents-imgproxy-gallery.selectall.component";
import LocalMediaGrid from "../jobs-documents-local-gallery/local-media-grid.component";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop bodyshop: selectBodyshop
}); });
const mapDispatchToProps = () => ({}); 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({ function JobsDocumentsImgproxyComponent({
bodyshop, bodyshop,
data, data,
@@ -119,17 +112,12 @@ function JobsDocumentsImgproxyComponent({
)} )}
<Col span={24}> <Col span={24}>
<Card title={t("jobs.labels.documents-images")}> <Card title={t("jobs.labels.documents-images")}>
<Gallery <LocalMediaGrid
images={galleryImages.images} images={galleryImages.images}
onClick={(index) => { onClick={(index) => {
setModalState({ open: true, index: index }); setModalState({ open: true, index: index });
// window.open(
// item.fullsize,
// "_blank",
// "toolbar=0,location=0,menubar=0"
// );
}} }}
onSelect={(index) => { onToggle={(index) => {
setGalleryImages({ setGalleryImages({
...galleryImages, ...galleryImages,
images: galleryImages.images.map((g, idx) => images: galleryImages.images.map((g, idx) =>
@@ -137,30 +125,26 @@ function JobsDocumentsImgproxyComponent({
) )
}); });
}} }}
minColumns={4}
expandHeight={true}
/> />
</Card> </Card>
</Col> </Col>
<Col span={24}> <Col span={24}>
<Card title={t("jobs.labels.documents-other")}> <Card title={t("jobs.labels.documents-other")}>
<Gallery <LocalMediaGrid
images={galleryImages.other} images={galleryImages.other}
thumbnailStyle={() => {
return {
backgroundImage: <FileExcelFilled />,
height: "100%",
width: "100%",
cursor: "pointer"
};
}}
onClick={(index) => { onClick={(index) => {
window.open(galleryImages.other[index].source, "_blank", "toolbar=0,location=0,menubar=0"); window.open(galleryImages.other[index].source, "_blank", "toolbar=0,location=0,menubar=0");
}} }}
onSelect={(index) => { onToggle={(index) => {
setGalleryImages({ setGalleryImages({
...galleryImages, ...galleryImages,
other: galleryImages.other.map((g, idx) => (index === idx ? { ...g, isSelected: !g.isSelected } : g)) other: galleryImages.other.map((g, idx) => (index === idx ? { ...g, isSelected: !g.isSelected } : g))
}); });
}} }}
minColumns={4}
expandHeight={true}
/> />
</Card> </Card>
</Col> </Col>
@@ -221,6 +205,7 @@ export const fetchImgproxyThumbnails = async ({ setStateCallback, jobId, billId,
width: 225, width: 225,
isSelected: false, isSelected: false,
key: value.key, key: value.key,
filename: value.key,
extension: value.extension, extension: value.extension,
id: value.id, id: value.id,
type: value.type, type: value.type,
@@ -259,6 +244,7 @@ export const fetchImgproxyThumbnails = async ({ setStateCallback, jobId, billId,
isSelected: false, isSelected: false,
extension: value.extension, extension: value.extension,
key: value.key, key: value.key,
filename: value.key,
id: value.id, id: value.id,
type: value.type, type: value.type,
size: value.size size: value.size

View File

@@ -1,31 +1,112 @@
import { useEffect } from "react"; import { useEffect, useMemo, useState, useCallback } from "react";
import { Gallery } from "react-grid-gallery"; import axios from "axios";
import { fetchImgproxyThumbnails } from "./jobs-documents-imgproxy-gallery.component"; import { useTranslation } from "react-i18next";
import LocalMediaGrid from "../jobs-documents-local-gallery/local-media-grid.component";
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
/* function JobsDocumentImgproxyGalleryExternal({ jobId, externalMediaState, context = "chat" }) {
################################################################################################
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 }) {
const [galleryImages, setgalleryImages] = externalMediaState; 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(() => { useEffect(() => {
if (jobId) fetchImgproxyThumbnails({ setStateCallback: setgalleryImages, jobId, imagesOnly: true }); if (!jobId) return;
}, [jobId, setgalleryImages]); 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 (
<div aria-label="media gallery unavailable" style={{ position: "relative", minHeight: 80 }}>
<div style={messageStyle}>No job selected.</div>
</div>
);
}
return ( return (
<div className="clearfix"> <div
<Gallery className="clearfix"
images={galleryImages} style={{ position: "relative", minHeight: 80 }}
backdropClosesModal={true} data-jobid={jobId}
onSelect={(index) => { aria-label={`media gallery for job ${jobId}`}
setgalleryImages(galleryImages.map((g, idx) => (index === idx ? { ...g, isSelected: !g.isSelected } : g))); >
}} {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> </div>
); );
} }

View File

@@ -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 { Alert, Button, Card, Col, Row, Space } from "antd";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Gallery } from "react-grid-gallery";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; 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 JobsLocalGalleryDownloadButton from "./jobs-documents-local-gallery.download";
import JobsDocumentsLocalGalleryReassign from "./jobs-documents-local-gallery.reassign.component"; import JobsDocumentsLocalGalleryReassign from "./jobs-documents-local-gallery.reassign.component";
import JobsDocumentsLocalGallerySelectAllComponent from "./jobs-documents-local-gallery.selectall.component"; import JobsDocumentsLocalGallerySelectAllComponent from "./jobs-documents-local-gallery.selectall.component";
import LocalMediaGrid from "./local-media-grid.component";
import Lightbox from "react-image-lightbox"; import Lightbox from "react-image-lightbox";
import "react-image-lightbox/style.css"; import "react-image-lightbox/style.css";
@@ -132,54 +132,34 @@ export function JobsDocumentsLocalGallery({
</Col> </Col>
<Col span={24}> <Col span={24}>
<Card title={t("jobs.labels.documents-images")}> <Card title={t("jobs.labels.documents-images")}>
<Gallery {optimized && (
<Alert style={{ margin: "4px" }} message={t("documents.labels.optimizedimage")} type="success" />
)}
<LocalMediaGrid
images={jobMedia.images} images={jobMedia.images}
onSelect={(index, image) => {
toggleMediaSelected({ jobid: job.id, filename: image.filename });
}}
{...(optimized && {
customControls: [
<Alert
key="optimizedImageALert"
style={{ margin: "4px" }}
message={t("documents.labels.optimizedimage")}
type="success"
/>
]
})}
onClick={(index) => { onClick={(index) => {
setModalState({ open: true, index: 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}
/> />
</Card> </Card>
</Col> </Col>
<Col span={24}> <Col span={24}>
<Card title={t("jobs.labels.documents-other")}> <Card title={t("jobs.labels.documents-other")}>
<Gallery <LocalMediaGrid
images={jobMedia.other} images={jobMedia.other}
thumbnailStyle={() => {
return {
backgroundImage: <FileExcelFilled />,
height: "100%",
width: "100%",
cursor: "pointer"
};
}}
onClick={(index) => { onClick={(index) => {
window.open(jobMedia.other[index].fullsize, "_blank", "toolbar=0,location=0,menubar=0"); window.open(jobMedia.other[index].fullsize, "_blank", "toolbar=0,location=0,menubar=0");
}} }}
onSelect={(index, image) => { onToggle={(index) => {
toggleMediaSelected({ jobid: job.id, filename: image.filename }); toggleMediaSelected({ jobid: job.id, filename: jobMedia.other[index].filename });
}} }}
minColumns={4}
expandHeight={true}
/> />
</Card> </Card>
</Col> </Col>

View File

@@ -6,15 +6,18 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
* Props: * Props:
* - images: Array<{ src, fullsize, filename?, isSelected? }> * - images: Array<{ src, fullsize, filename?, isSelected? }>
* - onToggle(index) * - onToggle(index)
* - onClick(index) optional for viewing
*/ */
export function LocalMediaGrid({ export function LocalMediaGrid({
images, images,
onToggle, onToggle,
onClick,
thumbSize = 100, thumbSize = 100,
gap = 8, gap = 8,
minColumns = 3, minColumns = 3,
maxColumns = 12, maxColumns = 12,
context = "default" context = "default",
expandHeight = false
}) { }) {
const containerRef = useRef(null); const containerRef = useRef(null);
const [cols, setCols] = useState(() => { const [cols, setCols] = useState(() => {
@@ -114,8 +117,7 @@ export function LocalMediaGrid({
display: "grid", display: "grid",
gridTemplateColumns, gridTemplateColumns,
gap, gap,
maxHeight: 420, ...(expandHeight ? {} : { maxHeight: 420, overflowY: "auto" }),
overflowY: "auto",
overflowX: "hidden", overflowX: "hidden",
padding: 4, padding: 4,
justifyContent: justifyMode, justifyContent: justifyMode,
@@ -131,7 +133,7 @@ export function LocalMediaGrid({
role="listitem" role="listitem"
tabIndex={0} tabIndex={0}
aria-label={img.filename || `image ${idx + 1}`} aria-label={img.filename || `image ${idx + 1}`}
onClick={() => onToggle(idx)} onClick={() => onClick ? onClick(idx) : onToggle(idx)}
onKeyDown={(e) => handleKeyDown(e, idx)} onKeyDown={(e) => handleKeyDown(e, idx)}
style={{ style={{
position: "relative", position: "relative",
@@ -197,6 +199,23 @@ export function LocalMediaGrid({
}} }}
/> />
)} )}
{onClick && (
<input
type="checkbox"
checked={img.isSelected}
onChange={(e) => {
e.stopPropagation();
onToggle(idx);
}}
onClick={(e) => e.stopPropagation()}
style={{
position: 'absolute',
top: 5,
right: 5,
zIndex: 2
}}
/>
)}
</div> </div>
))} ))}
{/* No placeholders needed; layout uses auto-fit for non-chat or fixed columns for chat */} {/* No placeholders needed; layout uses auto-fit for non-chat or fixed columns for chat */}