From d4756444ac8c551701090c703650de0f92ef8cb6 Mon Sep 17 00:00:00 2001 From: adam Date: Sat, 10 Oct 2026 12:50:41 +0000 Subject: [PATCH] Edit blocklists and rules in the web UI An Edit button opens a dialog for a list's name, URL, views and comment, or a rule's action, scope, views and comment. The download-now button is called Refresh. Checkboxes in dialogs are no longer stretched. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 + internal/web/static/assets/pages/blocking.js | 62 +++++++++++++++++--- internal/web/static/assets/style.css | 1 + 3 files changed, 59 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ed87be..94b58e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,9 @@ All notable changes to Jarl are documented here. The format follows indents and Enter keeps the indentation (Esc, then Tab, moves on). With a draft open, the tables show the draft's values, so a staged edit stays visible after Save. +- Blocking page: blocklists and rules have an Edit button (name, URL, + views, comment; a rule's action and scope). The list button that + downloads now is called Refresh, like `jarl blocklist refresh`. - Records table: disabled records are hatched and have a "disabled" badge next to their name, instead of only a muted colour and a badge in the comment column. diff --git a/internal/web/static/assets/pages/blocking.js b/internal/web/static/assets/pages/blocking.js index f3330c5..59f63c7 100644 --- a/internal/web/static/assets/pages/blocking.js +++ b/internal/web/static/assets/pages/blocking.js @@ -1,5 +1,5 @@ // Blocking: your block/allow rules and blocklists (docs/blocking.md). -import { h, mount, table, input, select, checkbox, toast, errorBox, badge, confirmDialog, relTime, time } from "../ui.js"; +import { h, mount, table, field, input, select, checkbox, toast, errorBox, badge, confirmDialog, modal, relTime, time } from "../ui.js"; import { get, change, describeResult, draftQuery, can, api } from "../api.js"; import { rerender } from "../app.js"; import { pendingBadge } from "./zones.js"; @@ -134,9 +134,9 @@ async function addRule(name, action, exact, views, comment) { // viewPicker is a compact choice of views: it shows the choice, and opens // a list of checkboxes (none ticked: every view that offers recursion). -function viewPicker(views, autoViews) { - if (!views.length) return { el: null, value: () => [] }; - const boxes = views.map((v) => checkbox(v.name, false)); +function viewPicker(views, autoViews, selected = []) { + if (!views.length) return { el: null, picker: null, value: () => [] }; + const boxes = views.map((v) => checkbox(v.name, selected.includes(v.name))); const value = () => views.filter((v, i) => boxes[i].input.checked).map((v) => v.name); const summary = h("summary"); const show = () => { @@ -147,7 +147,52 @@ function viewPicker(views, autoViews) { show(); const picker = h("details", { class: "view-picker" }, summary, h("div", { class: "view-picker-list" }, h("div", { class: "muted small" }, "None ticked: every view that offers recursion"), boxes)); - return { el: h("div", { class: "f" }, h("label", null, "Views"), picker), value }; + return { el: h("div", { class: "f" }, h("label", null, "Views"), picker), picker, value }; +} + +// The views a list or rule names itself ([] when automatic). +const ownViews = (x) => (x.auto_views ? [] : x.views); + +// editDialog changes a list or rule: fields are [label, control, help] +// rows, and data() the PATCH body. +async function editDialog(title, path, rows, data) { + const err = h("div"); + await modal(title, h("div", null, h("div", { class: "form-grid" }, rows.filter(Boolean).map(([l, c, help]) => field(l, c, help))), err), [ + { label: "Cancel", value: null }, + { label: "Save", kind: "primary", onclick: async () => { + try { + const r = await change("PATCH", path, data()); + toast(describeResult(r)); + rerender(); + return true; + } catch (e) { mount(err, errorBox(e)); return false; } + } }, + ], { sticky: true }); +} + +function editList(l, views, autoViews) { + const name = input({ value: l.name, class: "mono" }); + const url = input({ value: l.url, class: "mono", style: { width: "100%" } }); + const comment = input({ value: l.comment || "" }); + const vp = viewPicker(views, autoViews, ownViews(l)); + editDialog("Edit blocklist " + l.name, "/blocking/lists/" + l.id, [ + ["Name", name], ["URL", url, "A new URL is downloaded right after saving."], + vp.picker ? ["Views", vp.picker, "None ticked: every view that offers recursion."] : null, + ["Comment", comment], + ], () => ({ name: name.value.trim(), url: url.value.trim(), views: vp.value(), comment: comment.value })); +} + +function editRule(r, views, autoViews) { + const name = input({ value: r.name, class: "mono" }); + const action = select([["block", "Block"], ["allow", "Allow"]], r.action); + const exact = checkbox("only this exact name", !!r.exact); + const comment = input({ value: r.comment || "" }); + const vp = viewPicker(views, autoViews, ownViews(r)); + editDialog("Edit the rule for " + r.name, "/blocking/rules/" + r.id, [ + ["Action", action], ["Domain", name], ["", exact], + vp.picker ? ["Views", vp.picker, "None ticked: every view that offers recursion."] : null, + ["Comment", comment], + ], () => ({ name: name.value.trim(), action: action.value, exact: exact.input.checked, views: vp.value(), comment: comment.value })); } // An open view picker closes when you click elsewhere. @@ -190,7 +235,9 @@ function rulesPanel(rules, views, autoViews, writable) { { label: "Domain", render: (r) => [h("span", { class: "mono" }, r.name), r.exact ? null : h("span", { class: "muted small" }, " and below")] }, views.length ? { label: "Views", render: viewsCell } : null, { label: "Comment", render: (r) => h("span", { class: "muted" }, r.comment || "") }, - writable ? { label: "", render: (r) => r.pending === "delete" ? "" : h("button", { class: "small danger", onclick: () => del("/blocking/rules/" + r.id, "the rule for " + r.name) }, "Delete"), tdClass: "actions-cell" } : null, + writable ? { label: "", render: (r) => r.pending === "delete" ? "" : h("span", null, + h("button", { class: "small", onclick: () => editRule(r, views, autoViews) }, "Edit"), " ", + h("button", { class: "small danger", onclick: () => del("/blocking/rules/" + r.id, "the rule for " + r.name) }, "Delete")), tdClass: "actions-cell" } : null, ].filter(Boolean), rules, { empty: "No rules yet.", rowClass: (r) => r.pending ? "pending-" + r.pending : "" })); } @@ -229,7 +276,8 @@ function listsPanel(lists, views, autoViews, writable) { { label: "Memory", render: (l) => l.memory_mb ? h("span", { title: "named's memory for this list, estimated (all its views)" }, "~" + l.memory_mb + " MB") : "–", tdClass: "small right" }, { label: "URL", render: (l) => h("a", { href: l.url, target: "_blank", rel: "noopener", class: "small mono", title: l.url }, shortURL(l.url)) }, writable ? { label: "", render: (l) => l.pending === "delete" ? "" : h("span", null, - h("button", { class: "small", disabled: l.state === "disabled" || !!l.pending, title: "Download now", onclick: () => refresh(l) }, "Update"), " ", + h("button", { class: "small", onclick: () => editList(l, views, autoViews) }, "Edit"), " ", + h("button", { class: "small", disabled: l.state === "disabled" || !!l.pending, title: "Download it now (it is checked daily anyway)", onclick: () => refresh(l) }, "Refresh"), " ", h("button", { class: "small", onclick: () => toggle(l) }, l.disabled ? "Enable" : "Disable"), " ", h("button", { class: "small danger", onclick: () => del("/blocking/lists/" + l.id, "blocklist " + l.name) }, "Delete")), tdClass: "actions-cell" } : null, ].filter(Boolean), lists, { empty: "No blocklists yet.", rowClass: (l) => l.pending ? "pending-" + l.pending : l.disabled ? "disabled" : "" }), diff --git a/internal/web/static/assets/style.css b/internal/web/static/assets/style.css index c5accd4..5e0a61a 100644 --- a/internal/web/static/assets/style.css +++ b/internal/web/static/assets/style.css @@ -171,6 +171,7 @@ textarea { width: 100%; min-height: 80px; resize: vertical; } .form-grid > label { padding-top: 6px; color: var(--muted); } .form-grid .help { grid-column: 2; margin-top: -4px; color: var(--muted); font-size: 12px; } .form-grid input, .form-grid select { width: 100%; max-width: 560px; } +.form-grid input[type=checkbox] { width: auto; } .inline-form { display: flex; gap: 6px; flex-wrap: wrap; align-items: flex-end; } .inline-form .f { display: flex; flex-direction: column; gap: 2px; } .inline-form .f label:not(.check) { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.3px; }