client/posts: add bulk safety editing (#122)

This commit is contained in:
rr- 2017-02-11 21:58:18 +01:00
parent 0dc7a4058e
commit 1caf76b1b2
7 changed files with 242 additions and 47 deletions

View file

@ -84,6 +84,37 @@
&[data-disabled] &[data-disabled]
background: rgba(200, 200, 200, 0.7) background: rgba(200, 200, 200, 0.7)
.safety-flipper a
display: inline-block
margin: 0.1em
box-sizing: border-box
border: 0
display: inline-block
width: 1.2em
height: 1.2em
text-align: center
line-height: 1em
font-size: 20pt
border: 3px solid
&.safety-safe
background-color: darken($safety-safe, 5%)
border-color: @background-color
&:not(.active)
background-color: alpha(@background-color, 0.3)
&.safety-sketchy
background-color: $safety-sketchy
border-color: @background-color
&:not(.active)
background-color: alpha(@background-color, 0.3)
&.safety-unsafe
background-color: $safety-unsafe
border-color: @background-color
&:not(.active)
background-color: alpha(@background-color, 0.3)
&[data-disabled]
background: rgba(200, 200, 200, 0.7)
.thumbnail .thumbnail
background-position: 50% 30% background-position: 50% 30%
width: 100% width: 100%
@ -122,20 +153,25 @@
.append .append
font-size: 0.95em font-size: 0.95em
color: $inactive-link-color color: $inactive-link-color
.bulk-edit-tags .bulk-edit
&:not(.opened) &:not(.opened)
[type=text],
.start,
.close .close
display: none display: none
.hint
display: none
&.opened &.opened
.open .open
display: none display: none
&.hidden
display: none
.bulk-edit-tags
&:not(.opened)
[type=text],
.start
display: none
.hint
display: none
input[name=tag] input[name=tag]
width: 12em width: 12em
.hint, .open .hint
margin-right: 1em margin-right: 1em
.safety .safety

View file

@ -11,7 +11,7 @@
%><a class='mousetrap button append' href='<%- ctx.formatClientLink('help', 'search', 'posts') %>'>Syntax help</a><% %><a class='mousetrap button append' href='<%- ctx.formatClientLink('help', 'search', 'posts') %>'>Syntax help</a><%
%></form><% %></form><%
%><% if (ctx.canBulkEditTags) { %><% %><% if (ctx.canBulkEditTags) { %><%
%><form class='horizontal bulk-edit-tags'><% %><form class='horizontal bulk-edit bulk-edit-tags'><%
%><span class='append hint'>Tagging with:</span><% %><span class='append hint'>Tagging with:</span><%
%><a href class='mousetrap button append open'>Mass tag</a><% %><a href class='mousetrap button append open'>Mass tag</a><%
%><wbr/><% %><wbr/><%
@ -20,4 +20,10 @@
%><a href class='mousetrap button append close'>Stop tagging</a><% %><a href class='mousetrap button append close'>Stop tagging</a><%
%></form><% %></form><%
%><% } %><% %><% } %><%
%><% if (ctx.canBulkEditSafety) { %><%
%><form class='horizontal bulk-edit bulk-edit-safety'><%
%><a href class='mousetrap button append open'>Mass edit safety</a><%
%><a href class='mousetrap button append close'>Stop editing safety</a><%
%></form><%
%><% } %><%
%></div> %></div>

View file

@ -2,7 +2,7 @@
<% if (ctx.response.results.length) { %> <% if (ctx.response.results.length) { %>
<ul> <ul>
<% for (let post of ctx.response.results) { %> <% for (let post of ctx.response.results) { %>
<li> <li data-post-id='<%= post.id %>'>
<a class='thumbnail-wrapper <%= post.tags.length > 0 ? "tags" : "no-tags" %>' <a class='thumbnail-wrapper <%= post.tags.length > 0 ? "tags" : "no-tags" %>'
title='@<%- post.id %> (<%- post.type %>)&#10;&#10;Tags: <%- post.tags.map(tag => '#' + tag).join(' ') || 'none' %>' title='@<%- post.id %> (<%- post.type %>)&#10;&#10;Tags: <%- post.tags.map(tag => '#' + tag).join(' ') || 'none' %>'
href='<%= ctx.canViewPosts ? ctx.getPostUrl(post.id, ctx.parameters) : '' %>'> href='<%= ctx.canViewPosts ? ctx.getPostUrl(post.id, ctx.parameters) : '' %>'>
@ -35,9 +35,17 @@
</a> </a>
<span class='edit-overlay'> <span class='edit-overlay'>
<% if (ctx.canBulkEditTags && ctx.parameters && ctx.parameters.tag) { %> <% if (ctx.canBulkEditTags && ctx.parameters && ctx.parameters.tag) { %>
<a href data-post-id='<%= post.id %>' class='tag-flipper'> <a href class='tag-flipper'>
</a> </a>
<% } %> <% } %>
<% if (ctx.canBulkEditSafety && ctx.parameters && ctx.parameters.safety) { %>
<span class='safety-flipper'>
<% for (let safety of ['safe', 'sketchy', 'unsafe']) { %>
<a href data-safety='<%- safety %>' class='safety-<%- safety %><%- post.safety === safety ? ' active' : '' %>'>
</a>
<% } %>
</span>
<% } %>
</span> </span>
</li> </li>
<% } %> <% } %>

View file

@ -11,7 +11,7 @@ const PostsPageView = require('../views/posts_page_view.js');
const EmptyView = require('../views/empty_view.js'); const EmptyView = require('../views/empty_view.js');
const fields = [ const fields = [
'id', 'thumbnailUrl', 'type', 'id', 'thumbnailUrl', 'type', 'safety',
'score', 'favoriteCount', 'commentCount', 'tags', 'version']; 'score', 'favoriteCount', 'commentCount', 'tags', 'version'];
class PostListController { class PostListController {
@ -32,6 +32,7 @@ class PostListController {
hostNode: this._pageController.view.pageHeaderHolderNode, hostNode: this._pageController.view.pageHeaderHolderNode,
parameters: ctx.parameters, parameters: ctx.parameters,
canBulkEditTags: api.hasPrivilege('posts:bulkEdit:tags'), canBulkEditTags: api.hasPrivilege('posts:bulkEdit:tags'),
canBulkEditSafety: api.hasPrivilege('posts:bulkEdit:safety'),
bulkEdit: { bulkEdit: {
tags: this._bulkEditTags tags: this._bulkEditTags
}, },
@ -73,6 +74,11 @@ class PostListController {
e.detail.post.save().catch(error => window.alert(error.message)); e.detail.post.save().catch(error => window.alert(error.message));
} }
_evtChangeSafety(e) {
e.detail.post.safety = e.detail.safety;
e.detail.post.save().catch(error => window.alert(error.message));
}
_decorateSearchQuery(text) { _decorateSearchQuery(text) {
const browsingSettings = settings.get(); const browsingSettings = settings.get();
let disabledSafety = []; let disabledSafety = [];
@ -106,6 +112,8 @@ class PostListController {
Object.assign(pageCtx, { Object.assign(pageCtx, {
canViewPosts: api.hasPrivilege('posts:view'), canViewPosts: api.hasPrivilege('posts:view'),
canBulkEditTags: api.hasPrivilege('posts:bulkEdit:tags'), canBulkEditTags: api.hasPrivilege('posts:bulkEdit:tags'),
canBulkEditSafety:
api.hasPrivilege('posts:bulkEdit:safety'),
bulkEdit: { bulkEdit: {
tags: this._bulkEditTags, tags: this._bulkEditTags,
}, },
@ -113,6 +121,8 @@ class PostListController {
const view = new PostsPageView(pageCtx); const view = new PostsPageView(pageCtx);
view.addEventListener('tag', e => this._evtTag(e)); view.addEventListener('tag', e => this._evtTag(e));
view.addEventListener('untag', e => this._evtUntag(e)); view.addEventListener('untag', e => this._evtUntag(e));
view.addEventListener(
'changeSafety', e => this._evtChangeSafety(e));
return view; return view;
}, },
}); });

View file

@ -11,26 +11,19 @@ const TagAutoCompleteControl =
const template = views.getTemplate('posts-header'); const template = views.getTemplate('posts-header');
class BulkTagEditor extends events.EventTarget { class BulkEditor extends events.EventTarget {
constructor(hostNode) { constructor(hostNode) {
super(); super();
this._hostNode = hostNode; this._hostNode = hostNode;
this._autoCompleteControl = new TagAutoCompleteControl(
this._inputNode, {addSpace: false});
this._openLinkNode.addEventListener( this._openLinkNode.addEventListener(
'click', e => this._evtOpenLinkClick(e)); 'click', e => this._evtOpenLinkClick(e));
this._closeLinkNode.addEventListener( this._closeLinkNode.addEventListener(
'click', e => this._evtCloseLinkClick(e)); 'click', e => this._evtCloseLinkClick(e));
this._hostNode.addEventListener('submit', e => this._evtFormSubmit(e));
}
get value() {
return this._inputNode.value;
} }
get opened() { get opened() {
return this._hostNode.classList.contains('opened'); return this._hostNode.classList.contains('opened') &&
!this._hostNode.classList.contains('hidden');
} }
get _openLinkNode() { get _openLinkNode() {
@ -41,6 +34,53 @@ class BulkTagEditor extends events.EventTarget {
return this._hostNode.querySelector('.close'); return this._hostNode.querySelector('.close');
} }
toggleOpen(state) {
this._hostNode.classList.toggle('opened', state);
}
toggleHide(state) {
this._hostNode.classList.toggle('hidden', state);
}
_evtOpenLinkClick(e) {
throw new Error('Not implemented');
}
_evtCloseLinkClick(e) {
throw new Error('Not implemented');
}
}
class BulkSafetyEditor extends BulkEditor {
constructor(hostNode) {
super(hostNode);
}
_evtOpenLinkClick(e) {
e.preventDefault();
this.toggleOpen(true);
this.dispatchEvent(new CustomEvent('open', {detail: {}}));
}
_evtCloseLinkClick(e) {
e.preventDefault();
this.toggleOpen(false);
this.dispatchEvent(new CustomEvent('close', {detail: {}}));
}
}
class BulkTagEditor extends BulkEditor {
constructor(hostNode) {
super(hostNode);
this._autoCompleteControl = new TagAutoCompleteControl(
this._inputNode, {addSpace: false});
this._hostNode.addEventListener('submit', e => this._evtFormSubmit(e));
}
get value() {
return this._inputNode.value;
}
get _inputNode() { get _inputNode() {
return this._hostNode.querySelector('input[name=tag]'); return this._hostNode.querySelector('input[name=tag]');
} }
@ -54,10 +94,6 @@ class BulkTagEditor extends events.EventTarget {
this._inputNode.blur(); this._inputNode.blur();
} }
toggleOpen(state) {
this._hostNode.classList.toggle('opened', state);
}
_evtFormSubmit(e) { _evtFormSubmit(e) {
e.preventDefault(); e.preventDefault();
this.dispatchEvent(new CustomEvent('submit', {detail: {}})); this.dispatchEvent(new CustomEvent('submit', {detail: {}}));
@ -99,18 +135,38 @@ class PostsHeaderView extends events.EventTarget {
safetyButtonNode.addEventListener( safetyButtonNode.addEventListener(
'click', e => this._evtSafetyButtonClick(e)); 'click', e => this._evtSafetyButtonClick(e));
} }
this._formNode.addEventListener( this._formNode.addEventListener('submit', e => this._evtFormSubmit(e));
'submit', e => this._evtFormSubmit(e));
this._bulkEditors = [];
if (this._bulkEditTagsNode) { if (this._bulkEditTagsNode) {
this._bulkTagEditor = new BulkTagEditor(this._bulkEditTagsNode); this._bulkTagEditor = new BulkTagEditor(this._bulkEditTagsNode);
this._bulkTagEditor.toggleOpen(!!ctx.parameters.tag); this._bulkEditors.push(this._bulkTagEditor);
}
if (this._bulkEditSafetyNode) {
this._bulkSafetyEditor = new BulkSafetyEditor(
this._bulkEditSafetyNode);
this._bulkEditors.push(this._bulkSafetyEditor);
}
for (let editor of this._bulkEditors) {
this._bulkTagEditor.addEventListener('submit', e => { this._bulkTagEditor.addEventListener('submit', e => {
this._navigate(); this._navigate();
}); });
this._bulkTagEditor.addEventListener('close', e => { editor.addEventListener('open', e => {
this._hideBulkEditorsExcept(editor);
this._navigate(); this._navigate();
}); });
editor.addEventListener('close', e => {
this._closeAndShowAllBulkEditors();
this._navigate();
});
}
if (ctx.parameters.tag && this._bulkTagEditor) {
this._openBulkEditor(this._bulkTagEditor);
} else if (ctx.parameters.safety && this._bulkSafetyEditor) {
this._openBulkEditor(this._bulkSafetyEditor);
} }
} }
@ -130,6 +186,31 @@ class PostsHeaderView extends events.EventTarget {
return this._hostNode.querySelector('.bulk-edit-tags'); return this._hostNode.querySelector('.bulk-edit-tags');
} }
get _bulkEditSafetyNode() {
return this._hostNode.querySelector('.bulk-edit-safety');
}
_openBulkEditor(editor) {
editor.toggleOpen(true);
this._hideBulkEditorsExcept(editor);
}
_hideBulkEditorsExcept(editor) {
for (let otherEditor of this._bulkEditors) {
if (otherEditor !== editor) {
otherEditor.toggleOpen(false);
otherEditor.toggleHide(true);
}
}
}
_closeAndShowAllBulkEditors() {
for (let otherEditor of this._bulkEditors) {
otherEditor.toggleOpen(false);
otherEditor.toggleHide(false);
}
}
_evtSafetyButtonClick(e, url) { _evtSafetyButtonClick(e, url) {
e.preventDefault(); e.preventDefault();
e.target.classList.toggle('disabled'); e.target.classList.toggle('disabled');
@ -164,6 +245,9 @@ class PostsHeaderView extends events.EventTarget {
} else { } else {
parameters.tag = null; parameters.tag = null;
} }
parameters.safety = (
this._bulkSafetyEditor &&
this._bulkSafetyEditor.opened ? '1' : null);
this.dispatchEvent( this.dispatchEvent(
new CustomEvent('navigate', {detail: {parameters: parameters}})); new CustomEvent('navigate', {detail: {parameters: parameters}}));
} }

View file

@ -18,26 +18,48 @@ class PostsPageView extends events.EventTarget {
post.addEventListener('change', e => this._evtPostChange(e)); post.addEventListener('change', e => this._evtPostChange(e));
} }
this._postIdToLinkNode = {}; this._postIdToListItemNode = {};
for (let linkNode of this._tagFlipperNodes) { for (let listItemNode of this._listItemNodes) {
const postId = linkNode.getAttribute('data-post-id'); const postId = listItemNode.getAttribute('data-post-id');
const post = this._postIdToPost[postId]; const post = this._postIdToPost[postId];
this._postIdToLinkNode[postId] = linkNode; this._postIdToListItemNode[postId] = listItemNode;
linkNode.addEventListener(
'click', e => this._evtBulkEditTagsClick(e, post)); const tagFlipperNode = this._getTagFlipperNode(listItemNode);
if (tagFlipperNode) {
tagFlipperNode.addEventListener(
'click', e => this._evtBulkEditTagsClick(e, post));
}
const safetyFlipperNode = this._getSafetyFlipperNode(listItemNode);
if (safetyFlipperNode) {
for (let linkNode of safetyFlipperNode.querySelectorAll('a')) {
linkNode.addEventListener(
'click', e => this._evtBulkEditSafetyClick(e, post));
}
}
} }
this._syncTagFlippersHighlights(); this._syncBulkEditorsHighlights();
} }
get _tagFlipperNodes() { get _listItemNodes() {
return this._hostNode.querySelectorAll('.tag-flipper'); return this._hostNode.querySelectorAll('li');
}
_getTagFlipperNode(listItemNode) {
return listItemNode.querySelector('.tag-flipper');
}
_getSafetyFlipperNode(listItemNode) {
return listItemNode.querySelector('.safety-flipper');
} }
_evtPostChange(e) { _evtPostChange(e) {
const linkNode = this._postIdToLinkNode[e.detail.post.id]; const listItemNode = this._postIdToListItemNode[e.detail.post.id];
linkNode.removeAttribute('data-disabled'); for (let node of listItemNode.querySelectorAll('[data-disabled]')) {
this._syncTagFlippersHighlights(); node.removeAttribute('data-disabled');
}
this._syncBulkEditorsHighlights();
} }
_evtBulkEditTagsClick(e, post) { _evtBulkEditTagsClick(e, post) {
@ -53,15 +75,43 @@ class PostsPageView extends events.EventTarget {
{detail: {post: post}})); {detail: {post: post}}));
} }
_syncTagFlippersHighlights() { _evtBulkEditSafetyClick(e, post) {
for (let linkNode of this._tagFlipperNodes) { e.preventDefault();
const postId = linkNode.getAttribute('data-post-id'); const linkNode = e.target;
if (linkNode.getAttribute('data-disabled')) {
return;
}
const newSafety = linkNode.getAttribute('data-safety');
if (post.safety === newSafety) {
return;
}
linkNode.setAttribute('data-disabled', true);
this.dispatchEvent(
new CustomEvent(
'changeSafety', {detail: {post: post, safety: newSafety}}));
}
_syncBulkEditorsHighlights() {
for (let listItemNode of this._listItemNodes) {
const postId = listItemNode.getAttribute('data-post-id');
const post = this._postIdToPost[postId]; const post = this._postIdToPost[postId];
let tagged = true;
for (let tag of this._ctx.bulkEdit.tags) { const tagFlipperNode = this._getTagFlipperNode(listItemNode);
tagged = tagged & post.isTaggedWith(tag); if (tagFlipperNode) {
let tagged = true;
for (let tag of this._ctx.bulkEdit.tags) {
tagged = tagged & post.isTaggedWith(tag);
}
tagFlipperNode.classList.toggle('tagged', tagged);
}
const safetyFlipperNode = this._getSafetyFlipperNode(listItemNode);
if (safetyFlipperNode) {
for (let linkNode of safetyFlipperNode.querySelectorAll('a')) {
const safety = linkNode.getAttribute('data-safety');
linkNode.classList.toggle('active', post.safety == safety);
}
} }
linkNode.classList.toggle('tagged', tagged);
} }
} }
} }

View file

@ -90,6 +90,7 @@ privileges:
'posts:merge': moderator 'posts:merge': moderator
'posts:favorite': regular 'posts:favorite': regular
'posts:bulk-edit:tags': power 'posts:bulk-edit:tags': power
'posts:bulk-edit:safety': power
'tags:create': regular 'tags:create': regular
'tags:edit:names': power 'tags:edit:names': power