summaryrefslogtreecommitdiff
path: root/client
diff options
context:
space:
mode:
authorShyam Sunder <sgsunder1@gmail.com>2020-06-04 21:01:28 -0400
committerShyam Sunder <sgsunder1@gmail.com>2020-06-04 21:01:28 -0400
commitc5358f7f83ef49f51d5ba557dde1b16ce2c83d1d (patch)
tree253e0be234540c266202993567d998a5c951947d /client
parent4329b1620fe38c2a7bf44615cfb176daa2837590 (diff)
parentb0f1b8c2309277a7b170972d463b4e8d16a79ecc (diff)
client+server: add post pools feature
Diffstat (limited to 'client')
-rwxr-xr-xclient/build.js10
-rw-r--r--client/css/pool-categories-view.styl30
-rw-r--r--client/css/pool-input-control.styl53
-rw-r--r--client/css/pool-list-view.styl52
-rw-r--r--client/css/pool-view.styl33
-rw-r--r--client/html/help_search.tpl1
-rw-r--r--client/html/help_search_pools.tpl97
-rw-r--r--client/html/help_search_posts.tpl6
-rw-r--r--client/html/pool.tpl18
-rw-r--r--client/html/pool_categories.tpl30
-rw-r--r--client/html/pool_category_row.tpl43
-rw-r--r--client/html/pool_create.tpl42
-rw-r--r--client/html/pool_delete.tpl21
-rw-r--r--client/html/pool_edit.tpl50
-rw-r--r--client/html/pool_input.tpl7
-rw-r--r--client/html/pool_merge.tpl22
-rw-r--r--client/html/pool_summary.tpl23
-rw-r--r--client/html/pools_header.tpl22
-rw-r--r--client/html/pools_page.tpl48
-rw-r--r--client/html/post_edit_sidebar.tpl6
-rw-r--r--client/html/tags_page.tpl10
-rw-r--r--client/js/api.js4
-rw-r--r--client/js/controllers/auth_controller.js2
-rw-r--r--client/js/controllers/pool_categories_controller.js57
-rw-r--r--client/js/controllers/pool_controller.js147
-rw-r--r--client/js/controllers/pool_create_controller.js58
-rw-r--r--client/js/controllers/pool_list_controller.js108
-rw-r--r--client/js/controllers/post_list_controller.js7
-rw-r--r--client/js/controllers/tag_list_controller.js7
-rw-r--r--client/js/controllers/user_list_controller.js3
-rw-r--r--client/js/controls/pool_auto_complete_control.js48
-rw-r--r--client/js/controls/pool_input_control.js185
-rw-r--r--client/js/controls/post_edit_sidebar_control.js29
-rw-r--r--client/js/main.js6
-rw-r--r--client/js/models/abstract_list.js4
-rw-r--r--client/js/models/pool.js175
-rw-r--r--client/js/models/pool_category.js109
-rw-r--r--client/js/models/pool_category_list.js82
-rw-r--r--client/js/models/pool_list.js47
-rw-r--r--client/js/models/post.js62
-rw-r--r--client/js/models/post_list.js25
-rw-r--r--client/js/models/top_navigation.js1
-rw-r--r--client/js/pools.js26
-rw-r--r--client/js/util/views.js25
-rw-r--r--client/js/views/help_view.js1
-rw-r--r--client/js/views/pool_categories_view.js166
-rw-r--r--client/js/views/pool_create_view.js132
-rw-r--r--client/js/views/pool_delete_view.js53
-rw-r--r--client/js/views/pool_edit_view.js144
-rw-r--r--client/js/views/pool_merge_view.js80
-rw-r--r--client/js/views/pool_summary_view.js23
-rw-r--r--client/js/views/pool_view.js106
-rw-r--r--client/js/views/pools_header_view.js51
-rw-r--r--client/js/views/pools_page_view.js13
-rw-r--r--client/js/views/snapshots_page_view.js15
-rw-r--r--client/package.json2
56 files changed, 2605 insertions, 22 deletions
diff --git a/client/build.js b/client/build.js
index 44d8c01..19c7aeb 100755
--- a/client/build.js
+++ b/client/build.js
@@ -35,7 +35,7 @@ const app_manifest = {
src: baseUrl() + 'img/android-chrome-192x192.png',
type: 'image/png',
sizes: '192x192'
- },
+ },
{
src: baseUrl() + 'img/android-chrome-512x512.png',
type: 'image/png',
@@ -301,8 +301,12 @@ function makeOutputDirs() {
makeOutputDirs();
bundleConfig();
-bundleBinaryAssets();
-bundleWebAppFiles();
+if (!process.argv.includes('--no-binary-assets')) {
+ bundleBinaryAssets();
+}
+if (!process.argv.includes('--no-web-app-files')) {
+ bundleWebAppFiles();
+}
if (!process.argv.includes('--no-html')) {
bundleHtml();
}
diff --git a/client/css/pool-categories-view.styl b/client/css/pool-categories-view.styl
new file mode 100644
index 0000000..3e87635
--- /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 0000000..3f71abe
--- /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 0000000..0f57649
--- /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: 84%
+ .post-count
+ 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)
+ .posts
+ 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 0000000..bf16b79
--- /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 7073789..e382f2e 100644
--- a/client/html/help_search.tpl
+++ b/client/html/help_search.tpl
@@ -4,6 +4,7 @@
--><li data-name='posts'><a href='<%- ctx.formatClientLink('help', 'search', 'posts') %>'>Posts</a></li><!--
--><li data-name='users'><a href='<%- ctx.formatClientLink('help', 'search', 'users') %>'>Users</a></li><!--
--><li data-name='tags'><a href='<%- ctx.formatClientLink('help', 'search', 'tags') %>'>Tags</a></li><!--
+ --><li data-name='pools'><a href='<%- ctx.formatClientLink('help', 'search', 'pools') %>'>Pools</li><!--
--></ul><!--
--></nav>
diff --git a/client/html/help_search_pools.tpl b/client/html/help_search_pools.tpl
new file mode 100644
index 0000000..a1f0c80
--- /dev/null
+++ b/client/html/help_search_pools.tpl
@@ -0,0 +1,97 @@
+<p><strong>Anonymous tokens</strong></p>
+
+<p>Same as <code>name</code> token.</p>
+
+<p><strong>Named tokens</strong></p>
+
+<table>
+ <tbody>
+ <tr>
+ <td><code>name</code></td>
+ <td>having given name (accepts wildcards)</td>
+ </tr>
+ <tr>
+ <td><code>category</code></td>
+ <td>having given category (accepts wildcards)</td>
+ </tr>
+ <tr>
+ <td><code>creation-date</code></td>
+ <td>created at given date</td>
+ </tr>
+ <tr>
+ <td><code>creation-time</code></td>
+ <td>alias of <code>creation-date</code></td>
+ </tr>
+ <tr>
+ <td><code>last-edit-date</code></td>
+ <td>edited at given date</td>
+ </tr>
+ <tr>
+ <td><code>last-edit-time</code></td>
+ <td>alias of <code>last-edit-date</code></td>
+ </tr>
+ <tr>
+ <td><code>edit-date</code></td>
+ <td>alias of <code>last-edit-date</code></td>
+ </tr>
+ <tr>
+ <td><code>edit-time</code></td>
+ <td>alias of <code>last-edit-date</code></td>
+ </tr>
+ <tr>
+ <td><code>post-count</code></td>
+ <td>alias of <code>usages</code></td>
+ </tr>
+ </tbody>
+</table>
+
+<p><strong>Sort style tokens</strong></p>
+
+<table>
+ <tbody>
+ <tr>
+ <td><code>random</code></td>
+ <td>as random as it can get</td>
+ </tr>
+ <tr>
+ <td><code>name</code></td>
+ <td>A to Z</td>
+ </tr>
+ <tr>
+ <td><code>category</code></td>
+ <td>category (A to Z)</td>
+ </tr>
+ <tr>
+ <td><code>creation-date</code></td>
+ <td>recently created first</td>
+ </tr>
+ <tr>
+ <td><code>creation-time</code></td>
+ <td>alias of <code>creation-date</code></td>
+ </tr>
+ <tr>
+ <td><code>last-edit-date</code></td>
+ <td>recently edited first</td>
+ </tr>
+ <tr>
+ <td><code>last-edit-time</code></td>
+ <td>alias of <code>creation-time</code></td>
+ </tr>
+ <tr>
+ <td><code>edit-date</code></td>
+ <td>alias of <code>creation-time</code></td>
+ </tr>
+ <tr>
+ <td><code>edit-time</code></td>
+ <td>alias of <code>creation-time</code></td>
+ </tr>
+ <tr>
+ <td><code>post-count</code></td>
+ <td>number of posts</td>
+ </tr>
+ </tbody>
+</table>
+
+<p><strong>Special tokens</strong></p>
+
+<p>None.</p>
diff --git a/client/html/help_search_posts.tpl b/client/html/help_search_posts.tpl
index d1097b7..e6cb707 100644
--- a/client/html/help_search_posts.tpl
+++ b/client/html/help_search_posts.tpl
@@ -20,7 +20,7 @@
</tr>
<tr>
<td><code>uploader</code></td>
- <td>uploaded by given use (accepts wildcards)r</td>
+ <td>uploaded by given user (accepts wildcards)</td>
</tr>
<tr>
<td><code>upload</code></td>
@@ -43,6 +43,10 @@
<td>having given source URL (accepts wildcards)</td>
</tr>
<tr>
+ <td><code>pool</code></td>
+ <td>belonging to the pool with the given ID</td>
+ </tr>
+ <tr>
<td><code>tag-count</code></td>
<td>having given number of tags</td>
</tr>
diff --git a/client/html/pool.tpl b/client/html/pool.tpl
new file mode 100644
index 0000000..e1e5311
--- /dev/null
+++ b/client/html/pool.tpl
@@ -0,0 +1,18 @@
+<div class='content-wrapper' id='pool'>
+ <h1><%- ctx.pool.names[0] %></h1>
+ <nav class='buttons'><!--
+ --><ul><!--
+ --><li data-name='summary'><a href='<%- ctx.formatClientLink('pool', ctx.pool.id) %>'>Summary</a></li><!--
+ --><% if (ctx.canEditAnything) { %><!--
+ --><li data-name='edit'><a href='<%- ctx.formatClientLink('pool', ctx.pool.id, 'edit') %>'>Edit</a></li><!--
+ --><% } %><!--
+ --><% if (ctx.canMerge) { %><!--
+ --><li data-name='merge'><a href='<%- ctx.formatClientLink('pool', ctx.pool.id, 'merge') %>'>Merge with&hellip;</a></li><!--
+ --><% } %><!--
+ --><% if (ctx.canDelete) { %><!--
+ --><li data-name='delete'><a href='<%- ctx.formatClientLink('pool', ctx.pool.id, 'delete') %>'>Delete</a></li><!--
+ --><% } %><!--
+ --></ul><!--
+ --></nav>
+ <div class='pool-content-holder'></div>
+</div>
diff --git a/client/html/pool_categories.tpl b/client/html/pool_categories.tpl
new file mode 100644
index 0000000..b045038
--- /dev/null
+++ b/client/html/pool_categories.tpl
@@ -0,0 +1,30 @@
+<div class='content-wrapper pool-categories'>
+ <form>
+ <h1>Pool categories</h1>
+ <div class="table-wrap">
+ <table>
+ <thead>
+ <tr>
+ <th class='name'>Category name</th>
+ <th class='color'>CSS color</th>
+ <th class='usages'>Usages</th>
+ </tr>
+ </thead>
+ <tbody>
+ </tbody>
+ </table>
+ </div>
+
+ <% if (ctx.canCreate) { %>
+ <p><a href class='add'>Add new category</a></p>
+ <% } %>
+
+ <div class='messages'></div>
+
+ <% if (ctx.canCreate || ctx.canEditName || ctx.canEditColor || ctx.canDelete) { %>
+ <div class='buttons'>
+ <input type='submit' class='save' value='Save changes'>
+ </div>
+ <% } %>
+ </form>
+</div>
diff --git a/client/html/pool_category_row.tpl b/client/html/pool_category_row.tpl
new file mode 100644
index 0000000..5ff4456
--- /dev/null
+++ b/client/html/pool_category_row.tpl
@@ -0,0 +1,43 @@
+<% if (ctx.poolCategory.isDefault) { %><%
+ %><tr data-category='<%- ctx.poolCategory.name %>' class='default'><%
+%><% } else { %><%
+ %><tr data-category='<%- ctx.poolCategory.name %>'><%
+%><% } %>
+ <td class='name'>
+ <% if (ctx.canEditName) { %>
+ <%= ctx.makeTextInput({value: ctx.poolCategory.name, required: true}) %>
+ <% } else { %>
+ <%- ctx.poolCategory.name %>
+ <% } %>
+ </td>
+ <td class='color'>
+ <% if (ctx.canEditColor) { %>
+ <%= ctx.makeColorInput({value: ctx.poolCategory.color}) %>
+ <% } else { %>
+ <%- ctx.poolCategory.color %>
+ <% } %>
+ </td>
+ <td class='usages'>
+ <% if (ctx.poolCategory.name) { %>
+ <a href='<%- ctx.formatClientLink('pools', {query: 'category:' + ctx.poolCategory.name}) %>'>
+ <%- ctx.poolCategory.poolCount %>
+ </a>
+ <% } else { %>
+ <%- ctx.poolCategory.poolCount %>
+ <% } %>
+ </td>
+ <% if (ctx.canDelete) { %>
+ <td class='remove'>
+ <% if (ctx.poolCategory.poolCount) { %>
+ <a class='inactive' title="Can't delete category in use">Remove</a>
+ <% } else { %>
+ <a href>Remove</a>
+ <% } %>
+ </td>
+ <% } %>
+ <% if (ctx.canSetDefault) { %>
+ <td class='set-default'>
+ <a href>Make default</a>
+ </td>
+ <% } %>
+</tr>
diff --git a/client/html/pool_create.tpl b/client/html/pool_create.tpl
new file mode 100644
index 0000000..3c04545
--- /dev/null
+++ b/client/html/pool_create.tpl
@@ -0,0 +1,42 @@
+<div class='content-wrapper pool-create'>
+ <form>
+ <ul class='input'>
+ <li class='names'>
+ <%= ctx.makeTextInput({
+ text: 'Names',
+ value: '',
+ required: true,
+ }) %>
+ </li>
+ <li class='category'>
+ <%= ctx.makeSelect({
+ text: 'Category',
+ keyValues: ctx.categories,
+ selectedKey: 'default',
+ required: true,
+ }) %>
+ </li>
+ <li class='description'>
+ <%= ctx.makeTextarea({
+ text: 'Description',
+ value: '',
+ }) %>
+ </li>
+ <li class='posts'>
+ <%= ctx.makeTextInput({
+ text: 'Posts',
+ value: '',
+ placeholder: 'space-separated post IDs',
+ }) %>
+ </li>
+ </ul>
+
+ <% if (ctx.canCreate) { %>
+ <div class='messages'></div>
+
+ <div class='buttons'>
+ <input type='submit' class='save' value='Create pool'>
+ </div>
+ <% } %>
+ </form>
+</div>
diff --git a/client/html/pool_delete.tpl b/client/html/pool_delete.tpl
new file mode 100644
index 0000000..1ef7fb5
--- /dev/null
+++ b/client/html/pool_delete.tpl
@@ -0,0 +1,21 @@
+<div class='pool-delete'>
+ <form>
+ <p>This pool has <a href='<%- ctx.formatClientLink('posts', {query: 'pool:' + ctx.pool.id}) %>'><%- ctx.pool.postCount %> post(s)</a>.</p>
+
+ <ul class='input'>
+ <li>
+ <%= ctx.makeCheckbox({
+ name: 'confirm-deletion',
+ text: 'I confirm that I want to delete this pool.',
+ required: true,
+ }) %>
+ </li>
+ </ul>
+
+ <div class='messages'></div>
+
+ <div class='buttons'>
+ <input type='submit' value='Delete pool'/>
+ </div>
+ </form>
+</div>
diff --git a/client/html/pool_edit.tpl b/client/html/pool_edit.tpl
new file mode 100644
index 0000000..c9f2adb
--- /dev/null
+++ b/client/html/pool_edit.tpl
@@ -0,0 +1,50 @@
+<div class='content-wrapper pool-edit'>
+ <form>
+ <ul class='input'>
+ <li class='names'>
+ <% if (ctx.canEditNames) { %>
+ <%= ctx.makeTextInput({
+ text: 'Names',
+ value: ctx.pool.names.join(' '),
+ required: true,
+ }) %>
+ <% } %>
+ </li>
+ <li class='category'>
+ <% if (ctx.canEditCategory) { %>
+ <%= ctx.makeSelect({
+ text: 'Category',
+ keyValues: ctx.categories,
+ selectedKey: ctx.pool.category,
+ required: true,
+ }) %>
+ <% } %>
+ </li>
+ <li class='description'>
+ <% if (ctx.canEditDescription) { %>
+ <%= ctx.makeTextarea({
+ text: 'Description',
+ value: ctx.pool.description,
+ }) %>
+ <% } %>
+ </li>
+ <li class='posts'>
+ <% if (ctx.canEditPosts) { %>
+ <%= ctx.makeTextInput({
+ text: 'Posts',
+ placeholder: 'space-separated post IDs',
+ value: ctx.pool.posts.map(post => post.id).join(' ')
+ }) %>
+ <% } %>
+ </li>
+ </ul>
+
+ <% if (ctx.canEditAnything) { %>
+ <div class='messages'></div>
+
+ <div class='buttons'>
+ <input type='submit' class='save' value='Save changes'>
+ </div>
+ <% } %>
+ </form>
+</div>
diff --git a/client/html/pool_input.tpl b/client/html/pool_input.tpl
new file mode 100644
index 0000000..483e790
--- /dev/null
+++ b/client/html/pool_input.tpl
@@ -0,0 +1,7 @@
+<div class='pool-input'>
+ <div class='main-control'>
+ <input type='text' placeholder='type to add…'/>
+ </div>
+
+ <ul class='compact-pools'></ul>
+</div>
diff --git a/client/html/pool_merge.tpl b/client/html/pool_merge.tpl
new file mode 100644
index 0000000..ea5776a
--- /dev/null
+++ b/client/html/pool_merge.tpl
@@ -0,0 +1,22 @@
+<div class='pool-merge'>
+ <form>
+ <ul class='input'>
+ <li class='target'>
+ <%= ctx.makeTextInput({name: 'target-pool', required: true, text: 'Target pool', pattern: ctx.poolNamePattern}) %>
+ </li>
+
+ <li>
+ <p>Posts in the two pools will be combined.
+ Category needs to be handled manually.</p>
+
+ <%= ctx.makeCheckbox({required: true, text: 'I confirm that I want to merge this pool.'}) %>
+ </li>
+ </ul>
+
+ <div class='messages'></div>
+
+ <div class='buttons'>
+ <input type='submit' value='Merge pool'/>
+ </div>
+ </form>
+</div>
diff --git a/client/html/pool_summary.tpl b/client/html/pool_summary.tpl
new file mode 100644
index 0000000..2b70d27
--- /dev/null
+++ b/client/html/pool_summary.tpl
@@ -0,0 +1,23 @@
+<div class='content-wrapper pool-summary'>
+ <section class='details'>
+ <section>
+ Category:
+ <span class='<%= ctx.makeCssName(ctx.pool.category, 'pool') %>'><%- ctx.pool.category %></span>
+ </section>
+
+ <section>
+ Aliases:<br/>
+ <ul>
+ <% for (let name of ctx.pool.names.slice(1)) { %>
+ <li><%= ctx.makePoolLink(ctx.pool.id, false, false, ctx.pool, name) %></li>
+ <% } %>
+ </ul>
+ </section>
+ </section>
+
+ <section class='description'>
+ <hr/>
+ <%= ctx.makeMarkdown(ctx.pool.description || 'This pool has no description yet.') %>
+ <p>This pool has <a href='<%- ctx.formatClientLink('posts', {query: 'pool:' + ctx.pool.id}) %>'><%- ctx.pool.postCount %> post(s)</a>.</p>
+ </section>
+</div>
diff --git a/client/html/pools_header.tpl b/client/html/pools_header.tpl
new file mode 100644
index 0000000..c54959f
--- /dev/null
+++ b/client/html/pools_header.tpl
@@ -0,0 +1,22 @@
+<div class='pool-list-header'>
+ <form class='horizontal'>
+ <ul class='input'>
+ <li>
+ <%= ctx.makeTextInput({text: 'Search query', id: 'search-text', name: 'search-text', value: ctx.parameters.query}) %>
+ </li>
+ </ul>
+
+ <div class='buttons'>
+ <input type='submit' value='Search'/>
+ <a class='button append' href='<%- ctx.formatClientLink('help', 'search', 'pools') %>'>Syntax help</a>
+
+ <% if (ctx.canCreate) { %>
+ <a class='append' href='<%- ctx.formatClientLink('pool', 'create') %>'>Add new pool</a>
+ <% } %>
+
+ <% if (ctx.canEditPoolCategories) { %>
+ <a class='append' href='<%- ctx.formatClientLink('pool-categories') %>'>Pool categories</a>
+ <% } %>
+ </div>
+ </form>
+</div>
diff --git a/client/html/pools_page.tpl b/client/html/pools_page.tpl
new file mode 100644
index 0000000..0d81180
--- /dev/null
+++ b/client/html/pools_page.tpl
@@ -0,0 +1,48 @@
+<div class='pool-list table-wrap'>
+ <% if (ctx.response.results.length) { %>
+ <table>
+ <thead>
+ <th class='names'>
+ <% if (ctx.parameters.query == 'sort:name' || !ctx.parameters.query) { %>
+ <a href='<%- ctx.formatClientLink('pools', {query: '-sort:name'}) %>'>Pool name(s)</a>
+ <% } else { %>
+ <a href='<%- ctx.formatClientLink('pools', {query: 'sort:name'}) %>'>Pool name(s)</a>
+ <% } %>
+ </th>
+ <th class='post-count'>
+ <% if (ctx.parameters.query == 'sort:post-count') { %>
+ <a href='<%- ctx.formatClientLink('pools', {query: '-sort:post-count'}) %>'>Post count</a>
+ <% } else { %>
+ <a href='<%- ctx.formatClientLink('pools', {query: 'sort:post-count'}) %>'>Post count</a>
+ <% } %>
+ </th>
+ <th class='creation-time'>
+ <% if (ctx.parameters.query == 'sort:creation-time') { %>
+ <a href='<%- ctx.formatClientLink('pools', {query: '-sort:creation-time'}) %>'>Created on</a>
+ <% } else { %>
+ <a href='<%- ctx.formatClientLink('pools', {query: 'sort:creation-time'}) %>'>Created on</a>
+ <% } %>
+ </th>
+ </thead>
+ <tbody>
+ <% for (let pool of ctx.response.results) { %>
+ <tr>
+ <td class='names'>
+ <ul>
+ <% for (let name of pool.names) { %>
+ <li><%= ctx.makePoolLink(pool.id, false, false, pool, name) %></li>
+ <% } %>
+ </ul>
+ </td>
+ <td class='post-count'>
+ <a href='<%- ctx.formatClientLink('posts', {query: 'pool:' + pool.id}) %>'><%- pool.postCount %></a>
+ </td>
+ <td class='creation-time'>
+ <%= ctx.makeRelativeTime(pool.creationTime) %>
+ </td>
+ </tr>
+ <% } %>
+ </tbody>
+ </table>
+ <% } %>
+</div>
diff --git a/client/html/post_edit_sidebar.tpl b/client/html/post_edit_sidebar.tpl
index ecc6569..07dcf6f 100644
--- a/client/html/post_edit_sidebar.tpl
+++ b/client/html/post_edit_sidebar.tpl
@@ -73,6 +73,12 @@
</section>
<% } %>
+ <% if (ctx.canEditPoolPosts) { %>
+ <section class='pools'>
+ <%= ctx.makeTextInput({}) %>
+ </section>
+ <% } %>
+
<% if (ctx.canEditPostNotes) { %>
<section class='notes'>
<a href class='add'>Add a note</a>
diff --git a/client/html/tags_page.tpl b/client/html/tags_page.tpl
index 8c97398..2e08239 100644
--- a/client/html/tags_page.tpl
+++ b/client/html/tags_page.tpl
@@ -3,35 +3,35 @@
<table>
<thead>
<th class='names'>
- <% if (ctx.query == 'sort:name' || !ctx.query) { %>
+ <% if (ctx.parameters.query == 'sort:name' || !ctx.parameters.query) { %>
<a href='<%- ctx.formatClientLink('tags', {query: '-sort:name'}) %>'>Tag name(s)</a>
<% } else { %>
<a href='<%- ctx.formatClientLink('tags', {query: 'sort:name'}) %>'>Tag name(s)</a>
<% } %>
</th>
<th class='implications'>
- <% if (ctx.query == 'sort:implication-count') { %>
+ <% if (ctx.parameters.query == 'sort:implication-count') { %>
<a href='<%- ctx.formatClientLink('tags', {query: '-sort:implication-count'}) %>'>Implications</a>
<% } else { %>
<a href='<%- ctx.formatClientLink('tags', {query: 'sort:implication-count'}) %>'>Implications</a>
<% } %>
</th>
<th class='suggestions'>
- <% if (ctx.query == 'sort:suggestion-count') { %>
+ <% if (ctx.parameters.query == 'sort:suggestion-count') { %>
<a href='<%- ctx.formatClientLink('tags', {query: '-sort:suggestion-count'}) %>'>Suggestions</a>
<% } else { %>
<a href='<%- ctx.formatClientLink('tags', {query: 'sort:suggestion-count'}) %>'>Suggestions</a>
<% } %>
</th>
<th class='usages'>
- <% if (ctx.query == 'sort:usages') { %>
+ <% if (ctx.parameters.query == 'sort:usages') { %>
<a href='<%- ctx.formatClientLink('tags', {query: '-sort:usages'}) %>'>Usages</a>
<% } else { %>
<a href='<%- ctx.formatClientLink('tags', {query: 'sort:usages'}) %>'>Usages</a>
<% } %>
</th>
<th class='creation-time'>
- <% if (ctx.query == 'sort:creation-time') { %>
+ <% if (ctx.parameters.query == 'sort:creation-time') { %>
<a href='<%- ctx.formatClientLink('tags', {query: '-sort:creation-time'}) %>'>Created on</a>
<% } else { %>
<a href='<%- ctx.formatClientLink('tags', {query: 'sort:creation-time'}) %>'>Created on</a>
diff --git a/client/js/api.js b/client/js/api.js
index 303c077..befe02f 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 bec4b7a..a6ec97d 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 0000000..4c2c3b7
--- /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 0000000..a06ba5c
--- /dev/null
+++ b/client/js/controllers/pool_controller.js
@@ -0,0 +1,147 @@
+'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 Post = require('../models/post.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'),
+ canEditPosts: api.hasPrivilege('pools:edit:posts'),
+ 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;
+ }
+ if (e.detail.posts !== undefined) {
+ e.detail.pool.posts.clear();
+ for (let postId of e.detail.posts) {
+ e.detail.pool.posts.add(Post.fromResponse({id: parseInt(postId)}));
+ }
+ }
+ 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.targetPoolId, e.detail.addAlias)
+ .then(() => {
+ this._view.showSuccess('Pool merged.');
+ this._view.enableForm();
+ router.replace(
+ uri.formatClientLink(
+ 'pool', e.detail.targetPoolId, '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 0000000..2b8e7e6
--- /dev/null
+++ b/client/js/controllers/pool_create_controller.js
@@ -0,0 +1,58 @@
+'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();
+ api.post(uri.formatApiLink('pool'), e.detail)
+ .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();
+ });
+ }
+}
+
+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 0000000..dff649f
--- /dev/null
+++ b/client/js/controllers/pool_list_controller.js
@@ -0,0 +1,108 @@
+'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',
+ 'posts',
+ 'creationTime',
+ 'postCount',
+ 'category'
+];
+
+class PoolListController {
+ constructor(ctx) {
+ this._pageController = new PageController();
+
+ if (!api.hasPrivilege('pools:list')) {
+ this._view = new EmptyView();
+ this._view.showError('You don\'t have privileges to view pools.');
+ return;
+ }
+
+ this._ctx = ctx;
+
+ topNavigation.activate('pools');
+ topNavigation.setTitle('Listing pools');
+
+ 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/controllers/post_list_controller.js b/client/js/controllers/post_list_controller.js
index d783607..357386c 100644
--- a/client/js/controllers/post_list_controller.js
+++ b/client/js/controllers/post_list_controller.js
@@ -18,18 +18,19 @@ const fields = [
class PostListController {
constructor(ctx) {
+ this._pageController = new PageController();
+
if (!api.hasPrivilege('posts:list')) {
this._view = new EmptyView();
this._view.showError('You don\'t have privileges to view posts.');
return;
}
+ this._ctx = ctx;
+
topNavigation.activate('posts');
topNavigation.setTitle('Listing posts');
- this._ctx = ctx;
- this._pageController = new PageController();
-
this._headerView = new PostsHeaderView({
hostNode: this._pageController.view.pageHeaderHolderNode,
parameters: ctx.parameters,
diff --git a/client/js/controllers/tag_list_controller.js b/client/js/controllers/tag_list_controller.js
index db31cc6..34ff8b2 100644
--- a/client/js/controllers/tag_list_controller.js
+++ b/client/js/controllers/tag_list_controller.js
@@ -21,18 +21,19 @@ const fields = [
class TagListController {
constructor(ctx) {
+ this._pageController = new PageController();
+
if (!api.hasPrivilege('tags:list')) {
this._view = new EmptyView();
this._view.showError('You don\'t have privileges to view tags.');
return;
}
+ this._ctx = ctx;
+
topNavigation.activate('tags');
topNavigation.setTitle('Listing tags');
- this._ctx = ctx;
- this._pageController = new PageController();
-
this._headerView = new TagsHeaderView({
hostNode: this._pageController.view.pageHeaderHolderNode,
parameters: ctx.parameters,
diff --git a/client/js/controllers/user_list_controller.js b/client/js/controllers/user_list_controller.js
index 13dcf43..9860de4 100644
--- a/client/js/controllers/user_list_controller.js
+++ b/client/js/controllers/user_list_controller.js
@@ -12,6 +12,8 @@ const EmptyView = require('../views/empty_view.js');
class UserListController {
constructor(ctx) {
+ this._pageController = new PageController();
+
if (!api.hasPrivilege('users:list')) {
this._view = new EmptyView();
this._view.showError('You don\'t have privileges to view users.');
@@ -22,7 +24,6 @@ class UserListController {
topNavigation.setTitle('Listing users');
this._ctx = ctx;
- this._pageController = new PageController();
this._headerView = new UsersHeaderView({
hostNode: this._pageController.view.pageHeaderHolderNode,
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 0000000..f720833
--- /dev/null
+++ b/client/js/controls/pool_auto_complete_control.js
@@ -0,0 +1,48 @@
+'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');
+ const caption = (
+ '<span class="' + cssName + '">'
+ + misc.escapeHtml(pool.names[0] + ' (' + pool.postCount + ')')
+ + '</span>');
+ return {
+ caption: caption,
+ value: pool,
+ };
+ });
+}
+
+class PoolAutoCompleteControl extends AutoCompleteControl {
+ constructor(input, options) {
+ const minLengthForPartialSearch = 3;
+
+ 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', 'version'])
+ .then(
+ response => resolve(
+ _poolListToMatches(response.results, this._options)),
+ reject);
+ });
+ };
+
+ super(input, options);
+ }
+}
+
+module.exports = PoolAutoCompleteControl;
diff --git a/client/js/controls/pool_input_control.js b/client/js/controls/pool_input_control.js
new file mode 100644
index 0000000..af1c744
--- /dev/null
+++ b/client/js/controls/pool_input_control.js
@@ -0,0 +1,185 @@
+'use strict';
+
+const api = require('../api.js');
+const pools = require('../pools.js');
+const misc = require('../util/misc.js');
+const uri = require('../util/uri.js');
+const Pool = require('../models/pool.js');
+const settings = require('../models/settings.js');
+const events = require('../events.js');
+const views = require('../util/views.js');
+const PoolAutoCompleteControl = require('./pool_auto_complete_control.js');
+
+const KEY_SPACE = 32;
+const KEY_RETURN = 13;
+
+const SOURCE_INIT = 'init';
+const SOURCE_IMPLICATION = 'implication';
+const SOURCE_USER_INPUT = 'user-input';
+const SOURCE_CLIPBOARD = 'clipboard';
+
+const template = views.getTemplate('pool-input');
+
+function _fadeOutListItemNodeStatus(listItemNode) {
+ if (listItemNode.classList.length) {
+ if (listItemNode.fadeTimeout) {
+ window.clearTimeout(listItemNode.fadeTimeout);
+ }
+ listItemNode.fadeTimeout = window.setTimeout(() => {
+ while (listItemNode.classList.length) {
+ listItemNode.classList.remove(
+ listItemNode.classList.item(0));
+ }
+ listItemNode.fadeTimeout = null;
+ }, 2500);
+ }
+}
+
+class PoolInputControl extends events.EventTarget {
+ constructor(hostNode, poolList) {
+ super();
+ this.pools = poolList;
+ this._hostNode = hostNode;
+ this._poolToListItemNode = new Map();
+
+ // dom
+ const editAreaNode = template();
+ this._editAreaNode = editAreaNode;
+ this._poolInputNode = editAreaNode.querySelector('input');
+ this._poolListNode = editAreaNode.querySelector('ul.compact-pools');
+
+ this._autoCompleteControl = new PoolAutoCompleteControl(
+ this._poolInputNode, {
+ getTextToFind: () => {
+ return this._poolInputNode.value;
+ },
+ confirm: pool => {
+ this._poolInputNode.value = '';
+ this.addPool(pool, SOURCE_USER_INPUT);
+ },
+ delete: pool => {
+ this._poolInputNode.value = '';
+ this.deletePool(pool);
+ },
+ verticalShift: -2
+ });
+
+ // show
+ this._hostNode.style.display = 'none';
+ this._hostNode.parentNode.insertBefore(
+ this._editAreaNode, hostNode.nextSibling);
+
+ // add existing pools
+ for (let pool of [...this.pools]) {
+ const listItemNode = this._createListItemNode(pool);
+ this._poolListNode.appendChild(listItemNode);
+ }
+ }
+
+ addPool(pool, source) {
+ if (source !== SOURCE_INIT && this.pools.hasPoolId(pool.id)) {
+ return Promise.resolve();
+ }
+
+ this.pools.add(pool, false)
+
+ const listItemNode = this._createListItemNode(pool);
+ if (!pool.category) {
+ listItemNode.classList.add('new');
+ }
+ this._poolListNode.prependChild(listItemNode);
+ _fadeOutListItemNodeStatus(listItemNode);
+
+ this.dispatchEvent(new CustomEvent('add', {
+ detail: {pool: pool, source: source},
+ }));
+ this.dispatchEvent(new CustomEvent('change'));
+
+ return Promise.resolve();
+ }
+
+ deletePool(pool) {
+ if (!this.pools.hasPoolId(pool.id)) {
+ return;
+ }
+ this.pools.removeById(pool.id);
+ this._hideAutoComplete();
+
+ this._deleteListItemNode(pool);
+
+ this.dispatchEvent(new CustomEvent('remove', {
+ detail: {pool: pool},
+ }));
+ this.dispatchEvent(new CustomEvent('change'));
+ }
+
+ _createListItemNode(pool) {
+ const className = pool.category ?
+ misc.makeCssName(pool.category, 'pool') :
+ null;
+
+ const poolLinkNode = document.createElement('a');
+ if (className) {
+ poolLinkNode.classList.add(className);
+ }
+ poolLinkNode.setAttribute(
+ 'href', uri.formatClientLink('pool', pool.names[0]));
+
+ const poolIconNode = document.createElement('i');
+ poolIconNode.classList.add('fa');
+ poolIconNode.classList.add('fa-pool');
+ poolLinkNode.appendChild(poolIconNode);
+
+ const searchLinkNode = document.createElement('a');
+ if (className) {
+ searchLinkNode.classList.add(className);
+ }
+ searchLinkNode.setAttribute(
+ 'href', uri.formatClientLink(
+ 'posts', {query: "pool:" + pool.id}));
+ searchLinkNode.textContent = pool.names[0] + ' ';
+
+ const usagesNode = document.createElement('span');
+ usagesNode.classList.add('pool-usages');
+ usagesNode.setAttribute('data-pseudo-content', pool.postCount);
+
+ const removalLinkNode = document.createElement('a');
+ removalLinkNode.classList.add('remove-pool');
+ removalLinkNode.setAttribute('href', '');
+ removalLinkNode.setAttribute('data-pseudo-content', '×');
+ removalLinkNode.addEventListener('click', e => {
+ e.preventDefault();
+ this.deletePool(pool);
+ });
+
+ const listItemNode = document.createElement('li');
+ listItemNode.appendChild(removalLinkNode);
+ listItemNode.appendChild(poolLinkNode);
+ listItemNode.appendChild(searchLinkNode);
+ listItemNode.appendChild(usagesNode);
+ for (let name of pool.names) {
+ this._poolToListItemNode.set(name, listItemNode);
+ }
+ return listItemNode;
+ }
+
+ _deleteListItemNode(pool) {
+ const listItemNode = this._getListItemNode(pool);
+ if (listItemNode) {
+ listItemNode.parentNode.removeChild(listItemNode);
+ }
+ for (let name of pool.names) {
+ this._poolToListItemNode.delete(name);
+ }
+ }
+
+ _getListItemNode(pool) {
+ return this._poolToListItemNode.get(pool.names[0]);
+ }
+
+ _hideAutoComplete() {
+ this._autoCompleteControl.hide();
+ }
+}
+
+module.exports = PoolInputControl;
diff --git a/client/js/controls/post_edit_sidebar_control.js b/client/js/controls/post_edit_sidebar_control.js
index 11ce210..495dcdb 100644
--- a/client/js/controls/post_edit_sidebar_control.js
+++ b/client/js/controls/post_edit_sidebar_control.js
@@ -7,6 +7,7 @@ const views = require('../util/views.js');
const Note = require('../models/note.js');
const Point = require('../models/point.js');
const TagInputControl = require('./tag_input_control.js');
+const PoolInputControl = require('./pool_input_control.js');
const ExpanderControl = require('../controls/expander_control.js');
const FileDropperControl = require('../controls/file_dropper_control.js');
@@ -37,6 +38,7 @@ class PostEditSidebarControl extends events.EventTarget {
canEditPostFlags: api.hasPrivilege('posts:edit:flags'),
canEditPostContent: api.hasPrivilege('posts:edit:content'),
canEditPostThumbnail: api.hasPrivilege('posts:edit:thumbnail'),
+ canEditPoolPosts: api.hasPrivilege('pools:edit:posts'),
canCreateAnonymousPosts: api.hasPrivilege('posts:create:anonymous'),
canDeletePosts: api.hasPrivilege('posts:delete'),
canFeaturePosts: api.hasPrivilege('posts:feature'),
@@ -55,6 +57,10 @@ class PostEditSidebarControl extends events.EventTarget {
'post-notes',
'Notes',
this._hostNode.querySelectorAll('.notes'));
+ this._poolsExpander = new ExpanderControl(
+ 'post-pools',
+ `Pools (${this._post.pools.length})`,
+ this._hostNode.querySelectorAll('.pools'));
new ExpanderControl(
'post-content',
'Content',
@@ -75,6 +81,11 @@ class PostEditSidebarControl extends events.EventTarget {
this._tagInputNode, post.tags);
}
+ if (this._poolInputNode) {
+ this._poolControl = new PoolInputControl(
+ this._poolInputNode, post.pools);
+ }
+
if (this._contentInputNode) {
this._contentFileDropper = new FileDropperControl(
this._contentInputNode, {allowUrls: true,
@@ -168,6 +179,9 @@ class PostEditSidebarControl extends events.EventTarget {
this._post.notes.addEventListener(eventType, e => {
this._syncExpanderTitles();
});
+ this._post.pools.addEventListener(eventType, e => {
+ this._syncExpanderTitles();
+ });
}
this._tagControl.addEventListener(
@@ -180,11 +194,18 @@ class PostEditSidebarControl extends events.EventTarget {
this._noteTextareaNode.addEventListener(
'change', e => this._evtNoteTextChangeRequest(e));
}
+
+ this._poolControl.addEventListener(
+ 'change', e => {
+ this.dispatchEvent(new CustomEvent('change'));
+ this._syncExpanderTitles();
+ });
}
_syncExpanderTitles() {
this._notesExpander.title = `Notes (${this._post.notes.length})`;
this._tagsExpander.title = `Tags (${this._post.tags.length})`;
+ this._poolsExpander.title = `Pools (${this._post.pools.length})`;
}
_evtPostContentChange(e) {
@@ -337,6 +358,10 @@ class PostEditSidebarControl extends events.EventTarget {
misc.splitByWhitespace(this._tagInputNode.value) :
undefined,
+ pools: this._poolInputNode ?
+ misc.splitByWhitespace(this._poolInputNode.value) :
+ undefined,
+
relations: this._relationsInputNode ?
misc.splitByWhitespace(this._relationsInputNode.value)
.map(x => parseInt(x)) :
@@ -373,6 +398,10 @@ class PostEditSidebarControl extends events.EventTarget {
return this._formNode.querySelector('.tags input');
}
+ get _poolInputNode() {
+ return this._formNode.querySelector('.pools input');
+ }
+
get _loopVideoInputNode() {
return this._formNode.querySelector('.flags input[name=loop]');
}
diff --git a/client/js/main.js b/client/js/main.js
index cb0eb07..8406c79 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/abstract_list.js b/client/js/models/abstract_list.js
index 10edd61..fb4dec8 100644
--- a/client/js/models/abstract_list.js
+++ b/client/js/models/abstract_list.js
@@ -86,6 +86,10 @@ class AbstractList extends events.EventTarget {
return this._list.map(...args);
}
+ filter(...args) {
+ return this._list.filter(...args);
+ }
+
[Symbol.iterator]() {
return this._list[Symbol.iterator]();
}
diff --git a/client/js/models/pool.js b/client/js/models/pool.js
new file mode 100644
index 0000000..8a86e12
--- /dev/null
+++ b/client/js/models/pool.js
@@ -0,0 +1,175 @@
+'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 PostList = require('./post_list.js');
+
+ super();
+ this._orig = {};
+
+ for (let obj of [this, this._orig]) {
+ obj._posts = new PostList();
+ }
+
+ this._updateFromResponse({});
+ }
+
+ get id() {
+ return this._id;
+ }
+
+ get names() {
+ return this._names;
+ }
+
+ get category() {
+ return this._category;
+ }
+
+ get description() {
+ return this._description;
+ }
+
+ get posts() {
+ return this._posts;
+ }
+
+ 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;
+ }
+ if (misc.arraysDiffer(this._posts, this._orig._posts)) {
+ detail.posts = this._posts.map(post => post.id);
+ }
+
+ 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.postCount || 0,
+ };
+
+ for (let obj of [this, this._orig]) {
+ obj._posts.sync(response.posts);
+ }
+
+ 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 0000000..1ce4b24
--- /dev/null
+++ b/client/js/models/pool_category.js
@@ -0,0 +1,109 @@
+'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 0000000..2699620
--- /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 0000000..8a3b858
--- /dev/null
+++ b/client/js/models/pool_list.js
@@ -0,0 +1,47 @@
+'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)}));
+ });
+ }
+
+ hasPoolId(poolId) {
+ for (let pool of this._list) {
+ if (pool.id === poolId) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ removeById(poolId) {
+ for (let pool of this._list) {
+ if (pool.id === poolId) {
+ this.remove(pool);
+ }
+ }
+ }
+}
+
+PoolList._itemClass = Pool;
+PoolList._itemName = 'pool';
+
+module.exports = PoolList;
diff --git a/client/js/models/post.js b/client/js/models/post.js
index be21904..a11d3cb 100644
--- a/client/js/models/post.js
+++ b/client/js/models/post.js
@@ -7,6 +7,8 @@ const events = require('../events.js');
const TagList = require('./tag_list.js');
const NoteList = require('./note_list.js');
const CommentList = require('./comment_list.js');
+const PoolList = require('./pool_list.js');
+const Pool = require('./pool.js');
const misc = require('../util/misc.js');
class Post extends events.EventTarget {
@@ -18,6 +20,7 @@ class Post extends events.EventTarget {
obj._tags = new TagList();
obj._notes = new NoteList();
obj._comments = new CommentList();
+ obj._pools = new PoolList();
}
this._updateFromResponse({});
@@ -111,6 +114,10 @@ class Post extends events.EventTarget {
return this._relations;
}
+ get pools() {
+ return this._pools;
+ }
+
get score() {
return this._score;
}
@@ -191,6 +198,43 @@ class Post extends events.EventTarget {
});
}
+ _savePoolPosts() {
+ const difference = (a, b) => a.filter(post => !b.hasPoolId(post.id));
+
+ // find the pools where the post was added or removed
+ const added = difference(this.pools, this._orig._pools);
+ const removed = difference(this._orig._pools, this.pools);
+
+ let ops = [];
+
+ // update each pool's list of posts
+ for (let pool of added) {
+ let op = Pool.get(pool.id).then(response => {
+ if (!response.posts.hasPostId(this._id)) {
+ response.posts.addById(this._id);
+ return response.save();
+ } else {
+ return Promise.resolve(response);
+ }
+ });
+ ops.push(op);
+ }
+
+ for (let pool of removed) {
+ let op = Pool.get(pool.id).then(response => {
+ if (response.posts.hasPostId(this._id)) {
+ response.posts.removeById(this._id);
+ return response.save();
+ } else {
+ return Promise.resolve(response);
+ }
+ });
+ ops.push(op);
+ }
+
+ return Promise.all(ops);
+ }
+
save(anonymous) {
const files = {};
const detail = {version: this._version};
@@ -232,6 +276,12 @@ class Post extends events.EventTarget {
api.post(uri.formatApiLink('posts'), detail, files);
return apiPromise.then(response => {
+ if (misc.arraysDiffer(this._pools, this._orig._pools)) {
+ return this._savePoolPosts()
+ .then(() => Promise.resolve(response));
+ }
+ return Promise.resolve(response);
+ }).then(response => {
this._updateFromResponse(response);
this.dispatchEvent(
new CustomEvent('change', {detail: {post: this}}));
@@ -243,12 +293,13 @@ class Post extends events.EventTarget {
this.dispatchEvent(
new CustomEvent('changeThumbnail', {detail: {post: this}}));
}
+
return Promise.resolve();
}, error => {
if (error.response &&
- error.response.name === 'PostAlreadyUploadedError') {
+ error.response.name === 'PostAlreadyUploadedError') {
error.message =
- `Post already uploaded (@${error.response.otherPostId})`;
+ `Post already uploaded (@${error.response.otherPostId})`;
}
return Promise.reject(error);
});
@@ -365,9 +416,9 @@ class Post extends events.EventTarget {
mutateContentUrl() {
this._contentUrl =
- this._orig._contentUrl +
- '?bypass-cache=' +
- Math.round(Math.random() * 1000);
+ this._orig._contentUrl +
+ '?bypass-cache=' +
+ Math.round(Math.random() * 1000);
}
_updateFromResponse(response) {
@@ -402,6 +453,7 @@ class Post extends events.EventTarget {
obj._tags.sync(response.tags);
obj._notes.sync(response.notes);
obj._comments.sync(response.comments);
+ obj._pools.sync(response.pools);
}
Object.assign(this, map());
diff --git a/client/js/models/post_list.js b/client/js/models/post_list.js
index eae1128..cd94e40 100644
--- a/client/js/models/post_list.js
+++ b/client/js/models/post_list.js
@@ -49,6 +49,31 @@ class PostList extends AbstractList {
return text.trim();
}
+ hasPostId(testId) {
+ for (let post of this._list) {
+ if (post.id === testId) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ addById(id) {
+ if (this.hasPostId(id)) {
+ return;
+ }
+
+ let post = Post.fromResponse({id: id});
+ this.add(post);
+ }
+
+ removeById(testId) {
+ for (let post of this._list) {
+ if (post.id === testId) {
+ this.remove(post);
+ }
+ }
+ }
}
PostList._itemClass = Post;
diff --git a/client/js/models/top_navigation.js b/client/js/models/top_navigation.js
index 44ad671..91b8976 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 0000000..8484f0b
--- /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/views.js b/client/js/util/views.js
index 0ac9ca7..afdda76 100644
--- a/client/js/util/views.js
+++ b/client/js/util/views.js
@@ -215,6 +215,29 @@ function makeTagLink(name, includeHash, includeCount, tag) {
misc.escapeHtml(text));
}
+function makePoolLink(id, includeHash, includeCount, pool, name) {
+ const category = pool ? pool.category : 'unknown';
+ let text = name ? name : pool.names[0];
+ if (includeHash === true) {
+ text = '#' + text;
+ }
+ if (includeCount === true) {
+ text += ' (' + (pool ? pool.postCount : 0) + ')';
+ }
+ return api.hasPrivilege('pools:view') ?
+ makeElement(
+ 'a',
+ {
+ href: uri.formatClientLink('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';
@@ -393,6 +416,7 @@ function getTemplate(templatePath) {
makeDateInput: makeDateInput,
makePostLink: makePostLink,
makeTagLink: makeTagLink,
+ makePoolLink: makePoolLink,
makeUserLink: makeUserLink,
makeFlexboxAlign: makeFlexboxAlign,
makeAccessKey: makeAccessKey,
@@ -522,6 +546,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 faf2e96..1ab016d 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 0000000..1928358
--- /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 0000000..f22cf71
--- /dev/null
+++ b/client/js/views/pool_create_view.js
@@ -0,0 +1,132 @@
+'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));
+ }
+
+ if (this._postsFieldNode) {
+ this._postsFieldNode.addEventListener(
+ 'input', e => this._evtPostsInput(e));
+ }
+
+ for (let node of this._formNode.querySelectorAll(
+ 'input, select, textarea, posts')) {
+ 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('');
+ }
+
+ _evtPostsInput(e) {
+ const regex = /^\d+$/;
+ const list = misc.splitByWhitespace(this._postsFieldNode.value);
+
+ for (let item of list) {
+ if (!regex.test(item)) {
+ this._postsFieldNode.setCustomValidity(
+ `Pool ID "${item}" is not an integer.`);
+ return;
+ }
+ }
+
+ this._postsFieldNode.setCustomValidity('');
+ }
+
+ _evtSubmit(e) {
+ e.preventDefault();
+
+ this.dispatchEvent(new CustomEvent('submit', {
+ detail: {
+ names: misc.splitByWhitespace(this._namesFieldNode.value),
+ category: this._categoryFieldNode.value,
+ description: this._descriptionFieldNode.value,
+ posts: misc.splitByWhitespace(this._postsFieldNode.value)
+ .map(i => parseInt(i))
+ },
+ }));
+ }
+
+ 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');
+ }
+
+ get _postsFieldNode() {
+ return this._formNode.querySelector('.posts input');
+ }
+}
+
+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 0000000..d3707db
--- /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 0000000..9c118b3
--- /dev/null
+++ b/client/js/views/pool_edit_view.js
@@ -0,0 +1,144 @@
+'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 Post = require('../models/post.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));
+ }
+
+ if (this._postsFieldNode) {
+ this._postsFieldNode.addEventListener(
+ 'input', e => this._evtPostsInput(e));
+ }
+
+ for (let node of this._formNode.querySelectorAll(
+ 'input, select, textarea, posts')) {
+ 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('');
+ }
+
+ _evtPostsInput(e) {
+ const regex = /^\d+$/;
+ const list = misc.splitByWhitespace(this._postsFieldNode.value);
+
+ for (let item of list) {
+ if (!regex.test(item)) {
+ this._postsFieldNode.setCustomValidity(
+ `Pool ID "${item}" is not an integer.`);
+ return;
+ }
+ }
+
+ this._postsFieldNode.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,
+
+ posts: this._postsFieldNode ?
+ misc.splitByWhitespace(this._postsFieldNode.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');
+ }
+
+ get _postsFieldNode() {
+ return this._formNode.querySelector('.posts input');
+ }
+}
+
+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 0000000..09c4e8d
--- /dev/null
+++ b/client/js/views/pool_merge_view.js
@@ -0,0 +1,80 @@
+'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;
+ this._targetPoolId = null;
+ 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._targetPoolId = pool.id;
+ 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,
+ targetPoolId: this._targetPoolId
+ },
+ }));
+ }
+
+ 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 0000000..a5808cc
--- /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 0000000..f296e90
--- /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 0000000..65bbe1b
--- /dev/null
+++ b/client/js/views/pools_header_view.js
@@ -0,0 +1,51 @@
+'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 0000000..234d446
--- /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/js/views/snapshots_page_view.js b/client/js/views/snapshots_page_view.js
index 77fbe13..e7ea264 100644
--- a/client/js/views/snapshots_page_view.js
+++ b/client/js/views/snapshots_page_view.js
@@ -36,6 +36,8 @@ function _makeResourceLink(type, id) {
return views.makeTagLink(id, true);
} else if (type === 'tag_category') {
return 'category "' + id + '"';
+ } else if (type === 'pool') {
+ return views.makePoolLink(id, true);
}
}
@@ -113,6 +115,19 @@ function _makeItemModification(type, data) {
if (diff.flags) {
_extend(lines, ['Changed flags']);
}
+
+ } else if (type === 'pool') {
+ if (diff.names) {
+ _extend(lines, _formatBasicChange(diff.names, 'names'));
+ }
+ if (diff.category) {
+ _extend(
+ lines, _formatBasicChange(diff.category, 'category'));
+ }
+ if (diff.posts) {
+ _extend(
+ lines, _formatBasicChange(diff.posts, 'posts'));
+ }
}
return lines.join('<br/>');
diff --git a/client/package.json b/client/package.json
index 3d390ec..e776126 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",