Toggle tasks and edit view settings via htmx

Task checkboxes post themselves (hx-post="?toggle=N") and flip back when
the save fails, replacing tasks.js. View settings are a server-rendered
form dialog with real radios and a select, replacing view-settings.js;
with that, postReplace has no callers left.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 16:41:59 +02:00
co-authored by Claude Opus 5.5
parent 3a3fb3b51f
commit 353df29ee6
12 changed files with 93 additions and 162 deletions
-34
View File
@@ -3,40 +3,6 @@ function encodePickedPath(p) {
return '/' + p.replace(/^\/+/, '').split('/').map(encodeURIComponent).join('/');
}
// postReplace POSTs to action with the optional form body, then loads target
// into the current history entry — so the action and its result occupy one
// entry instead of two, and back-navigation skips past the stale pre-mutation
// snapshot in bfcache. body may be null for empty POSTs.
//
// We can't just call window.location.replace(target): when target differs from
// the current URL only by fragment, the browser updates the URL bar without
// re-fetching, so a server-side mutation wouldn't be reflected. Instead,
// rewrite the current entry's URL via history.replaceState, then reload — the
// reload always re-fetches and preserves the (new) URL including its fragment.
function navigateReplace(target) {
window.history.replaceState(null, '', target);
window.location.reload();
}
function postReplace(action, body, target) {
var init = { method: 'POST', redirect: 'manual' };
if (body) {
init.headers = { 'Content-Type': 'application/x-www-form-urlencoded' };
init.body = body;
}
fetch(action, init).then(function (res) {
if (res.type === 'opaqueredirect' || res.ok) {
navigateReplace(target);
return;
}
return res.text().then(function (msg) {
alert(msg || ('Request failed (' + res.status + ')'));
});
}).catch(function () {
alert('Network error');
});
}
function promptPageName(title, initial, confirmLabel, onName) {
var input = document.createElement('input');
input.type = 'text';
+34
View File
@@ -44,3 +44,37 @@ The destination folder already exists but has no page of its own. Merge this pag
</div>
</form>
</div>{{end}}
{{define "view"}}<div class="modal-backdrop">
<form class="modal panel panel-floating" role="dialog" aria-modal="true" aria-labelledby="modal-title" hx-post="?settings" hx-target="#modal-host">
<div class="panel-header"><span id="modal-title">View settings</span></div>
<div class="panel-body col">
<fieldset class="col gap-1">
<legend class="caption">View style</legend>
<div class="row gap-1 segmented">
<label class="btn"><input type="radio" name="view" value="list"{{if eq .View "list"}} checked{{end}} />List</label>
<label class="btn"><input type="radio" name="view" value="thumbnail"{{if eq .View "thumbnail"}} checked{{end}} />Thumbnail</label>
</div>
</fieldset>
<label class="col gap-1">
<span class="caption">Sort by</span>
<select class="input" name="sort">
<option value="name"{{if eq .Sort "name"}} selected{{end}}>Name</option>
<option value="modified"{{if eq .Sort "modified"}} selected{{end}}>Modified</option>
<option value="size"{{if eq .Sort "size"}} selected{{end}}>Size</option>
</select>
</label>
<fieldset class="col gap-1">
<legend class="caption">Order</legend>
<div class="row gap-1 segmented">
<label class="btn"><input type="radio" name="order" value="asc"{{if eq .Order "asc"}} checked{{end}} />Asc</label>
<label class="btn"><input type="radio" name="order" value="desc"{{if eq .Order "desc"}} checked{{end}} />Desc</label>
</div>
</fieldset>
</div>
<div class="panel-footer">
<button class="btn" type="button" data-modal-close>CANCEL</button>
<button class="btn" type="submit" autofocus>SAVE</button>
</div>
</form>
</div>{{end}}
+1 -3
View File
@@ -21,7 +21,7 @@
<p class="empty">{{if .Entries}}No page{{else}}Empty folder{{end}} — <a href="?edit">[CREATE]</a></p>
{{end}}
{{if .Entries}}
<h2 id="files">Files <button class="btn btn-small" data-companion-reveal hidden title="Open folder in file manager">open</button>{{if .CanEdit}} <button class="btn btn-small" id="view-settings-btn" onclick="openViewSettings()" title="View &amp; sorting" data-view="{{.View}}" data-sort="{{.Sort}}" data-order="{{.Order}}">view</button>{{end}}</h2>
<h2 id="files">Files <button class="btn btn-small" data-companion-reveal hidden title="Open folder in file manager">open</button>{{if .CanEdit}} <button class="btn btn-small" hx-get="?dialog=view" hx-target="#modal-host" title="View &amp; sorting">view</button>{{end}}</h2>
{{if eq .View "thumbnail"}}
<div class="thumb-grid">
{{range .Entries}}
@@ -44,12 +44,10 @@
</tbody>
</table>
{{end}}
{{if .CanEdit}}<script src="{{asset "page/view-settings.js"}}"></script>{{end}}
{{end}}
{{if or .Content .SpecialContent}}
<script src="{{asset "page/content.js"}}"></script>
{{if not .SuppressTOC}}<script src="{{asset "page/toc.js"}}"></script>{{end}}
<script src="{{asset "page/tasks.js"}}"></script>
{{end}}
{{if .Content}}
<script src="{{asset "page/sections.js"}}"></script>
-24
View File
@@ -1,24 +0,0 @@
(function () {
document.querySelectorAll('input.task-checkbox[data-task-index]').forEach(function (cb) {
cb.addEventListener('change', function () {
var idx = cb.dataset.taskIndex;
var checked = cb.checked;
cb.disabled = true;
fetch(window.location.pathname + '?toggle=' + idx, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'checked=' + checked
}).then(function (res) {
if (!res.ok) {
cb.checked = !checked;
alert('Failed to save task state (' + res.status + ')');
}
}).catch(function () {
cb.checked = !checked;
alert('Failed to save task state');
}).finally(function () {
cb.disabled = false;
});
});
});
})();
-85
View File
@@ -1,85 +0,0 @@
// View-settings modal: lets the user pick the folder listing's view style,
// sort key, and order, then persists them by POSTing to the folder with
// ?settings. Reuses openModal/closeModal and postReplace from page/actions.js.
function openViewSettings() {
var btn = document.getElementById('view-settings-btn');
var state = {
view: (btn && btn.dataset.view) || 'list',
sort: (btn && btn.dataset.sort) || 'name',
order: (btn && btn.dataset.order) || 'asc'
};
// segmented builds a row of mutually-exclusive .btn toggles bound to a
// single state key, marking the current choice with .is-active.
function segmented(key, options) {
var wrap = document.createElement('div');
wrap.className = 'row gap-1';
options.forEach(function (opt) {
var b = document.createElement('button');
b.type = 'button';
b.className = 'btn';
b.textContent = opt.label;
if (state[key] === opt.value) b.classList.add('is-active');
b.addEventListener('click', function () {
state[key] = opt.value;
wrap.querySelectorAll('button').forEach(function (x) {
x.classList.remove('is-active');
});
b.classList.add('is-active');
});
wrap.appendChild(b);
});
return wrap;
}
function field(labelText, control) {
var row = document.createElement('div');
row.className = 'col gap-1';
var label = document.createElement('span');
label.className = 'caption';
label.textContent = labelText;
row.appendChild(label);
row.appendChild(control);
return row;
}
var sortSelect = document.createElement('select');
sortSelect.className = 'input';
[['name', 'Name'], ['modified', 'Modified'], ['size', 'Size']].forEach(function (o) {
var opt = document.createElement('option');
opt.value = o[0];
opt.textContent = o[1];
if (state.sort === o[0]) opt.selected = true;
sortSelect.appendChild(opt);
});
sortSelect.addEventListener('change', function () { state.sort = sortSelect.value; });
var body = document.createElement('div');
body.className = 'col';
body.appendChild(field('View style', segmented('view', [
{ value: 'list', label: 'List' },
{ value: 'thumbnail', label: 'Thumbnail' }
])));
body.appendChild(field('Sort by', sortSelect));
body.appendChild(field('Order', segmented('order', [
{ value: 'asc', label: 'Asc' },
{ value: 'desc', label: 'Desc' }
])));
openModal({
title: 'View settings',
body: body,
confirm: {
label: 'SAVE',
onConfirm: function () {
var action = window.location.pathname + '?settings';
var formBody = 'view=' + encodeURIComponent(state.view) +
'&sort=' + encodeURIComponent(state.sort) +
'&order=' + encodeURIComponent(state.order);
var target = window.location.pathname;
closeModal();
postReplace(action, formBody, target);
}
}
});
}