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 @@
+
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 @@
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 @@
<% _.each(tag.names, name => { %>
- - <%= name %>
+ - <%= makeTagLink(name) %>
<% }) %>
|
@@ -21,7 +21,7 @@
<% if (tag.implications.length) { %>
<% _.each(tag.implications, name => { %>
- - <%= name %>
+ - <%= makeTagLink(name) %>
<% }) %>
<% } else { %>
@@ -32,7 +32,7 @@
<% if (tag.suggestions.length) { %>
<% _.each(tag.suggestions, name => { %>
- - <%= name %>
+ - <%= makeTagLink(name) %>
<% }) %>
<% } 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;