feat: replace multi-select fields with dual-panel picker on settings page

Replaces the 5 native <select multiple> fields (Managers, Monitors,
Threshold config, Channels in Hosts; Channels in Users) with a compact
picker widget: a truncated pill display with tooltip, and a click-to-open
panel split into Available / Selected columns for moving items between sides.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Andreas Wrede
2026-05-12 10:10:18 -04:00
parent 59e256a042
commit 668a135e53
+181 -44
View File
@@ -382,9 +382,65 @@
.modal-box h3 { margin: 0 0 12px; font-size: 1em; } .modal-box h3 { margin: 0 0 12px; font-size: 1em; }
.backup-row { display: flex; align-items: center; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid #f0f0f0; font-size: .87em; } .backup-row { display: flex; align-items: center; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid #f0f0f0; font-size: .87em; }
.backup-row:last-child { border-bottom: none; } .backup-row:last-child { border-bottom: none; }
/* ---- Multi-picker ---- */
.mpick-wrapper { display: block; }
.mpick-display {
display: flex; align-items: center; gap: 4px; flex-wrap: nowrap;
cursor: pointer; padding: 3px 7px; border: 1px solid #ccc; border-radius: 4px;
min-height: 26px; min-width: 80px; width: 100%; box-sizing: border-box;
background: #fff; user-select: none; overflow: hidden;
}
.mpick-display:hover { border-color: #0066cc; background: #f8fbff; }
.mpick-tag {
padding: 1px 6px; background: #e8eaf6; color: #283593;
border-radius: 10px; font-size: 0.82em; white-space: nowrap; flex-shrink: 0;
}
.mpick-more { color: #888; font-size: 0.82em; white-space: nowrap; flex-shrink: 0; }
.mpick-empty { color: #bbb; font-style: italic; font-size: 0.82em; }
.mpick-panel {
position: fixed; background: #fff; border: 1px solid #d0d0d0;
border-radius: 6px; box-shadow: 0 4px 20px rgba(0,0,0,.18);
z-index: 2000; width: 360px; overflow: hidden;
}
.mpick-panel-header {
padding: 6px 12px; font-size: 0.78em; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.04em; color: #555;
border-bottom: 1px solid #eee; display: flex;
justify-content: space-between; align-items: center; background: #f5f5f5;
}
.mpick-panel-body { display: flex; }
.mpick-col { flex: 1; min-width: 0; max-height: 200px; overflow-y: auto; }
.mpick-col-header {
padding: 4px 10px; font-size: 0.72em; font-weight: 700;
text-transform: uppercase; letter-spacing: 0.04em; color: #888;
border-bottom: 1px solid #f0f0f0; background: #fafafa;
position: sticky; top: 0; z-index: 1;
}
.mpick-col:first-child { border-right: 1px solid #eee; }
.mpick-item {
padding: 5px 10px; font-size: 0.85em; cursor: pointer;
display: flex; align-items: center; justify-content: space-between;
border-bottom: 1px solid #f8f8f8; gap: 4px;
}
.mpick-item:last-child { border-bottom: none; }
.mpick-item-avail:hover { background: #e8f5e9; }
.mpick-item-sel:hover { background: #fce4ec; }
.mpick-arrow { font-size: 1.1em; opacity: 0.4; flex-shrink: 0; line-height: 1; }
.mpick-item:hover .mpick-arrow { opacity: 1; }
.mpick-item-avail .mpick-arrow { color: #2a7a2a; }
.mpick-item-sel .mpick-arrow { color: #c62828; }
.mpick-panel-footer {
padding: 6px 10px; border-top: 1px solid #eee;
display: flex; justify-content: flex-end; background: #f8f8f8;
}
.mpick-none { padding: 10px; font-size: .82em; color: #aaa; text-align: center; }
</style> </style>
<body> <body>
{%- macro mpick(all_items, sel, cls) -%}
<div class="mpick-wrapper"><div class="mpick-display" onclick="openMpick(this)" title="{{ sel | join(', ') | e }}">{%- if sel -%}{%- for v in sel[:2] -%}<span class="mpick-tag">{{ v | e }}</span>{%- endfor -%}{%- if sel|length > 2 %}<span class="mpick-more">+{{ sel|length - 2 }}</span>{%- endif -%}{%- else -%}<span class="mpick-empty">(none)</span>{%- endif -%}</div><select class="{{ cls }}" multiple hidden>{%- for item in all_items %}<option value="{{ item | e }}"{% if item in sel %} selected{% endif %}>{{ item | e }}</option>{%- endfor %}</select></div>
{%- endmacro %}
{% include 'nav.html' %} {% include 'nav.html' %}
<div class="container"> <div class="container">
@@ -501,11 +557,7 @@
<td><input class="field-input user-avatar" value="{{ u.avatar | e }}"></td> <td><input class="field-input user-avatar" value="{{ u.avatar | e }}"></td>
<td style="text-align:center"><input type="checkbox" class="user-admin" {% if u.admin %}checked{% endif %}></td> <td style="text-align:center"><input type="checkbox" class="user-admin" {% if u.admin %}checked{% endif %}></td>
<td style="min-width:140px"> <td style="min-width:140px">
<select class="field-input user-ch-select" multiple size="{{ [all_channel_names|length, 4]|min }}" style="min-width:130px"> {{ mpick(all_channel_names, u.notification_channels, 'user-ch-select') }}
{% for ch in all_channel_names %}
<option value="{{ ch | e }}" {% if ch in u.notification_channels %}selected{% endif %}>{{ ch | e }}</option>
{% endfor %}
</select>
</td> </td>
<td><input type="password" class="field-input user-password" placeholder="(leave blank to keep)"></td> <td><input type="password" class="field-input user-password" placeholder="(leave blank to keep)"></td>
<td><button class="btn-danger" onclick="toggleDeleteRow(this)"></button></td> <td><button class="btn-danger" onclick="toggleDeleteRow(this)"></button></td>
@@ -576,34 +628,10 @@
<td style="text-align:center"><input type="checkbox" class="host-watch" {% if h.watch %}checked{% endif %}></td> <td style="text-align:center"><input type="checkbox" class="host-watch" {% if h.watch %}checked{% endif %}></td>
<td style="text-align:center"><input type="checkbox" class="host-dyndns" {% if h.dyndns %}checked{% endif %}></td> <td style="text-align:center"><input type="checkbox" class="host-dyndns" {% if h.dyndns %}checked{% endif %}></td>
<td><input class="field-input host-owner" value="{{ h.owner | e }}" placeholder="(none)" style="min-width:90px"></td> <td><input class="field-input host-owner" value="{{ h.owner | e }}" placeholder="(none)" style="min-width:90px"></td>
<td> <td>{{ mpick(all_usernames, h.managers, 'host-managers') }}</td>
<select class="field-input host-managers" multiple size="{{ [all_usernames|length, 4]|min or 2 }}"> <td>{{ mpick(all_usernames, h.monitors, 'host-monitors') }}</td>
{% for u in all_usernames %} <td>{{ mpick(all_threshold_configs, h.threshold_configs, 'host-tc') }}</td>
<option value="{{ u | e }}" {% if u in h.managers %}selected{% endif %}>{{ u | e }}</option> <td>{{ mpick(all_channel_names, h.notification_channels, 'host-channels') }}</td>
{% endfor %}
</select>
</td>
<td>
<select class="field-input host-monitors" multiple size="{{ [all_usernames|length, 4]|min or 2 }}">
{% for u in all_usernames %}
<option value="{{ u | e }}" {% if u in h.monitors %}selected{% endif %}>{{ u | e }}</option>
{% endfor %}
</select>
</td>
<td>
<select class="field-input host-tc" multiple size="{{ [all_threshold_configs|length, 4]|min or 2 }}">
{% for tc in all_threshold_configs %}
<option value="{{ tc | e }}" {% if tc in h.threshold_configs %}selected{% endif %}>{{ tc | e }}</option>
{% endfor %}
</select>
</td>
<td>
<select class="field-input host-channels" multiple size="{{ [all_channel_names|length, 4]|min or 2 }}">
{% for ch in all_channel_names %}
<option value="{{ ch | e }}" {% if ch in h.notification_channels %}selected{% endif %}>{{ ch | e }}</option>
{% endfor %}
</select>
</td>
<td><button class="btn-danger" onclick="toggleDeleteRow(this)"></button></td> <td><button class="btn-danger" onclick="toggleDeleteRow(this)"></button></td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -1008,10 +1036,6 @@
function addHostRow() { function addHostRow() {
const tbody = document.getElementById('hosts-tbody'); const tbody = document.getElementById('hosts-tbody');
const sz = n => Math.min(Math.max(n, 1), 4);
const usersOpts = _allUsers.map(u => `<option value="${escHtml(u)}">${escHtml(u)}</option>`).join('');
const tcOpts = _allThresholdConfigs.map(t => `<option value="${escHtml(t)}">${escHtml(t)}</option>`).join('');
const chOpts = _allChannels.map(c => `<option value="${escHtml(c)}">${escHtml(c)}</option>`).join('');
const row = document.createElement('tr'); const row = document.createElement('tr');
row.setAttribute('data-new-host', 'true'); row.setAttribute('data-new-host', 'true');
row.innerHTML = ` row.innerHTML = `
@@ -1019,10 +1043,10 @@
<td style="text-align:center"><input type="checkbox" class="host-watch" checked></td> <td style="text-align:center"><input type="checkbox" class="host-watch" checked></td>
<td style="text-align:center"><input type="checkbox" class="host-dyndns"></td> <td style="text-align:center"><input type="checkbox" class="host-dyndns"></td>
<td><input class="field-input host-owner" placeholder="(none)" style="min-width:90px"></td> <td><input class="field-input host-owner" placeholder="(none)" style="min-width:90px"></td>
<td><select class="field-input host-managers" multiple size="${sz(_allUsers.length)}">${usersOpts}</select></td> <td>${makeMpickHTML(_allUsers, [], 'host-managers')}</td>
<td><select class="field-input host-monitors" multiple size="${sz(_allUsers.length)}">${usersOpts}</select></td> <td>${makeMpickHTML(_allUsers, [], 'host-monitors')}</td>
<td><select class="field-input host-tc" multiple size="${sz(_allThresholdConfigs.length)}">${tcOpts}</select></td> <td>${makeMpickHTML(_allThresholdConfigs, [], 'host-tc')}</td>
<td><select class="field-input host-channels" multiple size="${sz(_allChannels.length)}">${chOpts}</select></td> <td>${makeMpickHTML(_allChannels, [], 'host-channels')}</td>
<td><button class="btn-danger" onclick="this.closest('tr').remove()">✕</button></td>`; <td><button class="btn-danger" onclick="this.closest('tr').remove()">✕</button></td>`;
tbody.appendChild(row); tbody.appendChild(row);
} }
@@ -1097,8 +1121,6 @@
function addUserRow() { function addUserRow() {
const tbody = document.getElementById('users-tbody'); const tbody = document.getElementById('users-tbody');
const opts = _allChannels.map(ch => `<option value="${escHtml(ch)}">${escHtml(ch)}</option>`).join('');
const chHtml = `<select class="field-input user-ch-select" multiple size="${Math.min(_allChannels.length, 4)}" style="min-width:130px">${opts}</select>`;
const row = document.createElement('tr'); const row = document.createElement('tr');
row.setAttribute('data-new-user', 'true'); row.setAttribute('data-new-user', 'true');
row.innerHTML = ` row.innerHTML = `
@@ -1106,7 +1128,7 @@
<td><input class="field-input user-full-name" placeholder="Display Name"></td> <td><input class="field-input user-full-name" placeholder="Display Name"></td>
<td><input class="field-input user-avatar" placeholder="Avatar URL or path"></td> <td><input class="field-input user-avatar" placeholder="Avatar URL or path"></td>
<td style="text-align:center"><input type="checkbox" class="user-admin"></td> <td style="text-align:center"><input type="checkbox" class="user-admin"></td>
<td>${chHtml}</td> <td>${makeMpickHTML(_allChannels, [], 'user-ch-select')}</td>
<td><input type="password" class="field-input user-password" placeholder="(required)"></td> <td><input type="password" class="field-input user-password" placeholder="(required)"></td>
<td><button class="btn-danger" onclick="this.closest('tr').remove()">✕</button></td>`; <td><button class="btn-danger" onclick="this.closest('tr').remove()">✕</button></td>`;
tbody.appendChild(row); tbody.appendChild(row);
@@ -1186,6 +1208,121 @@
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;'); return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
} }
// ---- Multi-picker ----
let _mpickPanel = null;
let _mpickTarget = null;
function _initMpickPanel() {
if (_mpickPanel) return;
const p = document.createElement('div');
p.className = 'mpick-panel';
p.style.display = 'none';
p.innerHTML = `
<div class="mpick-panel-header">
<span>Select items</span>
<button style="background:none;border:none;cursor:pointer;color:#888;font-size:1.1em;padding:0 2px;line-height:1" onclick="closeMpick()">✕</button>
</div>
<div class="mpick-panel-body">
<div class="mpick-col" id="mpick-avail-col">
<div class="mpick-col-header">Available</div>
<div id="mpick-avail"></div>
</div>
<div class="mpick-col" id="mpick-sel-col">
<div class="mpick-col-header">Selected</div>
<div id="mpick-sel"></div>
</div>
</div>
<div class="mpick-panel-footer">
<button class="btn btn-primary" style="font-size:.82em;padding:4px 12px" onclick="closeMpick()">Done</button>
</div>`;
document.body.appendChild(p);
_mpickPanel = p;
document.addEventListener('mousedown', e => {
if (!_mpickPanel || _mpickPanel.style.display === 'none') return;
if (!_mpickPanel.contains(e.target) && !e.target.closest('.mpick-display')) closeMpick();
}, true);
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && _mpickPanel && _mpickPanel.style.display !== 'none') closeMpick();
});
}
function openMpick(displayEl) {
if (displayEl.closest('tr')?.dataset.deleted === 'true') return;
_initMpickPanel();
_mpickTarget = displayEl.closest('.mpick-wrapper');
_rerenderMpick();
_mpickPanel.style.display = 'block';
const rect = displayEl.getBoundingClientRect();
const pw = _mpickPanel.offsetWidth || 360;
const ph = _mpickPanel.offsetHeight || 280;
let top = rect.bottom + 4;
let left = rect.left;
if (left + pw > window.innerWidth - 8) left = Math.max(8, window.innerWidth - pw - 8);
if (top + ph > window.innerHeight - 8) top = Math.max(8, rect.top - ph - 4);
_mpickPanel.style.top = top + 'px';
_mpickPanel.style.left = left + 'px';
}
function _rerenderMpick() {
const sel = _mpickTarget.querySelector('select');
const allOpts = [...sel.options];
const selVals = new Set([...sel.selectedOptions].map(o => o.value));
const avail = allOpts.filter(o => !selVals.has(o.value));
const chosen = allOpts.filter(o => selVals.has(o.value));
document.getElementById('mpick-avail').innerHTML = avail.length
? avail.map(o => `<div class="mpick-item mpick-item-avail" data-val="${escHtml(o.value)}" onclick="_mpickToggle(this,true)"><span>${escHtml(o.value)}</span><span class="mpick-arrow">+</span></div>`).join('')
: '<div class="mpick-none">All selected</div>';
document.getElementById('mpick-sel').innerHTML = chosen.length
? chosen.map(o => `<div class="mpick-item mpick-item-sel" data-val="${escHtml(o.value)}" onclick="_mpickToggle(this,false)"><span>${escHtml(o.value)}</span><span class="mpick-arrow"></span></div>`).join('')
: '<div class="mpick-none">None selected</div>';
}
function _mpickToggle(itemEl, toSelected) {
const val = itemEl.dataset.val;
const sel = _mpickTarget.querySelector('select');
const opt = [...sel.options].find(o => o.value === val);
if (opt) opt.selected = toSelected;
_updateMpickDisplay(_mpickTarget);
_rerenderMpick();
}
function _updateMpickDisplay(wrapper) {
const sel = wrapper.querySelector('select');
const display = wrapper.querySelector('.mpick-display');
const selected = [...sel.selectedOptions].map(o => o.value);
if (!selected.length) {
display.innerHTML = '<span class="mpick-empty">(none)</span>';
display.title = '';
return;
}
const MAX = 2;
let html = selected.slice(0, MAX).map(v => `<span class="mpick-tag">${escHtml(v)}</span>`).join('');
if (selected.length > MAX) html += `<span class="mpick-more">+${selected.length - MAX}</span>`;
display.innerHTML = html;
display.title = selected.join(', ');
}
function closeMpick() {
if (_mpickPanel) _mpickPanel.style.display = 'none';
_mpickTarget = null;
}
function makeMpickHTML(allItems, selectedItems, cls) {
const selSet = new Set(selectedItems);
const opts = allItems.map(v => `<option value="${escHtml(v)}"${selSet.has(v) ? ' selected' : ''}>${escHtml(v)}</option>`).join('');
const selected = allItems.filter(v => selSet.has(v));
const MAX = 2;
let dispHtml;
if (!selected.length) {
dispHtml = '<span class="mpick-empty">(none)</span>';
} else {
dispHtml = selected.slice(0, MAX).map(v => `<span class="mpick-tag">${escHtml(v)}</span>`).join('');
if (selected.length > MAX) dispHtml += `<span class="mpick-more">+${selected.length - MAX}</span>`;
}
const title = escHtml(selected.join(', '));
return `<div class="mpick-wrapper"><div class="mpick-display" onclick="openMpick(this)" title="${title}">${dispHtml}</div><select class="${cls}" multiple hidden>${opts}</select></div>`;
}
// Highlight sidebar link for the section currently in view // Highlight sidebar link for the section currently in view
const sections = document.querySelectorAll('.section'); const sections = document.querySelectorAll('.section');
const navLinks = document.querySelectorAll('.sidebar-nav a'); const navLinks = document.querySelectorAll('.sidebar-nav a');