From 353df29ee6af7e47cdd677e835cfffc3f6d7ec30 Mon Sep 17 00:00:00 2001 From: luxick Date: Wed, 30 Sep 2026 16:41:04 +0200 Subject: [PATCH] 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 --- assets/page/actions.js | 34 --------------- assets/page/dialogs.html | 34 +++++++++++++++ assets/page/main.html | 4 +- assets/page/tasks.js | 24 ---------- assets/page/view-settings.js | 85 ------------------------------------ assets/style.css | 14 ++++++ dialogs.go | 10 +++-- e2e/tests/pages.spec.js | 29 +++++++++++- main.go | 4 +- pagesettings.go | 2 +- render.go | 2 - tasks.go | 13 +++--- 12 files changed, 93 insertions(+), 162 deletions(-) delete mode 100644 assets/page/tasks.js delete mode 100644 assets/page/view-settings.js diff --git a/assets/page/actions.js b/assets/page/actions.js index b1cb03c..2d0e793 100644 --- a/assets/page/actions.js +++ b/assets/page/actions.js @@ -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'; diff --git a/assets/page/dialogs.html b/assets/page/dialogs.html index 34f8cda..6d1e65a 100644 --- a/assets/page/dialogs.html +++ b/assets/page/dialogs.html @@ -44,3 +44,37 @@ The destination folder already exists but has no page of its own. Merge this pag {{end}} + +{{define "view"}}{{end}} diff --git a/assets/page/main.html b/assets/page/main.html index ba6262d..fb76518 100644 --- a/assets/page/main.html +++ b/assets/page/main.html @@ -21,7 +21,7 @@

{{if .Entries}}No page{{else}}Empty folder{{end}} — [CREATE]

{{end}} {{if .Entries}} -

Files {{if .CanEdit}} {{end}}

+

Files {{if .CanEdit}} {{end}}

{{if eq .View "thumbnail"}}
{{range .Entries}} @@ -44,12 +44,10 @@ {{end}} -{{if .CanEdit}}{{end}} {{end}} {{if or .Content .SpecialContent}} {{if not .SuppressTOC}}{{end}} - {{end}} {{if .Content}} diff --git a/assets/page/tasks.js b/assets/page/tasks.js deleted file mode 100644 index 180ce36..0000000 --- a/assets/page/tasks.js +++ /dev/null @@ -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; - }); - }); - }); -})(); diff --git a/assets/page/view-settings.js b/assets/page/view-settings.js deleted file mode 100644 index 7766044..0000000 --- a/assets/page/view-settings.js +++ /dev/null @@ -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); - } - } - }); -} diff --git a/assets/style.css b/assets/style.css index a0a639f..4065483 100644 --- a/assets/style.css +++ b/assets/style.css @@ -241,6 +241,20 @@ footer { .danger { color: var(--danger); } .danger:hover { color: var(--danger-hover); } .btn.is-active { color: var(--primary-hover); } +/* Segmented choice: radios styled as .btn toggles. The label is the button; + the input stays in the accessibility tree and keyboard order. */ +.segmented input { + position: absolute; + opacity: 0; + width: 1px; + height: 1px; + margin: 0; +} +.segmented .btn { position: relative; } +.segmented .btn:has(input:checked) { color: var(--primary-hover); } +.segmented .btn:has(input:focus-visible) { outline: 1px solid var(--secondary); } +fieldset { border: 0; margin: 0; padding: 0; min-width: 0; } +legend { padding: 0; margin-bottom: var(--space-1); } /* not a flex item: .gap-* skips it */ /* === Form controls === .input baseline is shared by search-input, modal inputs, and the editor diff --git a/dialogs.go b/dialogs.go index 03fcf3e..05a882c 100644 --- a/dialogs.go +++ b/dialogs.go @@ -46,13 +46,17 @@ func renderDialog(w http.ResponseWriter, name string, data any) { } // serveDialog handles GET ?dialog= for the page at urlPath. -func (h *handler) serveDialog(w http.ResponseWriter, r *http.Request, urlPath string) { +func (h *handler) serveDialog(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) { page := normalizeMovePath(urlPath) - if page == "/" { + name := r.URL.Query().Get("dialog") + if page == "/" && (name == "delete" || name == "move") { http.Error(w, "not available for the wiki root", http.StatusBadRequest) return } - switch r.URL.Query().Get("dialog") { + switch name { + case "view": + view, sortKey, order := readPageSettings(fsPath).viewSettings() + renderDialog(w, "view", struct{ View, Sort, Order string }{view, sortKey, order}) case "delete": renderDialog(w, "delete", struct{ Path string }{urlPath}) case "move": diff --git a/e2e/tests/pages.spec.js b/e2e/tests/pages.spec.js index 84b197a..a11e5a8 100644 --- a/e2e/tests/pages.spec.js +++ b/e2e/tests/pages.spec.js @@ -47,14 +47,41 @@ test("task checkbox toggle persists", async ({ page }) => { await expect(page.locator("main input.task-checkbox").first()).toBeChecked(); }); +test("unchecking a task persists", async ({ page }) => { + await page.goto("/Projects/"); + const box = page.locator("main input.task-checkbox").nth(1); + await expect(box).toBeChecked(); + await box.uncheck(); + await expect(box).toBeEnabled(); + await expect.poll(() => readFileSync(join(wiki, "Projects", "index.md"), "utf8")).toContain("- [ ] second task"); +}); + +test("a failed task save reverts the checkbox", async ({ page }) => { + page.on("dialog", (d) => d.dismiss()); + await page.route("**/?toggle=*", (route) => route.fulfill({ status: 500, body: "boom" })); + await page.goto("/Projects/"); + const box = page.locator("main input.task-checkbox").nth(1); + const before = await box.isChecked(); + await box.click(); + await expect(box).toBeEnabled(); + await expect(box).toBeChecked({ checked: before }); +}); + test("view settings switch the listing to thumbnails", async ({ page }) => { await page.goto("/Projects/"); await expect(page.locator("main table.data-table")).toBeVisible(); await page.getByTitle("View & sorting").click(); - await modal(page).getByRole("button", { name: "Thumbnail" }).click(); + await modal(page).getByText("Thumbnail").click(); + await modal(page).getByLabel("Sort by").selectOption("size"); + await modal(page).getByText("Desc").click(); await modal(page).getByRole("button", { name: "SAVE" }).click(); await expect(page.locator("main .thumb-grid")).toBeVisible(); await expect(page).toHaveURL(/\/Projects\/$/); + // Reopening shows the saved state. + await page.getByTitle("View & sorting").click(); + await expect(modal(page).getByLabel("Thumbnail")).toBeChecked(); + await expect(modal(page).getByLabel("Sort by")).toHaveValue("size"); + await expect(modal(page).getByLabel("Desc")).toBeChecked(); }); test("editor saves a page", async ({ page }) => { diff --git a/main.go b/main.go index 61a2a1f..26d3619 100644 --- a/main.go +++ b/main.go @@ -302,7 +302,7 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa return } if r.URL.Query().Has("dialog") { - h.serveDialog(w, r, urlPath) + h.serveDialog(w, r, urlPath, fsPath) return } @@ -406,8 +406,6 @@ func (h *handler) serveDir(w http.ResponseWriter, r *http.Request, urlPath, fsPa Content: rendered, Entries: entries, View: view, - Sort: sortKey, - Order: order, SpecialContent: specialContent, SidebarWidget: sidebarWidget, SuppressTOC: suppressTOC, diff --git a/pagesettings.go b/pagesettings.go index 7b874ec..48a3e48 100644 --- a/pagesettings.go +++ b/pagesettings.go @@ -23,7 +23,7 @@ func (h *handler) handleSettings(w http.ResponseWriter, r *http.Request, urlPath http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError) return } - http.Redirect(w, r, urlPath, http.StatusSeeOther) + redirectAfter(w, r, pageURL(urlPath)) } // writePageSettings performs a read-modify-write of /.page-settings, diff --git a/render.go b/render.go index 871b357..4ebad60 100644 --- a/render.go +++ b/render.go @@ -58,8 +58,6 @@ type pageData struct { Content template.HTML Entries []entry View string // listing view style: "list" or "thumbnail" - Sort string // listing sort key: "name" / "modified" / "size" - Order string // listing sort order: "asc" / "desc" SpecialContent template.HTML SidebarWidget template.HTML SuppressTOC bool diff --git a/tasks.go b/tasks.go index 3cf5e15..1f6bef8 100644 --- a/tasks.go +++ b/tasks.go @@ -17,18 +17,19 @@ var taskCheckboxRe = regexp.MustCompile(`