Skip to content

Commit

Permalink
Add feature of tagging users in photos (#838)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
  • Loading branch information
wilco375 and DrumsnChocolate authored Oct 7, 2024
1 parent b751aa4 commit 0a74842
Show file tree
Hide file tree
Showing 32 changed files with 372 additions and 14 deletions.
36 changes: 36 additions & 0 deletions app/abilities/photo-tag.js
Original file line number Diff line number Diff line change
@@ -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')
);
}
}
4 changes: 4 additions & 0 deletions app/abilities/photo.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,8 @@ export default class Photo extends Ability {
this.model.photoAlbum.get('publiclyVisible')
);
}

get canShowPhotoTags() {
return this.session.hasPermission('photo-tag.read');
}
}
8 changes: 7 additions & 1 deletion app/components/photo-albums/photo.hbs
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
<div class='d-flex justify-content-center align-items-center'>
<img src={{@model.imageUrl}} class='photo-large' />
{{#if this.showTags}}
<PhotoTags::PhotoTags @model={{@model}}>
<img src={{@model.imageUrl}} class='photo-large' />
</PhotoTags::PhotoTags>
{{else}}
<img src={{@model.imageUrl}} class='photo-large' />
{{/if}}
</div>

{{#if (can 'show individual users')}}
Expand Down
7 changes: 7 additions & 0 deletions app/components/photo-albums/photo.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,22 @@
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;

get showInfo() {
return this.args.showInfo ?? true;
}

get showTags() {
return this.showInfo && this.abilities.can('show photo-tags');
}

@action
toggleShowExif() {
this.showExif = !this.showExif;
Expand Down
41 changes: 41 additions & 0 deletions app/components/photo-tags/photo-tags.hbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<div class="position-relative photo-tags
{{if showTags 'photo-tags--show'}}"
{{on 'click' this.addTag}}
{{did-insert this.addCloseAddTagListener}}
{{will-destroy this.removeCloseAddTagListener}}
>
{{yield}}

{{#if (gt @model.amountOfTags 0)}}
<button class="btn btn-info photo-tags-button" type="button" {{on 'click' this.toggleShowTags}}>
<FaIcon @icon='tag' />
{{ @model.amountOfTags }}
</button>
{{/if}}

{{#each @model.tags as |tag|}}
<div class="photo-tag" style={{ tag.tagStyle }}>
<LinkTo @route='users.user.photos' @model={{tag.taggedUser.id}}>
{{ tag.taggedUser.fullName }}
</LinkTo>
{{#if (can 'destroy photo-tag' tag)}}
<FaIcon @icon='xmark' {{ on 'click' (fn this.deleteTag tag) }} />
{{/if}}
</div>
{{/each}}

{{#if this.newTagStyle }}
<div class="photo-tag photo-tag--new" style={{ this.newTagStyle }}>
<PowerSelect
@options={{this.users}}
@onChange={{this.storeTag}}
@searchEnabled={{true}}
@searchField='fullName'
@registerAPI={{this.openUserSelect}}
as |user|
>
{{user.fullName}}
</PowerSelect>
</div>
{{/if}}
</div>
113 changes: 113 additions & 0 deletions app/components/photo-tags/photo-tags.js
Original file line number Diff line number Diff line change
@@ -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)}%;`
);
}
}
18 changes: 18 additions & 0 deletions app/models/photo-tag.js
Original file line number Diff line number Diff line change
@@ -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)}%;`;
}
}
2 changes: 2 additions & 0 deletions app/models/photo.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export default class Photo extends Model {
@attr imageThumbUrl;
@attr imageMediumUrl;
@attr amountOfComments;
@attr amountOfTags;
@attr('date') updatedAt;
@attr('date') createdAt;

Expand All @@ -35,6 +36,7 @@ export default class Photo extends Model {
@belongsTo photoAlbum;
@belongsTo('user') uploader;
@hasMany('photoComment') comments;
@hasMany('photoTag') tags;

// Getters
get hasExif() {
Expand Down
7 changes: 7 additions & 0 deletions app/models/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions app/router.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
6 changes: 6 additions & 0 deletions app/routes/users/user/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
5 changes: 5 additions & 0 deletions app/routes/users/user/photos.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import UserIndexRoute from './index';

export default class UserPhotosRoute extends UserIndexRoute {
pageActions = null;
}
1 change: 1 addition & 0 deletions app/styles/app.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
47 changes: 47 additions & 0 deletions app/styles/components/photo-tags.scss
Original file line number Diff line number Diff line change
@@ -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;
}
2 changes: 1 addition & 1 deletion app/templates/users/user/edit/index.hbs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<TabbedView @tabItems={{tabItems}}>
<TabbedView @tabItems={{this.tabItems}}>
<Forms::UserForm
@model={{@model}}
@onSubmit={{action 'submit'}}
Expand Down
2 changes: 1 addition & 1 deletion app/templates/users/user/edit/permissions.hbs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<TabbedView @tabItems={{tabItems}}>
<TabbedView @tabItems={{this.tabItems}}>
<form class='form' {{action 'submit' on='submit'}}>
<h5>Permissies</h5>
<Permissions::PermissionsTable
Expand Down
2 changes: 1 addition & 1 deletion app/templates/users/user/edit/privacy.hbs
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
<TabbedView @tabItems={{tabItems}}>
<TabbedView @tabItems={{this.tabItems}}>
<Users::PrivacySettings @model={{model}} @onSubmit={{action 'submit'}} @onCancel={{action 'cancel'}}/>
</TabbedView>
Loading

0 comments on commit 0a74842

Please sign in to comment.