diff --git a/client/css/banned-posts.styl b/client/css/banned-posts.styl
new file mode 100644
index 00000000..331f35bf
--- /dev/null
+++ b/client/css/banned-posts.styl
@@ -0,0 +1,29 @@
+@import colors
+
+.content-wrapper.banned-posts
+ width: 100%
+ max-width: 45em
+ table
+ border-spacing: 0
+ width: 100%
+ tr.default td
+ background: $default-banned-post-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/html/banned_post_entry.tpl b/client/html/banned_post_entry.tpl
new file mode 100644
index 00000000..defc6a92
--- /dev/null
+++ b/client/html/banned_post_entry.tpl
@@ -0,0 +1,13 @@
+
<%
+
+ <%- ctx.postBan.checksum %>
+ |
+
+ <%- ctx.makeRelativeTime(ctx.postBan.time) %>
+ |
+ <% if (ctx.canDelete) { %>
+
+ Unban
+ |
+ <% } %>
+
diff --git a/client/html/banned_post_list.tpl b/client/html/banned_post_list.tpl
new file mode 100644
index 00000000..a27aa0b3
--- /dev/null
+++ b/client/html/banned_post_list.tpl
@@ -0,0 +1,25 @@
+
diff --git a/client/js/controllers/banned_post_controller.js b/client/js/controllers/banned_post_controller.js
new file mode 100644
index 00000000..1daf7a86
--- /dev/null
+++ b/client/js/controllers/banned_post_controller.js
@@ -0,0 +1,59 @@
+"use strict";
+
+const api = require("../api.js");
+const BannedPostList = require("../models/banned_post_list.js");
+const topNavigation = require("../models/top_navigation.js");
+const BannedPostsView = require("../views/banned_posts_view.js");
+const EmptyView = require("../views/empty_view.js");
+
+class BannedPostController {
+ constructor() {
+ if (!api.hasPrivilege("posts:ban:list")) {
+ this._view = new EmptyView();
+ this._view.showError(
+ "You don't have privileges to view banned posts."
+ );
+ return;
+ }
+
+ topNavigation.activate("banned-posts");
+ topNavigation.setTitle("Listing banned posts");
+ BannedPostList.get().then(
+ (response) => {
+ this._bannedPosts = response.results;
+ this._view = new BannedPostsView({
+ bannedPosts: this._bannedPosts,
+ canDelete: api.hasPrivilege("poolCategories:delete")
+ });
+ 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._bannedPosts.save().then(
+ () => {
+ this._view.enableForm();
+ this._view.showSuccess("Changes saved.");
+ },
+ (error) => {
+ this._view.enableForm();
+ this._view.showError(error.message);
+ }
+ );
+ }
+}
+
+module.exports = (router) => {
+ router.enter(["banned-posts"], (ctx, next) => {
+ ctx.controller = new BannedPostController(ctx, next);
+ });
+};
diff --git a/client/js/main.js b/client/js/main.js
index c5bdc537..a89de2e1 100644
--- a/client/js/main.js
+++ b/client/js/main.js
@@ -83,6 +83,7 @@ Promise.resolve()
controllers.push(
require("./controllers/user_registration_controller.js")
);
+ controllers.push(require("./controllers/banned_post_controller.js"));
// 404 controller needs to be registered last
controllers.push(require("./controllers/not_found_controller.js"));
diff --git a/client/js/models/banned_post.js b/client/js/models/banned_post.js
new file mode 100644
index 00000000..cfa80c38
--- /dev/null
+++ b/client/js/models/banned_post.js
@@ -0,0 +1,57 @@
+"use strict";
+
+const api = require("../api.js");
+const uri = require("../util/uri.js");
+const events = require("../events.js");
+
+class BannedPost extends events.EventTarget {
+ constructor() {
+ super();
+ this._checksum = "";
+ this._time = new Date();
+ }
+
+ get checksum() {
+ return this._checksum;
+ }
+
+ get time() {
+ return this._time;
+ }
+
+ set checksum(value) {
+ this._checksum = value;
+ }
+
+ set time(value) {
+ this._time = value;
+ }
+
+ static fromResponse(response) {
+ const ret = new BannedPost();
+ ret._updateFromResponse(response);
+ return ret;
+ }
+
+ delete() {
+ return api
+ .delete(uri.formatApiLink("post-ban", this._checksum))
+ .then((response) => {
+ this.dispatchEvent(
+ new CustomEvent("delete", {
+ detail: {
+ bannedPost: this,
+ },
+ })
+ );
+ return Promise.resolve();
+ });
+ }
+
+ _updateFromResponse(response) {
+ this._checksum = response.checksum;
+ this.time = response.time;
+ }
+}
+
+module.exports = BannedPost;
diff --git a/client/js/models/banned_post_list.js b/client/js/models/banned_post_list.js
new file mode 100644
index 00000000..2665b4c1
--- /dev/null
+++ b/client/js/models/banned_post_list.js
@@ -0,0 +1,47 @@
+const api = require("../api.js");
+const uri = require("../util/uri.js");
+const AbstractList = require("./abstract_list.js");
+const BannedPost = require("./banned_post.js");
+
+class BannedPostList extends AbstractList {
+ constructor() {
+ super();
+ this._deletedBans = [];
+ this.addEventListener("remove", (e) => this._evtBannedPostDeleted(e));
+ }
+
+ static get() {
+ return api
+ .get(uri.formatApiLink("post-ban"))
+ .then((response) => {
+ return Promise.resolve(
+ Object.assign({}, response, {
+ results: BannedPostList.fromResponse(
+ response.results
+ ),
+ })
+ );
+ });
+ }
+
+ save() {
+ let promises = [];
+ for (let BannedPost of this._deletedBans) {
+ promises.push(BannedPost.delete());
+ }
+
+ return Promise.all(promises).then((response) => {
+ this._deletedBans = [];
+ return Promise.resolve();
+ });
+ }
+
+ _evtBannedPostDeleted(e) {
+ this._deletedBans.push(e.detail.BannedPost);
+ }
+}
+
+BannedPostList._itemClass = BannedPost;
+BannedPostList._itemName = "bannedPost";
+
+module.exports = BannedPostList;
diff --git a/client/js/views/banned_posts_view.js b/client/js/views/banned_posts_view.js
new file mode 100644
index 00000000..89fe3b4b
--- /dev/null
+++ b/client/js/views/banned_posts_view.js
@@ -0,0 +1,103 @@
+"use strict";
+
+const events = require("../events.js");
+const views = require("../util/views.js");
+const BannedPost = require("../models/banned_post.js");
+
+const template = views.getTemplate("banned-post-list");
+const rowTemplate = views.getTemplate("banned-post-entry");
+
+class BannedPostsView 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 bannedPostsToAdd = Array.from(ctx.bannedPosts);
+ for (let bannedPost of bannedPostsToAdd) {
+ this._addBannedPostRowNode(bannedPost);
+ }
+
+ ctx.bannedPosts.addEventListener("remove", (e) =>
+ this._evtBannedPostDeleted(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");
+ }
+
+ _addBannedPostRowNode(bannedPost) {
+ const rowNode = rowTemplate(
+ Object.assign({}, this._ctx, { postBan: bannedPost })
+ );
+
+ const removeLinkNode = rowNode.querySelector(".remove a");
+ if (removeLinkNode) {
+ removeLinkNode.addEventListener("click", (e) =>
+ this._evtDeleteButtonClick(e, rowNode)
+ );
+ }
+
+ this._tableBodyNode.appendChild(rowNode);
+
+ rowNode._bannedPost = bannedPost;
+ bannedPost._rowNode = rowNode;
+ }
+
+ _removeBannedPostRowNode(bannedPost) {
+ const rowNode = bannedPost._rowNode;
+ rowNode.parentNode.removeChild(rowNode);
+ }
+
+ _evtBannedPostDeleted(e) {
+ this._removeBannedPostRowNode(e.detail.poolCategory);
+ }
+
+ _evtDeleteButtonClick(e, rowNode, link) {
+ e.preventDefault();
+ if (e.target.classList.contains("inactive")) {
+ return;
+ }
+ this._ctx.bannedPosts.remove(rowNode._bannedPost);
+ }
+
+ _evtSaveButtonClick(e, ctx) {
+ e.preventDefault();
+ this.dispatchEvent(new CustomEvent("submit"));
+ }
+}
+
+module.exports = BannedPostsView;