diff --git a/frontend/components/Item/CreateModal.vue b/frontend/components/Item/CreateModal.vue index c470956f0..f0e9f506b 100644 --- a/frontend/components/Item/CreateModal.vue +++ b/frontend/components/Item/CreateModal.vue @@ -235,16 +235,7 @@ - @@ -566,6 +557,21 @@ } } + async function rotatePhoto(index: number) { + const photo = form.photos[index]; + if (!photo) { + return; + } + + try { + photo.fileBase64 = await rotateImageDataUrl90Deg(photo.fileBase64); + photo.file = dataUrlToFile(photo.fileBase64, photo.photoName); + } catch (error) { + toast.error(t("components.item.create_modal.toast.rotate_process_failed")); + console.error(error); + } + } + onMounted(() => { const cleanup = registerOpenDialogCallback(DialogID.CreateItem, async params => { // needed since URL will be cleared in the next step => ParentId Selection should stay though @@ -617,7 +623,7 @@ photoName: "product_view.jpg", fileBase64: params.product.imageBase64, primary: form.photos.length === 0, - file: dataURLtoFile(params.product.imageBase64, "product_view.jpg"), + file: dataUrlToFile(params.product.imageBase64, "product_view.jpg"), }); } } @@ -730,81 +736,6 @@ } } - function dataURLtoFile(dataURL: string, fileName: string) { - try { - const arr = dataURL.split(","); - const mimeMatch = arr[0]!.match(/:(.*?);/); - if (!mimeMatch || !mimeMatch[1]) { - throw new Error("Invalid data URL format"); - } - const mime = mimeMatch[1]; - - // Validate mime type is an image - if (!mime.startsWith("image/")) { - throw new Error("Invalid mime type, expected image"); - } - - const bstr = atob(arr[arr.length - 1]!); - let n = bstr.length; - const u8arr = new Uint8Array(n); - while (n--) { - u8arr[n] = bstr.charCodeAt(n); - } - return new File([u8arr], fileName, { type: mime }); - } catch (error) { - console.error("Error converting data URL to file:", error); - // Return a fallback or rethrow based on your error handling strategy - throw error; - } - } - - async function rotateBase64Image90Deg(base64Image: string, index: number) { - // Create an off-screen canvas - const offScreenCanvas = document.createElement("canvas"); - const offScreenCanvasCtx = offScreenCanvas.getContext("2d"); - - if (!offScreenCanvasCtx) { - toast.error(t("components.item.create_modal.toast.no_canvas_support")); - return; - } - - // Create an image - const img = new Image(); - - // Create a promise to handle the image loading - await new Promise((resolve, reject) => { - img.onload = () => resolve(); - img.onerror = () => reject(new Error("Failed to load image")); - img.src = base64Image; - }).catch(error => { - toast.error(t("components.item.create_modal.toast.rotate_failed", { error: error.message })); - }); - - // Set its dimensions to rotated size - offScreenCanvas.height = img.width; - offScreenCanvas.width = img.height; - - // Rotate and draw source image into the off-screen canvas - offScreenCanvasCtx.rotate((90 * Math.PI) / 180); - offScreenCanvasCtx.translate(0, -offScreenCanvas.width); - offScreenCanvasCtx.drawImage(img, 0, 0); - - const imageType = base64Image.match(/^data:(.+);base64/)?.[1] || "image/jpeg"; - - // Encode image to data-uri with base64 - try { - form.photos[index]!.fileBase64 = offScreenCanvas.toDataURL(imageType, 100); - form.photos[index]!.file = dataURLtoFile(form.photos[index]!.fileBase64, form.photos[index]!.photoName); - } catch (error) { - toast.error(t("components.item.create_modal.toast.rotate_process_failed")); - console.error(error); - } finally { - // Clean up resources - offScreenCanvas.width = 0; - offScreenCanvas.height = 0; - } - } - function openQrScannerPage() { openDialog(DialogID.Scanner); } diff --git a/frontend/components/Item/ImageDialog.vue b/frontend/components/Item/ImageDialog.vue index d0f291476..41b50a7b8 100644 --- a/frontend/components/Item/ImageDialog.vue +++ b/frontend/components/Item/ImageDialog.vue @@ -3,10 +3,14 @@ import { Dialog, DialogContent } from "@/components/ui/dialog"; import { Button, buttonVariants } from "@/components/ui/button"; import { useDialog } from "@/components/ui/dialog-provider"; + import type { ItemAttachment } from "~~/lib/api/types/data-contracts"; + import { AttachmentTypes } from "~~/lib/api/types/non-generated"; import { DialogID } from "~/components/ui/dialog-provider/utils"; + import { blobToDataUrl, dataUrlToFile, rotateImageDataUrl90Deg } from "~/composables/utils"; import { useConfirm } from "@/composables/use-confirm"; import { toast } from "@/components/ui/sonner"; import MdiClose from "~icons/mdi/close"; + import MdiRotateClockwise from "~icons/mdi/rotate-clockwise"; import MdiDownload from "~icons/mdi/download"; import MdiDelete from "~icons/mdi/delete"; @@ -19,25 +23,112 @@ const image = reactive<{ attachmentId: string; + busy: boolean; + dirty: boolean; itemId: string; originalSrc: string; originalType?: string; thumbnailSrc?: string; + workingDataUrl: string; + workingSrc: string; }>({ attachmentId: "", + busy: false, + dirty: false, itemId: "", originalSrc: "", + originalType: undefined, + thumbnailSrc: undefined, + workingDataUrl: "", + workingSrc: "", }); + const displaySrc = computed(() => image.workingSrc || image.thumbnailSrc || image.originalSrc || ""); + const displayType = computed(() => image.workingDataUrl.match(/^data:(.+);base64/)?.[1] || image.originalType); + + function resetImageState() { + image.attachmentId = ""; + image.busy = false; + image.dirty = false; + image.itemId = ""; + image.originalSrc = ""; + image.originalType = undefined; + image.thumbnailSrc = undefined; + image.workingDataUrl = ""; + image.workingSrc = ""; + } + + function findNewAttachment(attachments: ItemAttachment[], existingAttachmentIds: string[]) { + const knownIds = new Set(existingAttachmentIds); + const candidates = attachments.filter(attachment => { + return ( + attachment.id !== image.attachmentId && + attachment.type === AttachmentTypes.Photo && + !knownIds.has(attachment.id) + ); + }); + + const attachmentsToSearch = + candidates.length > 0 + ? candidates + : attachments.filter( + attachment => attachment.id !== image.attachmentId && attachment.type === AttachmentTypes.Photo + ); + + return attachmentsToSearch.sort((left, right) => { + return new Date(right.createdAt).getTime() - new Date(left.createdAt).getTime(); + })[0]; + } + + async function getCurrentAttachmentState() { + const { data, error } = await api.items.get(image.itemId); + if (error || !data) { + throw new Error("Failed to load attachment state"); + } + + const currentAttachment = data.attachments.find(attachment => attachment.id === image.attachmentId); + if (!currentAttachment) { + throw new Error("Attachment not found"); + } + + return { + attachment: currentAttachment, + existingAttachmentIds: data.attachments.map(attachment => attachment.id), + }; + } + + async function ensureWorkingImageLoaded() { + if (image.workingDataUrl) { + return; + } + + if (!image.originalSrc) { + throw new Error("Missing attachment source"); + } + + const response = await fetch(image.originalSrc); + if (!response.ok) { + throw new Error(`Failed to fetch image: ${response.status}`); + } + + image.workingDataUrl = await blobToDataUrl(await response.blob()); + image.workingSrc = image.workingDataUrl; + } + onMounted(() => { const cleanup = registerOpenDialogCallback(DialogID.ItemImage, params => { image.attachmentId = params.attachmentId; + image.busy = false; + image.dirty = false; image.itemId = params.itemId; + image.workingDataUrl = ""; + image.workingSrc = ""; + if (params.type === "preloaded") { image.originalSrc = params.originalSrc; image.originalType = params.originalType; image.thumbnailSrc = params.thumbnailSrc; - } else if (params.type === "attachment") { + } else { image.originalSrc = api.authURL(`/entities/${params.itemId}/attachments/${params.attachmentId}`); image.originalType = params.mimeType; image.thumbnailSrc = params.thumbnailId @@ -49,7 +140,110 @@ onUnmounted(cleanup); }); + async function rotateAttachment() { + if (image.busy) { + return; + } + + image.busy = true; + + try { + await ensureWorkingImageLoaded(); + image.workingDataUrl = await rotateImageDataUrl90Deg(image.workingDataUrl); + image.workingSrc = image.workingDataUrl; + image.dirty = true; + } catch (error) { + toast.error(t("items.toast.failed_update_attachment")); + console.error(error); + } finally { + image.busy = false; + } + } + + async function persistRotatedAttachmentAndClose() { + if (!image.workingDataUrl) { + resetImageState(); + closeDialog(DialogID.ItemImage); + return; + } + + const { attachment: currentAttachment, existingAttachmentIds } = await getCurrentAttachmentState(); + const fileName = currentAttachment.title || `attachment-${image.attachmentId}.jpg`; + const file = dataUrlToFile(image.workingDataUrl, fileName); + const { data, error } = await api.items.attachments.add( + image.itemId, + file, + file.name, + AttachmentTypes.Photo, + currentAttachment.primary + ); + + if (error || !data) { + toast.error(t("items.toast.failed_update_attachment")); + return; + } + + const createdAttachment = findNewAttachment(data.attachments, existingAttachmentIds); + if (!createdAttachment) { + toast.error(t("items.toast.failed_update_attachment")); + return; + } + + const { error: deleteError } = await api.items.attachments.delete(image.itemId, image.attachmentId); + if (deleteError) { + const { error: rollbackError } = await api.items.attachments.delete(image.itemId, createdAttachment.id); + if (rollbackError) { + console.error("Failed to rollback rotated attachment", rollbackError); + } + toast.error(t("items.toast.failed_update_attachment")); + return; + } + + const { data: refreshedData, error: refreshedError } = await api.items.get(image.itemId); + const refreshedAttachment = refreshedData?.attachments.find(attachment => attachment.id === createdAttachment.id); + if (refreshedError || !refreshedAttachment) { + toast.error(t("items.toast.failed_update_attachment")); + return; + } + + const oldId = image.attachmentId; + resetImageState(); + closeDialog(DialogID.ItemImage, { + action: "replace", + oldId, + attachment: refreshedAttachment, + }); + toast.success(t("items.toast.attachment_updated")); + } + + async function closeImageDialog() { + if (image.busy) { + return; + } + + if (!image.dirty) { + resetImageState(); + closeDialog(DialogID.ItemImage); + return; + } + + image.busy = true; + + try { + await persistRotatedAttachmentAndClose(); + } catch (error) { + toast.error(t("items.toast.failed_update_attachment")); + console.error(error); + } finally { + image.busy = false; + } + } + async function deleteAttachment() { + if (image.busy) { + return; + } + const confirmed = await confirm.open(t("items.delete_attachment_confirm")); if (confirmed.isCanceled) { @@ -63,12 +257,19 @@ return; } + const deletedId = image.attachmentId; + resetImageState(); closeDialog(DialogID.ItemImage, { action: "delete", - id: image.attachmentId, + id: deletedId, }); toast.success(t("items.toast.attachment_deleted")); } + + function handlePointerDownOutside(event: CustomEvent<{ originalEvent: PointerEvent }>) { + event.preventDefault(); + void closeImageDialog(); + } diff --git a/frontend/components/ui/dialog-provider/utils.ts b/frontend/components/ui/dialog-provider/utils.ts index fed5e5b98..1874da5c9 100644 --- a/frontend/components/ui/dialog-provider/utils.ts +++ b/frontend/components/ui/dialog-provider/utils.ts @@ -5,6 +5,7 @@ import type { BarcodeProduct, GroupInvitation, EntitySummary, + ItemAttachment, MaintenanceEntry, MaintenanceEntryWithDetails, } from "~~/lib/api/types/data-contracts"; @@ -84,7 +85,9 @@ export type DialogParamsMap = { * Defines the payload type for a dialog's onClose callback. */ export type DialogResultMap = { - [DialogID.ItemImage]?: { action: "delete"; id: string }; + [DialogID.ItemImage]?: + | { action: "delete"; id: string } + | { action: "replace"; oldId: string; attachment: ItemAttachment }; [DialogID.EditMaintenance]?: boolean; [DialogID.ItemChangeDetails]?: boolean; [DialogID.WipeInventory]?: { wipeTags: boolean; wipeLocations: boolean; wipeMaintenance: boolean }; diff --git a/frontend/composables/utils.ts b/frontend/composables/utils.ts index 7d964155f..2e0582b3d 100644 --- a/frontend/composables/utils.ts +++ b/frontend/composables/utils.ts @@ -27,6 +27,77 @@ export function validDate(dt: Date | string | null | undefined): boolean { return true; } +export function blobToDataUrl(blob: Blob): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + + reader.onload = () => { + const result = reader.result; + if (typeof result !== "string") { + reject(new Error("Failed to read blob as data URL")); + return; + } + + resolve(result); + }; + + reader.onerror = () => reject(reader.error ?? new Error("Failed to read blob as data URL")); + reader.readAsDataURL(blob); + }); +} + +export function dataUrlToFile(dataUrl: string, fileName: string): File { + const parts = dataUrl.split(","); + const mimeMatch = parts[0]?.match(/:(.*?);/); + if (!mimeMatch?.[1]) { + throw new Error("Invalid data URL format"); + } + + const mimeType = mimeMatch[1]; + if (!mimeType.startsWith("image/")) { + throw new Error("Invalid mime type, expected image"); + } + + const bytes = atob(parts[parts.length - 1] ?? ""); + const buffer = new Uint8Array(bytes.length); + + for (let index = 0; index < bytes.length; index += 1) { + buffer[index] = bytes.charCodeAt(index); + } + + return new File([buffer], fileName, { type: mimeType }); +} + +export async function rotateImageDataUrl90Deg(dataUrl: string): Promise { + const canvas = document.createElement("canvas"); + const context = canvas.getContext("2d"); + if (!context) { + throw new Error("Canvas is not supported"); + } + + const image = new Image(); + await new Promise((resolve, reject) => { + image.onload = () => resolve(); + image.onerror = () => reject(new Error("Failed to load image")); + image.src = dataUrl; + }); + + canvas.height = image.width; + canvas.width = image.height; + + context.rotate((90 * Math.PI) / 180); + context.translate(0, -canvas.width); + context.drawImage(image, 0, 0); + + const imageType = dataUrl.match(/^data:(.+);base64/)?.[1] || "image/jpeg"; + const rotated = canvas.toDataURL(imageType, 1); + + canvas.width = 0; + canvas.height = 0; + + return rotated; +} + // Currency cache to store decimal places information export const currencyDecimalsCache: Record = {}; diff --git a/frontend/pages/item/[id]/index.vue b/frontend/pages/item/[id]/index.vue index 305039a70..d4639e9c8 100644 --- a/frontend/pages/item/[id]/index.vue +++ b/frontend/pages/item/[id]/index.vue @@ -444,8 +444,20 @@ itemId, }, onClose: result => { + if (!item.value) { + return; + } + if (result?.action === "delete") { - item.value!.attachments = item.value!.attachments.filter(a => a.id !== result.id); + item.value = { + ...item.value, + attachments: item.value.attachments.filter(a => a.id !== result.id), + }; + } else if (result?.action === "replace") { + item.value = { + ...item.value, + attachments: item.value.attachments.map(a => (a.id === result.oldId ? result.attachment : a)), + }; } }, }); @@ -806,7 +818,7 @@
-