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:
@@ -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';
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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 & 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 & 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>
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user