diff --git a/client/css/forms.styl b/client/css/forms.styl index 404aaf61..ddfeac32 100644 --- a/client/css/forms.styl +++ b/client/css/forms.styl @@ -172,6 +172,15 @@ input[type=password] &:focus border-color: $main-color +label.color + position: relative + input[type=text] + text-align: center + pointer-events: none + input[type=color] + position: absolute + opacity: 0 + form.show-validation .input input:invalid outline: none diff --git a/client/css/main.styl b/client/css/main.styl index ef57300e..b27340f4 100644 --- a/client/css/main.styl +++ b/client/css/main.styl @@ -19,6 +19,8 @@ a color: $main-color text-decoration: none transition: color 0.1s linear + &.inactive + color: $inactive-link-color &.icon color: $inactive-link-color opacity: .5 diff --git a/client/css/tags.styl b/client/css/tags.styl index cfe750b9..5f4af2ee 100644 --- a/client/css/tags.styl +++ b/client/css/tags.styl @@ -38,3 +38,23 @@ .append font-size: 0.95em color: $inactive-link-color + +.tag-categories + td, th + padding: .2em + &:first-child + padding-left: 0 + &:last-child + padding-right: 0 + &.name + width: 12em + &.color + text-align: center + width: 5em + &.usages + text-align: center + width: 5em + tfoot + display: none + .messages, form + width: auto diff --git a/client/html/tag_categories.hbs b/client/html/tag_categories.hbs new file mode 100644 index 00000000..75a00bfe --- /dev/null +++ b/client/html/tag_categories.hbs @@ -0,0 +1,76 @@ +
+
+

Tag categories

+ + + + + + + + + + <% _.each(tagCategories, category => { %> + + + + + <% if (canDelete) { %> + + <% } %> + + <% }) %> + + + + + + + + + +
Category nameCSS colorUsages
+ <% if (canEditName) { %> + <%= makeTextInput({value: category.name, required: true}) %> + <% } else { %> + <%= category.name %> + <% } %> + + <% if (canEditColor) { %> + <%= makeColorInput({value: category.color}) %> + <% } else { %> + <%= category.color %> + <% } %> + + + <%= category.usages %> + + + <% if (category.usages) { %> + Remove + <% } else { %> + Remove + <% } %> +
+ <%= makeTextInput({required: true}) %> + + <%= makeColorInput({value: '#000000'}) %> + + 0 + + Remove +
+ + <% if (canCreate) { %> +

Add new category

+ <% } %> + +
+ + <% if (canCreate || canEditName || canEditColor || canDelete) { %> +
+ +
+ <% } %> +
+
diff --git a/client/html/tag_list_header.hbs b/client/html/tag_list_header.hbs index 49746ce4..76c34978 100644 --- a/client/html/tag_list_header.hbs +++ b/client/html/tag_list_header.hbs @@ -9,7 +9,10 @@
- Syntax help + Syntax help + <% if (canEditTagCategories) { %> + Tag categories + <% } %>
diff --git a/client/html/tag_list_page.hbs b/client/html/tag_list_page.hbs index b9cd21c6..22438661 100644 --- a/client/html/tag_list_page.hbs +++ b/client/html/tag_list_page.hbs @@ -13,7 +13,7 @@ @@ -21,7 +21,7 @@ <% if (tag.implications.length) { %> <% } else { %> @@ -32,7 +32,7 @@ <% if (tag.suggestions.length) { %> <% } else { %> diff --git a/client/js/controllers/tags_controller.js b/client/js/controllers/tags_controller.js index e7992140..0ebe0dac 100644 --- a/client/js/controllers/tags_controller.js +++ b/client/js/controllers/tags_controller.js @@ -2,25 +2,77 @@ const page = require('page'); const api = require('../api.js'); +const events = require('../events.js'); const misc = require('../util/misc.js'); const topNavController = require('../controllers/top_nav_controller.js'); const pageController = require('../controllers/page_controller.js'); const TagListHeaderView = require('../views/tag_list_header_view.js'); const TagListPageView = require('../views/tag_list_page_view.js'); +const TagCategoriesView = require('../views/tag_categories_view.js'); class TagsController { constructor() { this.tagListHeaderView = new TagListHeaderView(); this.tagListPageView = new TagListPageView(); + this.tagCategoriesView = new TagCategoriesView(); } registerRoutes() { + page('/tag-categories', () => { this.tagCategoriesRoute(); }); page( '/tags/:query?', (ctx, next) => { misc.parseSearchQueryRoute(ctx, next); }, (ctx, next) => { this.listTagsRoute(ctx, next); }); } + _saveTagCategories(addedCategories, changedCategories, removedCategories) { + let promises = []; + for (let category of addedCategories) { + promises.push(api.post('/tag-categories/', category)); + } + for (let category of changedCategories) { + promises.push( + api.put('/tag-category/' + category.originalName, category)); + } + for (let name of removedCategories) { + promises.push(api.delete('/tag-category/' + name)); + } + Promise.all(promises).then( + () => { + events.notify(events.TagsChange); + events.notify(events.Success, 'Changes saved successfully'); + }, + response => { + events.notify(events.Error, response.description); + }); + } + + tagCategoriesRoute(ctx, next) { + topNavController.activate('tags'); + api.get('/tag-categories/').then(response => { + this.tagCategoriesView.render({ + tagCategories: response.results, + canEditName: api.hasPrivilege('tagCategories:edit:name'), + canEditColor: api.hasPrivilege('tagCategories:edit:color'), + canDelete: api.hasPrivilege('tagCategories:delete'), + canCreate: api.hasPrivilege('tagCategories:create'), + saveChanges: (...args) => { + return this._saveTagCategories(...args); + }, + getCategories: () => { + return api.get('/tag-categories/').then(response => { + return Promise.resolve(response.results); + }, response => { + return Promise.reject(response); + }); + } + }); + }, response => { + this.emptyView.render(); + events.notify(events.Error, response.description); + }); + } + listTagsRoute(ctx, next) { topNavController.activate('tags'); @@ -37,6 +89,7 @@ class TagsController { searchQuery: ctx.searchQuery, headerRenderer: this.tagListHeaderView, pageRenderer: this.tagListPageView, + canEditTagCategories: api.hasPrivilege('tagCategories:edit'), }); } } diff --git a/client/js/events.js b/client/js/events.js index 16f44972..0493c3b0 100644 --- a/client/js/events.js +++ b/client/js/events.js @@ -28,6 +28,7 @@ module.exports = { Info: 3, Authentication: 4, SettingsChange: 5, + TagsChange: 6, notify: notify, listen: listen, diff --git a/client/js/main.js b/client/js/main.js index c9e07476..784db032 100644 --- a/client/js/main.js +++ b/client/js/main.js @@ -27,22 +27,24 @@ controllers.push(require('./controllers/settings_controller.js')); controllers.push(require('./controllers/home_controller.js')); +const tags = require('./tags.js'); const events = require('./events.js'); for (let controller of controllers) { controller.registerRoutes(); } const api = require('./api.js'); -api.loginFromCookies().then(() => { - page(); -}).catch(errorMessage => { - if (window.location.href.indexOf('login') !== -1) { - api.forget(); +Promise.all([tags.refreshExport(), api.loginFromCookies()]) + .then(() => { page(); - } else { - page('/'); - events.notify( - events.Error, - 'An error happened while trying to log you in: ' + errorMessage); - } -}); + }).catch(errorMessage => { + if (window.location.href.indexOf('login') !== -1) { + api.forget(); + page(); + } else { + page('/'); + events.notify( + events.Error, + 'An error happened while trying to log you in: ' + errorMessage); + } + }); diff --git a/client/js/tags.js b/client/js/tags.js new file mode 100644 index 00000000..49f70a2d --- /dev/null +++ b/client/js/tags.js @@ -0,0 +1,70 @@ +'use strict'; + +const request = require('superagent'); +const util = require('./util/misc.js'); +const events = require('./events.js'); + +let _export = null; +let _stylesheet = null; + +function _tagsToDictionary(tags) +{ + let dict = {}; + for (let tag of tags) { + for (let name of tag.names) { + dict[name] = tag; + } + } + return dict; +} + +function _tagCategoriesToDictionary(categories) +{ + let dict = {}; + for (let category of categories) { + dict[category.name] = category; + } + return dict; +} + +function _refreshStylesheet() { + if (_stylesheet) { + document.head.removeChild(_stylesheet); + } + _stylesheet = document.createElement('style'); + document.head.appendChild(_stylesheet); + for (let category of Object.values(_export.categories)) { + _stylesheet.sheet.insertRule( + '.tag-{0} { color: {1} }'.format(category.name, category.color), + _stylesheet.sheet.cssRules.length); + } +} + +function refreshExport() { + return new Promise((resolve, reject) => { + request.get('/data/tags.json').end((error, response) => { + if (error) { + console.log('Error while fetching exported tags', error); + _export = {tags: {}, categories: {}}; + reject(error); + } + _export = response.body; + _export.tags = _tagsToDictionary(_export.tags); + _export.categories = _tagCategoriesToDictionary( + _export.categories); + _refreshStylesheet(); + resolve(); + }); + }); +} + +function getExport() { + return _export || {}; +} + +events.listen(events.TagsChange, refreshExport); + +module.exports = { + getExport: getExport, + refreshExport: refreshExport, +}; diff --git a/client/js/util/views.js b/client/js/util/views.js index 09f97935..51b079c5 100644 --- a/client/js/util/views.js +++ b/client/js/util/views.js @@ -2,6 +2,7 @@ require('../util/polyfill.js'); const underscore = require('underscore'); +const tags = require('../tags.js'); const events = require('../events.js'); const domParser = new DOMParser(); const misc = require('./misc.js'); @@ -105,6 +106,37 @@ function makeEmailInput(options) { return makeInput(options); } +function makeColorInput(options) { + const textInput = makeVoidElement( + 'input', { + type: 'text', + value: options.value || '', + required: options.required, + style: 'color: ' + options.value, + disabled: true, + }); + const colorInput = makeVoidElement( + 'input', { + type: 'color', + value: options.value || '', + }); + return makeNonVoidElement('label', {class: 'color'}, colorInput + textInput); +} + +function makeTagLink(name) { + const tagExport = tags.getExport(); + let category = null; + try { + category = tagExport.tags[name].category; + } catch (e) { + category = 'unknown'; + } + return makeNonVoidElement('a', { + 'href': '/tag/' + name, + 'class': 'tag-' + category, + }, name); +} + function makeFlexboxAlign(options) { return Array.from(misc.range(20)) .map(() => '
  • ').join(''); @@ -201,6 +233,8 @@ function getTemplate(templatePath) { makeTextInput: makeTextInput, makePasswordInput: makePasswordInput, makeEmailInput: makeEmailInput, + makeColorInput: makeColorInput, + makeTagLink: makeTagLink, makeFlexboxAlign: makeFlexboxAlign, }); return htmlToDom(templateFactory(ctx)); @@ -210,10 +244,15 @@ function getTemplate(templatePath) { function decorateValidator(form) { // postpone showing form fields validity until user actually tries // to submit it (seeing red/green form w/o doing anything breaks POLA) - const submitButton = form.querySelector('.buttons input'); - submitButton.addEventListener('click', e => { - form.classList.add('show-validation'); - }); + let submitButton = form.querySelector('.buttons input'); + if (!submitButton) { + submitButton = form.querySelector('input[type=submit]'); + } + if (submitButton) { + submitButton.addEventListener('click', e => { + form.classList.add('show-validation'); + }); + } form.addEventListener('submit', e => { form.classList.remove('show-validation'); }); @@ -259,6 +298,14 @@ function scrollToHash() { }, 10); } +document.addEventListener('input', e => { + if (e.target.getAttribute('type').toLowerCase() === 'color') { + const textInput = e.target.parentNode.querySelector('input[type=text]'); + textInput.style.color = e.target.value; + textInput.value = e.target.value; + } +}); + module.exports = { htmlToDom: htmlToDom, getTemplate: getTemplate, diff --git a/client/js/views/tag_categories_view.js b/client/js/views/tag_categories_view.js new file mode 100644 index 00000000..93a6bb13 --- /dev/null +++ b/client/js/views/tag_categories_view.js @@ -0,0 +1,109 @@ +'use strict'; + +const misc = require('../util/misc.js'); +const views = require('../util/views.js'); + +class TagListHeaderView { + constructor() { + this.template = views.getTemplate('tag-categories'); + } + + _saveButtonClickHandler(e, ctx, target) { + e.preventDefault(); + + views.clearMessages(target); + const tableBody = target.querySelector('tbody'); + + ctx.getCategories().then(categories => { + let existingCategories = {}; + for (let category of categories) { + existingCategories[category.name] = category; + } + + let addedCategories = []; + let removedCategories = []; + let changedCategories = []; + let allNames = []; + for (let row of tableBody.querySelectorAll('tr')) { + let name = row.getAttribute('data-category'); + let category = { + originalName: name, + name: row.querySelector('.name input').value, + color: row.querySelector('.color input').value, + }; + if (!name) { + if (category.name) { + addedCategories.push(category); + } + } else { + const existingCategory = existingCategories[name]; + if (existingCategory.color !== category.color + || existingCategory.name !== category.name) { + changedCategories.push(category); + } + } + allNames.push(name); + } + for (let name of Object.keys(existingCategories)) { + if (allNames.indexOf(name) === -1) { + removedCategories.push(name); + } + } + ctx.saveChanges( + addedCategories, changedCategories, removedCategories); + }); + } + + _removeButtonClickHandler(e, row, link) { + e.preventDefault(); + if (link.classList.contains('inactive')) { + return; + } + row.parentNode.removeChild(row); + } + + _addRemoveButtonClickHandler(row) { + const link = row.querySelector('a.remove'); + if (!link) { + return; + } + link.addEventListener( + 'click', e => this._removeButtonClickHandler(e, row, link)); + } + + render(ctx) { + const target = document.getElementById('content-holder'); + const source = this.template(ctx); + + const form = source.querySelector('form'); + const newRowTemplate = source.querySelector('.add-template'); + const tableBody = source.querySelector('tbody'); + const addLink = source.querySelector('a.add'); + const saveButton = source.querySelector('button.save'); + + newRowTemplate.parentNode.removeChild(newRowTemplate); + views.decorateValidator(form); + + for (let row of tableBody.querySelectorAll('tr')) { + this._addRemoveButtonClickHandler(row); + } + + if (addLink) { + addLink.addEventListener('click', e => { + e.preventDefault(); + let newRow = newRowTemplate.cloneNode(true); + tableBody.appendChild(newRow); + this._addRemoveButtonClickHandler(newRow); + }); + } + + form.addEventListener('submit', e => { + this._saveButtonClickHandler(e, ctx, target); + }); + + views.listenToMessages(target); + views.showView(target, source); + } +} + +module.exports = TagListHeaderView;