Put each page's settings on that page

A watchdog threshold was reachable only from the bottom of a list of a hundred
and twenty on another tab.
This commit is contained in:
Gmer4Lfe
2026-08-12 19:55:51 -04:00
parent 8ca52c64b9
commit e64d26af6c
13 changed files with 284 additions and 151 deletions
+130
View File
@@ -64,6 +64,42 @@
// fatals on the one page that included this without it, which is what took the AI tab blank.
require_once __DIR__ . '/confform.php';
// A whole settings card: disclosure, filter, save, and the fields for one subject. Emitted by a
// page in one line rather than assembled there.
//
// This exists because the alternative was six copies. The AI tab, the Scheduler and the Settings
// page each grew their own load/filter/dirty/save cycle, and the third one was written by copying
// the second — which is exactly how this plugin ended up with nine settings vocabularies and six
// toggles. Everything subtle here is subtle because it already cost something once: the filter
// hides rather than removes so a pending edit survives it, the save rebases instead of refetching
// so the field under the cursor does not repaint, and a refused write leaves the row dirty
// because it genuinely did not happen.
//
// $prefix composes every id, so a page may hold more than one card
// $match which sections — a whole word matched against section headers, or "*" for all
// $title the card's heading
function vv_conf_ui_card(string $prefix, string $match, string $title = 'Settings'): void {
vv_conf_ui_assets();
$p = htmlspecialchars($prefix, ENT_QUOTES);
?>
<div class="vv-cf-card" id="<?= $p ?>-card">
<div class="vv-cf-card-h" id="<?= $p ?>-t">
<span class="vv-cf-card-c">▶</span>
<span class="vv-cf-card-l"><?= htmlspecialchars($title) ?></span>
<input type="text" class="vv-cf-card-f" id="<?= $p ?>-filter"
placeholder="filter…" style="display:none">
<span class="vv-cf-card-s" id="<?= $p ?>-sum"></span>
<button class="vv-cf-card-save" id="<?= $p ?>-save" type="button"
style="display:none" disabled>Save</button>
</div>
<div id="<?= $p ?>-body" style="display:none">
<div id="<?= $p ?>-fields"><p class="vv-cf-empty">opens when you do</p></div>
</div>
</div>
<script>VvConfCard(<?= json_encode($prefix) ?>, <?= json_encode($match) ?>);</script>
<?php
}
function vv_conf_ui_assets(): void {
static $done = false;
if ($done) return;
@@ -337,6 +373,100 @@ function vv_conf_ui_assets(): void {
});
},
};
// One card, self-contained. Everything a page used to write for itself.
window.VvConfCard = function (prefix, match) {
const $ = s => document.getElementById(prefix + '-' + s);
const API = '/plugins/varaverk/api/confform.php';
let loaded = false;
const dirty = () => {
const n = VvConfUI.dirtyCount(prefix + '-fields');
$('save').disabled = (n === 0);
$('sum').textContent = n ? (n + ' unsaved') : '';
$('sum').className = 'vv-cf-card-s' + (n ? ' warn' : '');
};
function load() {
if (loaded) return;
loaded = true;
fetch(API + '?sections=' + encodeURIComponent(match))
.then(r => r.json())
.then(d => {
if (!d.ok) throw new Error(d.error || 'could not be read');
VvConfUI.render(prefix + '-fields', d.groups || []);
VvConfUI.wire(prefix + '-fields', dirty);
dirty();
})
.catch(e => {
// Retryable — closing and reopening tries again rather than leaving a permanent error.
loaded = false;
$('fields').innerHTML = '<p class="vv-cf-empty">could not be read: '
+ String(e.message || e) + '</p>';
});
}
$('t').addEventListener('click', e => {
// The filter and the save live in the header; clicking either must not collapse the card.
if (e.target.closest('#' + prefix + '-filter, #' + prefix + '-save')) return;
const open = $('body').style.display === 'none';
$('body').style.display = open ? '' : 'none';
$('t').classList.toggle('open', open);
$('filter').style.display = open ? '' : 'none';
$('save').style.display = open ? '' : 'none';
// First open only, so collapsing to look at something else does not discard edits.
if (open) load();
});
// Hides whole sections, never individual fields — a setting means little without the heading
// saying what it belongs to. display:none rather than removal, so a field edited before
// filtering is still dirty and still saved: a filter that silently dropped pending edits
// would be a data-loss bug wearing a search box.
$('filter').addEventListener('input', () => {
const q = $('filter').value.trim().toLowerCase();
$('fields').querySelectorAll('.vv-cf-group').forEach(g => {
g.classList.toggle('vv-filtered', q !== '' && !g.textContent.toLowerCase().includes(q));
});
});
$('save').addEventListener('click', () => {
const changes = VvConfUI.collect(prefix + '-fields');
if (!changes.length) return;
$('save').disabled = true;
$('sum').textContent = 'saving…';
$('sum').className = 'vv-cf-card-s';
// URLSearchParams, not FormData — a multipart POST to this plugin's endpoints hangs with
// no status ever returned.
fetch(API, { method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
body: new URLSearchParams({ changes: JSON.stringify(changes) }) })
.then(r => r.text())
.then(t => {
let d;
try { d = JSON.parse(t); }
catch (_) {
throw new Error(t.trim() ? 'unparseable response'
: 'empty response — rejected before the endpoint ran');
}
if (!d.ok) throw new Error(d.error || 'save failed');
// Rebased, not refetched: the values on screen are the values on disk now, and a
// reload would repaint the control under the cursor.
VvConfUI.commit(prefix + '-fields');
dirty();
$('sum').textContent = 'saved ' + changes.length
+ ' change' + (changes.length > 1 ? 's' : '');
$('sum').className = 'vv-cf-card-s ok';
})
.catch(e => {
// Left dirty on purpose. A refused write changed nothing, and clearing the marks
// would say it had.
$('sum').textContent = String(e.message || e);
$('sum').className = 'vv-cf-card-s bad';
dirty();
});
});
};
})();
</script>
<?php