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:
@@ -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');
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -8,6 +8,8 @@
|
|||||||
<link rel="preload" href="/_/fonts/IosevkaEtoile.woff2" as="font" type="font/woff2" crossorigin />
|
<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="preload" href="/_/fonts/IosevkaSlab.woff2" as="font" type="font/woff2" crossorigin />
|
||||||
<link rel="stylesheet" href="{{asset "style.css"}}" />
|
<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 "modal.js"}}"></script>
|
||||||
<script src="{{asset "global-shortcuts.js"}}"></script>
|
<script src="{{asset "global-shortcuts.js"}}"></script>
|
||||||
<script src="{{asset "history-nav.js"}}"></script>
|
<script src="{{asset "history-nav.js"}}"></script>
|
||||||
@@ -77,6 +79,7 @@
|
|||||||
{{if not .EditMode}}{{block "aside" .}}{{end}}{{end}}
|
{{if not .EditMode}}{{block "aside" .}}{{end}}{{end}}
|
||||||
</div>
|
</div>
|
||||||
{{block "extras" .}}{{end}}
|
{{block "extras" .}}{{end}}
|
||||||
|
<div id="modal-host"></div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
+140
-136
@@ -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 () {
|
(function () {
|
||||||
var backdrop = null;
|
var current = null; // { backdrop, prevFocus, onKeydown }
|
||||||
var modal = null;
|
var drag = null; // { modal, startX, startY, originX, originY }
|
||||||
var titleEl = null;
|
|
||||||
var bodyEl = null;
|
|
||||||
var cancelBtn = null;
|
|
||||||
var confirmBtn = null;
|
|
||||||
var footerEl = null;
|
|
||||||
var prevFocus = null;
|
|
||||||
var currentOpts = null;
|
|
||||||
var onKeydown = null;
|
|
||||||
|
|
||||||
function build() {
|
// show mounts backdrop (whose child is the .modal) into parent and makes
|
||||||
backdrop = document.createElement('div');
|
// 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';
|
backdrop.className = 'modal-backdrop';
|
||||||
|
var modal = document.createElement('div');
|
||||||
modal = document.createElement('div');
|
|
||||||
modal.className = 'modal panel panel-floating';
|
modal.className = 'modal panel panel-floating';
|
||||||
modal.setAttribute('role', 'dialog');
|
modal.setAttribute('role', 'dialog');
|
||||||
modal.setAttribute('aria-modal', 'true');
|
modal.setAttribute('aria-modal', 'true');
|
||||||
|
|
||||||
var header = document.createElement('div');
|
var header = document.createElement('div');
|
||||||
header.className = 'panel-header';
|
header.className = 'panel-header';
|
||||||
titleEl = document.createElement('span');
|
var titleEl = document.createElement('span');
|
||||||
|
titleEl.textContent = opts.title || '';
|
||||||
header.appendChild(titleEl);
|
header.appendChild(titleEl);
|
||||||
|
|
||||||
bodyEl = document.createElement('div');
|
var bodyEl = document.createElement('div');
|
||||||
bodyEl.className = 'panel-body';
|
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) {
|
if (opts.body instanceof Node) {
|
||||||
bodyEl.appendChild(opts.body);
|
bodyEl.appendChild(opts.body);
|
||||||
} else if (typeof opts.body === 'string') {
|
} else if (typeof opts.body === 'string') {
|
||||||
@@ -124,14 +115,24 @@
|
|||||||
var confirmOpts = opts.confirm || {};
|
var confirmOpts = opts.confirm || {};
|
||||||
var cancelOpts = opts.cancel || {};
|
var cancelOpts = opts.cancel || {};
|
||||||
|
|
||||||
|
var confirmBtn = document.createElement('button');
|
||||||
|
confirmBtn.type = 'button';
|
||||||
confirmBtn.textContent = confirmOpts.label || 'OK';
|
confirmBtn.textContent = confirmOpts.label || 'OK';
|
||||||
confirmBtn.className = 'btn' + (confirmOpts.danger ? ' danger' : '');
|
confirmBtn.className = 'btn' + (confirmOpts.danger ? ' danger' : '');
|
||||||
confirmBtn.disabled = !!confirmOpts.initiallyDisabled;
|
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.textContent = cancelOpts.label || 'CANCEL';
|
||||||
cancelBtn.className = 'btn';
|
cancelBtn.className = 'btn';
|
||||||
|
cancelBtn.setAttribute('data-modal-close', '');
|
||||||
|
|
||||||
footerEl.textContent = '';
|
var footerEl = document.createElement('div');
|
||||||
|
footerEl.className = 'panel-footer';
|
||||||
if (opts.swapButtons) {
|
if (opts.swapButtons) {
|
||||||
footerEl.appendChild(confirmBtn);
|
footerEl.appendChild(confirmBtn);
|
||||||
footerEl.appendChild(cancelBtn);
|
footerEl.appendChild(cancelBtn);
|
||||||
@@ -140,7 +141,20 @@
|
|||||||
footerEl.appendChild(confirmBtn);
|
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 () {
|
setTimeout(function () {
|
||||||
if (cancelOpts.autofocus) {
|
if (cancelOpts.autofocus) {
|
||||||
@@ -156,22 +170,6 @@
|
|||||||
}
|
}
|
||||||
}, 0);
|
}, 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 {
|
return {
|
||||||
close: close,
|
close: close,
|
||||||
setConfirmDisabled: function (d) { confirmBtn.disabled = !!d; },
|
setConfirmDisabled: function (d) { confirmBtn.disabled = !!d; },
|
||||||
@@ -179,20 +177,26 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function close() {
|
// Server-rendered dialogs. A swap into #modal-host replaces whatever modal
|
||||||
if (!backdrop || !backdrop.parentNode) return;
|
// is open (e.g. the merge step replacing the move dialog); focus returns
|
||||||
if (onKeydown) {
|
// to where it was before the first one opened.
|
||||||
document.removeEventListener('keydown', onKeydown);
|
var hostPrevFocus = null;
|
||||||
onKeydown = 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.openModal = open;
|
||||||
window.closeModal = close;
|
window.closeModal = close;
|
||||||
|
|||||||
+7
-82
@@ -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() {
|
function movePage() {
|
||||||
var current = decodeURIComponent(window.location.pathname).replace(/\/+$/, '');
|
var current = decodeURIComponent(window.location.pathname).replace(/\/+$/, '');
|
||||||
if (!current) return;
|
if (!current) return;
|
||||||
var segs = current.split('/').filter(Boolean);
|
var parent = '/' + current.split('/').filter(Boolean).slice(0, -1).join('/');
|
||||||
var currentName = segs[segs.length - 1] || '';
|
|
||||||
var parent = '/' + segs.slice(0, -1).join('/');
|
|
||||||
if (parent === '/') parent = '/';
|
|
||||||
|
|
||||||
openTreePicker({
|
openTreePicker({
|
||||||
title: 'Move — new parent?',
|
title: 'Move — new parent?',
|
||||||
@@ -123,53 +88,13 @@ function movePage() {
|
|||||||
hideFiles: true,
|
hideFiles: true,
|
||||||
confirmLabel: 'NEXT',
|
confirmLabel: 'NEXT',
|
||||||
onSelect: function (newParent) {
|
onSelect: function (newParent) {
|
||||||
var input = document.createElement('input');
|
// The server renders the name step and handles what follows
|
||||||
input.type = 'text';
|
// (merge confirmation, errors, redirect).
|
||||||
input.className = 'input';
|
closeModal();
|
||||||
input.placeholder = 'Page name';
|
htmx.ajax('GET', window.location.pathname + '?dialog=move', {
|
||||||
input.value = currentName;
|
target: '#modal-host',
|
||||||
|
values: { parent: newParent }
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@@ -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}}
|
||||||
@@ -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>
|
<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}}
|
{{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" 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}}
|
{{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>
|
<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}}
|
{{end}}{{end}}
|
||||||
|
|||||||
Vendored
+1
File diff suppressed because one or more lines are too long
+68
@@ -0,0 +1,68 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"html/template"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var dialogTmpl = template.Must(template.New("dialogs").ParseFS(assets, "assets/page/dialogs.html"))
|
||||||
|
|
||||||
|
// isHX reports whether r was issued by htmx.
|
||||||
|
func isHX(r *http.Request) bool {
|
||||||
|
return r.Header.Get("HX-Request") == "true"
|
||||||
|
}
|
||||||
|
|
||||||
|
// redirectAfter sends the client to target once a mutation succeeded. htmx
|
||||||
|
// requests get HX-Redirect (assets/hx.js turns it into a history replace, so
|
||||||
|
// Back skips the pre-mutation page); plain form posts get a 303.
|
||||||
|
func redirectAfter(w http.ResponseWriter, r *http.Request, target string) {
|
||||||
|
if isHX(r) {
|
||||||
|
w.Header().Set("HX-Redirect", target)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, target, http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pageURL returns the escaped, slash-terminated browser URL of the page at
|
||||||
|
// the wiki path p ("/" or "/a/b", trailing slash optional).
|
||||||
|
func pageURL(p string) string {
|
||||||
|
p = strings.Trim(p, "/")
|
||||||
|
if p == "" {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
return fileURL(p) + "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderDialog writes the named dialog from dialogs.html.
|
||||||
|
func renderDialog(w http.ResponseWriter, name string, data any) {
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
if err := dialogTmpl.ExecuteTemplate(w, name, data); err != nil {
|
||||||
|
log.Printf("dialog template error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveDialog handles GET ?dialog=<name> for the page at urlPath.
|
||||||
|
func (h *handler) serveDialog(w http.ResponseWriter, r *http.Request, urlPath string) {
|
||||||
|
page := normalizeMovePath(urlPath)
|
||||||
|
if page == "/" {
|
||||||
|
http.Error(w, "not available for the wiki root", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch r.URL.Query().Get("dialog") {
|
||||||
|
case "delete":
|
||||||
|
renderDialog(w, "delete", struct{ Path string }{urlPath})
|
||||||
|
case "move":
|
||||||
|
// parent comes from the folder picker; default to the current one.
|
||||||
|
parent := r.URL.Query().Get("parent")
|
||||||
|
if parent == "" {
|
||||||
|
parent = path.Dir(page)
|
||||||
|
}
|
||||||
|
renderDialog(w, "move", struct{ Parent, Name string }{parent, path.Base(page)})
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
+50
-3
@@ -2,7 +2,7 @@
|
|||||||
// visible labels, not implementation details, so they keep passing when the
|
// visible labels, not implementation details, so they keep passing when the
|
||||||
// client-side mechanics change (e.g. the move to HTMX).
|
// client-side mechanics change (e.g. the move to HTMX).
|
||||||
import { test, expect } from "@playwright/test";
|
import { test, expect } from "@playwright/test";
|
||||||
import { mkdirSync, readFileSync } from "node:fs";
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
@@ -78,7 +78,7 @@ test("move renames a page", async ({ page }) => {
|
|||||||
await pageAction(page, "Move page (M)");
|
await pageAction(page, "Move page (M)");
|
||||||
// Tree picker preselects the current parent; keep it.
|
// Tree picker preselects the current parent; keep it.
|
||||||
await modal(page).getByRole("button", { name: "NEXT" }).click();
|
await modal(page).getByRole("button", { name: "NEXT" }).click();
|
||||||
await modal(page).locator("input").fill("Gamma");
|
await modal(page).getByPlaceholder("Page name").fill("Gamma");
|
||||||
await modal(page).getByRole("button", { name: "MOVE" }).click();
|
await modal(page).getByRole("button", { name: "MOVE" }).click();
|
||||||
await expect(page).toHaveURL(/\/Projects\/Gamma\/$/);
|
await expect(page).toHaveURL(/\/Projects\/Gamma\/$/);
|
||||||
await expect(page.locator("main h1")).toContainText("Beta");
|
await expect(page.locator("main h1")).toContainText("Beta");
|
||||||
@@ -89,7 +89,7 @@ test("move onto an empty folder offers a merge", async ({ page }) => {
|
|||||||
await page.goto("/Projects/Alpha/");
|
await page.goto("/Projects/Alpha/");
|
||||||
await pageAction(page, "Move page (M)");
|
await pageAction(page, "Move page (M)");
|
||||||
await modal(page).getByRole("button", { name: "NEXT" }).click();
|
await modal(page).getByRole("button", { name: "NEXT" }).click();
|
||||||
await modal(page).locator("input").fill("Empty");
|
await modal(page).getByPlaceholder("Page name").fill("Empty");
|
||||||
await modal(page).getByRole("button", { name: "MOVE" }).click();
|
await modal(page).getByRole("button", { name: "MOVE" }).click();
|
||||||
await expect(modal(page)).toContainText("Merge folders?");
|
await expect(modal(page)).toContainText("Merge folders?");
|
||||||
await modal(page).getByRole("button", { name: "MERGE" }).click();
|
await modal(page).getByRole("button", { name: "MERGE" }).click();
|
||||||
@@ -105,3 +105,50 @@ test("delete removes a page and returns to the parent", async ({ page }) => {
|
|||||||
await expect(page).toHaveURL(/127\.0\.0\.1:\d+\/$/);
|
await expect(page).toHaveURL(/127\.0\.0\.1:\d+\/$/);
|
||||||
await expect(sidebar(page).getByRole("link", { name: "Notes" })).toHaveCount(0);
|
await expect(sidebar(page).getByRole("link", { name: "Notes" })).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("delete replaces the history entry, so Back skips the deleted page", async ({ page }) => {
|
||||||
|
mkdirSync(join(wiki, "Scratch", "Gone"), { recursive: true });
|
||||||
|
writeFileSync(join(wiki, "Scratch", "Gone", "index.md"), "# Gone\n");
|
||||||
|
await page.goto("/Scratch/");
|
||||||
|
await page.goto("/Scratch/Gone/");
|
||||||
|
await pageAction(page, "Delete page");
|
||||||
|
await modal(page).getByRole("button", { name: "DELETE" }).click();
|
||||||
|
await expect(page).toHaveURL(/\/Scratch\/$/);
|
||||||
|
await page.goBack();
|
||||||
|
await expect(page).toHaveURL(/\/Scratch\/$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("server dialogs close with Escape and CANCEL", async ({ page }) => {
|
||||||
|
await page.goto("/Projects/");
|
||||||
|
await pageAction(page, "Delete page");
|
||||||
|
await expect(modal(page)).toBeVisible();
|
||||||
|
// CANCEL is focused, so Enter must not delete.
|
||||||
|
await expect(modal(page).getByRole("button", { name: "CANCEL" })).toBeFocused();
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(modal(page)).toHaveCount(0);
|
||||||
|
await pageAction(page, "Delete page");
|
||||||
|
await modal(page).getByRole("button", { name: "CANCEL" }).click();
|
||||||
|
await expect(modal(page)).toHaveCount(0);
|
||||||
|
await expect(page).toHaveURL(/\/Projects\/$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a failed move shows the server's reason and keeps the dialog", async ({ page }) => {
|
||||||
|
mkdirSync(join(wiki, "Taken"), { recursive: true });
|
||||||
|
writeFileSync(join(wiki, "Taken", "index.md"), "# Taken\n");
|
||||||
|
mkdirSync(join(wiki, "Mover"), { recursive: true });
|
||||||
|
writeFileSync(join(wiki, "Mover", "index.md"), "# Mover\n");
|
||||||
|
await page.goto("/Mover/");
|
||||||
|
await pageAction(page, "Move page (M)");
|
||||||
|
await modal(page).getByRole("button", { name: "NEXT" }).click();
|
||||||
|
await modal(page).getByPlaceholder("Page name").fill("Taken");
|
||||||
|
// Dismiss the alert from the handler: an open alert blocks the click.
|
||||||
|
const messages = [];
|
||||||
|
page.on("dialog", (d) => {
|
||||||
|
messages.push(d.message());
|
||||||
|
d.dismiss();
|
||||||
|
});
|
||||||
|
await modal(page).getByRole("button", { name: "MOVE" }).click();
|
||||||
|
await expect.poll(() => messages).toEqual([expect.stringContaining("destination already exists")]);
|
||||||
|
await expect(modal(page)).toBeVisible();
|
||||||
|
await expect(page).toHaveURL(/\/Mover\/$/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -301,6 +301,10 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa
|
|||||||
h.handlePost(w, r, urlPath, fsPath)
|
h.handlePost(w, r, urlPath, fsPath)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if r.URL.Query().Has("dialog") {
|
||||||
|
h.serveDialog(w, r, urlPath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
for _, ph := range pageTypeHandlers {
|
for _, ph := range pageTypeHandlers {
|
||||||
if target, ok := ph.redirect(h.root, fsPath, urlPath, r); ok {
|
if target, ok := ph.redirect(h.root, fsPath, urlPath, r); ok {
|
||||||
@@ -427,7 +431,18 @@ func (h *handler) handlePost(w http.ResponseWriter, r *http.Request, urlPath, fs
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, ok := query["move"]; ok {
|
if _, ok := query["move"]; ok {
|
||||||
h.handleMove(w, r, urlPath, fsPath, query.Get("move"), query.Has("merge"))
|
// The destination comes either whole (?move=/dst, or the merge
|
||||||
|
// dialog's move field) or as the move dialog's parent + name.
|
||||||
|
// Body fields take precedence over the bare ?move query key.
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dst := r.FormValue("move")
|
||||||
|
if name := strings.TrimSpace(r.FormValue("name")); dst == "" && name != "" {
|
||||||
|
dst = strings.TrimRight(r.FormValue("parent"), "/") + "/" + name
|
||||||
|
}
|
||||||
|
h.handleMove(w, r, urlPath, fsPath, dst, r.FormValue("merge") != "")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if query.Has("toggle") {
|
if query.Has("toggle") {
|
||||||
|
|||||||
@@ -14,8 +14,7 @@ import (
|
|||||||
// The one exception is a merge: when the source carries a page (index.md) and
|
// The one exception is a merge: when the source carries a page (index.md) and
|
||||||
// the destination folder has none, the source's contents can fill the empty
|
// the destination folder has none, the source's contents can fill the empty
|
||||||
// container without clobbering anything. That merge only proceeds when merge
|
// container without clobbering anything. That merge only proceeds when merge
|
||||||
// is true; otherwise the client is told a merge is available so it can ask the
|
// is true; otherwise the client is asked to confirm it.
|
||||||
// user to confirm.
|
|
||||||
func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, srcFsPath, dstURL string, merge bool) {
|
func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, srcFsPath, dstURL string, merge bool) {
|
||||||
oldPath := normalizeMovePath(srcURL)
|
oldPath := normalizeMovePath(srcURL)
|
||||||
if oldPath == "/" {
|
if oldPath == "/" {
|
||||||
@@ -51,9 +50,12 @@ func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, src
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !merge {
|
if !merge {
|
||||||
// Ask the client to confirm the merge. The header lets it tell
|
// Ask the user to confirm: htmx clients get the merge dialog as
|
||||||
// this apart from an ordinary conflict.
|
// the next step; others a conflict they can retry with &merge=1.
|
||||||
w.Header().Set("X-Merge-Available", "1")
|
if isHX(r) {
|
||||||
|
renderDialog(w, "merge", struct{ Dest string }{newPath})
|
||||||
|
return
|
||||||
|
}
|
||||||
http.Error(w, "destination already exists — merge folders?", http.StatusConflict)
|
http.Error(w, "destination already exists — merge folders?", http.StatusConflict)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -94,7 +96,7 @@ func (h *handler) handleMove(w http.ResponseWriter, r *http.Request, srcURL, src
|
|||||||
folderIndexRenameSubtree(strings.TrimPrefix(oldPath, "/"), strings.TrimPrefix(newPath, "/"))
|
folderIndexRenameSubtree(strings.TrimPrefix(oldPath, "/"), strings.TrimPrefix(newPath, "/"))
|
||||||
}
|
}
|
||||||
|
|
||||||
http.Redirect(w, r, fileURL(strings.TrimPrefix(newPath, "/"))+"/", http.StatusSeeOther)
|
redirectAfter(w, r, pageURL(newPath))
|
||||||
}
|
}
|
||||||
|
|
||||||
// canMergeMove reports whether moving onto an existing destination should
|
// canMergeMove reports whether moving onto an existing destination should
|
||||||
@@ -169,7 +171,7 @@ func (h *handler) handleDelete(w http.ResponseWriter, r *http.Request, urlPath,
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
folderIndexRemoveSubtree(strings.TrimPrefix(normalizeMovePath(urlPath), "/"))
|
folderIndexRemoveSubtree(strings.TrimPrefix(normalizeMovePath(urlPath), "/"))
|
||||||
http.Redirect(w, r, parentURL(urlPath), http.StatusSeeOther)
|
redirectAfter(w, r, pageURL(parentURL(urlPath)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// normalizeMovePath returns the absolute path with any trailing slash removed,
|
// normalizeMovePath returns the absolute path with any trailing slash removed,
|
||||||
|
|||||||
Reference in New Issue
Block a user