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 && (
+

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/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 && (
})
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/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}
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;