ui_locations_entries_cloudinary.mjs
/**
## /ui/locations/entries/cloudinary
The cloudinary module exports a method to upload or destroy resources with signed requests on cloudinary.
The exported method creates an interface in the location view with input for images and documents.
@requires /utils/xhr
@requires /utils/imagePreview
@module /ui/locations/entries/cloudinary
*/
const types = {
image,
images,
documents,
};
export default (entry) => types[entry.type](entry);
/**
@function image
@description
The image entry.type function returns an image with a button to detroy the image the entry has a value and is editable.
@param {Object} entry
@property {string} entry.value An existing cloudinary resource URL.
@property {Object} entry.edit The entry is editable.
@returns {HTMLElement} Returns either an image element or an input element to upload an image.
*/
function image(entry) {
if (entry.value) {
// The trash button will only be created when entry is editable.
const trashBtn =
entry.edit &&
mapp.utils.html`<button
title="${mapp.dictionary.delete}"
class="material-symbols-outlined color-danger delete"
data-name=${entry.value.replace(/^.*\//, '').replace(/\.([\w-]{3})/, '')}
data-src=${entry.value}
onclick=${(e) => trash(e, entry, mapp.dictionary.remove_image_confirm)}>delete`;
return mapp.utils.html.node`<div class="img-item"><img
style="width: 100%"
src=${entry.value}
onclick=${mapp.ui.utils.imagePreview}>
${trashBtn}`;
} else if (entry.edit) {
// Return image upload input for editable entry without a value.
return mapp.utils.html.node`<div
class="drag_and_drop_zone"
ondrop=${(e) => {
// the input element onchange trigger must be prevented.
e.preventDefault();
upload(e, entry);
}}>
<p><span class="material-symbols-outlined add">add_a_photo</span>${mapp.dictionary.drag_and_drop_image}</p>
<input
type="file"
accept="image/*;capture=camera" onchange=${(e) => {
upload(e, entry);
}}>`;
}
}
/**
@function images
@description
The images entry.type function returns an image-grid element of images with the ability to destroy resources if the entry is editable. An input is show to upload additional images if the entry is editable.
@param {Object} entry
@property {Array} entry.value An array of existing cloudinary resource URLs.
@property {Object} entry.edit The entry is editable.
@returns {HTMLElement} A grid element with existing images and an input to upload additional images on editable entry.
*/
function images(entry) {
const images = [];
entry.value?.map((image) => {
// The trash button will only be created when entry is editable.
const trashBtn =
entry.edit &&
mapp.utils.html`<button title="${mapp.dictionary.delete}"
class="material-symbols-outlined color-danger delete"
data-name=${image.replace(/^.*\//, '').replace(/\.([\w-]{3})/, '')}
data-src=${image}
onclick=${(e) => trash(e, entry, mapp.dictionary.remove_image_confirm)}>delete`;
images.push(mapp.utils.html`<div class="img-item"><img
src=${image}
onclick=${mapp.ui.utils.imagePreview}>
${trashBtn}`);
});
// Push upload input into images array.
if (entry.edit) {
const drap_and_drop_zone = mapp.utils.html.node`<div
class="drag_and_drop_zone mobile-display-none"
ondrop=${(e) => {
// the input element onchange trigger must be prevented.
e.preventDefault();
upload(e, entry);
}}>
<p><span class="material-symbols-outlined add">add_a_photo</span>${mapp.dictionary.drag_and_drop_image}</p>
<input
type="file"
accept="image/*;capture=camera" onchange=${(e) => {
upload(e, entry);
}}>`;
images.push(drap_and_drop_zone);
}
if (!images.length) return;
return mapp.utils.html.node`<div class="images-grid">${images}`;
}
/**
@function documents
@description
The documents entry.type function returns an document-list element of documents with the ability to destroy resources if the entry is editable. An input is show to upload additional documents if the entry is editable.
@param {Object} entry
@returns {HTMLElement} A list element with existing documents and an input to upload additional documents on editable entry.
*/
function documents(entry) {
const docs = [];
entry.value?.map((doc) => {
const trashBtn =
entry.edit &&
mapp.utils.html`<button
title="${mapp.dictionary.delete}"
class="material-symbols-outlined color-danger delete"
data-name=${doc.replace(/^.*\//, '').replace(/\.([\w-]{3})/, '')}
data-href=${doc}
onclick=${(e) => trash(e, entry, mapp.dictionary.remove_document_confirm)}>delete`;
const title = doc.replace(/^.*\//, '').replace(/\.([\w-]{3})/, '');
docs.push(mapp.utils.html`<div class="link-with-img">
<a target="_blank" href=${doc}>${title}</a>${trashBtn}`);
});
if (entry.edit) {
const drag_and_drop_zone = mapp.utils.html.node`<div
class="drag_and_drop_zone mobile-display-none"
ondrop=${(e) => {
// the input element onchange trigger must be prevented.
e.preventDefault();
upload(e, entry);
}}>
<p><span class="material-symbols-outlined add-doc">add_notes</span>${mapp.dictionary.drag_and_drop_doc}</p>
<input type="file"
accept=".txt,.pdf,.doc,.docx,.xls,.xlsx,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document;"
onchange=${(e) => upload(e, entry)}>`;
docs.unshift(drag_and_drop_zone);
}
if (!docs.length) return;
return mapp.utils.html.node`<div>${docs}`;
}
/**
@function upload
@description
The upload event can be assigned to the onchange event of an input to upload the resource selected in the input element.
@param {event} e The onchange event from the input element to select a resource.
@param {Object} entry
*/
async function upload(e, entry) {
const reader = new FileReader();
const file = e.type === 'drop' ? e.dataTransfer?.files[0] : e.target.files[0];
if (!file) return;
// Location view must disabled while uploading resource.
entry.location.view?.classList.add('disabled');
const onload = {
image: newImage,
images: newImage,
documents: docLoad,
};
reader.onload = (e) => onload[entry.type](e, entry, file);
reader.readAsDataURL(file);
}
/**
@function newImage
@description
The method creates a new Image object with the event target result as source. The imgOnload method is assigned as image onload.
@param {event} e
@param {Object} entry
*/
function newImage(e, entry, file) {
const img = new Image();
img.onload = async () => imgOnload(entry, img, file);
img.src = e.target.result;
}
/**
@function imgOnload
@description
The onload method of the image element will create a canvas element and apply a size transformation in regards to the max_size entry property.
A signeUrl will be requested to upload a dataURL for the canvas to the cloudinary service.
A successful cloudinary upload will respond with an item reference for the cloudinary resource.
The updateLocation method is called to store the item reference to the location [entry] field.
@param {image} img The image element to upload.
@param {object} entry
@property {integer} [entry.max_size=1024] The default max_size applied to image size transformation.
*/
async function imgOnload(entry, img, file) {
const canvas = document.createElement('canvas');
//Assign default max_size
entry.max_size ??= 1024;
// resize
if (img.width > img.height && img.width > entry.max_size) {
img.height *= entry.max_size / img.width;
img.width = entry.max_size;
} else if (img.height > entry.max_size) {
img.width *= entry.max_size / img.height;
img.height = entry.max_size;
}
canvas.width = img.width;
canvas.height = img.height;
canvas.getContext('2d').drawImage(img, 0, 0, img.width, img.height);
const public_id =
file.name.replace(/^.*\//, '').replace(/\.([\w-]{3})/, '') +
entry.suffix_date
? `@${Date.now()}`
: '';
const signedUrl = await getSignedUrl(entry, { public_id });
// Failed to generate a signedUrl for the request.
if (!signedUrl) return;
const data = new FormData();
data.append('file', canvas.toDataURL('image/jpeg', 0.5));
const response = await fetch(signedUrl, {
method: 'post',
body: data,
});
if (!response || response.error) {
const errorDetail = response?.error?.message
? `Error: ${response.error.message}`
: '';
const errorMessage = `Cloudinary Image upload failed! ${errorDetail}`;
mapp.ui.elements.alert({ text: errorMessage });
return;
}
const responseJson = await response.json();
if (entry.type === 'image') {
// Only a single image is supported by the entry.type.
entry.value = responseJson.secure_url;
} else {
// Add the secure_url to the entry values array and update the location.
entry.value = Array.isArray(entry.value)
? entry.value.concat([responseJson.secure_url])
: [responseJson.secure_url];
}
updateLocation(entry);
}
/**
@function docLoad
@description
The docLoad method handles the upload of document files to via signed URL to cloudinary.
@param {event} e
@param {Object} entry
*/
async function docLoad(e, entry, file) {
const date = new Date();
const stamp = `${date.getMonth() + 1}-${date.getDate()}T${date.getHours()}:${date.getMinutes()}`;
const file_type = file.name.substring(file.name.lastIndexOf('.'));
const public_id = `${file.name.replace(file_type, '')}-${stamp}${file_type}`;
const signedUrl = await getSignedUrl(entry, { public_id });
// Failed to generate a signedUrl for the request.
if (!signedUrl) return;
const data = new FormData();
data.append('file', e.target.result.toString());
const response = await fetch(signedUrl, {
method: 'post',
body: data,
});
if (!response || response.error) {
const errorDetail = response?.error?.message
? `Error: ${response.error.message}`
: '';
mapp.ui.elements.alert(`Cloudinary document upload failed! ${errorDetail}`);
return;
}
// Add the secure_url to the entry values array and update the location.
const responseJson = await response.json();
entry.value = Array.isArray(entry.value)
? entry.value.concat([responseJson.secure_url])
: [responseJson.secure_url];
updateLocation(entry);
}
/**
@function trash
@description
The trash function handles the deletion an image file.
@param {event} e
@param {Object} entry
*/
async function trash(e, entry, confirm_message) {
const confirm_remove = await mapp.ui.elements.confirm({
text: confirm_message,
});
if (!confirm_remove) return;
const public_id = decodeURIComponent(e.target.dataset.name);
const signedUrl = await getSignedUrl(entry, { public_id, destroy: true });
// Failed to generate a signedUrl for the request.
if (!signedUrl) return;
// Send request to cloudinary to destroy resource.
await fetch(signedUrl, { method: 'post' });
// Remove the resource link from the entry values array and update the location.
const valueSet = new Set(entry.value);
valueSet.delete(e.target.dataset.src || e.target.dataset.href);
if (entry.type === 'image') {
entry.value = null;
} else {
entry.value = valueSet.size ? Array.from(valueSet) : null;
}
updateLocation(entry);
}
/**
@function updateLocation
@description
The method disables the location view before updating the location data and re-creating the cloudinary entry.type interface.
@param {Object} entry
@property {HTMLElement} entry.node The location view element which holds the cloudinary interface elements.
*/
async function updateLocation(entry) {
entry.location.view?.classList.add('disabled');
await mapp.utils.xhr({
method: 'POST',
url:
`${entry.location.layer.mapview.host}/api/query?` +
mapp.utils.paramString({
template: 'location_update',
locale: entry.location.layer.mapview.locale.key,
layer: entry.location.layer.key,
table: entry.location.table,
id: entry.location.id,
}),
body: JSON.stringify({ [entry.field]: entry.value }),
});
// Render the type interface into the location view entry.node
entry.node.replaceChildren(
mapp.utils.html.node`<div class="label">${entry.title}`,
types[entry.type](entry),
);
entry.location.view?.classList.remove('disabled');
}
/**
@function getSignedUrl
@async
@description
Signs a parameterised request to upload or destroy a resource on cloudinary.
@param {Object} entry
@param {Object} params
@property {string} entry.cloudinary_folder
@property {string} params.public_id
@property {boolean} [params.destroy] The request is to detroy a stored resource.
@returns {Promise<String>} signedURL
*/
async function getSignedUrl(entry, params) {
const paramString = mapp.utils.paramString({
...params,
folder: entry.cloudinary_folder,
});
const signedUrl = await mapp.utils.xhr({
url: `${entry.location.layer.mapview.host}/api/sign/cloudinary?${paramString}`,
responseType: 'text',
});
if (signedUrl instanceof Error) {
console.error(signedUrl);
}
return signedUrl;
}