diff --git a/client/css/pool-categories-view.styl b/client/css/pool-categories-view.styl new file mode 100644 index 00000000..3e876355 --- /dev/null +++ b/client/css/pool-categories-view.styl @@ -0,0 +1,30 @@ +@import colors + +.content-wrapper.pool-categories + width: 100% + max-width: 45em + table + border-spacing: 0 + width: 100% + tr.default td + background: $default-pool-category-background-color + td, th + padding: .4em + &.color + input[type=text] + width: 8em + &.usages + text-align: center + &.remove, &.set-default + white-space: pre + th + white-space: nowrap + &:first-child + padding-left: 0 + &:last-child + padding-right: 0 + tfoot + display: none + form + width: auto + diff --git a/client/css/pool-input-control.styl b/client/css/pool-input-control.styl new file mode 100644 index 00000000..3f71abe8 --- /dev/null +++ b/client/css/pool-input-control.styl @@ -0,0 +1,53 @@ +@import colors + +div.pool-input + position: relative + + .main-control + display: flex + input + flex: 5 + button + flex: 1 + margin: 0 0 0 0.5em + + +ul.compact-pools + width: 100% + margin: 0.5em 0 0 0 + padding: 0 + li + margin: 0 + width: 100% + line-height: 140% + white-space: nowrap + overflow: hidden + text-overflow: ellipsis + transition: background-color 0.5s linear + a + display: inline + a:focus + outline: 0 + box-shadow: inset 0 0 0 2px $main-color + &.implication + background: $implied-pool-background-color + color: $implied-pool-text-color + &.new + background: $new-pool-background-color + color: $new-pool-text-color + &.duplicate + background: $duplicate-pool-background-color + color: $duplicate-pool-text-color + i + padding-right: 0.4em + +div.pool-input, ul.compact-pools + .pool-usages, .pool-weight, .remove-pool + color: $inactive-link-color + unselectable() + .pool-usages, .pool-weight + font-size: 90% + .pool-usages, .pool-weight + margin-left: 0.7em + .remove-pool + margin-right: 0.5em diff --git a/client/css/pool-list-view.styl b/client/css/pool-list-view.styl new file mode 100644 index 00000000..2333a372 --- /dev/null +++ b/client/css/pool-list-view.styl @@ -0,0 +1,52 @@ +@import colors + +.pool-list + table + width: 100% + border-spacing: 0 + text-align: left + line-height: 1.3em + tr:hover td + background: $top-navigation-color + th, td + padding: 0.1em 0.5em + th + white-space: nowrap + background: $top-navigation-color + .names + width: 28% + .usages + text-align: center + width: 8% + .creation-time + text-align: center + width: 8% + white-space: pre + ul + list-style-type: none + margin: 0 + padding: 0 + display: inline + li + padding: 0 + display: inline + &:not(:last-child):after + content: ', ' + @media (max-width: 800px) + .implications, .suggestions + display: none + +.pool-list-header + label + display: none !important + text-align: left + form + width: auto + input[name=search-text] + width: 25em + @media (max-width: 1000px) + width: 100% + .append + vertical-align: middle + font-size: 0.95em + color: $inactive-link-color diff --git a/client/css/pool-view.styl b/client/css/pool-view.styl new file mode 100644 index 00000000..bf16b798 --- /dev/null +++ b/client/css/pool-view.styl @@ -0,0 +1,33 @@ +#pool + width: 100% + max-width: 40em + h1 + word-break: break-all + line-height: 130% + margin-top: 0 + form + width: 100% + .pool-edit + textarea + height: 10em + .pool-summary + section + &.description + margin: 1.5em 0 0 0 + &.details + vertical-align: top + padding-right: 0.5em + ul + margin: 0 + padding: 0 + list-style-type: none + li + display: inline + margin: 0 + padding: 0 + li:not(:last-of-type):after + content: ', ' + ul:empty:after + content: '(none)' + section + margin-bottom: 1em diff --git a/client/html/help_search.tpl b/client/html/help_search.tpl index 70737893..d8708b01 100644 --- a/client/html/help_search.tpl +++ b/client/html/help_search.tpl @@ -4,6 +4,7 @@ -->
Anonymous tokens
+ +Same as name
token.
Named tokens
+ +name |
+ having given name (accepts wildcards) | +
category |
+ having given category (accepts wildcards) | +
creation-date |
+ created at given date | +
creation-time |
+ alias of creation-date |
+
last-edit-date |
+ edited at given date | +
last-edit-time |
+ alias of last-edit-date |
+
edit-date |
+ alias of last-edit-date |
+
edit-time |
+ alias of last-edit-date |
+
post-count |
+ alias of usages |
+
Sort style tokens
+ +random |
+ as random as it can get | +
name |
+ A to Z | +
category |
+ category (A to Z) | +
creation-date |
+ recently created first | +
creation-time |
+ alias of creation-date |
+
last-edit-date |
+ recently edited first | +
last-edit-time |
+ alias of creation-time |
+
edit-date |
+ alias of creation-time |
+
edit-time |
+ alias of creation-time |
+
post-count |
+ number of posts | +
Special tokens
+ +None.
diff --git a/client/html/pool.tpl b/client/html/pool.tpl new file mode 100644 index 00000000..5af10c96 --- /dev/null +++ b/client/html/pool.tpl @@ -0,0 +1,18 @@ +This pool has '><%- ctx.pool.postCount %> post(s).
++ <% if (ctx.parameters.query == 'sort:name' || !ctx.parameters.query) { %> + '>Pool name(s) + <% } else { %> + '>Pool name(s) + <% } %> + | ++ <% if (ctx.parameters.query == 'sort:post-count') { %> + '>Post Count + <% } else { %> + '>Post Count + <% } %> + | ++ <% if (ctx.parameters.query == 'sort:creation-time') { %> + '>Created on + <% } else { %> + '>Created on + <% } %> + | + + + <% for (let pool of ctx.response.results) { %> +
---|---|---|
+
|
+ + '><%- pool.postCount %> + | ++ <%= ctx.makeRelativeTime(pool.creationTime) %> + | +
- <% if (ctx.query == 'sort:name' || !ctx.query) { %> + <% if (ctx.parameters.query == 'sort:name' || !ctx.parameters.query) { %> '>Tag name(s) <% } else { %> '>Tag name(s) <% } %> | - <% if (ctx.query == 'sort:implication-count') { %> + <% if (ctx.parameters.query == 'sort:implication-count') { %> '>Implications <% } else { %> '>Implications <% } %> | - <% if (ctx.query == 'sort:suggestion-count') { %> + <% if (ctx.parameters.query == 'sort:suggestion-count') { %> '>Suggestions <% } else { %> '>Suggestions <% } %> | - <% if (ctx.query == 'sort:usages') { %> + <% if (ctx.parameters.query == 'sort:usages') { %> '>Usages <% } else { %> '>Usages <% } %> |
- <% if (ctx.query == 'sort:creation-time') { %>
+ <% if (ctx.parameters.query == 'sort:creation-time') { %>
'>Created on
<% } else { %>
'>Created on
diff --git a/client/js/api.js b/client/js/api.js
index 07ec5ec9..14c5bccb 100644
--- a/client/js/api.js
+++ b/client/js/api.js
@@ -84,6 +84,10 @@ class Api extends events.EventTarget {
return remoteConfig.tagNameRegex;
}
+ getPoolNameRegex() {
+ return remoteConfig.poolNameRegex;
+ }
+
getPasswordRegex() {
return remoteConfig.passwordRegex;
}
diff --git a/client/js/controllers/auth_controller.js b/client/js/controllers/auth_controller.js
index bec4b7a4..a6ec97dc 100644
--- a/client/js/controllers/auth_controller.js
+++ b/client/js/controllers/auth_controller.js
@@ -3,6 +3,7 @@
const router = require('../router.js');
const api = require('../api.js');
const tags = require('../tags.js');
+const pools = require('../pools.js');
const uri = require('../util/uri.js');
const topNavigation = require('../models/top_navigation.js');
const LoginView = require('../views/login_view.js');
@@ -27,6 +28,7 @@ class LoginController {
ctx.controller.showSuccess('Logged in');
// reload tag category color map, this is required when `tag_categories:list` has a permission other than anonymous
tags.refreshCategoryColorMap();
+ pools.refreshCategoryColorMap();
}, error => {
this._loginView.showError(error.message);
this._loginView.enableForm();
diff --git a/client/js/controllers/pool_categories_controller.js b/client/js/controllers/pool_categories_controller.js
new file mode 100644
index 00000000..4c2c3b7a
--- /dev/null
+++ b/client/js/controllers/pool_categories_controller.js
@@ -0,0 +1,57 @@
+'use strict';
+
+const api = require('../api.js');
+const pools = require('../pools.js');
+const PoolCategoryList = require('../models/pool_category_list.js');
+const topNavigation = require('../models/top_navigation.js');
+const PoolCategoriesView = require('../views/pool_categories_view.js');
+const EmptyView = require('../views/empty_view.js');
+
+class PoolCategoriesController {
+ constructor() {
+ if (!api.hasPrivilege('poolCategories:list')) {
+ this._view = new EmptyView();
+ this._view.showError(
+ 'You don\'t have privileges to view pool categories.');
+ return;
+ }
+
+ topNavigation.activate('pools');
+ topNavigation.setTitle('Listing pools');
+ PoolCategoryList.get().then(response => {
+ this._poolCategories = response.results;
+ this._view = new PoolCategoriesView({
+ poolCategories: this._poolCategories,
+ canEditName: api.hasPrivilege('poolCategories:edit:name'),
+ canEditColor: api.hasPrivilege('poolCategories:edit:color'),
+ canDelete: api.hasPrivilege('poolCategories:delete'),
+ canCreate: api.hasPrivilege('poolCategories:create'),
+ canSetDefault: api.hasPrivilege('poolCategories:setDefault'),
+ });
+ this._view.addEventListener('submit', e => this._evtSubmit(e));
+ }, error => {
+ this._view = new EmptyView();
+ this._view.showError(error.message);
+ });
+ }
+
+ _evtSubmit(e) {
+ this._view.clearMessages();
+ this._view.disableForm();
+ this._poolCategories.save()
+ .then(() => {
+ pools.refreshCategoryColorMap();
+ this._view.enableForm();
+ this._view.showSuccess('Changes saved.');
+ }, error => {
+ this._view.enableForm();
+ this._view.showError(error.message);
+ });
+ }
+}
+
+module.exports = router => {
+ router.enter(['pool-categories'], (ctx, next) => {
+ ctx.controller = new PoolCategoriesController(ctx, next);
+ });
+};
diff --git a/client/js/controllers/pool_controller.js b/client/js/controllers/pool_controller.js
new file mode 100644
index 00000000..8623cf84
--- /dev/null
+++ b/client/js/controllers/pool_controller.js
@@ -0,0 +1,141 @@
+'use strict';
+
+const router = require('../router.js');
+const api = require('../api.js');
+const misc = require('../util/misc.js');
+const uri = require('../util/uri.js');
+const Pool = require('../models/pool.js');
+const PoolCategoryList = require('../models/pool_category_list.js');
+const topNavigation = require('../models/top_navigation.js');
+const PoolView = require('../views/pool_view.js');
+const EmptyView = require('../views/empty_view.js');
+
+class PoolController {
+ constructor(ctx, section) {
+ if (!api.hasPrivilege('pools:view')) {
+ this._view = new EmptyView();
+ this._view.showError('You don\'t have privileges to view pools.');
+ return;
+ }
+
+ Promise.all([
+ PoolCategoryList.get(),
+ Pool.get(ctx.parameters.id)
+ ]).then(responses => {
+ const [poolCategoriesResponse, pool] = responses;
+
+ topNavigation.activate('pools');
+ topNavigation.setTitle('Pool #' + pool.names[0]);
+
+ this._name = ctx.parameters.name;
+ pool.addEventListener('change', e => this._evtSaved(e, section));
+
+ const categories = {};
+ for (let category of poolCategoriesResponse.results) {
+ categories[category.name] = category.name;
+ }
+
+ this._view = new PoolView({
+ pool: pool,
+ section: section,
+ canEditAnything: api.hasPrivilege('pools:edit'),
+ canEditNames: api.hasPrivilege('pools:edit:names'),
+ canEditCategory: api.hasPrivilege('pools:edit:category'),
+ canEditDescription: api.hasPrivilege('pools:edit:description'),
+ canMerge: api.hasPrivilege('pools:merge'),
+ canDelete: api.hasPrivilege('pools:delete'),
+ categories: categories,
+ escapeColons: uri.escapeColons,
+ });
+
+ this._view.addEventListener('change', e => this._evtChange(e));
+ this._view.addEventListener('submit', e => this._evtUpdate(e));
+ this._view.addEventListener('merge', e => this._evtMerge(e));
+ this._view.addEventListener('delete', e => this._evtDelete(e));
+ },
+ error => {
+ this._view = new EmptyView();
+ this._view.showError(error.message);
+ });
+ }
+
+ _evtChange(e) {
+ misc.enableExitConfirmation();
+ }
+
+ _evtSaved(e, section) {
+ misc.disableExitConfirmation();
+ if (this._name !== e.detail.pool.names[0]) {
+ router.replace(
+ uri.formatClientLink('pool', e.detail.pool.id, section),
+ null, false);
+ }
+ }
+
+ _evtUpdate(e) {
+ this._view.clearMessages();
+ this._view.disableForm();
+ if (e.detail.names !== undefined) {
+ e.detail.pool.names = e.detail.names;
+ }
+ if (e.detail.category !== undefined) {
+ e.detail.pool.category = e.detail.category;
+ }
+ if (e.detail.description !== undefined) {
+ e.detail.pool.description = e.detail.description;
+ }
+ e.detail.pool.save().then(() => {
+ this._view.showSuccess('Pool saved.');
+ this._view.enableForm();
+ }, error => {
+ this._view.showError(error.message);
+ this._view.enableForm();
+ });
+ }
+
+ _evtMerge(e) {
+ this._view.clearMessages();
+ this._view.disableForm();
+ e.detail.pool
+ .merge(e.detail.targetPoolName, e.detail.addAlias)
+ .then(() => {
+ this._view.showSuccess('Pool merged.');
+ this._view.enableForm();
+ router.replace(
+ uri.formatClientLink(
+ 'pool', e.detail.targetPoolName, 'merge'),
+ null, false);
+ }, error => {
+ this._view.showError(error.message);
+ this._view.enableForm();
+ });
+ }
+
+ _evtDelete(e) {
+ this._view.clearMessages();
+ this._view.disableForm();
+ e.detail.pool.delete()
+ .then(() => {
+ const ctx = router.show(uri.formatClientLink('pools'));
+ ctx.controller.showSuccess('Pool deleted.');
+ }, error => {
+ this._view.showError(error.message);
+ this._view.enableForm();
+ });
+ }
+}
+
+module.exports = router => {
+ router.enter(['pool', ':id', 'edit'], (ctx, next) => {
+ ctx.controller = new PoolController(ctx, 'edit');
+ });
+ router.enter(['pool', ':id', 'merge'], (ctx, next) => {
+ ctx.controller = new PoolController(ctx, 'merge');
+ });
+ router.enter(['pool', ':id', 'delete'], (ctx, next) => {
+ ctx.controller = new PoolController(ctx, 'delete');
+ });
+ router.enter(['pool', ':id'], (ctx, next) => {
+ ctx.controller = new PoolController(ctx, 'summary');
+ });
+};
diff --git a/client/js/controllers/pool_create_controller.js b/client/js/controllers/pool_create_controller.js
new file mode 100644
index 00000000..0aa3c764
--- /dev/null
+++ b/client/js/controllers/pool_create_controller.js
@@ -0,0 +1,63 @@
+'use strict';
+
+const router = require('../router.js');
+const api = require('../api.js');
+const misc = require('../util/misc.js');
+const uri = require('../util/uri.js');
+const PoolCategoryList = require('../models/pool_category_list.js');
+const PoolCreateView = require('../views/pool_create_view.js');
+const EmptyView = require('../views/empty_view.js');
+
+class PoolCreateController {
+ constructor(ctx) {
+ if (!api.hasPrivilege('pools:create')) {
+ this._view = new EmptyView();
+ this._view.showError('You don\'t have privileges to create pools.');
+ return;
+ }
+
+ PoolCategoryList.get()
+ .then(poolCategoriesResponse => {
+ const categories = {};
+ for (let category of poolCategoriesResponse.results) {
+ categories[category.name] = category.name;
+ }
+
+ this._view = new PoolCreateView({
+ canCreate: api.hasPrivilege('pools:create'),
+ categories: categories,
+ escapeColons: uri.escapeColons,
+ });
+
+ this._view.addEventListener('submit', e => this._evtCreate(e));
+ }, error => {
+ this._view = new EmptyView();
+ this._view.showError(error.message);
+ });
+ }
+
+ _evtCreate(e) {
+ this._view.clearMessages();
+ this._view.disableForm();
+ e.detail.pool.save()
+ .then(() => {
+ this._view.clearMessages();
+ misc.disableExitConfirmation();
+ const ctx = router.show(uri.formatClientLink('pools'));
+ ctx.controller.showSuccess('Pool created.');
+ }, error => {
+ this._view.showError(error.message);
+ this._view.enableForm();
+ });
+ }
+
+ _evtChange(e) {
+ misc.enableExitConfirmation();
+ }
+}
+
+module.exports = router => {
+ router.enter(['pool', 'create'], (ctx, next) => {
+ ctx.controller = new PoolCreateController(ctx, 'create');
+ });
+};
diff --git a/client/js/controllers/pool_list_controller.js b/client/js/controllers/pool_list_controller.js
new file mode 100644
index 00000000..626dc1ec
--- /dev/null
+++ b/client/js/controllers/pool_list_controller.js
@@ -0,0 +1,105 @@
+'use strict';
+
+const router = require('../router.js');
+const api = require('../api.js');
+const uri = require('../util/uri.js');
+const PoolList = require('../models/pool_list.js');
+const topNavigation = require('../models/top_navigation.js');
+const PageController = require('../controllers/page_controller.js');
+const PoolsHeaderView = require('../views/pools_header_view.js');
+const PoolsPageView = require('../views/pools_page_view.js');
+const EmptyView = require('../views/empty_view.js');
+
+const fields = [
+ 'id',
+ 'names',
+ /* 'suggestions',
+ * 'implications', */
+ 'creationTime',
+ 'postCount',
+ 'category'];
+
+class PoolListController {
+ constructor(ctx) {
+ if (!api.hasPrivilege('pools:list')) {
+ this._view = new EmptyView();
+ this._view.showError('You don\'t have privileges to view pools.');
+ return;
+ }
+
+ topNavigation.activate('pools');
+ topNavigation.setTitle('Listing pools');
+
+ this._ctx = ctx;
+ this._pageController = new PageController();
+
+ this._headerView = new PoolsHeaderView({
+ hostNode: this._pageController.view.pageHeaderHolderNode,
+ parameters: ctx.parameters,
+ canCreate: api.hasPrivilege('pools:create'),
+ canEditPoolCategories: api.hasPrivilege('poolCategories:edit'),
+ });
+ this._headerView.addEventListener(
+ 'submit', e => this._evtSubmit(e),
+ 'navigate', e => this._evtNavigate(e));
+
+ this._syncPageController();
+ }
+
+ showSuccess(message) {
+ this._pageController.showSuccess(message);
+ }
+
+ showError(message) {
+ this._pageController.showError(message);
+ }
+
+ _evtSubmit(e) {
+ this._view.clearMessages();
+ this._view.disableForm();
+ e.detail.pool.save()
+ .then(() => {
+ this._installView(e.detail.pool, 'edit');
+ this._view.showSuccess('Pool created.');
+ router.replace(
+ uri.formatClientLink(
+ 'pool', e.detail.pool.id, 'edit'),
+ null, false);
+ }, error => {
+ this._view.showError(error.message);
+ this._view.enableForm();
+ });
+ }
+
+ _evtNavigate(e) {
+ router.showNoDispatch(
+ uri.formatClientLink('pools', e.detail.parameters));
+ Object.assign(this._ctx.parameters, e.detail.parameters);
+ this._syncPageController();
+ }
+
+ _syncPageController() {
+ this._pageController.run({
+ parameters: this._ctx.parameters,
+ defaultLimit: 50,
+ getClientUrlForPage: (offset, limit) => {
+ const parameters = Object.assign(
+ {}, this._ctx.parameters, {offset: offset, limit: limit});
+ return uri.formatClientLink('pools', parameters);
+ },
+ requestPage: (offset, limit) => {
+ return PoolList.search(
+ this._ctx.parameters.query, offset, limit, fields);
+ },
+ pageRenderer: pageCtx => {
+ return new PoolsPageView(pageCtx);
+ },
+ });
+ }
+}
+
+module.exports = router => {
+ router.enter(
+ ['pools'],
+ (ctx, next) => { ctx.controller = new PoolListController(ctx); });
+};
diff --git a/client/js/controls/pool_auto_complete_control.js b/client/js/controls/pool_auto_complete_control.js
new file mode 100644
index 00000000..3c4bff7c
--- /dev/null
+++ b/client/js/controls/pool_auto_complete_control.js
@@ -0,0 +1,57 @@
+'use strict';
+
+const misc = require('../util/misc.js');
+const PoolList = require('../models/pool_list.js');
+const AutoCompleteControl = require('./auto_complete_control.js');
+
+function _poolListToMatches(pools, options) {
+ return [...pools].sort((pool1, pool2) => {
+ return pool2.postCount - pool1.postCount;
+ }).map(pool => {
+ let cssName = misc.makeCssName(pool.category, 'pool');
+ // TODO
+ if (options.isPooledWith(pool.id)) {
+ cssName += ' disabled';
+ }
+ const caption = (
+ ''
+ + misc.escapeHtml(pool.names[0] + ' (' + pool.postCount + ')')
+ + '');
+ return {
+ caption: caption,
+ value: pool,
+ };
+ });
+}
+
+class PoolAutoCompleteControl extends AutoCompleteControl {
+ constructor(input, options) {
+ const minLengthForPartialSearch = 3;
+
+ options = Object.assign({
+ isPooledWith: poolId => false,
+ }, options);
+
+ options.getMatches = text => {
+ const term = misc.escapeSearchTerm(text);
+ const query = (
+ text.length < minLengthForPartialSearch
+ ? term + '*'
+ : '*' + term + '*') + ' sort:post-count';
+
+ return new Promise((resolve, reject) => {
+ PoolList.search(
+ query, 0, this._options.maxResults,
+ ['id', 'names', 'category', 'postCount'])
+ .then(
+ response => resolve(
+ _poolListToMatches(response.results, this._options)),
+ reject);
+ });
+ };
+
+ super(input, options);
+ }
+};
+
+module.exports = PoolAutoCompleteControl;
diff --git a/client/js/main.js b/client/js/main.js
index dd974fd8..61469a2d 100644
--- a/client/js/main.js
+++ b/client/js/main.js
@@ -27,6 +27,7 @@ router.enter(
});
const tags = require('./tags.js');
+const pools = require('./pools.js');
const api = require('./api.js');
api.fetchConfig().then(() => {
@@ -45,6 +46,10 @@ api.fetchConfig().then(() => {
controllers.push(require('./controllers/tag_controller.js'));
controllers.push(require('./controllers/tag_list_controller.js'));
controllers.push(require('./controllers/tag_categories_controller.js'));
+ controllers.push(require('./controllers/pool_create_controller.js'));
+ controllers.push(require('./controllers/pool_controller.js'));
+ controllers.push(require('./controllers/pool_list_controller.js'));
+ controllers.push(require('./controllers/pool_categories_controller.js'));
controllers.push(require('./controllers/settings_controller.js'));
controllers.push(require('./controllers/user_controller.js'));
controllers.push(require('./controllers/user_list_controller.js'));
@@ -61,6 +66,7 @@ api.fetchConfig().then(() => {
}).then(() => {
api.loginFromCookies().then(() => {
tags.refreshCategoryColorMap();
+ pools.refreshCategoryColorMap();
router.start();
}, error => {
if (window.location.href.indexOf('login') !== -1) {
diff --git a/client/js/models/pool.js b/client/js/models/pool.js
new file mode 100644
index 00000000..e51832b7
--- /dev/null
+++ b/client/js/models/pool.js
@@ -0,0 +1,155 @@
+'use strict';
+
+const api = require('../api.js');
+const uri = require('../util/uri.js');
+const events = require('../events.js');
+const misc = require('../util/misc.js');
+
+class Pool extends events.EventTarget {
+ constructor() {
+ // const PoolList = require('./pool_list.js');
+
+ super();
+ this._orig = {};
+
+ for (let obj of [this, this._orig]) {
+ // TODO
+ // obj._suggestions = new PoolList();
+ // obj._implications = new PoolList();
+ }
+
+ this._updateFromResponse({});
+ }
+
+ get id() { return this._id; }
+ get names() { return this._names; }
+ get category() { return this._category; }
+ get description() { return this._description; }
+ /* get suggestions() { return this._suggestions; }
+ * get implications() { return this._implications; } */
+ get postCount() { return this._postCount; }
+ get creationTime() { return this._creationTime; }
+ get lastEditTime() { return this._lastEditTime; }
+
+ set names(value) { this._names = value; }
+ set category(value) { this._category = value; }
+ set description(value) { this._description = value; }
+
+ static fromResponse(response) {
+ const ret = new Pool();
+ ret._updateFromResponse(response);
+ return ret;
+ }
+
+ static get(id) {
+ return api.get(uri.formatApiLink('pool', id))
+ .then(response => {
+ return Promise.resolve(Pool.fromResponse(response));
+ });
+ }
+
+ save() {
+ const detail = {version: this._version};
+
+ // send only changed fields to avoid user privilege violation
+ if (misc.arraysDiffer(this._names, this._orig._names, true)) {
+ detail.names = this._names;
+ }
+ if (this._category !== this._orig._category) {
+ detail.category = this._category;
+ }
+ if (this._description !== this._orig._description) {
+ detail.description = this._description;
+ }
+ // TODO
+ // if (misc.arraysDiffer(this._implications, this._orig._implications)) {
+ // detail.implications = this._implications.map(
+ // relation => relation.names[0]);
+ // }
+ // if (misc.arraysDiffer(this._suggestions, this._orig._suggestions)) {
+ // detail.suggestions = this._suggestions.map(
+ // relation => relation.names[0]);
+ // }
+
+ let promise = this._id ?
+ api.put(uri.formatApiLink('pool', this._id), detail) :
+ api.post(uri.formatApiLink('pools'), detail);
+ return promise
+ .then(response => {
+ this._updateFromResponse(response);
+ this.dispatchEvent(new CustomEvent('change', {
+ detail: {
+ pool: this,
+ },
+ }));
+ return Promise.resolve();
+ });
+ }
+
+ merge(targetId, addAlias) {
+ return api.get(uri.formatApiLink('pool', targetId))
+ .then(response => {
+ return api.post(uri.formatApiLink('pool-merge'), {
+ removeVersion: this._version,
+ remove: this._id,
+ mergeToVersion: response.version,
+ mergeTo: targetId,
+ });
+ }).then(response => {
+ if (!addAlias) {
+ return Promise.resolve(response);
+ }
+ return api.put(uri.formatApiLink('pool', targetId), {
+ version: response.version,
+ names: response.names.concat(this._names),
+ });
+ }).then(response => {
+ this._updateFromResponse(response);
+ this.dispatchEvent(new CustomEvent('change', {
+ detail: {
+ pool: this,
+ },
+ }));
+ return Promise.resolve();
+ });
+ }
+
+ delete() {
+ return api.delete(
+ uri.formatApiLink('pool', this._id),
+ {version: this._version})
+ .then(response => {
+ this.dispatchEvent(new CustomEvent('delete', {
+ detail: {
+ pool: this,
+ },
+ }));
+ return Promise.resolve();
+ });
+ }
+
+ _updateFromResponse(response) {
+ const map = {
+ _id: response.id,
+ _version: response.version,
+ _origName: response.names ? response.names[0] : null,
+ _names: response.names,
+ _category: response.category,
+ _description: response.description,
+ _creationTime: response.creationTime,
+ _lastEditTime: response.lastEditTime,
+ _postCount: response.usages || 0,
+ };
+
+ for (let obj of [this, this._orig]) {
+ // TODO
+ // obj._suggestions.sync(response.suggestions);
+ // obj._implications.sync(response.implications);
+ }
+
+ Object.assign(this, map);
+ Object.assign(this._orig, map);
+ }
+};
+
+module.exports = Pool;
diff --git a/client/js/models/pool_category.js b/client/js/models/pool_category.js
new file mode 100644
index 00000000..4cdb68e6
--- /dev/null
+++ b/client/js/models/pool_category.js
@@ -0,0 +1,90 @@
+'use strict';
+
+const api = require('../api.js');
+const uri = require('../util/uri.js');
+const events = require('../events.js');
+
+class PoolCategory extends events.EventTarget {
+ constructor() {
+ super();
+ this._name = '';
+ this._color = '#000000';
+ this._poolCount = 0;
+ this._isDefault = false;
+ this._origName = null;
+ this._origColor = null;
+ }
+
+ get name() { return this._name; }
+ get color() { return this._color; }
+ get poolCount() { return this._poolCount; }
+ get isDefault() { return this._isDefault; }
+ get isTransient() { return !this._origName; }
+
+ set name(value) { this._name = value; }
+ set color(value) { this._color = value; }
+
+ static fromResponse(response) {
+ const ret = new PoolCategory();
+ ret._updateFromResponse(response);
+ return ret;
+ }
+
+ save() {
+ const detail = {version: this._version};
+
+ if (this.name !== this._origName) {
+ detail.name = this.name;
+ }
+ if (this.color !== this._origColor) {
+ detail.color = this.color;
+ }
+
+ if (!Object.keys(detail).length) {
+ return Promise.resolve();
+ }
+
+ let promise = this._origName ?
+ api.put(
+ uri.formatApiLink('pool-category', this._origName),
+ detail) :
+ api.post(uri.formatApiLink('pool-categories'), detail);
+
+ return promise
+ .then(response => {
+ this._updateFromResponse(response);
+ this.dispatchEvent(new CustomEvent('change', {
+ detail: {
+ poolCategory: this,
+ },
+ }));
+ return Promise.resolve();
+ });
+ }
+
+ delete() {
+ return api.delete(
+ uri.formatApiLink('pool-category', this._origName),
+ {version: this._version})
+ .then(response => {
+ this.dispatchEvent(new CustomEvent('delete', {
+ detail: {
+ poolCategory: this,
+ },
+ }));
+ return Promise.resolve();
+ });
+ }
+
+ _updateFromResponse(response) {
+ this._version = response.version;
+ this._name = response.name;
+ this._color = response.color;
+ this._isDefault = response.default;
+ this._poolCount = response.usages;
+ this._origName = this.name;
+ this._origColor = this.color;
+ }
+}
+
+module.exports = PoolCategory;
diff --git a/client/js/models/pool_category_list.js b/client/js/models/pool_category_list.js
new file mode 100644
index 00000000..2699620d
--- /dev/null
+++ b/client/js/models/pool_category_list.js
@@ -0,0 +1,82 @@
+'use strict';
+
+const api = require('../api.js');
+const uri = require('../util/uri.js');
+const AbstractList = require('./abstract_list.js');
+const PoolCategory = require('./pool_category.js');
+
+class PoolCategoryList extends AbstractList {
+ constructor() {
+ super();
+ this._defaultCategory = null;
+ this._origDefaultCategory = null;
+ this._deletedCategories = [];
+ this.addEventListener('remove', e => this._evtCategoryDeleted(e));
+ }
+
+ static fromResponse(response) {
+ const ret = super.fromResponse(response);
+ ret._defaultCategory = null;
+ for (let poolCategory of ret) {
+ if (poolCategory.isDefault) {
+ ret._defaultCategory = poolCategory;
+ }
+ }
+ ret._origDefaultCategory = ret._defaultCategory;
+ return ret;
+ }
+
+ static get() {
+ return api.get(uri.formatApiLink('pool-categories'))
+ .then(response => {
+ return Promise.resolve(Object.assign(
+ {},
+ response,
+ {results: PoolCategoryList.fromResponse(response.results)}));
+ });
+ }
+
+ get defaultCategory() {
+ return this._defaultCategory;
+ }
+
+ set defaultCategory(poolCategory) {
+ this._defaultCategory = poolCategory;
+ }
+
+ save() {
+ let promises = [];
+ for (let poolCategory of this) {
+ promises.push(poolCategory.save());
+ }
+ for (let poolCategory of this._deletedCategories) {
+ promises.push(poolCategory.delete());
+ }
+
+ if (this._defaultCategory !== this._origDefaultCategory) {
+ promises.push(
+ api.put(
+ uri.formatApiLink(
+ 'pool-category',
+ this._defaultCategory.name,
+ 'default')));
+ }
+
+ return Promise.all(promises)
+ .then(response => {
+ this._deletedCategories = [];
+ return Promise.resolve();
+ });
+ }
+
+ _evtCategoryDeleted(e) {
+ if (!e.detail.poolCategory.isTransient) {
+ this._deletedCategories.push(e.detail.poolCategory);
+ }
+ }
+}
+
+PoolCategoryList._itemClass = PoolCategory;
+PoolCategoryList._itemName = 'poolCategory';
+
+module.exports = PoolCategoryList;
diff --git a/client/js/models/pool_list.js b/client/js/models/pool_list.js
new file mode 100644
index 00000000..d72ece29
--- /dev/null
+++ b/client/js/models/pool_list.js
@@ -0,0 +1,30 @@
+'use strict';
+
+const api = require('../api.js');
+const uri = require('../util/uri.js');
+const AbstractList = require('./abstract_list.js');
+const Pool = require('./pool.js');
+
+class PoolList extends AbstractList {
+ static search(text, offset, limit, fields) {
+ return api.get(
+ uri.formatApiLink(
+ 'pools', {
+ query: text,
+ offset: offset,
+ limit: limit,
+ fields: fields.join(','),
+ }))
+ .then(response => {
+ return Promise.resolve(Object.assign(
+ {},
+ response,
+ {results: PoolList.fromResponse(response.results)}));
+ });
+ }
+}
+
+PoolList._itemClass = Pool;
+PoolList._itemName = 'pool';
+
+module.exports = PoolList;
diff --git a/client/js/models/top_navigation.js b/client/js/models/top_navigation.js
index bf2cffe0..41b1def6 100644
--- a/client/js/models/top_navigation.js
+++ b/client/js/models/top_navigation.js
@@ -81,6 +81,7 @@ function _makeTopNavigation() {
ret.add('upload', new TopNavigationItem('U', 'Upload', 'upload'));
ret.add('comments', new TopNavigationItem('C', 'Comments', 'comments'));
ret.add('tags', new TopNavigationItem('T', 'Tags', 'tags'));
+ ret.add('pools', new TopNavigationItem('O', 'Pools', 'pools'));
ret.add('users', new TopNavigationItem('S', 'Users', 'users'));
ret.add('account', new TopNavigationItem('A', 'Account', 'user/{me}'));
ret.add('register', new TopNavigationItem('R', 'Register', 'register'));
diff --git a/client/js/pools.js b/client/js/pools.js
new file mode 100644
index 00000000..8484f0b5
--- /dev/null
+++ b/client/js/pools.js
@@ -0,0 +1,26 @@
+'use strict';
+
+const misc = require('./util/misc.js');
+const PoolCategoryList = require('./models/pool_category_list.js');
+
+let _stylesheet = null;
+
+function refreshCategoryColorMap() {
+ return PoolCategoryList.get().then(response => {
+ if (_stylesheet) {
+ document.head.removeChild(_stylesheet);
+ }
+ _stylesheet = document.createElement('style');
+ document.head.appendChild(_stylesheet);
+ for (let category of response.results) {
+ const ruleName = misc.makeCssName(category.name, 'pool');
+ _stylesheet.sheet.insertRule(
+ `.${ruleName} { color: ${category.color} }`,
+ _stylesheet.sheet.cssRules.length);
+ }
+ });
+}
+
+module.exports = {
+ refreshCategoryColorMap: refreshCategoryColorMap,
+};
diff --git a/client/js/util/misc.js b/client/js/util/misc.js
index aa62097d..5911a363 100644
--- a/client/js/util/misc.js
+++ b/client/js/util/misc.js
@@ -163,6 +163,11 @@ function escapeHtml(unsafe) {
}
function arraysDiffer(source1, source2, orderImportant) {
+ if ((source1 instanceof Array && source2 === undefined)
+ || (source1 === undefined && source2 instanceof Array)) {
+ return true
+ }
+
source1 = [...source1];
source2 = [...source2];
if (orderImportant === true) {
diff --git a/client/js/util/views.js b/client/js/util/views.js
index 9c26fb0a..38020d82 100644
--- a/client/js/util/views.js
+++ b/client/js/util/views.js
@@ -221,6 +221,29 @@ function makeTagLink(name, includeHash, includeCount, tag) {
misc.escapeHtml(text));
}
+function makePoolLink(pool, includeHash, includeCount, name) {
+ const category = pool.category;
+ let text = name ? name : pool.names[0];
+ if (includeHash === true) {
+ text = '#' + text;
+ }
+ if (includeCount === true) {
+ text += ' (' + pool.postCount + ')';
+ }
+ return api.hasPrivilege('pools:view') ?
+ makeElement(
+ 'a',
+ {
+ href: uri.formatClientLink('pool', pool.id),
+ class: misc.makeCssName(category, 'pool'),
+ },
+ misc.escapeHtml(text)) :
+ makeElement(
+ 'span',
+ {class: misc.makeCssName(category, 'pool')},
+ misc.escapeHtml(text));
+}
+
function makeUserLink(user) {
let text = makeThumbnail(user ? user.avatarUrl : null);
text += user && user.name ? misc.escapeHtml(user.name) : 'Anonymous';
@@ -400,6 +423,7 @@ function getTemplate(templatePath) {
makeDateInput: makeDateInput,
makePostLink: makePostLink,
makeTagLink: makeTagLink,
+ makePoolLink: makePoolLink,
makeUserLink: makeUserLink,
makeFlexboxAlign: makeFlexboxAlign,
makeAccessKey: makeAccessKey,
@@ -529,6 +553,7 @@ module.exports = {
decorateValidator: decorateValidator,
makeTagLink: makeTagLink,
makePostLink: makePostLink,
+ makePoolLink: makePoolLink,
makeCheckbox: makeCheckbox,
makeRadio: makeRadio,
syncScrollPosition: syncScrollPosition,
diff --git a/client/js/views/help_view.js b/client/js/views/help_view.js
index 11f6a39a..5d670836 100644
--- a/client/js/views/help_view.js
+++ b/client/js/views/help_view.js
@@ -17,6 +17,7 @@ const subsectionTemplates = {
'posts': views.getTemplate('help-search-posts'),
'users': views.getTemplate('help-search-users'),
'tags': views.getTemplate('help-search-tags'),
+ 'pools': views.getTemplate('help-search-pools'),
},
};
diff --git a/client/js/views/pool_categories_view.js b/client/js/views/pool_categories_view.js
new file mode 100644
index 00000000..19283581
--- /dev/null
+++ b/client/js/views/pool_categories_view.js
@@ -0,0 +1,166 @@
+'use strict';
+
+const events = require('../events.js');
+const views = require('../util/views.js');
+const PoolCategory = require('../models/pool_category.js');
+
+const template = views.getTemplate('pool-categories');
+const rowTemplate = views.getTemplate('pool-category-row');
+
+class PoolCategoriesView extends events.EventTarget {
+ constructor(ctx) {
+ super();
+ this._ctx = ctx;
+ this._hostNode = document.getElementById('content-holder');
+
+ views.replaceContent(this._hostNode, template(ctx));
+ views.syncScrollPosition();
+ views.decorateValidator(this._formNode);
+
+ const categoriesToAdd = Array.from(ctx.poolCategories);
+ categoriesToAdd.sort((a, b) => {
+ if (b.isDefault) {
+ return 1;
+ } else if (a.isDefault) {
+ return -1;
+ }
+ return a.name.localeCompare(b.name);
+ });
+ for (let poolCategory of categoriesToAdd) {
+ this._addPoolCategoryRowNode(poolCategory);
+ }
+
+ if (this._addLinkNode) {
+ this._addLinkNode.addEventListener(
+ 'click', e => this._evtAddButtonClick(e));
+ }
+
+ ctx.poolCategories.addEventListener(
+ 'add', e => this._evtPoolCategoryAdded(e));
+
+ ctx.poolCategories.addEventListener(
+ 'remove', e => this._evtPoolCategoryDeleted(e));
+
+ this._formNode.addEventListener(
+ 'submit', e => this._evtSaveButtonClick(e, ctx));
+ }
+
+ enableForm() {
+ views.enableForm(this._formNode);
+ }
+
+ disableForm() {
+ views.disableForm(this._formNode);
+ }
+
+ clearMessages() {
+ views.clearMessages(this._hostNode);
+ }
+
+ showSuccess(message) {
+ views.showSuccess(this._hostNode, message);
+ }
+
+ showError(message) {
+ views.showError(this._hostNode, message);
+ }
+
+ get _formNode() {
+ return this._hostNode.querySelector('form');
+ }
+
+ get _tableBodyNode() {
+ return this._hostNode.querySelector('tbody');
+ }
+
+ get _addLinkNode() {
+ return this._hostNode.querySelector('a.add');
+ }
+
+ _addPoolCategoryRowNode(poolCategory) {
+ const rowNode = rowTemplate(
+ Object.assign(
+ {}, this._ctx, {poolCategory: poolCategory}));
+
+ const nameInput = rowNode.querySelector('.name input');
+ if (nameInput) {
+ nameInput.addEventListener(
+ 'change', e => this._evtNameChange(e, rowNode));
+ }
+
+ const colorInput = rowNode.querySelector('.color input');
+ if (colorInput) {
+ colorInput.addEventListener(
+ 'change', e => this._evtColorChange(e, rowNode));
+ }
+
+ const removeLinkNode = rowNode.querySelector('.remove a');
+ if (removeLinkNode) {
+ removeLinkNode.addEventListener(
+ 'click', e => this._evtDeleteButtonClick(e, rowNode));
+ }
+
+ const defaultLinkNode = rowNode.querySelector('.set-default a');
+ if (defaultLinkNode) {
+ defaultLinkNode.addEventListener(
+ 'click', e => this._evtSetDefaultButtonClick(e, rowNode));
+ }
+
+ this._tableBodyNode.appendChild(rowNode);
+
+ rowNode._poolCategory = poolCategory;
+ poolCategory._rowNode = rowNode;
+ }
+
+ _removePoolCategoryRowNode(poolCategory) {
+ const rowNode = poolCategory._rowNode;
+ rowNode.parentNode.removeChild(rowNode);
+ }
+
+ _evtPoolCategoryAdded(e) {
+ this._addPoolCategoryRowNode(e.detail.poolCategory);
+ }
+
+ _evtPoolCategoryDeleted(e) {
+ this._removePoolCategoryRowNode(e.detail.poolCategory);
+ }
+
+ _evtAddButtonClick(e) {
+ e.preventDefault();
+ this._ctx.poolCategories.add(new PoolCategory());
+ }
+
+ _evtNameChange(e, rowNode) {
+ rowNode._poolCategory.name = e.target.value;
+ }
+
+ _evtColorChange(e, rowNode) {
+ e.target.value = e.target.value.toLowerCase();
+ rowNode._poolCategory.color = e.target.value;
+ }
+
+ _evtDeleteButtonClick(e, rowNode, link) {
+ e.preventDefault();
+ if (e.target.classList.contains('inactive')) {
+ return;
+ }
+ this._ctx.poolCategories.remove(rowNode._poolCategory);
+ }
+
+ _evtSetDefaultButtonClick(e, rowNode) {
+ e.preventDefault();
+ this._ctx.poolCategories.defaultCategory = rowNode._poolCategory;
+ const oldRowNode = rowNode.parentNode.querySelector('tr.default');
+ if (oldRowNode) {
+ oldRowNode.classList.remove('default');
+ }
+ rowNode.classList.add('default');
+ }
+
+ _evtSaveButtonClick(e, ctx) {
+ e.preventDefault();
+ this.dispatchEvent(new CustomEvent('submit'));
+ }
+}
+
+module.exports = PoolCategoriesView;
diff --git a/client/js/views/pool_create_view.js b/client/js/views/pool_create_view.js
new file mode 100644
index 00000000..20218e53
--- /dev/null
+++ b/client/js/views/pool_create_view.js
@@ -0,0 +1,108 @@
+'use strict';
+
+const events = require('../events.js');
+const api = require('../api.js');
+const misc = require('../util/misc.js');
+const views = require('../util/views.js');
+const Pool = require('../models/pool.js')
+
+const template = views.getTemplate('pool-create');
+
+class PoolCreateView extends events.EventTarget {
+ constructor(ctx) {
+ super();
+
+ this._hostNode = document.getElementById('content-holder');
+ views.replaceContent(this._hostNode, template(ctx));
+
+ views.decorateValidator(this._formNode);
+
+ if (this._namesFieldNode) {
+ this._namesFieldNode.addEventListener(
+ 'input', e => this._evtNameInput(e));
+ }
+
+ for (let node of this._formNode.querySelectorAll(
+ 'input, select, textarea')) {
+ node.addEventListener(
+ 'change', e => {
+ this.dispatchEvent(new CustomEvent('change'));
+ });
+ }
+
+ this._formNode.addEventListener('submit', e => this._evtSubmit(e));
+ }
+
+ clearMessages() {
+ views.clearMessages(this._hostNode);
+ }
+
+ enableForm() {
+ views.enableForm(this._formNode);
+ }
+
+ disableForm() {
+ views.disableForm(this._formNode);
+ }
+
+ showSuccess(message) {
+ views.showSuccess(this._hostNode, message);
+ }
+
+ showError(message) {
+ views.showError(this._hostNode, message);
+ }
+
+ _evtNameInput(e) {
+ const regex = new RegExp(api.getPoolNameRegex());
+ const list = misc.splitByWhitespace(this._namesFieldNode.value);
+
+ if (!list.length) {
+ this._namesFieldNode.setCustomValidity(
+ 'Pools must have at least one name.');
+ return;
+ }
+
+ for (let item of list) {
+ if (!regex.test(item)) {
+ this._namesFieldNode.setCustomValidity(
+ `Pool name "${item}" contains invalid symbols.`);
+ return;
+ }
+ }
+
+ this._namesFieldNode.setCustomValidity('');
+ }
+
+ _evtSubmit(e) {
+ e.preventDefault();
+ let pool = new Pool()
+ pool.names = misc.splitByWhitespace(this._namesFieldNode.value);
+ pool.category = this._categoryFieldNode.value;
+ pool.description = this._descriptionFieldNode.value;
+
+ this.dispatchEvent(new CustomEvent('submit', {
+ detail: {
+ pool: pool,
+ },
+ }));
+ }
+
+ get _formNode() {
+ return this._hostNode.querySelector('form');
+ }
+
+ get _namesFieldNode() {
+ return this._formNode.querySelector('.names input');
+ }
+
+ get _categoryFieldNode() {
+ return this._formNode.querySelector('.category select');
+ }
+
+ get _descriptionFieldNode() {
+ return this._formNode.querySelector('.description textarea');
+ }
+}
+
+module.exports = PoolCreateView;
diff --git a/client/js/views/pool_delete_view.js b/client/js/views/pool_delete_view.js
new file mode 100644
index 00000000..d3707dbe
--- /dev/null
+++ b/client/js/views/pool_delete_view.js
@@ -0,0 +1,53 @@
+'use strict';
+
+const events = require('../events.js');
+const views = require('../util/views.js');
+
+const template = views.getTemplate('pool-delete');
+
+class PoolDeleteView extends events.EventTarget {
+ constructor(ctx) {
+ super();
+
+ this._hostNode = ctx.hostNode;
+ this._pool = ctx.pool;
+ views.replaceContent(this._hostNode, template(ctx));
+ views.decorateValidator(this._formNode);
+ this._formNode.addEventListener('submit', e => this._evtSubmit(e));
+ }
+
+ clearMessages() {
+ views.clearMessages(this._hostNode);
+ }
+
+ enableForm() {
+ views.enableForm(this._formNode);
+ }
+
+ disableForm() {
+ views.disableForm(this._formNode);
+ }
+
+ showSuccess(message) {
+ views.showSuccess(this._hostNode, message);
+ }
+
+ showError(message) {
+ views.showError(this._hostNode, message);
+ }
+
+ _evtSubmit(e) {
+ e.preventDefault();
+ this.dispatchEvent(new CustomEvent('submit', {
+ detail: {
+ pool: this._pool,
+ },
+ }));
+ }
+
+ get _formNode() {
+ return this._hostNode.querySelector('form');
+ }
+}
+
+module.exports = PoolDeleteView;
diff --git a/client/js/views/pool_edit_view.js b/client/js/views/pool_edit_view.js
new file mode 100644
index 00000000..fac84307
--- /dev/null
+++ b/client/js/views/pool_edit_view.js
@@ -0,0 +1,115 @@
+'use strict';
+
+const events = require('../events.js');
+const api = require('../api.js');
+const misc = require('../util/misc.js');
+const views = require('../util/views.js');
+
+const template = views.getTemplate('pool-edit');
+
+class PoolEditView extends events.EventTarget {
+ constructor(ctx) {
+ super();
+
+ this._pool = ctx.pool;
+ this._hostNode = ctx.hostNode;
+ views.replaceContent(this._hostNode, template(ctx));
+
+ views.decorateValidator(this._formNode);
+
+ if (this._namesFieldNode) {
+ this._namesFieldNode.addEventListener(
+ 'input', e => this._evtNameInput(e));
+ }
+
+ for (let node of this._formNode.querySelectorAll(
+ 'input, select, textarea')) {
+ node.addEventListener(
+ 'change', e => {
+ this.dispatchEvent(new CustomEvent('change'));
+ });
+ }
+
+ this._formNode.addEventListener('submit', e => this._evtSubmit(e));
+ }
+
+ clearMessages() {
+ views.clearMessages(this._hostNode);
+ }
+
+ enableForm() {
+ views.enableForm(this._formNode);
+ }
+
+ disableForm() {
+ views.disableForm(this._formNode);
+ }
+
+ showSuccess(message) {
+ views.showSuccess(this._hostNode, message);
+ }
+
+ showError(message) {
+ views.showError(this._hostNode, message);
+ }
+
+ _evtNameInput(e) {
+ const regex = new RegExp(api.getPoolNameRegex());
+ const list = misc.splitByWhitespace(this._namesFieldNode.value);
+
+ if (!list.length) {
+ this._namesFieldNode.setCustomValidity(
+ 'Pools must have at least one name.');
+ return;
+ }
+
+ for (let item of list) {
+ if (!regex.test(item)) {
+ this._namesFieldNode.setCustomValidity(
+ `Pool name "${item}" contains invalid symbols.`);
+ return;
+ }
+ }
+
+ this._namesFieldNode.setCustomValidity('');
+ }
+
+ _evtSubmit(e) {
+ e.preventDefault();
+ this.dispatchEvent(new CustomEvent('submit', {
+ detail: {
+ pool: this._pool,
+
+ names: this._namesFieldNode ?
+ misc.splitByWhitespace(this._namesFieldNode.value) :
+ undefined,
+
+ category: this._categoryFieldNode ?
+ this._categoryFieldNode.value :
+ undefined,
+
+ description: this._descriptionFieldNode ?
+ this._descriptionFieldNode.value :
+ undefined,
+ },
+ }));
+ }
+
+ get _formNode() {
+ return this._hostNode.querySelector('form');
+ }
+
+ get _namesFieldNode() {
+ return this._formNode.querySelector('.names input');
+ }
+
+ get _categoryFieldNode() {
+ return this._formNode.querySelector('.category select');
+ }
+
+ get _descriptionFieldNode() {
+ return this._formNode.querySelector('.description textarea');
+ }
+}
+
+module.exports = PoolEditView;
diff --git a/client/js/views/pool_merge_view.js b/client/js/views/pool_merge_view.js
new file mode 100644
index 00000000..ea9fe6f2
--- /dev/null
+++ b/client/js/views/pool_merge_view.js
@@ -0,0 +1,77 @@
+'use strict';
+
+const events = require('../events.js');
+const api = require('../api.js');
+const views = require('../util/views.js');
+const PoolAutoCompleteControl =
+ require('../controls/pool_auto_complete_control.js');
+
+const template = views.getTemplate('pool-merge');
+
+class PoolMergeView extends events.EventTarget {
+ constructor(ctx) {
+ super();
+
+ this._pool = ctx.pool;
+ this._hostNode = ctx.hostNode;
+ ctx.poolNamePattern = api.getPoolNameRegex();
+ views.replaceContent(this._hostNode, template(ctx));
+
+ views.decorateValidator(this._formNode);
+ if (this._targetPoolFieldNode) {
+ this._autoCompleteControl = new PoolAutoCompleteControl(
+ this._targetPoolFieldNode,
+ {
+ confirm: pool =>
+ this._autoCompleteControl.replaceSelectedText(
+ pool.names[0], false),
+ });
+ }
+
+ this._formNode.addEventListener('submit', e => this._evtSubmit(e));
+ }
+
+ clearMessages() {
+ views.clearMessages(this._hostNode);
+ }
+
+ enableForm() {
+ views.enableForm(this._formNode);
+ }
+
+ disableForm() {
+ views.disableForm(this._formNode);
+ }
+
+ showSuccess(message) {
+ views.showSuccess(this._hostNode, message);
+ }
+
+ showError(message) {
+ views.showError(this._hostNode, message);
+ }
+
+ _evtSubmit(e) {
+ e.preventDefault();
+ this.dispatchEvent(new CustomEvent('submit', {
+ detail: {
+ pool: this._pool,
+ targetPoolName: this._targetPoolFieldNode.value
+ },
+ }));
+ }
+
+ get _formNode() {
+ return this._hostNode.querySelector('form');
+ }
+
+ get _targetPoolFieldNode() {
+ return this._formNode.querySelector('input[name=target-pool]');
+ }
+
+ get _addAliasCheckboxNode() {
+ return this._formNode.querySelector('input[name=alias]');
+ }
+}
+
+module.exports = PoolMergeView;
diff --git a/client/js/views/pool_summary_view.js b/client/js/views/pool_summary_view.js
new file mode 100644
index 00000000..a5808cc0
--- /dev/null
+++ b/client/js/views/pool_summary_view.js
@@ -0,0 +1,23 @@
+'use strict';
+
+const views = require('../util/views.js');
+
+const template = views.getTemplate('pool-summary');
+
+class PoolSummaryView {
+ constructor(ctx) {
+ this._pool = ctx.pool;
+ this._hostNode = ctx.hostNode;
+ views.replaceContent(this._hostNode, template(ctx));
+ }
+
+ showSuccess(message) {
+ views.showSuccess(this._hostNode, message);
+ }
+
+ showError(message) {
+ views.showError(this._hostNode, message);
+ }
+}
+
+module.exports = PoolSummaryView;
diff --git a/client/js/views/pool_view.js b/client/js/views/pool_view.js
new file mode 100644
index 00000000..f296e906
--- /dev/null
+++ b/client/js/views/pool_view.js
@@ -0,0 +1,106 @@
+'use strict';
+
+const events = require('../events.js');
+const views = require('../util/views.js');
+const misc = require('../util/misc.js');
+const PoolSummaryView = require('./pool_summary_view.js');
+const PoolEditView = require('./pool_edit_view.js');
+const PoolMergeView = require('./pool_merge_view.js');
+const PoolDeleteView = require('./pool_delete_view.js');
+const EmptyView = require('../views/empty_view.js');
+
+const template = views.getTemplate('pool');
+
+class PoolView extends events.EventTarget {
+ constructor(ctx) {
+ super();
+
+ this._ctx = ctx;
+ ctx.pool.addEventListener('change', e => this._evtChange(e));
+ ctx.section = ctx.section || 'summary';
+ ctx.getPrettyPoolName = misc.getPrettyPoolName;
+
+ this._hostNode = document.getElementById('content-holder');
+ this._install();
+ }
+
+ _install() {
+ const ctx = this._ctx;
+ views.replaceContent(this._hostNode, template(ctx));
+
+ for (let item of this._hostNode.querySelectorAll('[data-name]')) {
+ item.classList.toggle(
+ 'active', item.getAttribute('data-name') === ctx.section);
+ if (item.getAttribute('data-name') === ctx.section) {
+ item.parentNode.scrollLeft =
+ item.getBoundingClientRect().left -
+ item.parentNode.getBoundingClientRect().left
+ }
+ }
+
+ ctx.hostNode = this._hostNode.querySelector('.pool-content-holder');
+ if (ctx.section === 'edit') {
+ if (!this._ctx.canEditAnything) {
+ this._view = new EmptyView();
+ this._view.showError(
+ 'You don\'t have privileges to edit pools.');
+ } else {
+ this._view = new PoolEditView(ctx);
+ events.proxyEvent(this._view, this, 'submit');
+ }
+
+ } else if (ctx.section === 'merge') {
+ if (!this._ctx.canMerge) {
+ this._view = new EmptyView();
+ this._view.showError(
+ 'You don\'t have privileges to merge pools.');
+ } else {
+ this._view = new PoolMergeView(ctx);
+ events.proxyEvent(this._view, this, 'submit', 'merge');
+ }
+
+ } else if (ctx.section === 'delete') {
+ if (!this._ctx.canDelete) {
+ this._view = new EmptyView();
+ this._view.showError(
+ 'You don\'t have privileges to delete pools.');
+ } else {
+ this._view = new PoolDeleteView(ctx);
+ events.proxyEvent(this._view, this, 'submit', 'delete');
+ }
+
+ } else {
+ this._view = new PoolSummaryView(ctx);
+ }
+
+ events.proxyEvent(this._view, this, 'change');
+ views.syncScrollPosition();
+ }
+
+ clearMessages() {
+ this._view.clearMessages();
+ }
+
+ enableForm() {
+ this._view.enableForm();
+ }
+
+ disableForm() {
+ this._view.disableForm();
+ }
+
+ showSuccess(message) {
+ this._view.showSuccess(message);
+ }
+
+ showError(message) {
+ this._view.showError(message);
+ }
+
+ _evtChange(e) {
+ this._ctx.pool = e.detail.pool;
+ this._install(this._ctx);
+ }
+}
+
+module.exports = PoolView;
diff --git a/client/js/views/pools_header_view.js b/client/js/views/pools_header_view.js
new file mode 100644
index 00000000..0c18f186
--- /dev/null
+++ b/client/js/views/pools_header_view.js
@@ -0,0 +1,52 @@
+'use strict';
+
+const events = require('../events.js');
+const misc = require('../util/misc.js');
+const search = require('../util/search.js');
+const views = require('../util/views.js');
+const PoolAutoCompleteControl =
+ require('../controls/pool_auto_complete_control.js');
+
+const template = views.getTemplate('pools-header');
+
+class PoolsHeaderView extends events.EventTarget {
+ constructor(ctx) {
+ super();
+
+ this._hostNode = ctx.hostNode;
+ views.replaceContent(this._hostNode, template(ctx));
+
+ if (this._queryInputNode) {
+ this._autoCompleteControl = new PoolAutoCompleteControl(
+ this._queryInputNode,
+ {
+ confirm: pool =>
+ this._autoCompleteControl.replaceSelectedText(
+ misc.escapeSearchTerm(pool.names[0]), true),
+ });
+ }
+
+ search.searchInputNodeFocusHelper(this._queryInputNode);
+
+ this._formNode.addEventListener('submit', e => this._evtSubmit(e));
+ }
+
+ get _formNode() {
+ return this._hostNode.querySelector('form');
+ }
+
+ get _queryInputNode() {
+ return this._hostNode.querySelector('[name=search-text]');
+ }
+
+ _evtSubmit(e) {
+ e.preventDefault();
+ this._queryInputNode.blur();
+ this.dispatchEvent(new CustomEvent('navigate', {detail: {parameters: {
+ query: this._queryInputNode.value,
+ page: 1,
+ }}}));
+ }
+}
+
+module.exports = PoolsHeaderView;
diff --git a/client/js/views/pools_page_view.js b/client/js/views/pools_page_view.js
new file mode 100644
index 00000000..234d4467
--- /dev/null
+++ b/client/js/views/pools_page_view.js
@@ -0,0 +1,13 @@
+'use strict';
+
+const views = require('../util/views.js');
+
+const template = views.getTemplate('pools-page');
+
+class PoolsPageView {
+ constructor(ctx) {
+ views.replaceContent(ctx.hostNode, template(ctx));
+ }
+}
+
+module.exports = PoolsPageView;
diff --git a/client/package.json b/client/package.json
index 3d390ec6..e7761265 100644
--- a/client/package.json
+++ b/client/package.json
@@ -3,7 +3,7 @@
"private": true,
"scripts": {
"build": "node build.js",
- "watch": "c1=\"\";while :;do c2=$(find html js css img -type f -and -not -iname '*autogen*'|sort|xargs cat|md5sum);[[ $c1 != $c2 ]]&&npm run build -- --no-vendor-js;c1=$c2;sleep 1;done"
+ "watch": "c1=\"\";while :;do c2=$(find html js css img -type f -and -not -iname '*autogen*'|sort|xargs cat|md5sum);[[ $c1 != $c2 ]]&&npm run build -- --debug --no-vendor-js;c1=$c2;sleep 1;done"
},
"dependencies": {
"font-awesome": "^4.7.0",
diff --git a/server/alembic.ini b/server/alembic.ini
index 6a4e5ff4..98ab83d4 100644
--- a/server/alembic.ini
+++ b/server/alembic.ini
@@ -3,6 +3,7 @@ script_location = szurubooru/migrations
# overriden by szurubooru's config
sqlalchemy.url =
+revision_environment = true
[loggers]
keys = root,sqlalchemy,alembic
diff --git a/server/config.yaml.dist b/server/config.yaml.dist
index 4f8f769d..68076239 100644
--- a/server/config.yaml.dist
+++ b/server/config.yaml.dist
@@ -4,7 +4,7 @@
# shown in the website title and on the front page
name: szurubooru
# full url to the homepage of this szurubooru site, with no trailing slash
-domain: # example: http://example.com
+domain: localhost # example: http://example.com
# used to salt the users' password hashes and generate filenames for static content
secret: change
@@ -49,6 +49,9 @@ enable_safety: yes
tag_name_regex: ^\S+$
tag_category_name_regex: ^[^\s%+#/]+$
+pool_name_regex: ^\S+$
+pool_category_name_regex: ^[^\s%+#/]+$
+
# don't make these more restrictive unless you want to annoy people; if you do
# customize them, make sure to update the instructions in the registration form
# template as well.
@@ -58,86 +61,100 @@ user_name_regex: '^[a-zA-Z0-9_-]{1,32}$'
default_rank: regular
privileges:
- 'users:create:self': anonymous # Registration permission
- 'users:create:any': administrator
- 'users:list': regular
- 'users:view': regular
- 'users:edit:any:name': moderator
- 'users:edit:any:pass': moderator
- 'users:edit:any:email': moderator
- 'users:edit:any:avatar': moderator
- 'users:edit:any:rank': moderator
- 'users:edit:self:name': regular
- 'users:edit:self:pass': regular
- 'users:edit:self:email': regular
- 'users:edit:self:avatar': regular
- 'users:edit:self:rank': moderator # one can't promote themselves or anyone to upper rank than their own.
- 'users:delete:any': administrator
- 'users:delete:self': regular
+ 'users:create:self': anonymous # Registration permission
+ 'users:create:any': administrator
+ 'users:list': regular
+ 'users:view': regular
+ 'users:edit:any:name': moderator
+ 'users:edit:any:pass': moderator
+ 'users:edit:any:email': moderator
+ 'users:edit:any:avatar': moderator
+ 'users:edit:any:rank': moderator
+ 'users:edit:self:name': regular
+ 'users:edit:self:pass': regular
+ 'users:edit:self:email': regular
+ 'users:edit:self:avatar': regular
+ 'users:edit:self:rank': moderator # one can't promote themselves or anyone to upper rank than their own.
+ 'users:delete:any': administrator
+ 'users:delete:self': regular
- 'user_tokens:list:any': administrator
- 'user_tokens:list:self': regular
- 'user_tokens:create:any': administrator
- 'user_tokens:create:self': regular
- 'user_tokens:edit:any': administrator
- 'user_tokens:edit:self': regular
- 'user_tokens:delete:any': administrator
- 'user_tokens:delete:self': regular
+ 'user_tokens:list:any': administrator
+ 'user_tokens:list:self': regular
+ 'user_tokens:create:any': administrator
+ 'user_tokens:create:self': regular
+ 'user_tokens:edit:any': administrator
+ 'user_tokens:edit:self': regular
+ 'user_tokens:delete:any': administrator
+ 'user_tokens:delete:self': regular
- 'posts:create:anonymous': regular
- 'posts:create:identified': regular
- 'posts:list': anonymous
- 'posts:reverse_search': regular
- 'posts:view': anonymous
- 'posts:view:featured': anonymous
- 'posts:edit:content': power
- 'posts:edit:flags': regular
- 'posts:edit:notes': regular
- 'posts:edit:relations': regular
- 'posts:edit:safety': power
- 'posts:edit:source': regular
- 'posts:edit:tags': regular
- 'posts:edit:thumbnail': power
- 'posts:feature': moderator
- 'posts:delete': moderator
- 'posts:score': regular
- 'posts:merge': moderator
- 'posts:favorite': regular
- 'posts:bulk-edit:tags': power
- 'posts:bulk-edit:safety': power
+ 'posts:create:anonymous': regular
+ 'posts:create:identified': regular
+ 'posts:list': anonymous
+ 'posts:reverse_search': regular
+ 'posts:view': anonymous
+ 'posts:view:featured': anonymous
+ 'posts:edit:content': power
+ 'posts:edit:flags': regular
+ 'posts:edit:notes': regular
+ 'posts:edit:relations': regular
+ 'posts:edit:safety': power
+ 'posts:edit:source': regular
+ 'posts:edit:tags': regular
+ 'posts:edit:thumbnail': power
+ 'posts:feature': moderator
+ 'posts:delete': moderator
+ 'posts:score': regular
+ 'posts:merge': moderator
+ 'posts:favorite': regular
+ 'posts:bulk-edit:tags': power
+ 'posts:bulk-edit:safety': power
- 'tags:create': regular
- 'tags:edit:names': power
- 'tags:edit:category': power
- 'tags:edit:description': power
- 'tags:edit:implications': power
- 'tags:edit:suggestions': power
- 'tags:list': regular
- 'tags:view': anonymous
- 'tags:merge': moderator
- 'tags:delete': moderator
+ 'tags:create': regular
+ 'tags:edit:names': power
+ 'tags:edit:category': power
+ 'tags:edit:description': power
+ 'tags:edit:implications': power
+ 'tags:edit:suggestions': power
+ 'tags:list': regular
+ 'tags:view': anonymous
+ 'tags:merge': moderator
+ 'tags:delete': moderator
- 'tag_categories:create': moderator
- 'tag_categories:edit:name': moderator
- 'tag_categories:edit:color': moderator
- 'tag_categories:list': anonymous
- 'tag_categories:view': anonymous
- 'tag_categories:delete': moderator
- 'tag_categories:set_default': moderator
+ 'tag_categories:create': moderator
+ 'tag_categories:edit:name': moderator
+ 'tag_categories:edit:color': moderator
+ 'tag_categories:list': anonymous
+ 'tag_categories:view': anonymous
+ 'tag_categories:delete': moderator
+ 'tag_categories:set_default': moderator
- 'comments:create': regular
- 'comments:delete:any': moderator
- 'comments:delete:own': regular
- 'comments:edit:any': moderator
- 'comments:edit:own': regular
- 'comments:list': regular
- 'comments:view': regular
- 'comments:score': regular
+ 'pools:create': regular
+ 'pools:list': regular
+ 'pools:view': anonymous
+ 'pools:merge': moderator
+ 'pools:delete': moderator
- 'snapshots:list': power
+ 'pool_categories:create': moderator
+ 'pool_categories:edit:name': moderator
+ 'pool_categories:edit:color': moderator
+ 'pool_categories:list': anonymous
+ 'pool_categories:view': anonymous
+ 'pool_categories:delete': moderator
+ 'pool_categories:set_default': moderator
- 'uploads:create': regular
- 'uploads:use_downloader': power
+ 'comments:create': regular
+ 'comments:delete:any': moderator
+ 'comments:delete:own': regular
+ 'comments:edit:any': moderator
+ 'comments:edit:own': regular
+ 'comments:list': regular
+ 'comments:view': regular
+ 'comments:score': regular
+
+ 'snapshots:list': power
+
+ 'uploads:create': regular
+ 'uploads:use_downloader': power
## ONLY SET THESE IF DEPLOYING OUTSIDE OF DOCKER
#debug: 0 # generate server logs?
diff --git a/server/requirements.txt b/server/requirements.txt
index 810452e0..8a337f9e 100644
--- a/server/requirements.txt
+++ b/server/requirements.txt
@@ -9,3 +9,4 @@ pillow>=4.3.0
pynacl>=1.2.1
pytz>=2018.3
pyRFC3339>=1.0
+youtube_dl>=2020.5.3
diff --git a/server/szurubooru/api/__init__.py b/server/szurubooru/api/__init__.py
index 0d7f75f8..28f0e984 100644
--- a/server/szurubooru/api/__init__.py
+++ b/server/szurubooru/api/__init__.py
@@ -4,6 +4,8 @@ import szurubooru.api.user_token_api
import szurubooru.api.post_api
import szurubooru.api.tag_api
import szurubooru.api.tag_category_api
+import szurubooru.api.pool_api
+import szurubooru.api.pool_category_api
import szurubooru.api.comment_api
import szurubooru.api.password_reset_api
import szurubooru.api.snapshot_api
diff --git a/server/szurubooru/api/pool_api.py b/server/szurubooru/api/pool_api.py
new file mode 100644
index 00000000..b2ec11b9
--- /dev/null
+++ b/server/szurubooru/api/pool_api.py
@@ -0,0 +1,127 @@
+from typing import Optional, List, Dict
+from datetime import datetime
+from szurubooru import db, model, search, rest
+from szurubooru.func import auth, pools, snapshots, serialization, versions
+
+
+_search_executor = search.Executor(search.configs.PoolSearchConfig())
+
+
+def _serialize(ctx: rest.Context, pool: model.Pool) -> rest.Response:
+ return pools.serialize_pool(
+ pool, options=serialization.get_serialization_options(ctx))
+
+
+def _get_pool(params: Dict[str, str]) -> model.Pool:
+ return pools.get_pool_by_id(params['pool_id'])
+
+
+# def _create_if_needed(pool_names: List[str], user: model.User) -> None:
+# if not pool_names:
+# return
+# _existing_pools, new_pools = pools.get_or_create_pools_by_names(pool_names)
+# if len(new_pools):
+# auth.verify_privilege(user, 'pools:create')
+# db.session.flush()
+# for pool in new_pools:
+# snapshots.create(pool, user)
+
+
+@rest.routes.get('/pools/?')
+def get_pools(ctx: rest.Context, _params: Dict[str, str] = {}) -> rest.Response:
+ auth.verify_privilege(ctx.user, 'pools:list')
+ return _search_executor.execute_and_serialize(
+ ctx, lambda pool: _serialize(ctx, pool))
+
+
+@rest.routes.post('/pools/?')
+def create_pool(
+ ctx: rest.Context, _params: Dict[str, str] = {}) -> rest.Response:
+ auth.verify_privilege(ctx.user, 'pools:create')
+
+ names = ctx.get_param_as_string_list('names')
+ category = ctx.get_param_as_string('category')
+ description = ctx.get_param_as_string('description', default='')
+ # TODO
+ # suggestions = ctx.get_param_as_string_list('suggestions', default=[])
+ # implications = ctx.get_param_as_string_list('implications', default=[])
+
+ # _create_if_needed(suggestions, ctx.user)
+ # _create_if_needed(implications, ctx.user)
+
+ pool = pools.create_pool(names, category)
+ pools.update_pool_description(pool, description)
+ ctx.session.add(pool)
+ ctx.session.flush()
+ snapshots.create(pool, ctx.user)
+ ctx.session.commit()
+ return _serialize(ctx, pool)
+
+
+@rest.routes.get('/pool/(?P |
---|