IO-3431 Jobs Image Gallery
Signed-off-by: Allan Carr <allan@imexsystems.ca>
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
Reference in New Issue
Block a user