feat(post-form): U6 — owner-scoped delete API route + card wiring
New custom-in-repo plugin entry-actions (un-ignored in .gitignore, NOT in
plugins.txt) registers DELETE /api/v1/entry/{slug} via onApiRegisterRoutes
(KTD5). The handler requires the authenticated site OWNER (not any login/admin),
rejects unsafe slugs (400), resolves the target through the page tree, asserts it
is a direct child of the active trip's dailies container, deletes the folder and
clears the cache — sharing EntryScopeGuard with the save path so R6 can't diverge.
A lazy per-namespace autoloader loads the controller on cached-route requests
(the router dispatches from route.cache without re-firing onApiRegisterRoutes).
EntryScopeGuard gains isOwnerUser() (API user comes from the request, not
$grav['user']) and enablePages() before find() (pages are lazily disabled in the
API context).
feed-actions.js (new, built via make build-assets; loaded on the trip/home feed
only when owner_can_edit) wires the inline Delete → Cancel/Confirm swap: on
Confirm it locks both buttons (D2, no double-DELETE), fetches the route
(credentials:include), removes the card, moves focus to the next card, and
announces via a page-level aria-live region (D4); on failure it restores the
control with an inline message (D7). Adds .sr-only + .entry-action[hidden] CSS.
Verified on the 2.0.4 container — API matrix 8/8 (anon 401, non-owner 403, bad
slug 400, out-of-scope 404, owner 204 + folder removed; V3/V5) and the delete UI
in a headless browser (confirm swap, card removal, disk deletion, live announce).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1FrCYNq6RXdGYbn5PFrhM
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* feed-actions.js (U6) — owner delete wiring for journal feed cards.
|
||||
*
|
||||
* Loaded only on the trip / home active-trip feed for the owner. The Edit link is
|
||||
* a plain navigation (no JS). Delete is a two-step inline confirm (no browser
|
||||
* dialog): Delete → Cancel / Confirm delete → DELETE /api/v1/entry/<slug>
|
||||
* (session cookie), then the card is removed and focus moves to the next card.
|
||||
*
|
||||
* Markup (rendered by partials/entry-journal.html.twig):
|
||||
* article.journal-post[data-entry-route]
|
||||
* .journal-post-actions[data-entry-route]
|
||||
* button[data-delete-start] (Delete)
|
||||
* .entry-delete-confirm[hidden]
|
||||
* button[data-delete-cancel] (Cancel)
|
||||
* button[data-delete-confirm] (Confirm delete)
|
||||
* .entry-delete-msg[aria-live] (inline error slot)
|
||||
*/
|
||||
|
||||
// One page-level polite live region for cross-card announcements (D4) — the
|
||||
// per-card .entry-delete-msg vanishes with the card it belongs to.
|
||||
function liveRegion() {
|
||||
var el = document.getElementById('feed-actions-live');
|
||||
if (!el) {
|
||||
el = document.createElement('div');
|
||||
el.id = 'feed-actions-live';
|
||||
el.setAttribute('aria-live', 'polite');
|
||||
el.setAttribute('role', 'status');
|
||||
el.className = 'sr-only';
|
||||
document.body.appendChild(el);
|
||||
}
|
||||
return el;
|
||||
}
|
||||
function announce(msg) { liveRegion().textContent = msg; }
|
||||
|
||||
function slugFromRoute(route) {
|
||||
var parts = String(route || '').replace(/\/+$/, '').split('/');
|
||||
return parts[parts.length - 1] || '';
|
||||
}
|
||||
|
||||
function showConfirm(actions, show) {
|
||||
var del = actions.querySelector('[data-delete-start]');
|
||||
var confirm = actions.querySelector('.entry-delete-confirm');
|
||||
if (del) del.hidden = show;
|
||||
if (confirm) confirm.hidden = !show;
|
||||
if (!show) {
|
||||
var msg = actions.querySelector('.entry-delete-msg');
|
||||
if (msg) msg.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
function performDelete(actions) {
|
||||
var route = actions.getAttribute('data-entry-route');
|
||||
var slug = slugFromRoute(route);
|
||||
var cancelBtn = actions.querySelector('[data-delete-cancel]');
|
||||
var confirmBtn = actions.querySelector('[data-delete-confirm]');
|
||||
var msg = actions.querySelector('.entry-delete-msg');
|
||||
var card = actions.closest('.journal-post');
|
||||
|
||||
// D2: lock both buttons immediately so a mobile double-tap can't fire two
|
||||
// DELETEs (the second would 404 on an already-removed folder).
|
||||
if (confirmBtn) { confirmBtn.disabled = true; confirmBtn.textContent = 'Deleting…'; }
|
||||
if (cancelBtn) cancelBtn.disabled = true;
|
||||
if (msg) msg.textContent = '';
|
||||
announce('Deleting entry…');
|
||||
|
||||
fetch('/api/v1/entry/' + encodeURIComponent(slug), {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
headers: { Accept: 'application/json' }
|
||||
}).then(function (r) {
|
||||
if (!(r.status === 204 || r.ok)) throw new Error('HTTP ' + r.status);
|
||||
|
||||
// D4: move focus to the next journal card (or the feed heading) BEFORE
|
||||
// removing this one, then announce.
|
||||
var next = card ? card.nextElementSibling : null;
|
||||
while (next && !next.classList.contains('journal-post')) next = next.nextElementSibling;
|
||||
var focusTarget = next
|
||||
|| (card && card.previousElementSibling && card.previousElementSibling.classList && card.previousElementSibling.classList.contains('journal-post') ? card.previousElementSibling : null)
|
||||
|| document.querySelector('.home-trip-name, .home-feed-col h1, .feed');
|
||||
if (card) card.remove();
|
||||
if (focusTarget) {
|
||||
if (!focusTarget.hasAttribute('tabindex')) focusTarget.setAttribute('tabindex', '-1');
|
||||
try { focusTarget.focus({ preventScroll: false }); } catch (e) { /* older browsers */ }
|
||||
}
|
||||
announce('Entry deleted.');
|
||||
}).catch(function () {
|
||||
// D7: restore the control + inline error, constrained to the card.
|
||||
if (confirmBtn) { confirmBtn.disabled = false; confirmBtn.textContent = 'Confirm delete'; }
|
||||
if (cancelBtn) cancelBtn.disabled = false;
|
||||
if (msg) msg.textContent = 'Could not delete — please try again.';
|
||||
announce('Delete failed.');
|
||||
});
|
||||
}
|
||||
|
||||
function initFeedActions() {
|
||||
document.addEventListener('click', function (e) {
|
||||
var start = e.target.closest ? e.target.closest('[data-delete-start]') : null;
|
||||
var cancel = e.target.closest ? e.target.closest('[data-delete-cancel]') : null;
|
||||
var confirm = e.target.closest ? e.target.closest('[data-delete-confirm]') : null;
|
||||
if (!start && !cancel && !confirm) return;
|
||||
|
||||
var actions = (start || cancel || confirm).closest('.journal-post-actions');
|
||||
if (!actions) return;
|
||||
e.preventDefault();
|
||||
|
||||
if (start) { showConfirm(actions, true); return; }
|
||||
if (cancel) { showConfirm(actions, false); return; }
|
||||
if (confirm) {
|
||||
var btn = actions.querySelector('[data-delete-confirm]');
|
||||
if (btn && btn.disabled) return; // already in flight (D2)
|
||||
performDelete(actions);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initFeedActions);
|
||||
} else {
|
||||
initFeedActions();
|
||||
}
|
||||
Reference in New Issue
Block a user