client/tags: add tag merging, editing and deleting
This commit is contained in:
parent
2a4241641c
commit
b1deb617bc
12 changed files with 380 additions and 15 deletions
|
@ -172,6 +172,11 @@ input[type=password]
|
|||
&:focus
|
||||
border-color: $main-color
|
||||
|
||||
&[readonly]
|
||||
border: 2px solid $input-disabled-border-color
|
||||
background: $input-disabled-background-color
|
||||
color: $input-disabled-text-color
|
||||
|
||||
label.color
|
||||
position: relative
|
||||
input[type=text]
|
||||
|
|
15
client/html/tag.hbs
Normal file
15
client/html/tag.hbs
Normal 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…</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>
|
20
client/html/tag_delete.hbs
Normal file
20
client/html/tag_delete.hbs
Normal 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
22
client/html/tag_merge.hbs
Normal 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>
|
26
client/html/tag_summary.hbs
Normal file
26
client/html/tag_summary.hbs
Normal 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>
|
|
@ -2,16 +2,19 @@
|
|||
|
||||
const page = require('page');
|
||||
const api = require('../api.js');
|
||||
const tags = require('../tags.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 TagView = require('../views/tag_view.js');
|
||||
const TagsHeaderView = require('../views/tags_header_view.js');
|
||||
const TagsPageView = require('../views/tags_page_view.js');
|
||||
const TagCategoriesView = require('../views/tag_categories_view.js');
|
||||
|
||||
class TagsController {
|
||||
constructor() {
|
||||
this.tagView = new TagView();
|
||||
this.tagsHeaderView = new TagsHeaderView();
|
||||
this.tagsPageView = new TagsPageView();
|
||||
this.tagCategoriesView = new TagCategoriesView();
|
||||
|
@ -19,6 +22,18 @@ class TagsController {
|
|||
|
||||
registerRoutes() {
|
||||
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(
|
||||
'/tags/:query?',
|
||||
(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) {
|
||||
topNavController.activate('tags');
|
||||
api.get('/tag-categories/').then(response => {
|
||||
|
|
|
@ -7,22 +7,22 @@ const events = require('./events.js');
|
|||
let _export = null;
|
||||
let _stylesheet = null;
|
||||
|
||||
function _tagsToDictionary(tags) {
|
||||
let dict = {};
|
||||
function _tagsToMap(tags) {
|
||||
let map = new Map();
|
||||
for (let tag of tags) {
|
||||
for (let name of tag.names) {
|
||||
dict[name] = tag;
|
||||
map.set(name, tag);
|
||||
}
|
||||
}
|
||||
return dict;
|
||||
return map;
|
||||
}
|
||||
|
||||
function _tagCategoriesToDictionary(categories) {
|
||||
let dict = {};
|
||||
function _tagCategoriesToMap(categories) {
|
||||
let map = new Map();
|
||||
for (let category of categories) {
|
||||
dict[category.name] = category;
|
||||
map.set(category.name, category);
|
||||
}
|
||||
return dict;
|
||||
return map;
|
||||
}
|
||||
|
||||
function _refreshStylesheet() {
|
||||
|
@ -31,7 +31,7 @@ function _refreshStylesheet() {
|
|||
}
|
||||
_stylesheet = document.createElement('style');
|
||||
document.head.appendChild(_stylesheet);
|
||||
for (let category of Object.values(_export.categories)) {
|
||||
for (let category of _export.categories.values()) {
|
||||
_stylesheet.sheet.insertRule(
|
||||
'.tag-{0} { color: {1} }'.format(category.name, category.color),
|
||||
_stylesheet.sheet.cssRules.length);
|
||||
|
@ -42,14 +42,12 @@ 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);
|
||||
_export.tags = _tagsToMap(_export.tags);
|
||||
_export.categories = _tagCategoriesToMap(_export.categories);
|
||||
_refreshStylesheet();
|
||||
resolve();
|
||||
});
|
||||
|
|
|
@ -7,6 +7,12 @@ const events = require('../events.js');
|
|||
const domParser = new DOMParser();
|
||||
const misc = require('./misc.js');
|
||||
|
||||
function _imbueId(options) {
|
||||
if (!options.id) {
|
||||
options.id = 'gen-' + Math.random().toString(36).substring(7);
|
||||
}
|
||||
}
|
||||
|
||||
function _makeLabel(options, attrs) {
|
||||
if (!options.text) {
|
||||
return '';
|
||||
|
@ -36,6 +42,7 @@ function makeThumbnail(url) {
|
|||
}
|
||||
|
||||
function makeRadio(options) {
|
||||
_imbueId(options);
|
||||
return makeVoidElement(
|
||||
'input',
|
||||
{
|
||||
|
@ -50,6 +57,7 @@ function makeRadio(options) {
|
|||
}
|
||||
|
||||
function makeCheckbox(options) {
|
||||
_imbueId(options);
|
||||
return makeVoidElement(
|
||||
'input',
|
||||
{
|
||||
|
@ -68,7 +76,11 @@ function makeSelect(options) {
|
|||
return _makeLabel(options) +
|
||||
makeNonVoidElement(
|
||||
'select',
|
||||
{id: options.id, name: options.name},
|
||||
{
|
||||
id: options.id,
|
||||
name: options.name,
|
||||
disabled: options.readonly,
|
||||
},
|
||||
Object.keys(options.keyValues).map(key => {
|
||||
return makeNonVoidElement(
|
||||
'option',
|
||||
|
@ -88,6 +100,7 @@ function makeInput(options) {
|
|||
required: options.required,
|
||||
pattern: options.pattern,
|
||||
placeholder: options.placeholder,
|
||||
readonly: options.readonly,
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -128,7 +141,7 @@ function makeTagLink(name) {
|
|||
const tagExport = tags.getExport();
|
||||
let category = null;
|
||||
try {
|
||||
category = tagExport.tags[name].category;
|
||||
category = tagExport.tags.get(name).category;
|
||||
} catch (e) {
|
||||
category = 'unknown';
|
||||
}
|
||||
|
|
31
client/js/views/tag_delete_view.js
Normal file
31
client/js/views/tag_delete_view.js
Normal 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;
|
36
client/js/views/tag_merge_view.js
Normal file
36
client/js/views/tag_merge_view.js
Normal 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;
|
49
client/js/views/tag_summary_view.js
Normal file
49
client/js/views/tag_summary_view.js
Normal 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;
|
47
client/js/views/tag_view.js
Normal file
47
client/js/views/tag_view.js
Normal 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;
|
||||
|
Loading…
Reference in a new issue