From 0a748428f9163ea6eefd673573aa4ab988834741 Mon Sep 17 00:00:00 2001 From: Wilco Date: Mon, 7 Oct 2024 20:28:45 +0200 Subject: [PATCH] Add feature of tagging users in photos (#838) * Add feature of tagging users in photos * Only show tag button if any users tagged * Hide tagging by clicking outside area, add profile link, fix XSS warning * Bugfixes, lint * apply suggestions and comments --------- Co-authored-by: DrumsnChocolate --- app/abilities/photo-tag.js | 36 ++++++ app/abilities/photo.js | 4 + app/components/photo-albums/photo.hbs | 8 +- app/components/photo-albums/photo.js | 7 ++ app/components/photo-tags/photo-tags.hbs | 41 +++++++ app/components/photo-tags/photo-tags.js | 113 ++++++++++++++++++ app/models/photo-tag.js | 18 +++ app/models/photo.js | 2 + app/models/user.js | 7 ++ app/router.js | 1 + app/routes/users/user/index.js | 6 + app/routes/users/user/photos.js | 5 + app/styles/app.scss | 1 + app/styles/components/photo-tags.scss | 47 ++++++++ app/templates/users/user/edit/index.hbs | 2 +- app/templates/users/user/edit/permissions.hbs | 2 +- app/templates/users/user/edit/privacy.hbs | 2 +- app/templates/users/user/edit/security.hbs | 2 +- app/templates/users/user/groups.hbs | 2 +- app/templates/users/user/index.hbs | 2 +- app/templates/users/user/mail.hbs | 2 +- app/templates/users/user/mandates.hbs | 2 +- app/templates/users/user/permissions.hbs | 2 +- app/templates/users/user/photos.hbs | 26 ++++ app/templates/users/user/settings.hbs | 2 +- config/icons.js | 1 + mirage/config.js | 1 + mirage/models/photo-tag.js | 7 ++ mirage/scenarios/default.js | 1 + package.json | 2 +- tests/unit/routes/users/user/photos-test.js | 11 ++ yarn.lock | 21 +++- 32 files changed, 372 insertions(+), 14 deletions(-) create mode 100644 app/abilities/photo-tag.js create mode 100644 app/components/photo-tags/photo-tags.hbs create mode 100644 app/components/photo-tags/photo-tags.js create mode 100644 app/models/photo-tag.js create mode 100644 app/routes/users/user/photos.js create mode 100644 app/styles/components/photo-tags.scss create mode 100644 app/templates/users/user/photos.hbs create mode 100644 mirage/models/photo-tag.js create mode 100644 tests/unit/routes/users/user/photos-test.js diff --git a/app/abilities/photo-tag.js b/app/abilities/photo-tag.js new file mode 100644 index 000000000..11f00a498 --- /dev/null +++ b/app/abilities/photo-tag.js @@ -0,0 +1,36 @@ +import { Ability } from 'ember-can'; +import { isNone } from '@ember/utils'; + +export default class PhotoTag extends Ability { + get canShow() { + return this.session.hasPermission('photo-tag.read'); + } + + get canCreate() { + return this.session.hasPermission('photo-tag.create'); + } + + get canDestroy() { + return ( + this.session.hasPermission('photo-tag.destroy') || + this.isTagOwner(this.model) || + this.isTagged(this.model) + ); + } + + isTagOwner(photoTag) { + const { currentUser } = this.session; + return ( + !isNone(currentUser) && + photoTag.get('author.id') === currentUser.get('id') + ); + } + + isTagged(photoTag) { + const { currentUser } = this.session; + return ( + !isNone(currentUser) && + photoTag.get('taggedUser.id') === currentUser.get('id') + ); + } +} diff --git a/app/abilities/photo.js b/app/abilities/photo.js index 141478220..92ee0cff6 100644 --- a/app/abilities/photo.js +++ b/app/abilities/photo.js @@ -11,4 +11,8 @@ export default class Photo extends Ability { this.model.photoAlbum.get('publiclyVisible') ); } + + get canShowPhotoTags() { + return this.session.hasPermission('photo-tag.read'); + } } diff --git a/app/components/photo-albums/photo.hbs b/app/components/photo-albums/photo.hbs index 8fe2a4408..267c27bf3 100644 --- a/app/components/photo-albums/photo.hbs +++ b/app/components/photo-albums/photo.hbs @@ -1,5 +1,11 @@
- + {{#if this.showTags}} + + + + {{else}} + + {{/if}}
{{#if (can 'show individual users')}} diff --git a/app/components/photo-albums/photo.js b/app/components/photo-albums/photo.js index 9134071c0..a4524ddb5 100644 --- a/app/components/photo-albums/photo.js +++ b/app/components/photo-albums/photo.js @@ -1,8 +1,11 @@ import { action } from '@ember/object'; import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; export default class Photo extends Component { + @service abilities; + @tracked showExif = false; @@ -10,6 +13,10 @@ export default class Photo extends Component { return this.args.showInfo ?? true; } + get showTags() { + return this.showInfo && this.abilities.can('show photo-tags'); + } + @action toggleShowExif() { this.showExif = !this.showExif; diff --git a/app/components/photo-tags/photo-tags.hbs b/app/components/photo-tags/photo-tags.hbs new file mode 100644 index 000000000..9e935f9c4 --- /dev/null +++ b/app/components/photo-tags/photo-tags.hbs @@ -0,0 +1,41 @@ +
+ {{yield}} + + {{#if (gt @model.amountOfTags 0)}} + + {{/if}} + + {{#each @model.tags as |tag|}} +
+ + {{ tag.taggedUser.fullName }} + + {{#if (can 'destroy photo-tag' tag)}} + + {{/if}} +
+ {{/each}} + + {{#if this.newTagStyle }} +
+ + {{user.fullName}} + +
+ {{/if}} +
diff --git a/app/components/photo-tags/photo-tags.js b/app/components/photo-tags/photo-tags.js new file mode 100644 index 000000000..de3e5fe2c --- /dev/null +++ b/app/components/photo-tags/photo-tags.js @@ -0,0 +1,113 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { next } from '@ember/runloop'; +import { htmlSafe } from '@ember/template'; + +export default class PhotoTags extends Component { + @service store; + @service flashNotice; + @tracked newTagX; + @tracked newTagY; + @tracked selectApi; + @tracked showTags = false; + + get users() { + return this.store.findAll('user'); + } + + @action + toggleShowTags() { + this.showTags = !this.showTags; + } + + @action + addTag(e) { + if (e.target.tagName.toLowerCase() != 'img' || this.newTagX || this.newTagY) + return; + e.stopPropagation(); + let x = (e.layerX / e.target.width) * 100; + let y = (e.layerY / e.target.height) * 100; + this.newTagX = x; + this.newTagY = y; + next(this, () => { + this.selectApi.actions.open(); + }); + } + + @action + addCloseAddTagListener() { + this.closeAddTagListener = (e) => { + let element = e.target; + if ( + element.closest('.ember-power-select-dropdown') !== null || + element.closest('.photo-tag--new') !== null + ) + return; + e.stopPropagation(); + this.newTagX = null; + this.newTagY = null; + console.log('Closed tag', element); + }; + + document.addEventListener('click', this.closeAddTagListener); + } + + @action + removeCloseAddTagListener() { + document.removeEventListener('click', this.closeAddTagListener); + } + + @action + async storeTag(taggedUser) { + let photo = this.args.model; + let photoTag = this.store.createRecord('photo-tag', { + photo, + taggedUser, + x: this.newTagX, + y: this.newTagY, + }); + this.newTagX = null; + this.newTagY = null; + + try { + await photoTag.save(); + this.flashNotice.sendSuccess('Tag opgeslagen!'); + photo.reload(); + this.showTags = true; + } catch (e) { + this.flashNotice.sendError( + 'Tag opslaan mislukt. Is deze gebruiker al getagged?' + ); + photoTag.deleteRecord(); + } + } + + @action + async deleteTag(tag) { + try { + tag.deleteRecord(); + await tag.save(); + this.flashNotice.sendSuccess('Tag verwijderd!'); + this.args.model.reload(); + } catch (e) { + this.flashNotice.sendError('Tag verwijderen mislukt.'); + tag.rollbackAttributes(); + } + } + + @action + openUserSelect(userSelect) { + if (this.selectApi == null) { + this.selectApi = userSelect; + } + } + + get newTagStyle() { + if (!this.newTagX || !this.newTagY) return null; + return htmlSafe( + `left: ${parseFloat(this.newTagX)}%; top: ${parseFloat(this.newTagY)}%;` + ); + } +} diff --git a/app/models/photo-tag.js b/app/models/photo-tag.js new file mode 100644 index 000000000..815431405 --- /dev/null +++ b/app/models/photo-tag.js @@ -0,0 +1,18 @@ +import Model, { belongsTo, attr } from '@ember-data/model'; + +export default class PhotoTag extends Model { + // Properties + @attr x; + @attr y; + @attr('date') updatedAt; + @attr('date') createdAt; + + // Relations + @belongsTo('user') author; + @belongsTo('user') taggedUser; + @belongsTo photo; + + get tagStyle() { + return `left: ${parseFloat(this.x)}%; top: ${parseFloat(this.y)}%;`; + } +} diff --git a/app/models/photo.js b/app/models/photo.js index 88b6a2f25..0458311ae 100644 --- a/app/models/photo.js +++ b/app/models/photo.js @@ -18,6 +18,7 @@ export default class Photo extends Model { @attr imageThumbUrl; @attr imageMediumUrl; @attr amountOfComments; + @attr amountOfTags; @attr('date') updatedAt; @attr('date') createdAt; @@ -35,6 +36,7 @@ export default class Photo extends Model { @belongsTo photoAlbum; @belongsTo('user') uploader; @hasMany('photoComment') comments; + @hasMany('photoTag') tags; // Getters get hasExif() { diff --git a/app/models/user.js b/app/models/user.js index 2c5a1cfef..2688bb730 100644 --- a/app/models/user.js +++ b/app/models/user.js @@ -64,6 +64,9 @@ export default class User extends Model { @hasMany('debit/mandate') mandates; @hasMany mailAliases; @hasMany('mail-alias') groupMailAliases; + @hasMany('photo-tags', { inverse: 'author' }) createdPhotoTags; + @hasMany('photo-tags', { inverse: 'taggedUser' }) photoTags; + @hasMany('photos') photos; // Computed properties get fullName() { @@ -151,6 +154,10 @@ export default class User extends Model { return this.avatarThumbUrl || AvatarThumbFallback; } + get sortedPhotos() { + return this.photos?.sortBy('exifDateTimeOriginal', 'createdAt'); + } + // Methods setNullIfEmptyString(property) { const value = this.get(property); diff --git a/app/router.js b/app/router.js index c12278f6d..f2b523945 100644 --- a/app/router.js +++ b/app/router.js @@ -240,6 +240,7 @@ Router.map(function () { this.route('mail'); this.route('mandates'); this.route('permissions'); + this.route('photos'); this.route('settings'); this.route('resend-activation-code'); diff --git a/app/routes/users/user/index.js b/app/routes/users/user/index.js index 06979c424..d294d009e 100644 --- a/app/routes/users/user/index.js +++ b/app/routes/users/user/index.js @@ -36,6 +36,12 @@ export default class UserIndexRoute extends AuthenticatedRoute { linkArgument: user, canAccess: this.abilities.can('show memberships'), }, + { + link: 'users.user.photos', + title: "Foto's", + linkArgument: user, + canAccess: this.abilities.can('show photo-tags'), + }, { link: 'users.user.settings', title: 'Instellingen', diff --git a/app/routes/users/user/photos.js b/app/routes/users/user/photos.js new file mode 100644 index 000000000..1c2227584 --- /dev/null +++ b/app/routes/users/user/photos.js @@ -0,0 +1,5 @@ +import UserIndexRoute from './index'; + +export default class UserPhotosRoute extends UserIndexRoute { + pageActions = null; +} diff --git a/app/styles/app.scss b/app/styles/app.scss index f4d3335e2..d9b27a4ad 100644 --- a/app/styles/app.scss +++ b/app/styles/app.scss @@ -22,6 +22,7 @@ @import 'components/navbar'; @import 'components/page-actions'; @import 'components/pagination'; +@import 'components/photo-tags'; @import 'components/public/index/about-alpha'; @import 'components/public/index/activities'; @import 'components/public/index/header'; diff --git a/app/styles/components/photo-tags.scss b/app/styles/components/photo-tags.scss new file mode 100644 index 000000000..8503e530a --- /dev/null +++ b/app/styles/components/photo-tags.scss @@ -0,0 +1,47 @@ +.photo-tag { + position: absolute; + transform: translate(-50%, -50%); + transition: 0.5s ease opacity; + opacity: 0; + border-radius: 10px; + background: rgb(0 0 0 / 80%); + padding: 5px 10px; + color: #fff; + font-size: 12px; + pointer-events: none; + + &--new { + opacity: 1; + z-index: 2; + min-width: 200px; + pointer-events: auto; + } + + &:hover { + z-index: 1; + } + + &:has(.fa-xmark) { + padding-right: 25px; + } + + .fa-xmark { + position: absolute; + top: 50%; + right: 10px; + transform: translateY(-50%); + cursor: pointer; + } +} + +.photo-tags.photo-tags--show .photo-tag { + opacity: 1; + pointer-events: auto; +} + +.photo-tags-button { + position: absolute; + top: 8px; + right: 8px; + color: #fff; +} diff --git a/app/templates/users/user/edit/index.hbs b/app/templates/users/user/edit/index.hbs index 70c5cd7c5..3139cabf9 100644 --- a/app/templates/users/user/edit/index.hbs +++ b/app/templates/users/user/edit/index.hbs @@ -1,4 +1,4 @@ - + +
Permissies
+ \ No newline at end of file diff --git a/app/templates/users/user/edit/security.hbs b/app/templates/users/user/edit/security.hbs index bae2d6997..0c6b47a71 100644 --- a/app/templates/users/user/edit/security.hbs +++ b/app/templates/users/user/edit/security.hbs @@ -1,4 +1,4 @@ - +

diff --git a/app/templates/users/user/groups.hbs b/app/templates/users/user/groups.hbs index c5702a368..7f37c01d1 100644 --- a/app/templates/users/user/groups.hbs +++ b/app/templates/users/user/groups.hbs @@ -2,7 +2,7 @@ - +