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 @@
-
-
-
    -
  • - <%= ctx.makeTextInput({text: 'Search query', id: 'search-text', name: 'search-text', value: ctx.searchQuery.text}) %> -
  • -
-
-
- - '/> - '/> - '/> - Syntax help -
+ + <%= ctx.makeTextInput({text: 'Search query', id: 'search-text', name: 'search-text', value: ctx.searchQuery.text}) %> + + '/> + '/> + '/> + Syntax help
+ <% if (ctx.canMassTag) { %> +
+ <% if (ctx.searchQuery.tag) { %> + Tagging with: + <% } else { %> + Mass tag + <% } %> + <%= ctx.makeTextInput({name: 'masstag', value: ctx.searchQuery.tag}) %> + + Stop tagging +
+ <% } %>
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}})); } }