client/tags: add tag merging, editing and deleting

This commit is contained in:
rr- 2016-05-11 23:46:56 +02:00
parent 2a4241641c
commit b1deb617bc
12 changed files with 380 additions and 15 deletions

View file

@ -172,6 +172,11 @@ input[type=password]
&:focus &:focus
border-color: $main-color border-color: $main-color
&[readonly]
border: 2px solid $input-disabled-border-color
background: $input-disabled-background-color
color: $input-disabled-text-color
label.color label.color
position: relative position: relative
input[type=text] input[type=text]

15
client/html/tag.hbs Normal file
View file

@ -0,0 +1,15 @@
<div class='content-wrapper tag'>
<h1><%= tag.names[0] %></h1>
<nav class='text-nav'><!--
--><ul><!--
--><li data-name='summary'><a href='/tag/<%= tag.names[0] %>'>Summary</a></li><!--
--><% if (canMerge) { %><!--
--><li data-name='merge'><a href='/tag/<%= tag.names[0] %>/merge'>Merge with&hellip;</a></li><!--
--><% } %><!--
--><% if (canDelete) { %><!--
--><li data-name='delete'><a href='/tag/<%= tag.names[0] %>/delete'>Delete</a></li><!--
--><% } %><!--
--></ul><!--
--></nav>
<div class='tag-content-holder'></div>
</div>

View file

@ -0,0 +1,20 @@
<div class='tag-delete'>
<form>
<% if (tag.usages) { %>
<p>For extra <s>paranoia</s> safety, only tags that are unused can be deleted.</p>
<p>Check <a href='/posts/<%= tag.names[0] %>'>which posts</a> are tagged with <%= tag.names[0] %>.</p>
<% } else { %>
<div class='input'>
<ul>
<li>
<%= makeCheckbox({id: 'confirm-deletion', name: 'confirm-deletion', required: true, text: 'I confirm that I want to delete this tag.'}) %>
</li>
</ul>
</div>
<div class='messages'></div>
<div class='buttons'>
<input type='submit' value='Delete tag'/>
</div>
<% } %>
</form>
</div>

22
client/html/tag_merge.hbs Normal file
View file

@ -0,0 +1,22 @@
<div class='tag-merge'>
<form class='tabular'>
<p>Proceeding will remove <%= tag.names[0] %> and retag its posts with
the tag specified below. Aliases and relations of <%= tag.names[0] %>
will be discarded and need to be handled by hand.</p>
<div class='input'>
<ul>
<li class='target'>
<%= makeTextInput({required: true, text: 'Target tag', pattern: tagNamePattern}) %>
</li>
<li class='confirm'>
<label></label>
<%= makeCheckbox({required: true, text: 'I confirm that I want to merge this tag.'}) %>
</li>
</ul>
</div>
<div class='messages'></div>
<div class='buttons'>
<input type='submit' value='Merge tag'/>
</div>
</form>
</div>

View file

@ -0,0 +1,26 @@
<div class='content-wrapper tag-summary'>
<form class='tabular'>
<div class='input'>
<ul>
<li class='names'>
<%= makeTextInput({text: 'Names', value: tag.names.join(' '), required: true, readonly: !canEditNames, pattern: tagNamesPattern}) %>
</li>
<li class='category'>
<%= makeSelect({text: 'Category', keyValues: categories, selectedKey: tag.category, required: true, readonly: !canEditCategory}) %>
</li>
<li class='implications'>
<%= makeTextInput({text: 'Implications', value: tag.implications.join(' '), readonly: !canEditImplications}) %>
</li>
<li class='suggestions'>
<%= makeTextInput({text: 'Suggestions', value: tag.suggestions.join(' '), readonly: !canEditSuggestions}) %>
</li>
</ul>
</div>
<% if (canEditNames || canEditCategory || canEditImplications || canEditSuggestions) { %>
<div class='messages'></div>
<div class='buttons'>
<input type='submit' class='save' value='Save changes'>
</div>
<% } %>
</form>
</div>

View file

@ -2,16 +2,19 @@
const page = require('page'); const page = require('page');
const api = require('../api.js'); const api = require('../api.js');
const tags = require('../tags.js');
const events = require('../events.js'); const events = require('../events.js');
const misc = require('../util/misc.js'); const misc = require('../util/misc.js');
const topNavController = require('../controllers/top_nav_controller.js'); const topNavController = require('../controllers/top_nav_controller.js');
const pageController = require('../controllers/page_controller.js'); const pageController = require('../controllers/page_controller.js');
const TagView = require('../views/tag_view.js');
const TagsHeaderView = require('../views/tags_header_view.js'); const TagsHeaderView = require('../views/tags_header_view.js');
const TagsPageView = require('../views/tags_page_view.js'); const TagsPageView = require('../views/tags_page_view.js');
const TagCategoriesView = require('../views/tag_categories_view.js'); const TagCategoriesView = require('../views/tag_categories_view.js');
class TagsController { class TagsController {
constructor() { constructor() {
this.tagView = new TagView();
this.tagsHeaderView = new TagsHeaderView(); this.tagsHeaderView = new TagsHeaderView();
this.tagsPageView = new TagsPageView(); this.tagsPageView = new TagsPageView();
this.tagCategoriesView = new TagCategoriesView(); this.tagCategoriesView = new TagCategoriesView();
@ -19,6 +22,18 @@ class TagsController {
registerRoutes() { registerRoutes() {
page('/tag-categories', () => { this.tagCategoriesRoute(); }); page('/tag-categories', () => { this.tagCategoriesRoute(); });
page(
'/tag/:name',
(ctx, next) => { this.loadTagRoute(ctx, next); },
(ctx, next) => { this.showTagRoute(ctx, next); });
page(
'/tag/:name/merge',
(ctx, next) => { this.loadTagRoute(ctx, next); },
(ctx, next) => { this.mergeTagRoute(ctx, next); });
page(
'/tag/:name/delete',
(ctx, next) => { this.loadTagRoute(ctx, next); },
(ctx, next) => { this.deleteTagRoute(ctx, next); });
page( page(
'/tags/:query?', '/tags/:query?',
(ctx, next) => { misc.parseSearchQueryRoute(ctx, next); }, (ctx, next) => { misc.parseSearchQueryRoute(ctx, next); },
@ -47,6 +62,94 @@ class TagsController {
}); });
} }
loadTagRoute(ctx, next) {
if (ctx.state.tag) {
next();
} else if (this.tag && this.tag.names == ctx.params.names) {
ctx.state.tag = this.tag;
next();
} else {
api.get('/tag/' + ctx.params.name).then(response => {
ctx.state.tag = response.tag;
ctx.save();
this.tag = response.tag;
next();
}, response => {
this.emptyView.render();
events.notify(events.Error, response.description);
});
}
}
showTagRoute(ctx, next) {
this._show(ctx.state.tag, 'summary');
}
mergeTagRoute(ctx, next) {
this._show(ctx.state.tag, 'merge');
}
deleteTagRoute(ctx, next) {
this._show(ctx.state.tag, 'delete');
}
_show(tag, section) {
topNavController.activate('tags');
const categories = {};
for (let [key, category] of tags.getExport().categories) {
categories[key] = category.name;
}
this.tagView.render({
tag: tag,
section: section,
canEditNames: api.hasPrivilege('tags:edit:names'),
canEditCategory: api.hasPrivilege('tags:edit:category'),
canEditImplications: api.hasPrivilege('tags:edit:implications'),
canEditSuggestions: api.hasPrivilege('tags:edit:suggestions'),
canMerge: api.hasPrivilege('tags:delete'),
canDelete: api.hasPrivilege('tags:merge'),
categories: categories,
save: (...args) => { return this._saveTag(tag, ...args); },
mergeTo: (...args) => { return this._mergeTag(tag, ...args); },
delete: (...args) => { return this._deleteTag(tag, ...args); },
});
}
_saveTag(tag, input) {
return api.put('/tag/' + tag.names[0], input).then(response => {
events.notify(events.Success, 'Tag saved.');
return Promise.resolve();
}, response => {
events.notify(events.Error, response.description);
return Promise.reject();
});
}
_mergeTag(tag, targetTagName) {
return api.post(
'/tag-merge/',
{remove: tag.names[0], mergeTo: targetTagName}
).then(response => {
page('/tag/' + targetTagName + '/merge');
events.notify(events.Success, 'Tag merged.');
return Promise.resolve();
}, response => {
events.notify(events.Error, response.description);
return Promise.reject();
});
}
_deleteTag(tag) {
return api.delete('/tag/' + tag.names[0]).then(response => {
page('/tags/');
events.notify(events.Success, 'Tag deleted.');
return Promise.resolve();
}, response => {
events.notify(events.Error, response.description);
return Promise.reject();
});
}
tagCategoriesRoute(ctx, next) { tagCategoriesRoute(ctx, next) {
topNavController.activate('tags'); topNavController.activate('tags');
api.get('/tag-categories/').then(response => { api.get('/tag-categories/').then(response => {

View file

@ -7,22 +7,22 @@ const events = require('./events.js');
let _export = null; let _export = null;
let _stylesheet = null; let _stylesheet = null;
function _tagsToDictionary(tags) { function _tagsToMap(tags) {
let dict = {}; let map = new Map();
for (let tag of tags) { for (let tag of tags) {
for (let name of tag.names) { for (let name of tag.names) {
dict[name] = tag; map.set(name, tag);
} }
} }
return dict; return map;
} }
function _tagCategoriesToDictionary(categories) { function _tagCategoriesToMap(categories) {
let dict = {}; let map = new Map();
for (let category of categories) { for (let category of categories) {
dict[category.name] = category; map.set(category.name, category);
} }
return dict; return map;
} }
function _refreshStylesheet() { function _refreshStylesheet() {
@ -31,7 +31,7 @@ function _refreshStylesheet() {
} }
_stylesheet = document.createElement('style'); _stylesheet = document.createElement('style');
document.head.appendChild(_stylesheet); document.head.appendChild(_stylesheet);
for (let category of Object.values(_export.categories)) { for (let category of _export.categories.values()) {
_stylesheet.sheet.insertRule( _stylesheet.sheet.insertRule(
'.tag-{0} { color: {1} }'.format(category.name, category.color), '.tag-{0} { color: {1} }'.format(category.name, category.color),
_stylesheet.sheet.cssRules.length); _stylesheet.sheet.cssRules.length);
@ -42,14 +42,12 @@ function refreshExport() {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
request.get('/data/tags.json').end((error, response) => { request.get('/data/tags.json').end((error, response) => {
if (error) { if (error) {
console.log('Error while fetching exported tags', error);
_export = {tags: {}, categories: {}}; _export = {tags: {}, categories: {}};
reject(error); reject(error);
} }
_export = response.body; _export = response.body;
_export.tags = _tagsToDictionary(_export.tags); _export.tags = _tagsToMap(_export.tags);
_export.categories = _tagCategoriesToDictionary( _export.categories = _tagCategoriesToMap(_export.categories);
_export.categories);
_refreshStylesheet(); _refreshStylesheet();
resolve(); resolve();
}); });

View file

@ -7,6 +7,12 @@ const events = require('../events.js');
const domParser = new DOMParser(); const domParser = new DOMParser();
const misc = require('./misc.js'); const misc = require('./misc.js');
function _imbueId(options) {
if (!options.id) {
options.id = 'gen-' + Math.random().toString(36).substring(7);
}
}
function _makeLabel(options, attrs) { function _makeLabel(options, attrs) {
if (!options.text) { if (!options.text) {
return ''; return '';
@ -36,6 +42,7 @@ function makeThumbnail(url) {
} }
function makeRadio(options) { function makeRadio(options) {
_imbueId(options);
return makeVoidElement( return makeVoidElement(
'input', 'input',
{ {
@ -50,6 +57,7 @@ function makeRadio(options) {
} }
function makeCheckbox(options) { function makeCheckbox(options) {
_imbueId(options);
return makeVoidElement( return makeVoidElement(
'input', 'input',
{ {
@ -68,7 +76,11 @@ function makeSelect(options) {
return _makeLabel(options) + return _makeLabel(options) +
makeNonVoidElement( makeNonVoidElement(
'select', 'select',
{id: options.id, name: options.name}, {
id: options.id,
name: options.name,
disabled: options.readonly,
},
Object.keys(options.keyValues).map(key => { Object.keys(options.keyValues).map(key => {
return makeNonVoidElement( return makeNonVoidElement(
'option', 'option',
@ -88,6 +100,7 @@ function makeInput(options) {
required: options.required, required: options.required,
pattern: options.pattern, pattern: options.pattern,
placeholder: options.placeholder, placeholder: options.placeholder,
readonly: options.readonly,
}); });
} }
@ -128,7 +141,7 @@ function makeTagLink(name) {
const tagExport = tags.getExport(); const tagExport = tags.getExport();
let category = null; let category = null;
try { try {
category = tagExport.tags[name].category; category = tagExport.tags.get(name).category;
} catch (e) { } catch (e) {
category = 'unknown'; category = 'unknown';
} }

View file

@ -0,0 +1,31 @@
'use strict';
const views = require('../util/views.js');
class TagDeleteView {
constructor() {
this.template = views.getTemplate('tag-delete');
}
render(ctx) {
const target = ctx.target;
const source = this.template(ctx);
const form = source.querySelector('form');
views.decorateValidator(form);
form.addEventListener('submit', e => {
e.preventDefault();
views.clearMessages(target);
views.disableForm(form);
ctx.delete(ctx.tag)
.catch(() => { views.enableForm(form); });
});
views.listenToMessages(source);
views.showView(target, source);
}
}
module.exports = TagDeleteView;

View file

@ -0,0 +1,36 @@
'use strict';
const config = require('../config.js');
const views = require('../util/views.js');
class TagMergeView {
constructor() {
this.template = views.getTemplate('tag-merge');
}
render(ctx) {
ctx.tagNamePattern = config.tagNameRegex;
const target = ctx.target;
const source = this.template(ctx);
const form = source.querySelector('form');
views.decorateValidator(form);
form.addEventListener('submit', e => {
const otherTagField = source.querySelector('.target input');
e.preventDefault();
views.clearMessages(target);
views.disableForm(form);
ctx.mergeTo(otherTagField.value)
.catch(() => { views.enableForm(form); });
});
views.listenToMessages(source);
views.showView(target, source);
}
}
module.exports = TagMergeView;

View file

@ -0,0 +1,49 @@
'use strict';
const config = require('../config.js');
const views = require('../util/views.js');
function split(str) {
return str.split(/\s+/).filter(s => s);
}
class TagSummaryView {
constructor() {
this.template = views.getTemplate('tag-summary');
}
render(ctx) {
const baseRegex = config.tagNameRegex.replace(/[\^\$]/g, '');
ctx.tagNamesPattern = '^((' + baseRegex + ')\\s+)*(' + baseRegex + ')$';
const target = ctx.target;
const source = this.template(ctx);
const form = source.querySelector('form');
views.decorateValidator(form);
form.addEventListener('submit', e => {
const namesField = source.querySelector('.names input');
const categoryField = source.querySelector('.category select');
const implicationsField =
source.querySelector('.implications input');
const suggestionsField = source.querySelector('.suggestions input');
e.preventDefault();
views.clearMessages(target);
views.disableForm(form);
ctx.save({
names: split(namesField.value),
category: categoryField.value,
implications: split(implicationsField.value),
suggestions: split(suggestionsField.value),
}).always(() => { views.enableForm(form); });
});
views.listenToMessages(source);
views.showView(target, source);
}
}
module.exports = TagSummaryView;

View file

@ -0,0 +1,47 @@
'use strict';
const views = require('../util/views.js');
const TagSummaryView = require('./tag_summary_view.js');
const TagMergeView = require('./tag_merge_view.js');
const TagDeleteView = require('./tag_delete_view.js');
class TagView {
constructor() {
this.template = views.getTemplate('tag');
this.summaryView = new TagSummaryView();
this.mergeView = new TagMergeView();
this.deleteView = new TagDeleteView();
}
render(ctx) {
const target = document.getElementById('content-holder');
const source = this.template(ctx);
ctx.section = ctx.section || 'summary';
for (let item of source.querySelectorAll('[data-name]')) {
if (item.getAttribute('data-name') === ctx.section) {
item.className = 'active';
} else {
item.className = '';
}
}
let view = null;
if (ctx.section == 'merge') {
view = this.mergeView;
} else if (ctx.section == 'delete') {
view = this.deleteView;
} else {
view = this.summaryView;
}
ctx.target = source.querySelector('.tag-content-holder');
view.render(ctx);
views.listenToMessages(source);
views.showView(target, source);
}
}
module.exports = TagView;