diff options
| author | Shyam Sunder <sgsunder1@gmail.com> | 2020-06-04 21:01:28 -0400 |
|---|---|---|
| committer | Shyam Sunder <sgsunder1@gmail.com> | 2020-06-04 21:01:28 -0400 |
| commit | c5358f7f83ef49f51d5ba557dde1b16ce2c83d1d (patch) | |
| tree | 253e0be234540c266202993567d998a5c951947d /client | |
| parent | 4329b1620fe38c2a7bf44615cfb176daa2837590 (diff) | |
| parent | b0f1b8c2309277a7b170972d463b4e8d16a79ecc (diff) | |
client+server: add post pools feature
Diffstat (limited to 'client')
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…</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", |