diff --git a/client/css/main.styl b/client/css/main.styl
index 60d985b0..b290c78f 100644
--- a/client/css/main.styl
+++ b/client/css/main.styl
@@ -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%
diff --git a/client/css/posts.styl b/client/css/posts.styl
index 6fe2fb38..ac8e19a9 100644
--- a/client/css/posts.styl
+++ b/client/css/posts.styl
@@ -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
diff --git a/client/html/posts_header.tpl b/client/html/posts_header.tpl
index 3940488e..f92cff7e 100644
--- a/client/html/posts_header.tpl
+++ b/client/html/posts_header.tpl
@@ -1,18 +1,22 @@
diff --git a/client/html/posts_page.tpl b/client/html/posts_page.tpl
index 5e2b3e87..89cb94ec 100644
--- a/client/html/posts_page.tpl
+++ b/client/html/posts_page.tpl
@@ -5,12 +5,12 @@
<% if (ctx.canViewPosts) { %>
<% if (ctx.searchQuery && ctx.searchQuery.text) { %>
- '>
+ '>
<% } else { %>
- '>
+ '>
<% } %>
<% } else { %>
-
+
<% } %>
<%= ctx.makeThumbnail(post.thumbnailUrl) %>
@@ -39,6 +39,10 @@
<% } %>
+ <% if (ctx.searchQuery && ctx.searchQuery.tag) { %>
+
+
+ <% } %>
<% } %>
<%= ctx.makeFlexboxAlign() %>
diff --git a/client/js/controllers/comments_controller.js b/client/js/controllers/comments_controller.js
index 8d672da5..0f2684f6 100644
--- a/client/js/controllers/comments_controller.js
+++ b/client/js/controllers/comments_controller.js
@@ -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);
diff --git a/client/js/controllers/page_controller.js b/client/js/controllers/page_controller.js
index 5fd95178..a7154ee7 100644
--- a/client/js/controllers/page_controller.js
+++ b/client/js/controllers/page_controller.js
@@ -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,
};
diff --git a/client/js/controllers/post_list_controller.js b/client/js/controllers/post_list_controller.js
index 5e2714f3..16f3765e 100644
--- a/client/js/controllers/post_list_controller.js
+++ b/client/js/controllers/post_list_controller.js
@@ -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 = [];
diff --git a/client/js/controllers/tag_list_controller.js b/client/js/controllers/tag_list_controller.js
index 0c1e27ad..74923985 100644
--- a/client/js/controllers/tag_list_controller.js
+++ b/client/js/controllers/tag_list_controller.js
@@ -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);
},
diff --git a/client/js/controllers/user_list_controller.js b/client/js/controllers/user_list_controller.js
index 660dcf42..e9f08947 100644
--- a/client/js/controllers/user_list_controller.js
+++ b/client/js/controllers/user_list_controller.js
@@ -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);
},
diff --git a/client/js/controls/tag_input_control.js b/client/js/controls/tag_input_control.js
index 17103793..ac083d4b 100644
--- a/client/js/controls/tag_input_control.js
+++ b/client/js/controls/tag_input_control.js
@@ -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) || []);
}
}
diff --git a/client/js/models/post.js b/client/js/models/post.js
index 0a228e12..37c8fe06 100644
--- a/client/js/models/post.js
+++ b/client/js/models/post.js
@@ -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 => {
diff --git a/client/js/tags.js b/client/js/tags.js
index f136bc28..ae910109 100644
--- a/client/js/tags.js
+++ b/client/js/tags.js
@@ -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,
};
diff --git a/client/js/views/endless_page_view.js b/client/js/views/endless_page_view.js
index 9736d817..59656427 100644
--- a/client/js/views/endless_page_view.js
+++ b/client/js/views/endless_page_view.js
@@ -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;
diff --git a/client/js/views/manual_page_view.js b/client/js/views/manual_page_view.js
index 1d9bb54f..4d896e94 100644
--- a/client/js/views/manual_page_view.js
+++ b/client/js/views/manual_page_view.js
@@ -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,
diff --git a/client/js/views/posts_header_view.js b/client/js/views/posts_header_view.js
index 9ac6725b..5958e2ae 100644
--- a/client/js/views/posts_header_view.js
+++ b/client/js/views/posts_header_view.js
@@ -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;
diff --git a/client/js/views/posts_page_view.js b/client/js/views/posts_page_view.js
index aeabc02b..686adcfe 100644
--- a/client/js/views/posts_page_view.js
+++ b/client/js/views/posts_page_view.js
@@ -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}}));
}
}