diff --git a/remarkbox/static/js/custom.js b/remarkbox/static/js/custom.js index 10dedad..737155a 100644 --- a/remarkbox/static/js/custom.js +++ b/remarkbox/static/js/custom.js @@ -394,8 +394,167 @@ function insertReply(data, form) { } } - // Wire up AJAX on any new reply forms. + // Wire up AJAX on any new reply forms and action buttons. initAjaxCommentForms(); + initAjaxActionForms(); +} + +// AJAX action buttons — capability-driven presentation. +// When JS is available, intercepts action form POSTs (lock, unlock, watch, +// unwatch, disable, enable, verify, approve, deny) and submits via fetch +// so the page does not reload. Falls back to normal POST + redirect when +// JS is disabled or on error. +function initAjaxActionForms() { + document.querySelectorAll('form.ajax-action').forEach(function(form) { + if (form.dataset.ajaxBound) return; + form.dataset.ajaxBound = '1'; + + form.addEventListener('submit', function(e) { + var btn = form.querySelector('[type="submit"]'); + if (!btn) return; + + e.preventDefault(); + var formData = new FormData(form); + btn.disabled = true; + + fetch(form.action, { + method: 'POST', + body: formData, + headers: { 'X-Requested-With': 'XMLHttpRequest' } + }) + .then(function(response) { + if (!response.ok) { + btn.disabled = false; + form.submit(); + return; + } + return response.json(); + }) + .then(function(data) { + if (!data) return; + handleActionResponse(data, form, btn); + }) + .catch(function() { + btn.disabled = false; + form.submit(); + }); + }); + }); +} + +function handleActionResponse(data, form, btn) { + if (!data.ok) { + btn.disabled = false; + form.submit(); + return; + } + + var action = form.action; + + // Top-level toggle pairs: /lock ↔ /unlock, /watch ↔ /unwatch + if (action.match(/\/lock$/)) { + form.action = action.replace(/\/lock$/, '/unlock'); + btn.value = 'unlock'; + btn.name = 'unlock'; + btn.className = 'unlock button-small'; + btn.disabled = false; + return; + } + if (action.match(/\/unlock$/)) { + form.action = action.replace(/\/unlock$/, '/lock'); + btn.value = '\uD83D\uDD12 lock'; + btn.name = 'lock'; + btn.className = 'lock button-small'; + btn.disabled = false; + return; + } + if (action.match(/\/watch$/)) { + form.action = action.replace(/\/watch$/, '/unwatch'); + btn.value = 'unwatch'; + btn.name = 'unwatch'; + btn.className = 'unwatch button-small'; + btn.disabled = false; + return; + } + if (action.match(/\/unwatch$/)) { + form.action = action.replace(/\/unwatch$/, '/watch'); + btn.value = '\uD83D\uDC41 watch'; + btn.name = 'watch'; + btn.className = 'watch button-small'; + btn.disabled = false; + return; + } + + // Node-level toggle pairs: .../disable ↔ .../enable + if (action.match(/\/disable$/)) { + form.action = action.replace(/\/disable$/, '/enable'); + btn.textContent = 'enable'; + btn.name = 'enable'; + btn.value = 'enable'; + btn.disabled = false; + // Visually mark the node as disabled. + var nodeDiv = form.closest('.node'); + if (nodeDiv) { + var statusSpan = nodeDiv.querySelector('.status'); + if (statusSpan) statusSpan.innerHTML = '(waiting for deletion)'; + var authorDate = nodeDiv.querySelector('.author-and-date'); + if (authorDate) authorDate.innerHTML = 'node was disabled'; + } + return; + } + if (action.match(/\/enable$/)) { + form.action = action.replace(/\/enable$/, '/disable'); + btn.textContent = 'disable'; + btn.name = 'disable'; + btn.value = 'disable'; + btn.disabled = false; + // Reload to restore full node content since we don't have it client-side. + window.location.reload(); + return; + } + + // .../approve ↔ .../deny + if (action.match(/\/approve$/)) { + form.action = action.replace(/\/approve$/, '/deny'); + btn.textContent = 'deny'; + btn.name = 'deny'; + btn.value = 'deny'; + btn.disabled = false; + // Remove the adjacent deny button if present (from the "both" state). + var sibling = form.nextElementSibling; + while (sibling && !sibling.matches('form.ajax-action')) { + sibling = sibling.nextElementSibling; + } + if (sibling && sibling.querySelector('[name="deny"]')) { + sibling.remove(); + } + return; + } + if (action.match(/\/deny$/)) { + form.action = action.replace(/\/deny$/, '/approve'); + btn.textContent = 'approve'; + btn.name = 'approve'; + btn.value = 'approve'; + btn.disabled = false; + // Remove the adjacent approve button if present (from the "both" state). + var sibling = form.previousElementSibling; + while (sibling && !sibling.matches('form.ajax-action')) { + sibling = sibling.previousElementSibling; + } + if (sibling && sibling.querySelector('[name="approve"]')) { + sibling.remove(); + } + return; + } + + // .../verify — no toggle, just remove the button. + if (action.match(/\/verify$/)) { + form.remove(); + return; + } + + // Unknown action — re-enable and let normal flow handle it. + btn.disabled = false; } // Initialize on DOM ready @@ -418,6 +577,9 @@ document.addEventListener('DOMContentLoaded', function() { // Initialize AJAX comment forms (capability-driven presentation). initAjaxCommentForms(); + // Initialize AJAX action buttons (capability-driven presentation). + initAjaxActionForms(); + // Vote button handlers document.querySelectorAll('button.vote-up').forEach(function(btn) { btn.addEventListener('click', function() { diff --git a/remarkbox/templates/snippets/snippets.j2 b/remarkbox/templates/snippets/snippets.j2 index 88fb240..6f13465 100644 --- a/remarkbox/templates/snippets/snippets.j2 +++ b/remarkbox/templates/snippets/snippets.j2 @@ -137,7 +137,7 @@ {% endmacro %} {% macro watch_node(node_id) %} -
+ {% include 'csrf.j2' %} @@ -145,7 +145,7 @@ {% endmacro %} {% macro unwatch_node(node_id="", watcher_id="") %} - + {% include 'csrf.j2' %} @@ -154,7 +154,7 @@ {% endmacro %} {% macro lock_node(node_id) %} - + {% include 'csrf.j2' %} @@ -162,7 +162,7 @@ {% endmacro %} {% macro unlock_node(node_id) %} - + {% include 'csrf.j2' %} @@ -171,35 +171,35 @@ {% macro disable_node(node) %} - + {% include 'csrf.j2' %}
{% endmacro %} {% macro enable_node(node) %} -
+ {% include 'csrf.j2' %}
{% endmacro %} {% macro verify_node(node) %} -
+ {% include 'csrf.j2' %}
{% endmacro %} {% macro approve_node(node) %} -
+ {% include 'csrf.j2' %}
{% endmacro %} {% macro deny_node(node) %} -
+ {% include 'csrf.j2' %}
diff --git a/remarkbox/views/authenticated/lock.py b/remarkbox/views/authenticated/lock.py index 7ca5971..0899654 100644 --- a/remarkbox/views/authenticated/lock.py +++ b/remarkbox/views/authenticated/lock.py @@ -1,6 +1,7 @@ from pyramid.view import view_config from pyramid.httpexceptions import HTTPFound +from pyramid.response import Response from remarkbox.models import get_node_by_id @@ -14,10 +15,13 @@ log = logging.getLogger(__name__) @view_config(route_name="lock", request_method=("POST", "PUT")) @user_required() def lock(request): + is_ajax = request.headers.get("X-Requested-With") == "XMLHttpRequest" root_id = request.params.get("root-id", None) root = get_node_by_id(request.dbsession, root_id) if not root: + if is_ajax: + return Response(json={"ok": False, "error": "Thread does not exist."}, status=404) request.session.flash( ( "Thread does not exist (root-id={}).".format(root_id), @@ -28,6 +32,8 @@ def lock(request): root.locked = True request.dbsession.add(root) request.dbsession.flush() + if is_ajax: + return Response(json={"ok": True, "action": "locked"}, status=200) request.session.flash( ( "You locked this thread to prevent new comments.", @@ -43,6 +49,8 @@ def lock(request): ) ) else: + if is_ajax: + return Response(json={"ok": False, "error": "Namespace moderator role needed."}, status=403) request.session.flash( ( "You may lock this thread. Namespace moderator role needed. ({}).".format(root.namespace.name), @@ -56,10 +64,13 @@ def lock(request): @view_config(route_name="unlock", request_method=("POST", "PUT")) @user_required() def unlock(request): + is_ajax = request.headers.get("X-Requested-With") == "XMLHttpRequest" root_id = request.params.get("root-id", None) root = get_node_by_id(request.dbsession, root_id) - + if not root: + if is_ajax: + return Response(json={"ok": False, "error": "Thread does not exist."}, status=404) request.session.flash( ( "Thread does not exist (root-id={}).".format(root_id), @@ -70,6 +81,8 @@ def unlock(request): root.locked = False request.dbsession.add(root) request.dbsession.flush() + if is_ajax: + return Response(json={"ok": True, "action": "unlocked"}, status=200) request.session.flash( ( "You unlocked this thread to allow new comments.", @@ -85,11 +98,13 @@ def unlock(request): ) ) else: + if is_ajax: + return Response(json={"ok": False, "error": "Namespace moderator role needed."}, status=403) request.session.flash( ( "You may unlock this thread. Namespace moderator role needed. ({}).".format(root.namespace.name), "error", ) ) - + return HTTPFound(get_referer_or_home(request)) diff --git a/remarkbox/views/authenticated/watch.py b/remarkbox/views/authenticated/watch.py index 1a685cb..ba1b943 100644 --- a/remarkbox/views/authenticated/watch.py +++ b/remarkbox/views/authenticated/watch.py @@ -1,23 +1,24 @@ from pyramid.view import view_config from pyramid.httpexceptions import HTTPFound +from pyramid.response import Response from remarkbox.models import get_node_by_id from remarkbox.views import get_referer_or_home, user_required -# TODO: maybe use this when javascript is enabled. -# @view_config(route_name="watch", renderer="json", request_method=("POST", "PUT"), xhr=True) -# otherwise fallback to this when javascript is disabled: @view_config(route_name="watch", request_method=("POST", "PUT")) @user_required() def watch(request): + is_ajax = request.headers.get("X-Requested-With") == "XMLHttpRequest" root_id = request.params.get("root-id", None) if root_id: root = get_node_by_id(request.dbsession, root_id) if root: watcher = request.user.watch_node(root) + if is_ajax: + return Response(json={"ok": True, "action": "watched"}, status=200) request.session.flash( ( "You watched this thread. We will notify you of changes {}.".format( @@ -27,6 +28,8 @@ def watch(request): ) ) else: + if is_ajax: + return Response(json={"ok": False, "error": "Thread does not exist."}, status=404) request.session.flash( ("You may not watch an empty thread.", "error") ) @@ -34,12 +37,10 @@ def watch(request): return HTTPFound(get_referer_or_home(request)) -# TODO: maybe use this when javascript is enabled. -# @view_config(route_name="watch", renderer="json", request_method=("POST", "PUT"), xhr=True) -# otherwise fallback to this when javascript is disabled: @view_config(route_name="unwatch", request_method=("POST", "PUT")) @user_required() def unwatch(request): + is_ajax = request.headers.get("X-Requested-With") == "XMLHttpRequest" root_id = request.params.get("root-id", None) watcher_id = request.params.get("watcher-id", None) @@ -50,6 +51,8 @@ def unwatch(request): if watcher: request.dbsession.delete(watcher) + if is_ajax: + return Response(json={"ok": True, "action": "unwatched"}, status=200) request.session.flash( ( "You unwatched this thread. We will not notify you of changes.", diff --git a/remarkbox/views/modify_node.py b/remarkbox/views/modify_node.py index a0fb3e4..33348b3 100644 --- a/remarkbox/views/modify_node.py +++ b/remarkbox/views/modify_node.py @@ -1,6 +1,7 @@ from pyramid.view import view_config from pyramid.httpexceptions import HTTPFound +from pyramid.response import Response from remarkbox.models.node import Node @@ -51,10 +52,15 @@ def _node_route_uri(request): @view_config(route_name="basic-disable", request_method=("POST", "PUT")) def disable_node(request): """disable node if user allowed.""" + is_ajax = request.headers.get("X-Requested-With") == "XMLHttpRequest" if request.namespace.can_alter_node(request.node, request.user): request.node.disable() request.dbsession.add(request.node) request.dbsession.flush() + if is_ajax: + return Response(json={"ok": True, "action": "disabled"}, status=200) + elif is_ajax: + return Response(json={"ok": False, "error": "Not allowed."}, status=403) return HTTPFound(_node_route_uri(request)) @@ -62,10 +68,15 @@ def disable_node(request): @view_config(route_name="basic-enable", request_method=("POST", "PUT")) def enable_node(request): """enable node if user allowed.""" + is_ajax = request.headers.get("X-Requested-With") == "XMLHttpRequest" if request.namespace.can_alter_node(request.node, request.user): request.node.enable() request.dbsession.add(request.node) request.dbsession.flush() + if is_ajax: + return Response(json={"ok": True, "action": "enabled"}, status=200) + elif is_ajax: + return Response(json={"ok": False, "error": "Not allowed."}, status=403) return HTTPFound(_node_route_uri(request)) @@ -73,10 +84,15 @@ def enable_node(request): @view_config(route_name="basic-verify", request_method=("POST", "PUT")) def verify_node(request): """only the node creator may verify ownership of a node.""" + is_ajax = request.headers.get("X-Requested-With") == "XMLHttpRequest" if request.node.user == request.user: request.node.verify() request.dbsession.add(request.node) request.dbsession.flush() + if is_ajax: + return Response(json={"ok": True, "action": "verified"}, status=200) + elif is_ajax: + return Response(json={"ok": False, "error": "Not allowed."}, status=403) return HTTPFound(_node_route_uri(request)) @@ -84,10 +100,15 @@ def verify_node(request): @view_config(route_name="basic-approve", request_method=("POST", "PUT")) def approve_node(request): """approve node if user allowed.""" + is_ajax = request.headers.get("X-Requested-With") == "XMLHttpRequest" if request.namespace.is_moderator(request.user): request.node.approve() request.dbsession.add(request.node) request.dbsession.flush() + if is_ajax: + return Response(json={"ok": True, "action": "approved"}, status=200) + elif is_ajax: + return Response(json={"ok": False, "error": "Moderator role needed."}, status=403) return HTTPFound(_node_route_uri(request)) @@ -95,10 +116,15 @@ def approve_node(request): @view_config(route_name="basic-deny", request_method=("POST", "PUT")) def deny_node(request): """deny node if user allowed.""" + is_ajax = request.headers.get("X-Requested-With") == "XMLHttpRequest" if request.namespace.is_moderator(request.user): request.node.deny() request.dbsession.add(request.node) request.dbsession.flush() + if is_ajax: + return Response(json={"ok": True, "action": "denied"}, status=200) + elif is_ajax: + return Response(json={"ok": False, "error": "Moderator role needed."}, status=403) return HTTPFound(_node_route_uri(request))