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 open {{if .CanEdit}} view {{end}}
+Files open {{if .CanEdit}} view {{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(`