From 56d50b855be6ceb61325304f01e29cd1c3020037 Mon Sep 17 00:00:00 2001 From: Allan Carr Date: Fri, 12 Dec 2025 19:39:02 -0800 Subject: [PATCH 1/2] IO-3464 S3 Document Editor Signed-off-by: Allan Carr --- .../document-editor.component.jsx | 64 ++++++++++++-- server/media/imgproxy-media.js | 87 +++++++++++++++++-- server/routes/mediaRoutes.js | 2 + 3 files changed, 140 insertions(+), 13 deletions(-) diff --git a/client/src/components/document-editor/document-editor.component.jsx b/client/src/components/document-editor/document-editor.component.jsx index f27875efe..8df1be57f 100644 --- a/client/src/components/document-editor/document-editor.component.jsx +++ b/client/src/components/document-editor/document-editor.component.jsx @@ -1,4 +1,5 @@ //import "tui-image-editor/dist/tui-image-editor.css"; +import axios from "axios"; import { Result } from "antd"; import * as markerjs2 from "markerjs2"; import { useCallback, useEffect, useRef, useState } from "react"; @@ -6,8 +7,7 @@ import { useTranslation } from "react-i18next"; import { connect } from "react-redux"; import { createStructuredSelector } from "reselect"; import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors"; -import { handleUpload } from "../documents-upload/documents-upload.utility"; -import { GenerateSrcUrl } from "../jobs-documents-gallery/job-documents.utility"; +import { handleUpload } from "../documents-upload-imgproxy/documents-upload-imgproxy.utility.js"; import LoadingSpinner from "../loading-spinner/loading-spinner.component"; import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; @@ -23,6 +23,9 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) { const imgRef = useRef(null); const [loading, setLoading] = useState(false); const [uploaded, setuploaded] = useState(false); + const [imageUrl, setImageUrl] = useState(null); + const [imageLoaded, setImageLoaded] = useState(false); + const [imageLoading, setImageLoading] = useState(true); const markerArea = useRef(null); const { t } = useTranslation(); const notification = useNotification(); @@ -55,7 +58,7 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) { ); useEffect(() => { - if (imgRef.current !== null) { + if (imgRef.current !== null && imageLoaded && !markerArea.current) { // create a marker.js MarkerArea markerArea.current = new markerjs2.MarkerArea(imgRef.current); @@ -78,7 +81,47 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) { //markerArea.current.settings.displayMode = "inline"; markerArea.current.show(); } - }, [triggerUpload]); + }, [triggerUpload, imageLoaded]); + + useEffect(() => { + // When the document changes, fetch the image via axios so auth and base URL are applied + let isCancelled = false; + + const loadImage = async () => { + if (!document || !document.id) return; + + setImageLoaded(false); + setImageLoading(true); + + try { + const response = await axios.post( + "/media/imgproxy/original", + { documentId: document.id }, + { responseType: "blob" } + ); + + if (isCancelled) return; + + const blobUrl = URL.createObjectURL(response.data); + setImageUrl((prevUrl) => { + if (prevUrl) URL.revokeObjectURL(prevUrl); + return blobUrl; + }); + } catch (error) { + console.error("Failed to fetch original image blob", error); + } finally { + if (!isCancelled) { + setImageLoading(false); + } + } + }; + + loadImage(); + + return () => { + isCancelled = true; + }; + }, [document]); async function b64toBlob(url) { const res = await fetch(url); @@ -87,16 +130,21 @@ export function DocumentEditorComponent({ currentUser, bodyshop, document }) { return (
- {!loading && !uploaded && ( + {!loading && !uploaded && imageUrl && ( sample setImageLoaded(true)} + onError={(error) => { + console.error("Failed to load original image", error); + }} style={{ maxWidth: "90vw", maxHeight: "90vh" }} /> )} - {loading && } + {(loading || imageLoading || !imageLoaded) && !uploaded && ( + + )} {uploaded && }
); diff --git a/server/media/imgproxy-media.js b/server/media/imgproxy-media.js index 85bc3393f..b6f480e5b 100644 --- a/server/media/imgproxy-media.js +++ b/server/media/imgproxy-media.js @@ -44,25 +44,25 @@ const generateSignedUploadUrls = async (req, res) => { for (const filename of filenames) { const key = filename; const client = new S3Client({ region: InstanceRegion() }); - + // Check if filename indicates PDF and set content type accordingly - const isPdf = filename.toLowerCase().endsWith('.pdf'); + const isPdf = filename.toLowerCase().endsWith(".pdf"); const commandParams = { Bucket: imgproxyDestinationBucket, Key: key, StorageClass: "INTELLIGENT_TIERING" }; - + if (isPdf) { commandParams.ContentType = "application/pdf"; } - + const command = new PutObjectCommand(commandParams); // For PDFs, we need to add conditions to the presigned URL to enforce content type const presignedUrlOptions = { expiresIn: 360 }; if (isPdf) { - presignedUrlOptions.signableHeaders = new Set(['content-type']); + presignedUrlOptions.signableHeaders = new Set(["content-type"]); } const presignedUrl = await getSignedUrl(client, command, presignedUrlOptions); @@ -265,6 +265,82 @@ const downloadFiles = async (req, res) => { } }; +/** + * Stream original image content by document ID + * @param req + * @param res + * @returns {Promise<*>} + */ +const getOriginalImageByDocumentId = async (req, res) => { + const { + body: { documentId }, + user, + userGraphQLClient + } = req; + + if (!documentId) { + return res.status(400).json({ message: "documentId is required" }); + } + + try { + logger.log("imgproxy-original-image", "DEBUG", user?.email, null, { documentId }); + + const { documents } = await userGraphQLClient.request(GET_DOCUMENTS_BY_IDS, { documentIds: [documentId] }); + + if (!documents || documents.length === 0) { + return res.status(404).json({ message: "Document not found" }); + } + + const [document] = documents; + const { type } = document; + + if (!type || !type.startsWith("image")) { + return res.status(400).json({ message: "Document is not an image" }); + } + + const s3client = new S3Client({ region: InstanceRegion() }); + const key = keyStandardize(document); + + let s3Response; + try { + s3Response = await s3client.send( + new GetObjectCommand({ + Bucket: imgproxyDestinationBucket, + Key: key + }) + ); + } catch (err) { + logger.log("imgproxy-original-image-s3-error", "ERROR", user?.email, null, { + key, + message: err.message, + stack: err.stack + }); + return res.status(400).json({ message: "Unable to retrieve image" }); + } + + res.setHeader("Content-Type", type || "image/jpeg"); + + s3Response.Body.on("error", (err) => { + logger.log("imgproxy-original-image-s3stream-error", "ERROR", user?.email, null, { + key, + message: err.message, + stack: err.stack + }); + res.destroy(err); + }); + + s3Response.Body.pipe(res); + } catch (error) { + logger.log("imgproxy-original-image-error", "ERROR", req.user?.email, null, { + documentId, + message: error.message, + stack: error.stack + }); + + return res.status(400).json({ message: error.message, stack: error.stack }); + } +}; + /** * Delete Files * @param req @@ -425,6 +501,7 @@ const keyStandardize = (doc) => { module.exports = { generateSignedUploadUrls, getThumbnailUrls, + getOriginalImageByDocumentId, downloadFiles, deleteFiles, moveFiles diff --git a/server/routes/mediaRoutes.js b/server/routes/mediaRoutes.js index 59ee836ec..1f356c5b9 100644 --- a/server/routes/mediaRoutes.js +++ b/server/routes/mediaRoutes.js @@ -4,6 +4,7 @@ const { createSignedUploadURL, downloadFiles, renameKeys, deleteFiles } = requir const { generateSignedUploadUrls: createSignedUploadURLImgproxy, getThumbnailUrls: getThumbnailUrlsImgproxy, + getOriginalImageByDocumentId: getOriginalImageByDocumentIdImgproxy, downloadFiles: downloadFilesImgproxy, moveFiles: moveFilesImgproxy, deleteFiles: deleteFilesImgproxy @@ -24,5 +25,6 @@ router.post("/imgproxy/thumbnails", getThumbnailUrlsImgproxy); router.post("/imgproxy/download", downloadFilesImgproxy); router.post("/imgproxy/rename", moveFilesImgproxy); router.post("/imgproxy/delete", deleteFilesImgproxy); +router.post("/imgproxy/original", getOriginalImageByDocumentIdImgproxy); module.exports = router; From 6eb432b5b714786bd3c2f8663b168417d36e922d Mon Sep 17 00:00:00 2001 From: Allan Carr Date: Fri, 12 Dec 2025 20:56:39 -0800 Subject: [PATCH 2/2] IO-3464 Local Media Edit Image Signed-off-by: Allan Carr --- client/src/App/App.jsx | 4 + .../document-editor-local.component.jsx | 162 ++++++++++++++++++ .../document-editor-local.container.jsx | 34 ++++ ...jobs-documents-local-gallery.container.jsx | 17 +- 4 files changed, 216 insertions(+), 1 deletion(-) create mode 100644 client/src/components/document-editor/document-editor-local.component.jsx create mode 100644 client/src/components/document-editor/document-editor-local.container.jsx diff --git a/client/src/App/App.jsx b/client/src/App/App.jsx index e14162274..69da6f077 100644 --- a/client/src/App/App.jsx +++ b/client/src/App/App.jsx @@ -7,6 +7,7 @@ import { connect } from "react-redux"; import { Route, Routes, useNavigate } from "react-router-dom"; import { createStructuredSelector } from "reselect"; import DocumentEditorContainer from "../components/document-editor/document-editor.container"; +import DocumentEditorLocalContainer from "../components/document-editor/document-editor-local.container"; import ErrorBoundary from "../components/error-boundary/error-boundary.component"; import LoadingSpinner from "../components/loading-spinner/loading-spinner.component"; import DisclaimerPage from "../pages/disclaimer/disclaimer.page"; @@ -241,6 +242,9 @@ export function App({ }> } /> + }> + } /> + diff --git a/client/src/components/document-editor/document-editor-local.component.jsx b/client/src/components/document-editor/document-editor-local.component.jsx new file mode 100644 index 000000000..b1511352a --- /dev/null +++ b/client/src/components/document-editor/document-editor-local.component.jsx @@ -0,0 +1,162 @@ +import axios from "axios"; +import { Result } from "antd"; +import * as markerjs2 from "markerjs2"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { connect } from "react-redux"; +import { createStructuredSelector } from "reselect"; +import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors"; +import { handleUpload } from "../documents-local-upload/documents-local-upload.utility"; +import LoadingSpinner from "../loading-spinner/loading-spinner.component"; +import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; + +const mapStateToProps = createStructuredSelector({ + currentUser: selectCurrentUser, + bodyshop: selectBodyshop +}); +const mapDispatchToProps = () => ({}); + +export function DocumentEditorLocalComponent({ imageUrl, filename, jobid }) { + const imgRef = useRef(null); + const [loading, setLoading] = useState(false); + const [uploaded, setuploaded] = useState(false); + const [loadedImageUrl, setLoadedImageUrl] = useState(null); + const [imageLoaded, setImageLoaded] = useState(false); + const [imageLoading, setImageLoading] = useState(true); + const markerArea = useRef(null); + const { t } = useTranslation(); + const notification = useNotification(); + const [uploading, setUploading] = useState(false); + + const triggerUpload = useCallback( + async (dataUrl) => { + if (uploading) return; + setUploading(true); + const blob = await b64toBlob(dataUrl); + const nameWithoutExt = filename.split(".").slice(0, -1).join(".").trim(); + const parts = nameWithoutExt.split("-"); + const baseParts = []; + for (let i = 0; i < parts.length; i++) { + if (/^\d+$/.test(parts[i])) { + break; + } + baseParts.push(parts[i]); + } + const adjustedBase = baseParts.length > 0 ? baseParts.join("-") : "edited"; + const adjustedFilename = `${adjustedBase}.jpg`; + const file = new File([blob], adjustedFilename, { type: "image/jpeg" }); + + handleUpload({ + ev: { + file: file, + filename: adjustedFilename, + onSuccess: () => { + setUploading(false); + setLoading(false); + setuploaded(true); + }, + onError: () => { + setUploading(false); + setLoading(false); + } + }, + context: { + jobid: jobid, + callback: () => {} // Optional callback + }, + notification + }); + }, + [filename, jobid, notification, uploading] + ); + + useEffect(() => { + if (imgRef.current !== null && imageLoaded && !markerArea.current) { + // create a marker.js MarkerArea + markerArea.current = new markerjs2.MarkerArea(imgRef.current); + + // attach an event handler to assign annotated image back to our image element + markerArea.current.addEventListener("close", () => { + // NO OP + }); + + markerArea.current.addEventListener("render", (event) => { + const dataUrl = event.dataUrl; + imgRef.current.src = dataUrl; + markerArea.current.close(); + triggerUpload(dataUrl); + }); + // launch marker.js + + markerArea.current.renderAtNaturalSize = true; + markerArea.current.renderImageType = "image/jpeg"; + markerArea.current.renderImageQuality = 1; + //markerArea.current.settings.displayMode = "inline"; + markerArea.current.show(); + } + }, [triggerUpload, imageLoaded]); + + useEffect(() => { + // Load the image from imageUrl + let isCancelled = false; + + const loadImage = async () => { + if (!imageUrl) return; + + setImageLoaded(false); + setImageLoading(true); + + try { + const response = await axios.get(imageUrl, { responseType: "blob" }); + + if (isCancelled) return; + + const blobUrl = URL.createObjectURL(response.data); + setLoadedImageUrl((prevUrl) => { + if (prevUrl) URL.revokeObjectURL(prevUrl); + return blobUrl; + }); + } catch (error) { + console.error("Failed to fetch image blob", error); + } finally { + if (!isCancelled) { + setImageLoading(false); + } + } + }; + + loadImage(); + + return () => { + isCancelled = true; + }; + }, [imageUrl]); + + async function b64toBlob(url) { + const res = await fetch(url); + return await res.blob(); + } + + return ( +
+ {!loading && !uploaded && loadedImageUrl && ( + sample setImageLoaded(true)} + onError={(error) => { + console.error("Failed to load image", error); + }} + style={{ maxWidth: "90vw", maxHeight: "90vh" }} + /> + )} + {(loading || imageLoading || !imageLoaded) && !uploaded && ( + + )} + {uploaded && } +
+ ); +} + +export default connect(mapStateToProps, mapDispatchToProps)(DocumentEditorLocalComponent); diff --git a/client/src/components/document-editor/document-editor-local.container.jsx b/client/src/components/document-editor/document-editor-local.container.jsx new file mode 100644 index 000000000..ce8aab23d --- /dev/null +++ b/client/src/components/document-editor/document-editor-local.container.jsx @@ -0,0 +1,34 @@ +import { useQuery } from "@apollo/client"; +import queryString from "query-string"; +import { useEffect } from "react"; +import { connect } from "react-redux"; +import { useLocation } from "react-router-dom"; +import { QUERY_BODYSHOP } from "../../graphql/bodyshop.queries"; +import { setBodyshop } from "../../redux/user/user.actions"; +import DocumentEditorLocalComponent from "./document-editor-local.component"; + +const mapDispatchToProps = (dispatch) => ({ + setBodyshop: (bs) => dispatch(setBodyshop(bs)) +}); + +export default connect(null, mapDispatchToProps)(DocumentEditorLocalContainer); + +export function DocumentEditorLocalContainer({ setBodyshop }) { + // Get the imageUrl, filename, jobid from the search string. + const { imageUrl, filename, jobid } = queryString.parse(useLocation().search); + + const { data: dataShop } = useQuery(QUERY_BODYSHOP, { + fetchPolicy: "network-only", + nextFetchPolicy: "network-only" + }); + + useEffect(() => { + if (dataShop) setBodyshop(dataShop.bodyshops[0]); + }, [dataShop, setBodyshop]); + + return ( +
+ +
+ ); +} diff --git a/client/src/components/jobs-documents-local-gallery/jobs-documents-local-gallery.container.jsx b/client/src/components/jobs-documents-local-gallery/jobs-documents-local-gallery.container.jsx index f5570257f..da9274eef 100644 --- a/client/src/components/jobs-documents-local-gallery/jobs-documents-local-gallery.container.jsx +++ b/client/src/components/jobs-documents-local-gallery/jobs-documents-local-gallery.container.jsx @@ -1,4 +1,4 @@ -import { FileExcelFilled, SyncOutlined } from "@ant-design/icons"; +import { EditFilled, FileExcelFilled, SyncOutlined } from "@ant-design/icons"; import { Alert, Button, Card, Col, Row, Space } from "antd"; import { useEffect, useState } from "react"; import { Gallery } from "react-grid-gallery"; @@ -185,6 +185,21 @@ export function JobsDocumentsLocalGallery({ {modalState.open && ( { + const newWindow = window.open( + `${window.location.protocol}//${window.location.host}/edit-local?imageUrl=${ + jobMedia.images[modalState.index].fullsize + }&filename=${jobMedia.images[modalState.index].filename}&jobid=${job.id}`, + "_blank", + "noopener,noreferrer" + ); + if (newWindow) newWindow.opener = null; + }} + /> + ]} mainSrc={jobMedia.images[modalState.index].fullsize} nextSrc={jobMedia.images[(modalState.index + 1) % jobMedia.images.length].fullsize} prevSrc={jobMedia.images[(modalState.index + jobMedia.images.length - 1) % jobMedia.images.length].fullsize}