@@ -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({
|
||||
<Route path="/edit/*" element={<PrivateRoute isAuthorized={currentUser.authorized} />}>
|
||||
<Route path="*" element={<DocumentEditorContainer />} />
|
||||
</Route>
|
||||
<Route path="/edit-local/*" element={<PrivateRoute isAuthorized={currentUser.authorized} />}>
|
||||
<Route path="*" element={<DocumentEditorLocalContainer />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</SoundWrapper>
|
||||
</NotificationProvider>
|
||||
|
||||
@@ -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 (
|
||||
<div>
|
||||
{!loading && !uploaded && loadedImageUrl && (
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={loadedImageUrl}
|
||||
alt="sample"
|
||||
onLoad={() => setImageLoaded(true)}
|
||||
onError={(error) => {
|
||||
console.error("Failed to load image", error);
|
||||
}}
|
||||
style={{ maxWidth: "90vw", maxHeight: "90vh" }}
|
||||
/>
|
||||
)}
|
||||
{(loading || imageLoading || !imageLoaded) && !uploaded && (
|
||||
<LoadingSpinner message={t("documents.labels.uploading")} />
|
||||
)}
|
||||
{uploaded && <Result status="success" title={t("documents.successes.edituploaded")} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(mapStateToProps, mapDispatchToProps)(DocumentEditorLocalComponent);
|
||||
@@ -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 (
|
||||
<div>
|
||||
<DocumentEditorLocalComponent imageUrl={imageUrl} filename={filename} jobid={jobid} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div>
|
||||
{!loading && !uploaded && (
|
||||
{!loading && !uploaded && imageUrl && (
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={GenerateSrcUrl(document)}
|
||||
src={imageUrl}
|
||||
alt="sample"
|
||||
crossOrigin="anonymous"
|
||||
onLoad={() => setImageLoaded(true)}
|
||||
onError={(error) => {
|
||||
console.error("Failed to load original image", error);
|
||||
}}
|
||||
style={{ maxWidth: "90vw", maxHeight: "90vh" }}
|
||||
/>
|
||||
)}
|
||||
{loading && <LoadingSpinner message={t("documents.labels.uploading")} />}
|
||||
{(loading || imageLoading || !imageLoaded) && !uploaded && (
|
||||
<LoadingSpinner message={t("documents.labels.uploading")} />
|
||||
)}
|
||||
{uploaded && <Result status="success" title={t("documents.successes.edituploaded")} />}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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({
|
||||
</Col>
|
||||
{modalState.open && (
|
||||
<Lightbox
|
||||
toolbarButtons={[
|
||||
<EditFilled
|
||||
key="edit"
|
||||
onClick={() => {
|
||||
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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user