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]
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
background-position: 50% 30%
width: 100%
@ -122,20 +153,25 @@
.append
font-size: 0.95em
color: $inactive-link-color
.bulk-edit-tags
.bulk-edit
&:not(.opened)
[type=text],
.start,
.close
display: none
.hint
display: none
&.opened
.open
display: none
&.hidden
display: none
.bulk-edit-tags
&:not(.opened)
[type=text],
.start
display: none
.hint
display: none
input[name=tag]
width: 12em
.hint, .open
.hint
margin-right: 1em
.safety

View file

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

View file

@ -2,7 +2,7 @@
<% if (ctx.response.results.length) { %>
<ul>
<% 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" %>'
title='@<%- post.id %> (<%- post.type %>)&#10;&#10;Tags: <%- post.tags.map(tag => '#' + tag).join(' ') || 'none' %>'
href='<%= ctx.canViewPosts ? ctx.getPostUrl(post.id, ctx.parameters) : '' %>'>
@ -35,9 +35,17 @@
</a>
<span class='edit-overlay'>
<% if (ctx.canBulkEditTags && ctx.parameters && ctx.parameters.tag) { %>
<a href data-post-id='<%= post.id %>' class='tag-flipper'>
<a href class='tag-flipper'>
</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>
</li>
<% } %>

View file

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

View file

@ -11,26 +11,19 @@ const TagAutoCompleteControl =
const template = views.getTemplate('posts-header');
class BulkTagEditor extends events.EventTarget {
class BulkEditor extends events.EventTarget {
constructor(hostNode) {
super();
this._hostNode = hostNode;
this._autoCompleteControl = new TagAutoCompleteControl(
this._inputNode, {addSpace: false});
this._openLinkNode.addEventListener(
'click', e => this._evtOpenLinkClick(e));
this._closeLinkNode.addEventListener(
'click', e => this._evtCloseLinkClick(e));
this._hostNode.addEventListener('submit', e => this._evtFormSubmit(e));
}
get value() {
return this._inputNode.value;
}
get opened() {
return this._hostNode.classList.contains('opened');
return this._hostNode.classList.contains('opened') &&
!this._hostNode.classList.contains('hidden');
}
get _openLinkNode() {
@ -41,6 +34,53 @@ class BulkTagEditor extends events.EventTarget {
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() {
return this._hostNode.querySelector('input[name=tag]');
}
@ -54,10 +94,6 @@ class BulkTagEditor extends events.EventTarget {
this._inputNode.blur();
}
toggleOpen(state) {
this._hostNode.classList.toggle('opened', state);
}
_evtFormSubmit(e) {
e.preventDefault();
this.dispatchEvent(new CustomEvent('submit', {detail: {}}));
@ -99,18 +135,38 @@ class PostsHeaderView extends events.EventTarget {
safetyButtonNode.addEventListener(
'click', e => this._evtSafetyButtonClick(e));
}
this._formNode.addEventListener(
'submit', e => this._evtFormSubmit(e));
this._formNode.addEventListener('submit', e => this._evtFormSubmit(e));
this._bulkEditors = [];
if (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._navigate();
});
this._bulkTagEditor.addEventListener('close', e => {
editor.addEventListener('open', e => {
this._hideBulkEditorsExcept(editor);
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');
}
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) {
e.preventDefault();
e.target.classList.toggle('disabled');
@ -164,6 +245,9 @@ class PostsHeaderView extends events.EventTarget {
} else {
parameters.tag = null;
}
parameters.safety = (
this._bulkSafetyEditor &&
this._bulkSafetyEditor.opened ? '1' : null);
this.dispatchEvent(
new CustomEvent('navigate', {detail: {parameters: parameters}}));
}

View file

@ -18,26 +18,48 @@ class PostsPageView extends events.EventTarget {
post.addEventListener('change', e => this._evtPostChange(e));
}
this._postIdToLinkNode = {};
for (let linkNode of this._tagFlipperNodes) {
const postId = linkNode.getAttribute('data-post-id');
this._postIdToListItemNode = {};
for (let listItemNode of this._listItemNodes) {
const postId = listItemNode.getAttribute('data-post-id');
const post = this._postIdToPost[postId];
this._postIdToLinkNode[postId] = linkNode;
linkNode.addEventListener(
'click', e => this._evtBulkEditTagsClick(e, post));
this._postIdToListItemNode[postId] = listItemNode;
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() {
return this._hostNode.querySelectorAll('.tag-flipper');
get _listItemNodes() {
return this._hostNode.querySelectorAll('li');
}
_getTagFlipperNode(listItemNode) {
return listItemNode.querySelector('.tag-flipper');
}
_getSafetyFlipperNode(listItemNode) {
return listItemNode.querySelector('.safety-flipper');
}
_evtPostChange(e) {
const linkNode = this._postIdToLinkNode[e.detail.post.id];
linkNode.removeAttribute('data-disabled');
this._syncTagFlippersHighlights();
const listItemNode = this._postIdToListItemNode[e.detail.post.id];
for (let node of listItemNode.querySelectorAll('[data-disabled]')) {
node.removeAttribute('data-disabled');
}
this._syncBulkEditorsHighlights();
}
_evtBulkEditTagsClick(e, post) {
@ -53,15 +75,43 @@ class PostsPageView extends events.EventTarget {
{detail: {post: post}}));
}
_syncTagFlippersHighlights() {
for (let linkNode of this._tagFlipperNodes) {
const postId = linkNode.getAttribute('data-post-id');
_evtBulkEditSafetyClick(e, post) {
e.preventDefault();
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];
let tagged = true;
for (let tag of this._ctx.bulkEdit.tags) {
tagged = tagged & post.isTaggedWith(tag);
const tagFlipperNode = this._getTagFlipperNode(listItemNode);
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:favorite': regular
'posts:bulk-edit:tags': power
'posts:bulk-edit:safety': power
'tags:create': regular
'tags:edit:names': power