diff --git a/app/controllers/user.js b/app/controllers/user.js index 3f1a9ba..27bea30 100644 --- a/app/controllers/user.js +++ b/app/controllers/user.js @@ -64,6 +64,12 @@ class UserController extends SiteController { this.postProfilePhoto.bind(this), ); + router.post('/:userId/header-image', + limiterService.create(limiterService.config.user.postHeaderImage), + upload.single('imageFile'), + this.postHeaderImage.bind(this), + ); + router.post('/:userId/settings', limiterService.create(limiterService.config.user.postUpdateSettings), upload.none(), @@ -96,6 +102,13 @@ class UserController extends SiteController { checkProfileOwner, this.deleteProfilePhoto.bind(this), ); + + router.delete('/:userId/header-image', + limiterService.create(limiterService.config.user.deleteHeaderImage), + authRequired, + checkProfileOwner, + this.deleteHeaderImage.bind(this), + ); } async populateUser (req, res, next, userId) { @@ -153,10 +166,7 @@ class UserController extends SiteController { const { user: userService } = this.dtp.services; try { const displayList = this.createDisplayList('profile-photo'); - - this.log.debug('received profile photo', { file: req.file }); await userService.updatePhoto(req.user, req.file); - displayList.showNotification( 'Profile photo updated successfully.', 'success', @@ -173,6 +183,27 @@ class UserController extends SiteController { } } + async postHeaderImage (req, res) { + const { user: userService } = this.dtp.services; + try { + const displayList = this.createDisplayList('header-image'); + await userService.updateHeaderImage(req.user, req.file); + displayList.showNotification( + 'Header image updated successfully.', + 'success', + 'bottom-center', + 2000, + ); + res.status(200).json({ success: true, displayList }); + } catch (error) { + this.log.error('failed to update header image', { error }); + return res.status(error.statusCode || 500).json({ + success: false, + message: error.message, + }); + } + } + async postUpdateSettings (req, res) { const { user: userService } = this.dtp.services; try { @@ -219,9 +250,7 @@ class UserController extends SiteController { const { user: userService } = this.dtp.services; try { const displayList = this.createDisplayList('app-settings'); - await userService.removePhoto(req.user); - displayList.showNotification( 'Profile photo removed successfully.', 'success', @@ -237,6 +266,27 @@ class UserController extends SiteController { }); } } + + async deleteHeaderImage (req, res) { + const { user: userService } = this.dtp.services; + try { + const displayList = this.createDisplayList('remove-header-image'); + await userService.removeHeaderImage(req.user); + displayList.showNotification( + 'Header image removed successfully.', + 'success', + 'bottom-center', + 2000, + ); + res.status(200).json({ success: true, displayList }); + } catch (error) { + this.log.error('failed to remove header image', { error }); + return res.status(error.statusCode || 500).json({ + success: false, + message: error.message, + }); + } + } } module.exports = { diff --git a/app/models/user.js b/app/models/user.js index 84aaf40..9a764d1 100644 --- a/app/models/user.js +++ b/app/models/user.js @@ -34,6 +34,7 @@ const UserSchema = new Schema({ large: { type: Schema.ObjectId, ref: 'Image' }, small: { type: Schema.ObjectId, ref: 'Image' }, }, + header: { type: Schema.ObjectId, ref: 'Image' }, flags: { type: UserFlagsSchema, select: false }, permissions: { type: UserPermissionsSchema, select: false }, stats: { type: ResourceStats, default: ResourceStatsDefaults, required: true }, diff --git a/app/services/image.js b/app/services/image.js index dbb0f29..2a13a65 100644 --- a/app/services/image.js +++ b/app/services/image.js @@ -35,8 +35,6 @@ class ImageService extends SiteService { const NOW = new Date(); const { minio: minioService } = this.dtp.services; try { - this.log.debug('processing uploaded image', { imageDefinition, file }); - const sharpImage = await sharp(file.path); const metadata = await sharpImage.metadata(); @@ -72,7 +70,6 @@ class ImageService extends SiteService { // save the Image model to the db await image.save(); - this.log.info('processed uploaded image', { ownerId, imageId, fileKey }); return image.toObject(); } catch (error) { this.log.error('failed to process image', { error }); @@ -111,7 +108,6 @@ class ImageService extends SiteService { } async processImageFile (owner, file, outputs, options) { - this.log.debug('processing image file', { owner, file, outputs }); const sharpImage = sharp(file.path); return this.processImage(owner, sharpImage, outputs, options); } @@ -133,8 +129,6 @@ class ImageService extends SiteService { outputMetadata.width = output.width; outputMetadata.height = output.height; - service.log.debug('processing image', { output, outputMetadata }); - const image = new SiteImage(); image.created = NOW; image.owner = owner._id; @@ -180,8 +174,6 @@ class ImageService extends SiteService { // save the Image model to the db await image.save(); - service.log.info('processed uploaded image', { ownerId, imageId, fileKey }); - if (options.removeWorkFiles) { service.log.debug('removing work file', { path: output.filePath }); await fs.promises.unlink(output.filePath); diff --git a/app/services/user.js b/app/services/user.js index 401965d..d437a71 100644 --- a/app/services/user.js +++ b/app/services/user.js @@ -270,6 +270,9 @@ class UserService { { path: 'picture.small', }, + { + path: 'header', + }, ]) .lean(); if (!user) { @@ -313,7 +316,7 @@ class UserService { username = username.trim().toLowerCase(); const user = await User .findOne({ username_lc: username }) - .select('_id created username username_lc displayName bio picture'); + .select('_id created username username_lc displayName bio picture header'); return user; } @@ -450,7 +453,6 @@ class UserService { async updatePhoto (user, file) { const { image: imageService } = this.dtp.services; - const images = [ { width: 512, @@ -484,19 +486,48 @@ class UserService { async removePhoto (user) { const { image: imageService } = this.dtp.services; - this.log.info('remove profile photo', { user }); + this.log.info('remove profile photo', { user: user._id }); user = await this.getUserAccount(user._id); - await imageService.deleteImage(user.picture.large); - await imageService.deleteImage(user.picture.small); + if (user.picture.large) { + await imageService.deleteImage(user.picture.large); + } + if (user.picture.small) { + await imageService.deleteImage(user.picture.small); + } + await User.updateOne({ _id: user._id }, { $unset: { 'picture': '' } }); + } + + async updateHeaderImage (user, file) { + const { image: imageService } = this.dtp.services; + const images = [ + { + width: 1400, + height: 400, + format: 'jpeg', + formatParameters: { + quality: 80, + }, + }, + ]; + await imageService.processImageFile(user, file, images); await User.updateOne( { _id: user._id }, { - $unset: { - 'picture': '', + $set: { + 'header': images[0].image._id, }, }, ); } + + async removeHeaderImage (user) { + const { image: imageService } = this.dtp.services; + user = await this.getUserAccount(user._id); + if (user.header) { + await imageService.deleteImage(user.header); + } + await User.updateOne({ _id: user._id }, { $unset: { 'header': '' } }); + } } module.exports = { diff --git a/app/views/components/file-upload-image.pug b/app/views/components/file-upload-image.pug index 9e20473..52869d0 100644 --- a/app/views/components/file-upload-image.pug +++ b/app/views/components/file-upload-image.pug @@ -8,9 +8,9 @@ mixin renderFileUploadImage (actionUrl, containerId, imageId, imageClass, defaul div(class="uk-width-1-1 uk-width-auto@m") .upload-image-container.size-512 if !!currentImage - img(id= imageId, data-cropper-options= cropperOptions, src= `/image/${currentImage._id}`, class= imageClass).sb-large + img(id= imageId, src= `/image/${currentImage._id}`, class= imageClass).sb-large else - img(id= imageId, data-cropper-options= cropperOptions, src= defaultImage, class= imageClass).sb-large + img(id= imageId, src= defaultImage, class= imageClass).sb-large div(class="uk-width-1-1 uk-width-auto@m") .uk-text-small.uk-margin(hidden= !!currentImage) @@ -28,8 +28,7 @@ mixin renderFileUploadImage (actionUrl, containerId, imageId, imageClass, defaul data-file-size-element= "file-size", data-file-max-size= 15 * 1024000, data-image-id= imageId, - data-image-w= 512, - data-image-h= 512, + data-cropper-options= cropperOptions, onchange="return dtp.app.selectImageFile(event);", ) button(type="button", tabindex="-1").uk-button.dtp-button-default Select diff --git a/app/views/layouts/public-profile.pug b/app/views/layouts/public-profile.pug index c0910cb..748f378 100644 --- a/app/views/layouts/public-profile.pug +++ b/app/views/layouts/public-profile.pug @@ -1,30 +1,38 @@ extends main block content-container + section.uk-section.uk-section-default.public-profile + .uk-container + if userProfile.header + .uk-margin.profile-header-container.header-offset + img(src= `/image/${userProfile.header._id}`).profile-header + if userProfile.picture && userProfile.picture.large + img(src= `/image/${userProfile.picture.large._id}`).profile-picture + else + img(src= "/img/icon/icon-512x512.png").profile-picture + else + .uk-margin.profile-header-container + if userProfile.picture && userProfile.picture.large + img(src= `/image/${userProfile.picture.large._id}`).profile-picture + else + img(src= "/img/icon/icon-512x512.png").profile-picture + + .uk-width-xlarge.uk-margin-auto + //- if user && user.flags.isAdmin + //- .uk-margin.uk-text-center + //- a(href=`/admin/user/${userProfile._id}`).uk-button.dtp-button-danger User Admin - section.uk-section.uk-section-default - .uk-container.uk-width-xlarge - if user && user.flags.isAdmin .uk-margin.uk-text-center - a(href=`/admin/user/${userProfile._id}`).uk-button.dtp-button-danger User Admin + if userProfile.displayName + h1.uk-margin-remove= userProfile.displayName + .uk-text-muted @#{userProfile.username} + else + h1.uk-margin-remove= userProfile.username - .uk-margin.uk-margin-auto.uk-width-small - if userProfile.picture && userProfile.picture.large - img(src= `/image/${userProfile.picture.large._id}`).responsive - else - img(src= "/img/icon/icon-512x512.png").responsive - - .uk-margin.uk-text-center - if userProfile.displayName - h1.uk-margin-remove= userProfile.displayName - .uk-text-muted @#{userProfile.username} - else - h1.uk-margin-remove= userProfile.username - - .uk-margin.uk-text-center - div= userProfile.bio + .uk-margin.uk-text-center + div= userProfile.bio - block content + block content block page-footer section.uk-section.uk-section-default.uk-section-small.dtp-site-footer diff --git a/app/views/user/settings.pug b/app/views/user/settings.pug index cea99fd..3496e5d 100644 --- a/app/views/user/settings.pug +++ b/app/views/user/settings.pug @@ -14,11 +14,31 @@ block content div(uk-grid) div(class="uk-width-1-1 uk-width-1-3@m") - - var currentImage = null; + var currentProfile = null; if (user.picture && user.picture.large) { - currentImage = user.picture.large; + currentProfile = user.picture.large; } - +renderFileUploadImage(`/user/${user._id}/profile-photo`, 'test-image-upload', 'profile-picture-file', 'site-profile-picture', `/img/default-member.png`, currentImage) + .uk-margin + +renderFileUploadImage( + `/user/${user._id}/header-image`, + 'header-image-upload', + 'header-image-file', + 'header-image-picture', + `/img/default-header.png`, + user.header, + { aspectRatio: 1400 / 400 }, + ) + + .uk-margin + +renderFileUploadImage( + `/user/${user._id}/profile-photo`, + 'profile-picture-upload', + 'profile-picture-file', + 'site-profile-picture', + `/img/default-member.png`, + currentProfile, + { aspectRatio: 1 }, + ) div(class="uk-width-1-1 uk-width-expand@m") form(method="POST", action=`/user/${user._id}/settings`, onsubmit="return dtp.app.submitForm(event, 'user account update');").uk-form diff --git a/client/img/default-header.png b/client/img/default-header.png new file mode 100644 index 0000000..765f888 Binary files /dev/null and b/client/img/default-header.png differ diff --git a/client/js/site-app.js b/client/js/site-app.js index 41c7a6e..0cd378f 100644 --- a/client/js/site-app.js +++ b/client/js/site-app.js @@ -243,7 +243,10 @@ export default class DtpSiteApp extends DtpApp { const imageId = event.target.getAttribute('data-image-id'); //z read the cropper options from the element on the page - const cropperOptions = event.target.getAttribute('data-cropper-options'); + let cropperOptions = event.target.getAttribute('data-cropper-options'); + if (cropperOptions) { + cropperOptions = JSON.parse(cropperOptions); + } this.log.debug('selectImageFile', 'cropper options', { cropperOptions }); //z remove when done const fileSelectContainerId = event.target.getAttribute('data-file-select-container'); @@ -285,20 +288,11 @@ export default class DtpSiteApp extends DtpApp { return; } - // const IMAGE_WIDTH = parseInt(event.target.getAttribute('data-image-w')); - // const IMAGE_HEIGHT = parseInt(event.target.getAttribute('data-image-h')); - const reader = new FileReader(); reader.onload = (e) => { const img = document.getElementById(imageId); img.onload = (e) => { console.log('image loaded', e, img.naturalWidth, img.naturalHeight); - // if (img.naturalWidth !== IMAGE_WIDTH || img.naturalHeight !== IMAGE_HEIGHT) { - // UIkit.modal.alert(`This image must be ${IMAGE_WIDTH}x${IMAGE_HEIGHT}`); - // img.setAttribute('hidden', ''); - // img.src = ''; - // return; - // } fileSelectContainer.querySelector('#file-name').textContent = selectedFile.name; fileSelectContainer.querySelector('#file-modified').textContent = moment(selectedFile.lastModifiedDate).fromNow(); @@ -315,16 +309,15 @@ export default class DtpSiteApp extends DtpApp { img.src = e.target.result; //z create cropper and set options here - this.createImageCropper(img); + this.createImageCropper(img, cropperOptions); }; // read in the file, which will trigger everything else in the event handler above. reader.readAsDataURL(selectedFile); } - async createImageCropper (img) { - this.log.info("createImageCropper", "Creating image cropper", { img }); - this.cropper = new Cropper(img, { + async createImageCropper (img, options) { + options = Object.assign({ aspectRatio: 1, dragMode: 'move', autoCropArea: 0.85, @@ -332,11 +325,13 @@ export default class DtpSiteApp extends DtpApp { guides: false, center: false, highlight: false, - cropBoxMovable: false, - cropBoxResizable: false, + cropBoxMovable: true, + cropBoxResizable: true, toggleDragModeOnDblclick: false, modal: true, - }); + }, options); + this.log.info("createImageCropper", "Creating image cropper", { img }); + this.cropper = new Cropper(img, options); } async attachTinyMCE (editor) { @@ -505,7 +500,11 @@ export default class DtpSiteApp extends DtpApp { let response; switch (imageType) { - case 'profile-picture-file': + case 'header-image-file': + response = await fetch(`/user/${this.user._id}/header-image`, { method: 'DELETE' }); + break; + + case 'profile-picture-file': response = await fetch(`/user/${this.user._id}/profile-photo`, { method: 'DELETE' }); break; diff --git a/client/less/site/profile.less b/client/less/site/profile.less new file mode 100644 index 0000000..dd81059 --- /dev/null +++ b/client/less/site/profile.less @@ -0,0 +1,31 @@ +.public-profile { + + .profile-header-container { + position: relative; + + img.profile-header { + border: solid 2px #4a4a4a; + border-radius: 16px; + } + + img.profile-picture { + display: block; + width: 128px; + height: auto; + margin: 0 auto; + + border: solid 2px #4a4a4a; + border-radius: 50%; + } + + &.header-offset { + margin-bottom: 64px; + + img.profile-picture { + position: absolute; + bottom: -48px; + left: calc(50% - 64px); + } + } + } +} \ No newline at end of file diff --git a/client/less/style.less b/client/less/style.less index 219a1b5..db823cf 100644 --- a/client/less/style.less +++ b/client/less/style.less @@ -13,6 +13,7 @@ @import "site/nav.less"; @import "site/dashboard.less"; +@import "site/profile.less"; @import "site/site.less"; @import "site/site-home.less"; @import "site/form.less"; diff --git a/config/limiter.js b/config/limiter.js index a5c08f1..7e53fe3 100644 --- a/config/limiter.js +++ b/config/limiter.js @@ -161,6 +161,11 @@ module.exports = { expire: ONE_MINUTE * 5, message: 'You are updating your profile photo too quickly', }, + postHeaderImage: { + total: 5, + expire: ONE_MINUTE * 5, + message: 'You are updating your header image too quickly', + }, postUpdateSettings: { total: 4, expire: ONE_MINUTE, @@ -181,6 +186,11 @@ module.exports = { expire: ONE_MINUTE * 5, message: 'You are deleting your profile photo too quickly', }, + deleteHeaderImage: { + total: 5, + expire: ONE_MINUTE * 5, + message: 'You are deleting your header images too quickly', + }, }, welcome: {