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) %} -
{% endmacro %} {% macro enable_node(node) %} - {% endmacro %} {% macro verify_node(node) %} - {% endmacro %} {% macro approve_node(node) %} - {% endmacro %} {% macro deny_node(node) %} - 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