The extra cropped-result panel duplicated what the crop pad already shows and the cleaned image supersedes it. Removes the panel, its state, the pointer- release refresh handlers and the app.js plumbing. The crop result is still uploaded (croppa stays the source when nothing is cleaned).
288 lines
10 KiB
JavaScript
288 lines
10 KiB
JavaScript
/* Image Cropper — review, crop, clean and save identity photos.
|
|
Identity comes from the YOUreka session; the only operator credential is
|
|
the save-time OTP (required by a2loft). Notifications are Carbon toasts. */
|
|
/* global Vue, axios, Croppa, customElements */
|
|
/* window.__SESSION__ is injected server-side by /cropper */
|
|
Vue.use(Croppa); // registers the <croppa> component — without this the crop UI never renders
|
|
Vue.config.ignoredElements = [/^cds-/];
|
|
|
|
(function () {
|
|
'use strict';
|
|
|
|
var toastSeq = 0;
|
|
|
|
var app = new Vue({
|
|
el: '#app',
|
|
data: {
|
|
loading: true,
|
|
session: null,
|
|
message: null, // set once a picture is loaded; null hides the review panel
|
|
agent: null,
|
|
process: null,
|
|
reviewed: false,
|
|
accepted: false,
|
|
cleanedImage: null,
|
|
requiresCleaning: '',
|
|
otp: '',
|
|
reviewOptions: [
|
|
'IMAGE: Not in the midline of the photo frame (aligned to the left or right)',
|
|
'IMAGE: Taken too close-up (Only head is visible, shoulders barely visible)',
|
|
'IMAGE: Blurry ',
|
|
'IMAGE: Improper lighting ',
|
|
'IMAGE: Not coloured.',
|
|
'IMAGE: Scanned photo',
|
|
'IMAGE: Has no adequate space to allow for proper cropping ',
|
|
'IMAGE: Not taken on a recommended background.',
|
|
'IMAGE: Photo is a selfie',
|
|
'DRESSING: Unacceptable dress. ',
|
|
'HEAD: Skewed (leaning away from the midline - vertically or horizontally)',
|
|
'EYES: Not looking into the camera (looking downward, upward, or sideward)',
|
|
'EYES: Not properly opened',
|
|
'EARS: Not visible ',
|
|
'EARS: Both ears are not equally visible (face not aligned central)',
|
|
'FACIAL EXPRESSION: NOT neutral (subject has a facial expression)',
|
|
'GLASSES: Subject is wearing glasses ',
|
|
'JUST WRING: Goes against almost all acceptable passport criteria.',
|
|
'Others not specified.',
|
|
].map(function (name, index) { return { index: index + 1, name: name }; }),
|
|
selectedReviewOptions: [],
|
|
otherReviewOption: '',
|
|
busy: { load: false, clean: false, review: false, save: false },
|
|
toasts: [],
|
|
myCroppa: {},
|
|
},
|
|
|
|
computed: {
|
|
canReview: function () {
|
|
if (this.busy.review) return false;
|
|
if (this.accepted) return true;
|
|
return this.selectedReviewOptions.length > 0 || this.otherReviewOption.length > 0;
|
|
},
|
|
canSave: function () {
|
|
return !this.busy.save && this.otp.length > 0 && this.requiresCleaning !== '';
|
|
},
|
|
otherOptionSelected: function () {
|
|
return this.selectedReviewOptions.indexOf('Others not specified.') >= 0;
|
|
},
|
|
croppaInstance: function () {
|
|
// the v-model object is not always the live instance; the ref is
|
|
return this.$refs.croppa || this.myCroppa;
|
|
},
|
|
},
|
|
|
|
watch: {
|
|
otherOptionSelected: function (selected) {
|
|
if (!selected) this.otherReviewOption = ''; // hidden field must not contribute
|
|
},
|
|
},
|
|
|
|
created: function () {
|
|
var self = this;
|
|
// injected by the server when the page was rendered (no round-trip)
|
|
this.session = window.__SESSION__ || null;
|
|
if (!this.session) {
|
|
window.location.href = '/auth/login';
|
|
return;
|
|
}
|
|
this.loading = false;
|
|
Promise.all(
|
|
['cds-button', 'cds-toast', 'cds-toast-item', 'cds-tag'].map(function (n) {
|
|
return customElements.whenDefined(n).catch(function () {});
|
|
})
|
|
).then(function () {
|
|
self.$forceUpdate();
|
|
});
|
|
},
|
|
|
|
methods: {
|
|
toast: function (kind, title, caption) {
|
|
var self = this;
|
|
var id = ++toastSeq;
|
|
this.toasts.push({ id: id, kind: kind, title: title, caption: caption || '' });
|
|
setTimeout(function () {
|
|
self.removeToast(id);
|
|
}, 6000);
|
|
},
|
|
|
|
removeToast: function (id) {
|
|
this.toasts = this.toasts.filter(function (t) { return t.id !== id; });
|
|
},
|
|
|
|
reset: function () {
|
|
this.message = null;
|
|
this.agent = null;
|
|
this.process = null;
|
|
this.reviewed = false;
|
|
this.accepted = false;
|
|
this.cleanedImage = null;
|
|
this.selectedReviewOptions = [];
|
|
this.otherReviewOption = '';
|
|
this.requiresCleaning = '';
|
|
this.otp = '';
|
|
},
|
|
|
|
loadPicture: async function (mode) {
|
|
var self = this;
|
|
this.busy.load = true;
|
|
try {
|
|
var uploadId = null;
|
|
if (mode === 'by-id') {
|
|
uploadId = prompt('Enter picture upload ID');
|
|
if (!uploadId) return;
|
|
}
|
|
var { data } = await axios.get(
|
|
uploadId ? '/cropper/picture/load/' + encodeURIComponent(uploadId) : '/cropper/picture/next'
|
|
);
|
|
if (!data.ok) {
|
|
this.reset(); // nothing (more) to review — back to the load state
|
|
this.toast('info', 'No picture', data.message || 'Nothing to load.');
|
|
return;
|
|
}
|
|
this.message = data.data;
|
|
var image = await axios.get(
|
|
'/cropper/picture/' + encodeURIComponent(this.message.message.file_name.replace(/\//gi, '_'))
|
|
);
|
|
if (!image.data.ok) {
|
|
this.reset();
|
|
this.toast('error', 'Load failed', "Couldn't load the attached image. Please try again.");
|
|
return;
|
|
}
|
|
this.message.message.file_url = image.data.data;
|
|
this.cleanedImage = null;
|
|
this.agent = null;
|
|
this.process = this.message.message.process || null;
|
|
if (this.message.message.upload_id) {
|
|
var review = await axios.get(
|
|
'/cropper/picture/review/' + encodeURIComponent(this.message.message.upload_id)
|
|
);
|
|
this.agent = review.data.ok ? review.data.data : null;
|
|
}
|
|
} catch ({ message }) {
|
|
this.toast('error', 'Operation Failed', message);
|
|
} finally {
|
|
this.busy.load = false;
|
|
}
|
|
},
|
|
|
|
cleanImage: async function () {
|
|
var self = this;
|
|
this.busy.clean = true;
|
|
try {
|
|
var dataUrl = this.croppaInstance.generateDataUrl('image/jpeg');
|
|
var response = await fetch('/cropper/api/removebg', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ image_file_b64: dataUrl.split(',')[1] }),
|
|
});
|
|
if (!response.ok) {
|
|
var err = await response.json().catch(function () { return {}; });
|
|
throw new Error(err.message || 'Failed to clean image.');
|
|
}
|
|
var contentType = response.headers.get('content-type') || '';
|
|
if (contentType.indexOf('image/') !== 0) {
|
|
throw new Error('Image cleanup service returned an invalid response.');
|
|
}
|
|
var blob = await response.blob();
|
|
var fr = new FileReader();
|
|
fr.onload = function () { self.cleanedImage = fr.result; };
|
|
fr.readAsDataURL(blob);
|
|
} catch ({ message }) {
|
|
this.toast('error', 'Image Cleanup Failed', message);
|
|
} finally {
|
|
this.busy.clean = false;
|
|
}
|
|
},
|
|
|
|
reviewPicture: async function () {
|
|
var self = this;
|
|
if (!this.canReview) return;
|
|
if (!this.accepted && this.selectedReviewOptions.length === 0 && !this.otherReviewOption) {
|
|
this.toast('error', 'Operation Failed', 'Please indicate why this image is not good enough.');
|
|
return;
|
|
}
|
|
this.busy.review = true;
|
|
var options = [...this.selectedReviewOptions, this.otherReviewOption].filter(function (o) {
|
|
return o && o.length > 0;
|
|
});
|
|
var reviewMessage = this.accepted ? null : options.join('\n\n');
|
|
try {
|
|
var hasProcess = !!this.message.message.process;
|
|
var data = {};
|
|
if (hasProcess) {
|
|
var { domain, reference } = this.message.message.process.variables;
|
|
data.process = {
|
|
correlationKey: domain + '-' + reference,
|
|
name: 'PictureReviewed',
|
|
variables: { ok: this.accepted, reviewRejectMessage: reviewMessage },
|
|
};
|
|
} else {
|
|
data.raw = { phone: this.message.message.caption, reviewRejectMessage: reviewMessage };
|
|
}
|
|
var { data: result } = await axios.post('/cropper/picture/review', {
|
|
deleteFromQueue: true,
|
|
message: this.message,
|
|
mode: hasProcess ? 'process' : 'raw',
|
|
...data,
|
|
});
|
|
if (!result.ok) {
|
|
this.toast('error', 'Operation Failed', result.message || 'Review failed.');
|
|
return;
|
|
}
|
|
if (reviewMessage) {
|
|
this.toast('success', 'Review Sent', 'Profile owner has been notified of the review.');
|
|
}
|
|
this.reset();
|
|
} catch ({ message }) {
|
|
this.toast('error', 'Operation Failed', message);
|
|
} finally {
|
|
this.busy.review = false;
|
|
}
|
|
},
|
|
|
|
removeFromQueue: async function () {
|
|
var self = this;
|
|
if (!confirm('Are you sure you want to dequeue?')) return;
|
|
this.busy.review = true;
|
|
try {
|
|
var { data } = await axios.post('/cropper/dequeue', { message: this.message });
|
|
if (!data.ok) {
|
|
this.toast('error', 'Not Removed', data.message || 'Removal failed.');
|
|
return;
|
|
}
|
|
this.toast('success', 'Removed!', data.message || '');
|
|
this.reset();
|
|
} catch ({ message }) {
|
|
this.toast('error', 'Not Removed', message);
|
|
} finally {
|
|
this.busy.review = false;
|
|
}
|
|
},
|
|
|
|
saveChanges: async function () {
|
|
this.busy.save = true;
|
|
try {
|
|
var { data } = await axios.post(
|
|
'/cropper/profile/picture?otp=' + encodeURIComponent(this.otp),
|
|
{
|
|
pictureDataUrl: this.cleanedImage || this.croppaInstance.generateDataUrl('image/jpeg'),
|
|
phone: this.message.message.caption,
|
|
message: this.message,
|
|
requiresCleaning: this.requiresCleaning !== 'No',
|
|
}
|
|
);
|
|
if (!data.ok) {
|
|
this.toast('error', 'Not Saved', data.message || 'Save failed.');
|
|
return;
|
|
}
|
|
this.toast('success', 'Saved', 'Picture saved for ' + this.message.message.caption);
|
|
this.reset();
|
|
} catch ({ message }) {
|
|
this.toast('error', 'Not Saved', message);
|
|
} finally {
|
|
this.busy.save = false;
|
|
}
|
|
},
|
|
},
|
|
});
|
|
})();
|