Add AJAX comment submission with graceful fallback.
Reply forms now submit via fetch() when JS is available, inserting the new comment into the DOM without a page reload. Falls back to the traditional POST + redirect when JS is disabled or on any error. Documents capability-driven presentation practice in CLAUDE.md.
This commit is contained in:
parent
e5693b61b0
commit
f9a00708db
3 changed files with 165 additions and 0 deletions
34
CLAUDE.md
34
CLAUDE.md
|
|
@ -242,6 +242,40 @@ curl -s "https://git.unturf.com/api/v4/projects/engineering%2Fremarkbox%2Fremark
|
|||
|
||||
The pipeline status will show `"status":"success"` when deployment is complete.
|
||||
|
||||
## Capability-Driven Presentation
|
||||
|
||||
Follow Russell Ballestrini's capability-driven presentation practice
|
||||
(russell.ballestrini.net/capability-driven-presentation/). A page need not look
|
||||
identical across all browsers. Accommodate what the user's browser can do:
|
||||
|
||||
1. **Single canonical URI** — one URL serves the content.
|
||||
2. **Consistent content** — regardless of viewer capabilities.
|
||||
3. **Graceful enhancement/degradation** — use available capabilities to enhance presentation.
|
||||
|
||||
### The `js-only` / `<noscript>` pattern
|
||||
|
||||
Already implemented in `base.j2`:
|
||||
|
||||
```html
|
||||
<noscript>
|
||||
<style>.js-only {display: none;}</style>
|
||||
</noscript>
|
||||
```
|
||||
|
||||
Apply the `js-only` class to any element that requires JavaScript to function
|
||||
(preview panels, AJAX submit buttons, typeahead UIs). When JS is unavailable,
|
||||
these elements hide automatically — the user never sees a broken control.
|
||||
|
||||
### AJAX form submission
|
||||
|
||||
Comment reply forms use progressive enhancement: the form works as a normal
|
||||
POST + redirect without JS. When JS is available, `initAjaxCommentForms()` in
|
||||
`custom.js` intercepts the submit, sends via `fetch()` with
|
||||
`X-Requested-With: XMLHttpRequest`, and inserts the new comment into the DOM
|
||||
without a page reload. The server returns JSON (HTTP 201) for AJAX requests
|
||||
from verified/anonymous users, and falls back to the normal redirect flow for
|
||||
unverified users or on any error.
|
||||
|
||||
## Style
|
||||
|
||||
- **Never use "AI" — always say "machine learning."** We grow machine learning, not "AI." This term is forbidden in all permacomputer discourse, marketing, & documentation.
|
||||
|
|
|
|||
|
|
@ -212,6 +212,110 @@ function escapeHtml(text) {
|
|||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// AJAX comment submission — capability-driven presentation.
|
||||
// When JS is available, intercepts reply form POSTs and submits
|
||||
// via fetch so the page does not reload. When JS is disabled,
|
||||
// the form falls back to the normal POST + redirect.
|
||||
function initAjaxCommentForms() {
|
||||
document.querySelectorAll('form[action*="/reply"]').forEach(function(form) {
|
||||
// Skip forms that are already wired up.
|
||||
if (form.dataset.ajaxBound) return;
|
||||
form.dataset.ajaxBound = '1';
|
||||
|
||||
form.addEventListener('submit', function(e) {
|
||||
var submitBtn = form.querySelector('.rb-submit');
|
||||
if (!submitBtn) return; // let normal submit proceed
|
||||
|
||||
e.preventDefault();
|
||||
var formData = new FormData(form);
|
||||
var originalLabel = submitBtn.value;
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.value = 'Sending...';
|
||||
|
||||
fetch(form.action, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: { 'X-Requested-With': 'XMLHttpRequest' }
|
||||
})
|
||||
.then(function(response) {
|
||||
if (!response.ok || response.status !== 201) {
|
||||
// Auth redirect, validation error, or server error
|
||||
// — fall back to regular form submit.
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.value = originalLabel;
|
||||
form.submit();
|
||||
return;
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(function(data) {
|
||||
if (!data) return;
|
||||
insertReply(data, form);
|
||||
// Clear the textarea.
|
||||
var textarea = form.querySelector('.common-textarea');
|
||||
if (textarea) textarea.value = '';
|
||||
// Clear the preview.
|
||||
var preview = form.querySelector('.preview');
|
||||
if (preview) preview.innerHTML = '';
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.value = originalLabel;
|
||||
})
|
||||
.catch(function() {
|
||||
// Network error — fall back to regular form submit.
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.value = originalLabel;
|
||||
form.submit();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function insertReply(data, form) {
|
||||
// Build a new node div matching the existing markup structure.
|
||||
var nodeDiv = document.createElement('div');
|
||||
nodeDiv.id = 'node-' + data.id;
|
||||
nodeDiv.className = 'node';
|
||||
|
||||
var authorSpan = '<span class="author-and-date">'
|
||||
+ '<strong>' + escapeHtml(data.author_name) + '</strong> '
|
||||
+ '<span class="date">' + escapeHtml(data.ago_string) + '</span>'
|
||||
+ '</span>';
|
||||
|
||||
var contentDiv = '<div id="node-data-' + data.id + '" class="node-data">'
|
||||
+ data.data_html + '</div>';
|
||||
|
||||
var statusHtml = '';
|
||||
if (!data.approved) {
|
||||
statusHtml = '<span>(hidden: waiting for approval)</span>';
|
||||
}
|
||||
|
||||
nodeDiv.innerHTML = authorSpan + statusHtml + contentDiv;
|
||||
|
||||
// Insert after the reply form's parent container.
|
||||
var parentNode = form.closest('.node, .remark-box-div-main');
|
||||
if (parentNode) {
|
||||
// Find or create a children container.
|
||||
var childrenContainer = parentNode.querySelector('[id^="node-children-"]');
|
||||
if (childrenContainer) {
|
||||
childrenContainer.insertBefore(nodeDiv, childrenContainer.firstChild);
|
||||
} else {
|
||||
parentNode.appendChild(nodeDiv);
|
||||
}
|
||||
}
|
||||
|
||||
// Add anchor for the new comment.
|
||||
var anchor = document.createElement('a');
|
||||
anchor.className = 'anchor';
|
||||
anchor.id = data.id;
|
||||
nodeDiv.parentNode.insertBefore(anchor, nodeDiv);
|
||||
|
||||
nodeDiv.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
|
||||
// Briefly highlight the new comment.
|
||||
nodeDiv.classList.add('focused');
|
||||
setTimeout(function() { nodeDiv.classList.remove('focused'); }, 3000);
|
||||
}
|
||||
|
||||
// Initialize on DOM ready
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
|
|
@ -229,6 +333,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
});
|
||||
});
|
||||
|
||||
// Initialize AJAX comment forms (capability-driven presentation).
|
||||
initAjaxCommentForms();
|
||||
|
||||
// Vote button handlers
|
||||
document.querySelectorAll('button.vote-up').forEach(function(btn) {
|
||||
btn.addEventListener('click', function() {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ from pyramid.csrf import check_csrf_token
|
|||
|
||||
from pyramid.httpexceptions import HTTPFound
|
||||
|
||||
from pyramid.response import Response
|
||||
|
||||
from . import (
|
||||
get_referer_or_home,
|
||||
get_embed_route_uri,
|
||||
|
|
@ -137,6 +139,28 @@ def reply_node(request):
|
|||
msg = ("Your post was successful!", "success")
|
||||
request.session.flash(msg)
|
||||
|
||||
# AJAX request — return JSON instead of redirect (capability-driven presentation).
|
||||
is_ajax = request.headers.get("X-Requested-With") == "XMLHttpRequest"
|
||||
if is_ajax and (user_surrogate or child.verified):
|
||||
author_name = ""
|
||||
if child.user:
|
||||
author_name = child.user.name or ""
|
||||
elif child.user_surrogate:
|
||||
author_name = child.user_surrogate.name or "Anonymous"
|
||||
return Response(
|
||||
json={
|
||||
"id": str(child.id),
|
||||
"parent_id": str(child.parent_id) if child.parent_id else None,
|
||||
"data_html": child.data_html,
|
||||
"author_name": author_name,
|
||||
"ago_string": child.human_created_timestamp,
|
||||
"approved": child.approved,
|
||||
"depth": child.graph_depth,
|
||||
"verified": child.verified,
|
||||
},
|
||||
status_code=201,
|
||||
)
|
||||
|
||||
# set return_to URI.
|
||||
if request.mode == "embed":
|
||||
return_to = get_embed_route_uri(request, child.root.uri.data, child.id)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue