Serve delete, move and merge dialogs as htmx fragments

The server renders the dialogs (?dialog=delete|move) and answers a move
that needs confirmation with the merge dialog itself, so the client no
longer builds action URLs or interprets X-Merge-Available. Successful
mutations send HX-Redirect, which hx.js turns into a history replace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 16:37:46 +02:00
co-authored by Claude Opus 5.5
parent 465aed47b3
commit 3a3fb3b51f
11 changed files with 371 additions and 230 deletions
+30
View File
@@ -0,0 +1,30 @@
// Site-wide htmx policy. Loaded right after htmx itself.
(function () {
// htmx wires up swapped-in content (e.g. a dialog's hx-post form) in its
// settle step, by default 20ms after the swap: a click or Enter in that
// window would submit the form natively. No settle transitions are used,
// so settle in the same tick.
htmx.config.defaultSettleDelay = 0;
// A server-sent HX-Redirect follows a mutation (move, delete, …): load the
// target into the current history entry rather than pushing a new one, so
// Back skips the stale pre-mutation page. replaceState + reload rather than
// location.replace: a target differing only by #fragment would otherwise
// not re-fetch.
htmx.location = {
set href(target) {
window.history.replaceState(null, '', target);
window.location.reload();
},
reload: function () { window.location.reload(); }
};
// Failed requests answer with a plain-text reason; show it.
document.addEventListener('htmx:responseError', function (e) {
var xhr = e.detail.xhr;
alert(xhr.responseText || ('Request failed (' + xhr.status + ')'));
});
document.addEventListener('htmx:sendError', function () {
alert('Network error');
});
})();
+3
View File
@@ -8,6 +8,8 @@
<link rel="preload" href="/_/fonts/IosevkaEtoile.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/_/fonts/IosevkaSlab.woff2" as="font" type="font/woff2" crossorigin />
<link rel="stylesheet" href="{{asset "style.css"}}" />
<script src="{{asset "vendor/htmx.min.js"}}" defer></script>
<script src="{{asset "hx.js"}}" defer></script>
<script src="{{asset "modal.js"}}"></script>
<script src="{{asset "global-shortcuts.js"}}"></script>
<script src="{{asset "history-nav.js"}}"></script>
@@ -77,6 +79,7 @@
{{if not .EditMode}}{{block "aside" .}}{{end}}{{end}}
</div>
{{block "extras" .}}{{end}}
<div id="modal-host"></div>
</body>
</html>
{{end}}
+140 -136
View File
@@ -1,120 +1,111 @@
// One modal at a time, from either of two sources sharing the same chrome
// (backdrop click, Escape, drag by the header, focus restore):
// - openModal(opts) builds a modal from JS;
// - server-rendered dialogs (assets/page/dialogs.html) that htmx swaps into
// #modal-host. Their forms post themselves; a [data-modal-close] element
// closes them.
(function () {
var backdrop = null;
var modal = null;
var titleEl = null;
var bodyEl = null;
var cancelBtn = null;
var confirmBtn = null;
var footerEl = null;
var prevFocus = null;
var currentOpts = null;
var onKeydown = null;
var current = null; // { backdrop, prevFocus, onKeydown }
var drag = null; // { modal, startX, startY, originX, originY }
function build() {
backdrop = document.createElement('div');
// show mounts backdrop (whose child is the .modal) into parent and makes
// it the current modal. onKeydown handles keys beyond Escape.
function show(backdrop, parent, prevFocus, onKeydown) {
var modal = backdrop.querySelector('.modal');
var header = modal.querySelector('.panel-header');
function onDown(e) { startDrag(e, modal); }
header.addEventListener('mousedown', onDown);
header.addEventListener('touchstart', onDown, { passive: false });
backdrop.addEventListener('mousedown', function (e) {
if (e.target === backdrop) close();
});
backdrop.addEventListener('click', function (e) {
if (e.target.closest('[data-modal-close]')) close();
});
var keydown = function (e) {
if (e.key === 'Escape') {
e.preventDefault();
close();
return;
}
if (onKeydown) onKeydown(e);
};
document.addEventListener('keydown', keydown);
current = { backdrop: backdrop, prevFocus: prevFocus, onKeydown: keydown };
if (backdrop.parentNode !== parent) parent.appendChild(backdrop);
}
// Dragging switches the modal to fixed positioning so flexbox alignment
// doesn't fight us.
function startDrag(e, modal) {
if (e.button !== undefined && e.button !== 0) return;
var pt = e.touches ? e.touches[0] : e;
var rect = modal.getBoundingClientRect();
modal.classList.add('is-dragged');
modal.style.position = 'fixed';
modal.style.left = rect.left + 'px';
modal.style.top = rect.top + 'px';
drag = { modal: modal, startX: pt.clientX, startY: pt.clientY, originX: rect.left, originY: rect.top };
e.preventDefault();
}
function onMove(e) {
if (!drag) return;
var pt = e.touches ? e.touches[0] : e;
var m = drag.modal;
var maxX = window.innerWidth - m.offsetWidth;
var maxY = window.innerHeight - m.offsetHeight;
m.style.left = Math.max(0, Math.min(maxX, drag.originX + pt.clientX - drag.startX)) + 'px';
m.style.top = Math.max(0, Math.min(maxY, drag.originY + pt.clientY - drag.startY)) + 'px';
}
function onUp() { drag = null; }
document.addEventListener('mousemove', onMove);
document.addEventListener('touchmove', onMove, { passive: false });
document.addEventListener('mouseup', onUp);
document.addEventListener('touchend', onUp);
// detach tears down the current modal and returns the element that had
// focus before it opened. keepDOM leaves the backdrop in place for a swap
// about to replace it: htmx must still find the element that issued the
// request in the document.
function detach(keepDOM) {
if (!current) return null;
var c = current;
current = null;
drag = null;
document.removeEventListener('keydown', c.onKeydown);
if (!keepDOM && c.backdrop.parentNode) c.backdrop.parentNode.removeChild(c.backdrop);
return c.prevFocus;
}
function close() {
var toRestore = detach();
if (toRestore && toRestore.focus) {
try { toRestore.focus(); } catch (e) {}
}
}
function open(opts) {
var prevFocus = current ? detach() : document.activeElement;
var backdrop = document.createElement('div');
backdrop.className = 'modal-backdrop';
modal = document.createElement('div');
var modal = document.createElement('div');
modal.className = 'modal panel panel-floating';
modal.setAttribute('role', 'dialog');
modal.setAttribute('aria-modal', 'true');
var header = document.createElement('div');
header.className = 'panel-header';
titleEl = document.createElement('span');
var titleEl = document.createElement('span');
titleEl.textContent = opts.title || '';
header.appendChild(titleEl);
bodyEl = document.createElement('div');
var bodyEl = document.createElement('div');
bodyEl.className = 'panel-body';
footerEl = document.createElement('div');
footerEl.className = 'panel-footer';
cancelBtn = document.createElement('button');
cancelBtn.type = 'button';
confirmBtn = document.createElement('button');
confirmBtn.type = 'button';
modal.appendChild(header);
modal.appendChild(bodyEl);
modal.appendChild(footerEl);
backdrop.appendChild(modal);
backdrop.addEventListener('mousedown', function (e) {
if (e.target === backdrop) close();
});
wireDrag(header);
cancelBtn.addEventListener('click', close);
confirmBtn.addEventListener('click', function () {
if (confirmBtn.disabled) return;
if (currentOpts && currentOpts.confirm && currentOpts.confirm.onConfirm) {
currentOpts.confirm.onConfirm();
}
});
}
// wireDrag makes the modal draggable by `handle`. Dragging switches the
// modal to fixed positioning so flexbox alignment doesn't fight us.
function wireDrag(handle) {
var dragging = false;
var startX, startY, originX, originY;
function onDown(e) {
if (e.button !== undefined && e.button !== 0) return;
var pt = e.touches ? e.touches[0] : e;
var rect = modal.getBoundingClientRect();
modal.classList.add('is-dragged');
modal.style.position = 'fixed';
modal.style.left = rect.left + 'px';
modal.style.top = rect.top + 'px';
dragging = true;
startX = pt.clientX;
startY = pt.clientY;
originX = rect.left;
originY = rect.top;
e.preventDefault();
}
function onMove(e) {
if (!dragging) return;
var pt = e.touches ? e.touches[0] : e;
var dx = pt.clientX - startX;
var dy = pt.clientY - startY;
var w = modal.offsetWidth;
var h = modal.offsetHeight;
var maxX = window.innerWidth - w;
var maxY = window.innerHeight - h;
var x = Math.max(0, Math.min(maxX, originX + dx));
var y = Math.max(0, Math.min(maxY, originY + dy));
modal.style.left = x + 'px';
modal.style.top = y + 'px';
}
function onUp() { dragging = false; }
handle.addEventListener('mousedown', onDown);
handle.addEventListener('touchstart', onDown, { passive: false });
document.addEventListener('mousemove', onMove);
document.addEventListener('touchmove', onMove, { passive: false });
document.addEventListener('mouseup', onUp);
document.addEventListener('touchend', onUp);
}
function open(opts) {
if (backdrop && backdrop.parentNode) close();
if (!backdrop) build();
currentOpts = opts;
prevFocus = document.activeElement;
modal.classList.remove('is-dragged');
modal.style.position = '';
modal.style.left = '';
modal.style.top = '';
titleEl.textContent = opts.title || '';
bodyEl.textContent = '';
if (opts.body instanceof Node) {
bodyEl.appendChild(opts.body);
} else if (typeof opts.body === 'string') {
@@ -124,14 +115,24 @@
var confirmOpts = opts.confirm || {};
var cancelOpts = opts.cancel || {};
var confirmBtn = document.createElement('button');
confirmBtn.type = 'button';
confirmBtn.textContent = confirmOpts.label || 'OK';
confirmBtn.className = 'btn' + (confirmOpts.danger ? ' danger' : '');
confirmBtn.disabled = !!confirmOpts.initiallyDisabled;
confirmBtn.addEventListener('click', function () {
if (confirmBtn.disabled) return;
if (confirmOpts.onConfirm) confirmOpts.onConfirm();
});
var cancelBtn = document.createElement('button');
cancelBtn.type = 'button';
cancelBtn.textContent = cancelOpts.label || 'CANCEL';
cancelBtn.className = 'btn';
cancelBtn.setAttribute('data-modal-close', '');
footerEl.textContent = '';
var footerEl = document.createElement('div');
footerEl.className = 'panel-footer';
if (opts.swapButtons) {
footerEl.appendChild(confirmBtn);
footerEl.appendChild(cancelBtn);
@@ -140,7 +141,20 @@
footerEl.appendChild(confirmBtn);
}
document.body.appendChild(backdrop);
modal.appendChild(header);
modal.appendChild(bodyEl);
modal.appendChild(footerEl);
backdrop.appendChild(modal);
var enterConfirms = confirmOpts.enterConfirms !== false;
show(backdrop, document.body, prevFocus, function (e) {
if (e.key === 'Enter' && enterConfirms) {
var tag = (e.target && e.target.tagName) || '';
if (tag === 'TEXTAREA') return;
e.preventDefault();
if (!confirmBtn.disabled) confirmBtn.click();
}
});
setTimeout(function () {
if (cancelOpts.autofocus) {
@@ -156,22 +170,6 @@
}
}, 0);
var enterConfirms = confirmOpts.enterConfirms !== false;
onKeydown = function (e) {
if (e.key === 'Escape') {
e.preventDefault();
close();
return;
}
if (e.key === 'Enter' && enterConfirms) {
var tag = (e.target && e.target.tagName) || '';
if (tag === 'TEXTAREA') return;
e.preventDefault();
if (!confirmBtn.disabled) confirmBtn.click();
}
};
document.addEventListener('keydown', onKeydown);
return {
close: close,
setConfirmDisabled: function (d) { confirmBtn.disabled = !!d; },
@@ -179,20 +177,26 @@
};
}
function close() {
if (!backdrop || !backdrop.parentNode) return;
if (onKeydown) {
document.removeEventListener('keydown', onKeydown);
onKeydown = null;
// Server-rendered dialogs. A swap into #modal-host replaces whatever modal
// is open (e.g. the merge step replacing the move dialog); focus returns
// to where it was before the first one opened.
var hostPrevFocus = null;
document.addEventListener('htmx:beforeSwap', function (e) {
if (e.detail.target.id !== 'modal-host') return;
hostPrevFocus = current ? detach(true) : document.activeElement;
});
document.addEventListener('htmx:afterSwap', function (e) {
var host = e.detail.target;
if (host.id !== 'modal-host') return;
var backdrop = host.querySelector('.modal-backdrop');
if (!backdrop) return;
show(backdrop, host, hostPrevFocus);
var f = backdrop.querySelector('[autofocus]') || backdrop.querySelector('input:not([type=hidden])');
if (f) {
f.focus();
if (f.select) f.select();
}
backdrop.parentNode.removeChild(backdrop);
currentOpts = null;
var toRestore = prevFocus;
prevFocus = null;
if (toRestore && toRestore.focus) {
try { toRestore.focus(); } catch (e) {}
}
}
});
window.openModal = open;
window.closeModal = close;
+7 -82
View File
@@ -75,45 +75,10 @@ function newPage() {
});
}
// submitMove POSTs a move and navigates on success. When the server reports
// the destination folder already exists but can be merged (the source carries
// a page and the destination has none), it asks the user to confirm and
// retries the same move with &merge=1.
function submitMove(action, target) {
fetch(action, { method: 'POST', redirect: 'manual' }).then(function (res) {
if (res.type === 'opaqueredirect' || res.ok) {
navigateReplace(target);
return;
}
if (res.status === 409 && res.headers.get('X-Merge-Available') === '1') {
openModal({
title: 'Merge folders?',
body: 'The destination folder already exists but has no page of its own. Merge this page and its contents into it?',
confirm: {
label: 'MERGE',
onConfirm: function () {
closeModal();
submitMove(action + '&merge=1', target);
}
}
});
return;
}
return res.text().then(function (msg) {
alert(msg || ('Request failed (' + res.status + ')'));
});
}).catch(function () {
alert('Network error');
});
}
function movePage() {
var current = decodeURIComponent(window.location.pathname).replace(/\/+$/, '');
if (!current) return;
var segs = current.split('/').filter(Boolean);
var currentName = segs[segs.length - 1] || '';
var parent = '/' + segs.slice(0, -1).join('/');
if (parent === '/') parent = '/';
var parent = '/' + current.split('/').filter(Boolean).slice(0, -1).join('/');
openTreePicker({
title: 'Move — new parent?',
@@ -123,53 +88,13 @@ function movePage() {
hideFiles: true,
confirmLabel: 'NEXT',
onSelect: function (newParent) {
var input = document.createElement('input');
input.type = 'text';
input.className = 'input';
input.placeholder = 'Page name';
input.value = currentName;
openModal({
title: 'Move — new name?',
body: input,
confirm: {
label: 'MOVE',
onConfirm: function () {
var name = input.value.trim();
if (!name) return;
var dest = (newParent === '/' ? '' : newParent) + '/' + name;
var action = window.location.pathname + '?move=' +
encodeURIComponent(dest);
var target = encodePickedPath(dest) + '/';
closeModal();
submitMove(action, target);
}
}
// The server renders the name step and handles what follows
// (merge confirmation, errors, redirect).
closeModal();
htmx.ajax('GET', window.location.pathname + '?dialog=move', {
target: '#modal-host',
values: { parent: newParent }
});
}
});
}
function deletePage() {
var decodedPath = decodeURIComponent(window.location.pathname);
openModal({
title: 'Delete page',
body: 'Delete ' + decodedPath + ' and everything inside it?',
confirm: {
label: 'DELETE',
danger: true,
enterConfirms: false,
onConfirm: function () {
var p = window.location.pathname.replace(/\/+$/, '');
var idx = p.lastIndexOf('/');
var parent = idx > 0 ? p.substring(0, idx + 1) : '/';
closeModal();
postReplace(window.location.pathname + '?delete=1', null, parent);
}
},
cancel: { autofocus: true },
swapButtons: true
});
}
+46
View File
@@ -0,0 +1,46 @@
{{/* Server-rendered dialogs, fetched with ?dialog=<name> and swapped into
#modal-host by htmx; modal.js supplies the chrome (backdrop, Escape, drag,
focus). Each dialog is a form that posts the action it offers, so the client
never builds action URLs itself. Responses to those posts either redirect
(HX-Redirect), replace the dialog with the next step (e.g. the merge
confirmation), or fail with a plain-text error. */}}
{{define "delete"}}<div class="modal-backdrop">
<form class="modal panel panel-floating" role="dialog" aria-modal="true" aria-labelledby="modal-title" hx-post="?delete=1" hx-target="#modal-host">
<div class="panel-header"><span id="modal-title">Delete page</span></div>
<div class="panel-body">Delete {{.Path}} and everything inside it?</div>
<div class="panel-footer">
<button class="btn danger" type="submit">DELETE</button>
<button class="btn" type="button" data-modal-close autofocus>CANCEL</button>
</div>
</form>
</div>{{end}}
{{define "move"}}<div class="modal-backdrop">
<form class="modal panel panel-floating" role="dialog" aria-modal="true" aria-labelledby="modal-title" hx-post="?move" hx-target="#modal-host">
<div class="panel-header"><span id="modal-title">Move — new name?</span></div>
<div class="panel-body">
<input type="hidden" name="parent" value="{{.Parent}}" />
<input class="input" type="text" name="name" value="{{.Name}}" placeholder="Page name" required autofocus />
</div>
<div class="panel-footer">
<button class="btn" type="button" data-modal-close>CANCEL</button>
<button class="btn" type="submit">MOVE</button>
</div>
</form>
</div>{{end}}
{{define "merge"}}<div class="modal-backdrop">
<form class="modal panel panel-floating" role="dialog" aria-modal="true" aria-labelledby="modal-title" hx-post="?move" hx-target="#modal-host">
<div class="panel-header"><span id="modal-title">Merge folders?</span></div>
<div class="panel-body">
The destination folder already exists but has no page of its own. Merge this page and its contents into it?
<input type="hidden" name="move" value="{{.Dest}}" />
<input type="hidden" name="merge" value="1" />
</div>
<div class="panel-footer">
<button class="btn" type="button" data-modal-close>CANCEL</button>
<button class="btn" type="submit" autofocus>MERGE</button>
</div>
</form>
</div>{{end}}
+1 -1
View File
@@ -5,7 +5,7 @@
<button class="btn btn-block" data-companion-reveal hidden title="Reveal in file manager"><span class="btn-icon">{{icon "reveal"}}</span><span class="btn-label">REVEAL</span></button>
{{if not .IsRoot}}
<button class="btn btn-block" onclick="movePage()" title="Move page (M)"><span class="btn-icon">{{icon "move"}}</span><span class="btn-label">MOVE</span></button>
<button class="btn btn-block danger" onclick="deletePage()" title="Delete page"><span class="btn-icon">{{icon "delete"}}</span><span class="btn-label">DELETE</span></button>
<button class="btn btn-block danger" hx-get="?dialog=delete" hx-target="#modal-host" title="Delete page"><span class="btn-icon">{{icon "delete"}}</span><span class="btn-label">DELETE</span></button>
{{end}}
<a class="btn btn-block" href="?edit" title="Edit page (E)"><span class="btn-icon">{{icon "edit"}}</span><span class="btn-label">EDIT</span></a>
{{end}}{{end}}
+1
View File
File diff suppressed because one or more lines are too long