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:
@@ -382,9 +382,65 @@
|
||||
.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: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>
|
||||
|
||||
<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' %}
|
||||
|
||||
<div class="container">
|
||||
@@ -501,11 +557,7 @@
|
||||
<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="min-width:140px">
|
||||
<select class="field-input user-ch-select" multiple size="{{ [all_channel_names|length, 4]|min }}" style="min-width:130px">
|
||||
{% for ch in all_channel_names %}
|
||||
<option value="{{ ch | e }}" {% if ch in u.notification_channels %}selected{% endif %}>{{ ch | e }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{{ mpick(all_channel_names, u.notification_channels, 'user-ch-select') }}
|
||||
</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>
|
||||
@@ -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-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>
|
||||
<select class="field-input host-managers" multiple size="{{ [all_usernames|length, 4]|min or 2 }}">
|
||||
{% for u in all_usernames %}
|
||||
<option value="{{ u | e }}" {% if u in h.managers %}selected{% endif %}>{{ u | e }}</option>
|
||||
{% 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>{{ mpick(all_usernames, h.managers, 'host-managers') }}</td>
|
||||
<td>{{ mpick(all_usernames, h.monitors, 'host-monitors') }}</td>
|
||||
<td>{{ mpick(all_threshold_configs, h.threshold_configs, 'host-tc') }}</td>
|
||||
<td>{{ mpick(all_channel_names, h.notification_channels, 'host-channels') }}</td>
|
||||
<td><button class="btn-danger" onclick="toggleDeleteRow(this)">✕</button></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@@ -1008,10 +1036,6 @@
|
||||
|
||||
function addHostRow() {
|
||||
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');
|
||||
row.setAttribute('data-new-host', 'true');
|
||||
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-dyndns"></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><select class="field-input host-monitors" multiple size="${sz(_allUsers.length)}">${usersOpts}</select></td>
|
||||
<td><select class="field-input host-tc" multiple size="${sz(_allThresholdConfigs.length)}">${tcOpts}</select></td>
|
||||
<td><select class="field-input host-channels" multiple size="${sz(_allChannels.length)}">${chOpts}</select></td>
|
||||
<td>${makeMpickHTML(_allUsers, [], 'host-managers')}</td>
|
||||
<td>${makeMpickHTML(_allUsers, [], 'host-monitors')}</td>
|
||||
<td>${makeMpickHTML(_allThresholdConfigs, [], 'host-tc')}</td>
|
||||
<td>${makeMpickHTML(_allChannels, [], 'host-channels')}</td>
|
||||
<td><button class="btn-danger" onclick="this.closest('tr').remove()">✕</button></td>`;
|
||||
tbody.appendChild(row);
|
||||
}
|
||||
@@ -1097,8 +1121,6 @@
|
||||
|
||||
function addUserRow() {
|
||||
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');
|
||||
row.setAttribute('data-new-user', 'true');
|
||||
row.innerHTML = `
|
||||
@@ -1106,7 +1128,7 @@
|
||||
<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 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><button class="btn-danger" onclick="this.closest('tr').remove()">✕</button></td>`;
|
||||
tbody.appendChild(row);
|
||||
@@ -1186,6 +1208,121 @@
|
||||
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||||
}
|
||||
|
||||
// ---- 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
|
||||
const sections = document.querySelectorAll('.section');
|
||||
const navLinks = document.querySelectorAll('.sidebar-nav a');
|
||||
|
||||
Reference in New Issue
Block a user