client/posts: add mass tag

This commit is contained in:
rr- 2016-07-05 21:20:28 +02:00
parent 99011b02d7
commit fccedc090f
16 changed files with 322 additions and 64 deletions

View file

@ -51,7 +51,7 @@ a
position: absolute
visibility: hidden
a.append
a.append, span.append
margin-left: 1em
form .fa-question-circle-o
font-size: 110%

View file

@ -67,7 +67,7 @@ $safety-unsafe = #F3985F
min-height: 7.5em
height: 9vw
a
.thumbnail-wrapper
display: inline-block
width: 100%
height: 100%
@ -99,6 +99,35 @@ $safety-unsafe = #F3985F
.icon:not(:first-of-type)
margin-left: 1em
.masstag
position: absolute
top: 0.5em
left: 0.5em
display: inline-block
padding: 0.5em
box-sizing: border-box
border: 0
cursor: pointer
&:after
display: inline-block
width: 1em
height: 1em
text-align: center
line-height: 1em
font-size: 20pt
&.tagged
background: rgba(0, 230, 0, 0.7)
&:after
color: white
content: '-'
&:not(.tagged)
background: rgba(255, 0, 0, 0.7)
&:after
color: white
content: '+'
&[data-disabled]
background: rgba(200, 200, 200, 0.7)
.thumbnail
background-position: 50% 30%
width: 100%
@ -122,12 +151,23 @@ $safety-unsafe = #F3985F
text-align: left
form
width: auto
*
vertical-align: top
input[name=search-text]
width: 25em
max-width: 90vw
input[name=masstag]
width: 15em
margin-left: 1em
.append
font-size: 0.95em
color: $inactive-link-color
.masstag
&:not(.active)
[type=text],
.start-tagging,
.stop-tagging
display: none
.safety
&.safety-safe
@ -148,7 +188,7 @@ $safety-unsafe = #F3985F
.post-container
.post-content.transparency-grid img
background: url('/img/transparency_grid.png');
background: url('/img/transparency_grid.png')
text-align: center
.post-content

View file

@ -1,18 +1,22 @@
<div class='post-list-header'>
<form class='horizontal'>
<div class='input'>
<ul>
<li>
<%= ctx.makeTextInput({text: 'Search query', id: 'search-text', name: 'search-text', value: ctx.searchQuery.text}) %>
</li>
</ul>
</div>
<div class='buttons'>
<input class='mousetrap' type='submit' value='Search'/>
<input data-safety=safe type='button' class='mousetrap safety safety-safe <%- ctx.settings.listPosts.safe ? '' : 'disabled' %>'/>
<input data-safety=sketchy type='button' class='mousetrap safety safety-sketchy <%- ctx.settings.listPosts.sketchy ? '' : 'disabled' %>'/>
<input data-safety=unsafe type='button' class='mousetrap safety safety-unsafe <%- ctx.settings.listPosts.unsafe ? '' : 'disabled' %>'/>
<a class='mousetrap button append' href='/help/search/posts'>Syntax help</a>
</div>
<form class='horizontal search'>
<%= ctx.makeTextInput({text: 'Search query', id: 'search-text', name: 'search-text', value: ctx.searchQuery.text}) %>
<input class='mousetrap' type='submit' value='Search'/>
<input data-safety=safe type='button' class='mousetrap safety safety-safe <%- ctx.settings.listPosts.safe ? '' : 'disabled' %>'/>
<input data-safety=sketchy type='button' class='mousetrap safety safety-sketchy <%- ctx.settings.listPosts.sketchy ? '' : 'disabled' %>'/>
<input data-safety=unsafe type='button' class='mousetrap safety safety-unsafe <%- ctx.settings.listPosts.unsafe ? '' : 'disabled' %>'/>
<a class='mousetrap button append' href='/help/search/posts'>Syntax help</a>
</form>
<% if (ctx.canMassTag) { %>
<form class='masstag horizontal'>
<% if (ctx.searchQuery.tag) { %>
<span class='append'>Tagging with:</span>
<% } else { %>
<a class='mousetrap button append open-masstag' href='#'>Mass tag</a>
<% } %>
<%= ctx.makeTextInput({name: 'masstag', value: ctx.searchQuery.tag}) %>
<input class='mousetrap start-tagging' type='submit' value='Start tagging'/>
<a class='mousetrap button append stop-tagging' href='#'>Stop tagging</a>
</form>
<% } %>
</div>

View file

@ -5,12 +5,12 @@
<li>
<% if (ctx.canViewPosts) { %>
<% if (ctx.searchQuery && ctx.searchQuery.text) { %>
<a href='/post/<%- encodeURIComponent(post.id) %>/text=<%- encodeURIComponent(ctx.searchQuery.text) %>' title='@<%- post.id %> (<%- post.type %>)&#10;&#10;Tags: <%- post.tags.map(tag => '#' + tag).join(' ') %>'>
<a class='thumbnail-wrapper' href='/post/<%- encodeURIComponent(post.id) %>/text=<%- encodeURIComponent(ctx.searchQuery.text) %>' title='@<%- post.id %> (<%- post.type %>)&#10;&#10;Tags: <%- post.tags.map(tag => '#' + tag).join(' ') %>'>
<% } else { %>
<a href='/post/<%- encodeURIComponent(post.id) %>' title='@<%- post.id %> (<%- post.type %>)&#10;&#10;Tags: <%- post.tags.map(tag => '#' + tag).join(' ') %>'>
<a class='thumbnail-wrapper' href='/post/<%- encodeURIComponent(post.id) %>' title='@<%- post.id %> (<%- post.type %>)&#10;&#10;Tags: <%- post.tags.map(tag => '#' + tag).join(' ') %>'>
<% } %>
<% } else { %>
<a>
<a class='thumbnail-wrapper'>
<% } %>
<%= ctx.makeThumbnail(post.thumbnailUrl) %>
<span class='type' data-type='<%- post.type %>'>
@ -39,6 +39,10 @@
</span>
<% } %>
</a>
<% if (ctx.searchQuery && ctx.searchQuery.tag) { %>
<a data-post-id='<%= post.id %>' class='masstag'>
</a>
<% } %>
</li>
<% } %>
<%= ctx.makeFlexboxAlign() %>

View file

@ -15,7 +15,11 @@ class CommentsController {
this._pageController = new PageController({
searchQuery: ctx.searchQuery,
clientUrl: '/comments/' + misc.formatSearchQuery({page: '{page}'}),
getClientUrlForPage: page => {
const searchQuery = Object.assign(
{}, ctx.searchQuery, {page: page});
return '/comments/' + misc.formatSearchQuery(searchQuery);
},
requestPage: page => {
return PostList.search(
'sort:comment-date+comment-count-min:1', page, 10, fields);

View file

@ -7,7 +7,7 @@ const ManualPageView = require('../views/manual_page_view.js');
class PageController {
constructor(ctx) {
const extendedContext = {
clientUrl: ctx.clientUrl,
getClientUrlForPage: ctx.getClientUrlForPage,
searchQuery: ctx.searchQuery,
};

View file

@ -17,27 +17,63 @@ class PostListController {
constructor(ctx) {
topNavigation.activate('posts');
this._ctx = ctx;
this._pageController = new PageController({
searchQuery: ctx.searchQuery,
clientUrl: '/posts/' + misc.formatSearchQuery({
text: ctx.searchQuery.text, page: '{page}'}),
getClientUrlForPage: page => {
const searchQuery = Object.assign(
{}, ctx.searchQuery, {page: page});
return '/posts/' + misc.formatSearchQuery(searchQuery);
},
requestPage: page => {
return PostList.search(
this._decorateSearchQuery(ctx.searchQuery.text),
page, 40, fields);
},
headerRenderer: headerCtx => {
Object.assign(headerCtx, {
canMassTag: api.hasPrivilege('tags:masstag'),
massTagTags: this._massTagTags,
});
return new PostsHeaderView(headerCtx);
},
pageRenderer: pageCtx => {
Object.assign(pageCtx, {
canViewPosts: api.hasPrivilege('posts:view'),
massTagTags: this._massTagTags,
});
return new PostsPageView(pageCtx);
const view = new PostsPageView(pageCtx);
view.addEventListener('tag', e => this._evtTag(e));
view.addEventListener('untag', e => this._evtUntag(e));
return view;
},
});
}
get _massTagTags() {
return (this._ctx.searchQuery.tag || '').split(/\s+/).filter(s => s);
}
_evtTag(e) {
for (let tag of this._massTagTags) {
e.detail.post.addTag(tag);
}
e.detail.post.save()
.catch(errorMessage => {
window.alert(errorMessage);
});
}
_evtUntag(e) {
for (let tag of this._massTagTags) {
e.detail.post.removeTag(tag);
}
e.detail.post.save()
.catch(errorMessage => {
window.alert(errorMessage);
});
}
_decorateSearchQuery(text) {
const browsingSettings = settings.get();
let disabledSafety = [];

View file

@ -17,8 +17,11 @@ class TagListController {
this._pageController = new PageController({
searchQuery: ctx.searchQuery,
clientUrl: '/tags/' + misc.formatSearchQuery({
text: ctx.searchQuery.text, page: '{page}'}),
getClientUrlForPage: page => {
const searchQuery = Object.assign(
{}, ctx.searchQuery, {page: page});
return '/tags/' + misc.formatSearchQuery(searchQuery);
},
requestPage: page => {
return TagList.search(ctx.searchQuery.text, page, 50, fields);
},

View file

@ -14,8 +14,11 @@ class UserListController {
this._pageController = new PageController({
searchQuery: ctx.searchQuery,
clientUrl: '/users/' + misc.formatSearchQuery({
text: ctx.searchQuery.text, page: '{page}'}),
getClientUrlForPage: page => {
const searchQuery = Object.assign(
{}, ctx.searchQuery, {page: page});
return '/users/' + misc.formatSearchQuery(searchQuery);
},
requestPage: page => {
return UserList.search(ctx.searchQuery.text, page);
},

View file

@ -105,18 +105,16 @@ class TagInputControl {
this._editAreaNode.insertBefore(targetWrapperNode, sourceWrapperNode);
this._editAreaNode.insertBefore(this._createSpace(), sourceWrapperNode);
const actualTag = tags.getTagByName(text) || {};
// XXX: perhaps we should aggregate suggestions from all implications
// for call to the _suggestRelations
if (addImplications) {
for (let otherTag of (actualTag.implications || [])) {
for (let otherTag of tags.getAllImplications(text)) {
this.addTag(otherTag, sourceNode, true, false);
}
}
if (suggestRelations) {
this._suggestRelations([], actualTag.suggestions || []);
this._suggestRelations([], tags.getSuggestions(text) || []);
}
}

View file

@ -1,6 +1,7 @@
'use strict';
const api = require('../api.js');
const tags = require('../tags.js');
const events = require('../events.js');
const CommentList = require('./comment_list.js');
@ -67,6 +68,48 @@ class Post extends events.EventTarget {
});
}
isTaggedWith(tagName) {
return this._tags.map(s => s.toLowerCase()).includes(tagName);
}
addTag(tagName, addImplications) {
if (this.isTaggedWith(tagName)) {
return;
}
this._tags.push(tagName);
if (addImplications !== false) {
for (let otherTag of tags.getAllImplications(tagName)) {
this.addTag(otherTag, addImplications);
}
}
}
removeTag(tagName) {
this._tags = this._tags.filter(
s => s.toLowerCase() != tagName.toLowerCase());
}
save() {
let promise = null;
let data = {
tags: this._tags,
};
if (this._id) {
promise = api.put('/post/' + this._id, data);
} else {
promise = api.post('/posts', data);
}
return promise.then(response => {
this._updateFromResponse(response);
this.dispatchEvent(
new CustomEvent('change', {detail: {post: this}}));
return Promise.resolve();
}, response => {
return Promise.reject(response.description);
});
}
setScore(score) {
return api.put('/post/' + this._id + '/score', {score: score})
.then(response => {

View file

@ -89,6 +89,17 @@ function refreshExport() {
});
}
function getAllImplications(tagName) {
const actualTag = getTagByName(tagName) || {};
// TODO: recursive
return actualTag.implications || [];
}
function getSuggestions(tagName) {
const actualTag = getTagByName(tagName) || {};
return actualTag.suggestions || [];
}
module.exports = {
getAllCategories: getAllCategories,
getAllTags: getAllTags,
@ -97,4 +108,6 @@ module.exports = {
getNameToTagMap: getNameToTagMap,
getOriginalTagName: getOriginalTagName,
refreshExport: refreshExport,
getAllImplications: getAllImplications,
getSuggestions: getSuggestions,
};

View file

@ -6,10 +6,6 @@ const views = require('../util/views.js');
const holderTemplate = views.getTemplate('endless-pager');
const pageTemplate = views.getTemplate('endless-pager-page');
function _formatUrl(url, page) {
return url.replace('{page}', page);
}
class EndlessPageView {
constructor(ctx) {
this._hostNode = document.getElementById('content-holder');
@ -68,7 +64,7 @@ class EndlessPageView {
let topPageNumber = parseInt(topPageNode.getAttribute('data-page'));
if (topPageNumber !== this.currentPage) {
router.replace(
_formatUrl(ctx.clientUrl, topPageNumber),
ctx.getClientUrlForPage(topPageNumber),
{},
false);
this.currentPage = topPageNumber;

View file

@ -8,10 +8,6 @@ const views = require('../util/views.js');
const holderTemplate = views.getTemplate('manual-pager');
const navTemplate = views.getTemplate('manual-pager-nav');
function _formatUrl(url, page) {
return url.replace('{page}', page);
}
function _removeConsecutiveDuplicates(a) {
return a.filter((item, pos, ary) => {
return !pos || item != ary[pos - 1];
@ -40,7 +36,7 @@ function _getVisiblePageNumbers(currentPage, totalPages) {
return pagesVisible;
}
function _getPages(currentPage, pageNumbers, clientUrl) {
function _getPages(currentPage, pageNumbers, ctx) {
const pages = [];
let lastPage = 0;
for (let page of pageNumbers) {
@ -49,7 +45,7 @@ function _getPages(currentPage, pageNumbers, clientUrl) {
}
pages.push({
number: page,
link: _formatUrl(clientUrl, page),
link: ctx.getClientUrlForPage(page),
active: currentPage === page,
});
lastPage = page;
@ -83,16 +79,16 @@ class ManualPageView {
const totalPages = Math.ceil(response.total / response.pageSize);
const pageNumbers = _getVisiblePageNumbers(currentPage, totalPages);
const pages = _getPages(currentPage, pageNumbers, ctx.clientUrl);
const pages = _getPages(currentPage, pageNumbers, ctx);
keyboard.bind(['a', 'left'], () => {
if (currentPage > 1) {
router.show(_formatUrl(ctx.clientUrl, currentPage - 1));
router.show(ctx.getClientUrlForPage(currentPage - 1));
}
});
keyboard.bind(['d', 'right'], () => {
if (currentPage < totalPages) {
router.show(_formatUrl(ctx.clientUrl, currentPage + 1));
router.show(ctx.getClientUrlForPage(currentPage + 1));
}
});
@ -100,8 +96,8 @@ class ManualPageView {
views.replaceContent(
pageNavNode,
navTemplate({
prevLink: _formatUrl(ctx.clientUrl, currentPage - 1),
nextLink: _formatUrl(ctx.clientUrl, currentPage + 1),
prevLink: ctx.getClientUrlForPage(currentPage - 1),
nextLink: ctx.getClientUrlForPage(currentPage + 1),
prevLinkActive: currentPage > 1,
nextLinkActive: currentPage < totalPages,
pages: pages,

View file

@ -13,15 +13,20 @@ const template = views.getTemplate('posts-header');
class PostsHeaderView {
constructor(ctx) {
ctx.settings = settings.get();
this._ctx = ctx;
this._hostNode = ctx.hostNode;
views.replaceContent(this._hostNode, template(ctx));
if (this._queryInputNode) {
new TagAutoCompleteControl(this._queryInputNode, {addSpace: true});
}
if (this._massTagInputNode) {
new TagAutoCompleteControl(
this._massTagInputNode, {addSpace: false});
}
keyboard.bind('q', () => {
this._formNode.querySelector('input').focus();
this._searchFormNode.querySelector('input').focus();
});
keyboard.bind('p', () => {
@ -32,20 +37,69 @@ class PostsHeaderView {
}
});
for (let safetyButton of this._formNode.querySelectorAll('.safety')) {
for (let safetyButton of this._safetyButtonNodes) {
safetyButton.addEventListener(
'click', e => this._evtSafetyButtonClick(e, ctx.clientUrl));
'click', e => this._evtSafetyButtonClick(e));
}
this._searchFormNode.addEventListener(
'submit', e => this._evtSearchFormSubmit(e));
if (this._massTagFormNode) {
if (this._openMassTagLinkNode) {
this._openMassTagLinkNode.addEventListener(
'click', e => this._evtMassTagClick(e));
}
this._stopMassTagLinkNode.addEventListener(
'click', e => this._evtStopTaggingClick(e));
this._massTagFormNode.addEventListener(
'submit', e => this._evtMassTagFormSubmit(e));
this._toggleMassTagVisibility(!!ctx.searchQuery.tag);
}
this._formNode.addEventListener(
'submit', e => this._evtFormSubmit(e, this._queryInputNode));
}
get _formNode() {
return this._hostNode.querySelector('form');
_toggleMassTagVisibility(state) {
this._massTagFormNode.classList.toggle('active', state);
}
get _searchFormNode() {
return this._hostNode.querySelector('form.search');
}
get _massTagFormNode() {
return this._hostNode.querySelector('form.masstag');
}
get _safetyButtonNodes() {
return this._searchFormNode.querySelectorAll('.safety');
}
get _queryInputNode() {
return this._formNode.querySelector('[name=search-text]');
return this._searchFormNode.querySelector('[name=search-text]');
}
get _massTagInputNode() {
return this._massTagFormNode.querySelector('[type=text]');
}
get _openMassTagLinkNode() {
return this._massTagFormNode.querySelector('.open-masstag');
}
get _stopMassTagLinkNode() {
return this._massTagFormNode.querySelector('.stop-tagging');
}
_evtMassTagClick(e) {
e.preventDefault();
this._toggleMassTagVisibility(true);
}
_evtStopTaggingClick(e) {
e.preventDefault();
router.show('/posts/' + misc.formatSearchQuery({
text: this._ctx.searchQuery.text,
page: this._ctx.searchQuery.page,
}));
}
_evtSafetyButtonClick(e, url) {
@ -56,15 +110,27 @@ class PostsHeaderView {
browsingSettings.listPosts[safety] =
!browsingSettings.listPosts[safety];
settings.save(browsingSettings, true);
router.show(url.replace(/{page}/, 1));
router.show(location.pathname + location.search + location.hash);
}
_evtFormSubmit(e, queryInputNode) {
_evtSearchFormSubmit(e) {
e.preventDefault();
const text = queryInputNode.value;
queryInputNode.blur();
const text = this._queryInputNode.value;
this._queryInputNode.blur();
router.show('/posts/' + misc.formatSearchQuery({text: text}));
}
_evtMassTagFormSubmit(e) {
e.preventDefault();
const text = this._queryInputNode.value;
const tag = this._massTagInputNode.value;
this._massTagInputNode.blur();
router.show('/posts/' + misc.formatSearchQuery({
text: text,
tag: tag,
page: this._ctx.searchQuery.page,
}));
}
}
module.exports = PostsHeaderView;

View file

@ -1,12 +1,64 @@
'use strict';
const events = require('../events.js');
const views = require('../util/views.js');
const template = views.getTemplate('posts-page');
class PostsPageView {
class PostsPageView extends events.EventTarget {
constructor(ctx) {
views.replaceContent(ctx.hostNode, template(ctx));
super();
this._ctx = ctx;
this._hostNode = ctx.hostNode;
views.replaceContent(this._hostNode, template(ctx));
this._postIdToPost = {};
for (let post of ctx.results) {
this._postIdToPost[post.id] = post;
post.addEventListener('change', e => this._evtPostChange(e));
}
this._postIdToLinkNode = {};
for (let linkNode of this._hostNode.querySelectorAll('.masstag')) {
const postId = linkNode.getAttribute('data-post-id');
const post = this._postIdToPost[postId];
this._postIdToLinkNode[postId] = linkNode;
linkNode.addEventListener(
'click', e => this._evtMassTagClick(e, post));
}
this._syncMassTagHighlights();
}
_evtPostChange(e) {
const linkNode = this._postIdToLinkNode[e.detail.post.id];
linkNode.removeAttribute('data-disabled');
this._syncMassTagHighlights();
}
_syncMassTagHighlights() {
for (let linkNode of this._hostNode.querySelectorAll('.masstag')) {
const postId = linkNode.getAttribute('data-post-id');
const post = this._postIdToPost[postId];
let tagged = true;
for (let tag of this._ctx.massTagTags) {
tagged = tagged & post.isTaggedWith(tag);
}
linkNode.classList.toggle('tagged', tagged);
}
}
_evtMassTagClick(e, post) {
e.preventDefault();
const linkNode = e.target;
if (linkNode.getAttribute('data-disabled')) {
return;
}
linkNode.setAttribute('data-disabled', true);
this.dispatchEvent(
new CustomEvent(
linkNode.classList.contains('tagged') ? 'untag' : 'tag',
{detail: {post: post}}));
}
}