Commit b87a3775 authored by Vitaly Lipatov's avatar Vitaly Lipatov

route-web-api: dropdown menus, compact buttons, loading spinner

- Replace per-entry move/remove buttons with single dropdown button (⇄) - Add context menu on right-click (check, move, remove) - Move "remove" into dropdown/context menu as last item (red) - Keep "?" check button inline for quick access - Add CSS spinner animation on check result block during loading - Show check result block immediately with domain name and spinner - Align sidebar IPs with flexbox columns - Reduce button padding for compact 3-column layout Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 5f77e023
...@@ -458,8 +458,8 @@ HTML_PAGE = """\ ...@@ -458,8 +458,8 @@ HTML_PAGE = """\
.sidebar-section h3 { font-size: 0.85em; color: #666; margin-bottom: 8px; .sidebar-section h3 { font-size: 0.85em; color: #666; margin-bottom: 8px;
padding-bottom: 6px; border-bottom: 1px solid #eee; } padding-bottom: 6px; border-bottom: 1px solid #eee; }
.sidebar-item { font-family: monospace; font-size: 0.75em; padding: 2px 0; .sidebar-item { font-family: monospace; font-size: 0.75em; padding: 2px 0;
color: #333; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } color: #333; display: flex; justify-content: space-between; }
.sidebar-item span.ip { color: #999; } .sidebar-item span.ip { color: #999; flex-shrink: 0; margin-left: 4px; }
.sidebar-note { font-size: 0.75em; color: #999; margin-top: 8px; font-style: italic; } .sidebar-note { font-size: 0.75em; color: #999; margin-top: 8px; font-style: italic; }
.content { flex: 1; min-width: 0; } .content { flex: 1; min-width: 0; }
.input-row { display: flex; gap: 8px; margin-bottom: 20px; } .input-row { display: flex; gap: 8px; margin-bottom: 20px; }
...@@ -474,10 +474,30 @@ HTML_PAGE = """\ ...@@ -474,10 +474,30 @@ HTML_PAGE = """\
.btn-direct:hover { background: #1e8449; } .btn-direct:hover { background: #1e8449; }
.btn-geo { background: #8e44ad; } .btn-geo { background: #8e44ad; }
.btn-geo:hover { background: #7d3c98; } .btn-geo:hover { background: #7d3c98; }
.btn-remove { background: #e74c3c; padding: 4px 10px; font-size: 0.8em; } .btn-remove { background: #e74c3c; padding: 2px 6px; font-size: 0.75em; }
.btn-remove:hover { background: #c0392b; } .btn-remove:hover { background: #c0392b; }
.btn-move { background: #3498db; padding: 4px 10px; font-size: 0.8em; } .btn-move { background: #3498db; padding: 2px 6px; font-size: 0.75em; position: relative; }
.btn-move:hover { background: #2980b9; } .btn-move:hover { background: #2980b9; }
.dropdown { position: absolute; top: 100%; right: 0; background: #fff; border: 1px solid #ddd;
border-radius: 4px; box-shadow: 0 2px 8px rgba(0,0,0,0.15); z-index: 100;
min-width: 100px; display: none; }
.dropdown.open { display: block; }
.dropdown-item { padding: 6px 12px; cursor: pointer; font-size: 0.85em; color: #333;
white-space: nowrap; }
.dropdown-item:hover { background: #f0f0f0; }
.dropdown-item:first-child { border-radius: 4px 4px 0 0; }
.dropdown-item:last-child { border-radius: 0 0 4px 4px; }
.dropdown-item.danger { color: #e74c3c; }
.dropdown-item.danger:hover { background: #fdf0ef; }
.ctx-menu { position: fixed; background: #fff; border: 1px solid #ddd; border-radius: 4px;
box-shadow: 0 2px 8px rgba(0,0,0,0.15); z-index: 200; min-width: 140px;
display: none; }
.ctx-menu.open { display: block; }
.ctx-menu-item { padding: 6px 12px; cursor: pointer; font-size: 0.85em; color: #333;
white-space: nowrap; }
.ctx-menu-item:hover { background: #f0f0f0; }
.ctx-menu-item.danger { color: #e74c3c; }
.ctx-menu-item.danger:hover { background: #fdf0ef; }
.btn-check { background: #3498db; } .btn-check { background: #3498db; }
.btn-check:hover { background: #2980b9; } .btn-check:hover { background: #2980b9; }
.btn-check:disabled { background: #95a5a6; cursor: wait; } .btn-check:disabled { background: #95a5a6; cursor: wait; }
...@@ -486,6 +506,10 @@ HTML_PAGE = """\ ...@@ -486,6 +506,10 @@ HTML_PAGE = """\
display: none; font-size: 0.9em; } display: none; font-size: 0.9em; }
.check-result.visible { display: block; } .check-result.visible { display: block; }
.check-result h3 { font-size: 1em; margin-bottom: 8px; } .check-result h3 { font-size: 1em; margin-bottom: 8px; }
.spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid #ddd;
border-top-color: #3498db; border-radius: 50%; vertical-align: middle;
margin-left: 8px; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
.check-gateways { display: flex; flex-wrap: wrap; gap: 6px; } .check-gateways { display: flex; flex-wrap: wrap; gap: 6px; }
.check-gw { padding: 4px 10px; border-radius: 4px; font-family: monospace; .check-gw { padding: 4px 10px; border-radius: 4px; font-family: monospace;
font-size: 0.85em; } font-size: 0.85em; }
...@@ -501,7 +525,7 @@ HTML_PAGE = """\ ...@@ -501,7 +525,7 @@ HTML_PAGE = """\
.check-no-route { color: #999; font-style: italic; font-size: 0.85em; } .check-no-route { color: #999; font-style: italic; font-size: 0.85em; }
.check-whois { font-family: monospace; font-size: 0.8em; color: #555; .check-whois { font-family: monospace; font-size: 0.8em; color: #555;
white-space: pre-line; } white-space: pre-line; }
.btn-entry-check { background: #3498db; padding: 4px 8px; font-size: 0.75em; } .btn-entry-check { background: #3498db; padding: 2px 6px; font-size: 0.75em; }
.btn-entry-check:hover { background: #2980b9; } .btn-entry-check:hover { background: #2980b9; }
.columns { display: flex; gap: 20px; } .columns { display: flex; gap: 20px; }
.column { flex: 1; background: #fff; border-radius: 6px; padding: 16px; .column { flex: 1; background: #fff; border-radius: 6px; padding: 16px;
...@@ -515,7 +539,7 @@ HTML_PAGE = """\ ...@@ -515,7 +539,7 @@ HTML_PAGE = """\
padding: 6px 0; border-bottom: 1px solid #f0f0f0; } padding: 6px 0; border-bottom: 1px solid #f0f0f0; }
.entry:last-child { border-bottom: none; } .entry:last-child { border-bottom: none; }
.entry-domain { font-family: monospace; font-size: 0.95em; word-break: break-all; } .entry-domain { font-family: monospace; font-size: 0.95em; word-break: break-all; }
.entry-actions { display: flex; gap: 4px; flex-shrink: 0; margin-left: 8px; } .entry-actions { display: flex; gap: 3px; flex-shrink: 0; margin-left: 6px; }
.empty { color: #999; font-style: italic; padding: 8px 0; } .empty { color: #999; font-style: italic; padding: 8px 0; }
.status { padding: 8px 12px; border-radius: 4px; margin-bottom: 16px; .status { padding: 8px 12px; border-radius: 4px; margin-bottom: 16px;
display: none; font-size: 0.9em; } display: none; font-size: 0.9em; }
...@@ -615,9 +639,16 @@ async function api(path, body) { ...@@ -615,9 +639,16 @@ async function api(path, body) {
} }
} }
const TARGETS = {bypass: ['direct', 'geo'], direct: ['bypass', 'geo'], geo: ['bypass', 'direct']};
const LABELS = {bypass: '\\u2192 egw', direct: '\\u2192 dgw', geo: '\\u2192 gre'};
const NAMES = {bypass: 'Обход (egw)', direct: 'Напрямую (dgw)', geo: 'Geo (gre)'};
function closeMenus() {
document.querySelectorAll('.dropdown.open, .ctx-menu.open').forEach(el => el.classList.remove('open'));
}
document.addEventListener('click', closeMenus);
function makeEntry(domain, mode) { function makeEntry(domain, mode) {
const targets = {bypass: ['direct', 'geo'], direct: ['bypass', 'geo'], geo: ['bypass', 'direct']};
const labels = {bypass: 'egw', direct: 'dgw', geo: 'gre'};
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'entry'; div.className = 'entry';
const span = document.createElement('span'); const span = document.createElement('span');
...@@ -625,26 +656,83 @@ function makeEntry(domain, mode) { ...@@ -625,26 +656,83 @@ function makeEntry(domain, mode) {
span.textContent = domain; span.textContent = domain;
const actions = document.createElement('span'); const actions = document.createElement('span');
actions.className = 'entry-actions'; actions.className = 'entry-actions';
// Check button
const btnCheck = document.createElement('button'); const btnCheck = document.createElement('button');
btnCheck.className = 'btn-entry-check'; btnCheck.className = 'btn-entry-check';
btnCheck.textContent = '?'; btnCheck.textContent = '?';
btnCheck.title = '\\u041f\\u0440\\u043e\\u0432\\u0435\\u0440\\u0438\\u0442\\u044c'; btnCheck.title = '\\u041f\\u0440\\u043e\\u0432\\u0435\\u0440\\u0438\\u0442\\u044c';
btnCheck.addEventListener('click', () => { $('domain').value = domain; checkDomain(); }); btnCheck.addEventListener('click', () => { $('domain').value = domain; checkDomain(); });
actions.appendChild(btnCheck); actions.appendChild(btnCheck);
for (const t of targets[mode]) {
const btn = document.createElement('button'); // Move button with dropdown (includes remove)
btn.className = 'btn-move'; const wrap = document.createElement('span');
btn.textContent = '\\u2192' + labels[t]; wrap.style.position = 'relative';
btn.addEventListener('click', () => moveEntry(domain, mode, t)); const btnMove = document.createElement('button');
actions.appendChild(btn); btnMove.className = 'btn-move';
btnMove.textContent = '\\u21c4';
btnMove.title = '\\u041f\\u0435\\u0440\\u0435\\u043c\\u0435\\u0441\\u0442\\u0438\\u0442\\u044c';
const dd = document.createElement('div');
dd.className = 'dropdown';
for (const t of TARGETS[mode]) {
const item = document.createElement('div');
item.className = 'dropdown-item';
item.textContent = NAMES[t];
item.addEventListener('click', (e) => { e.stopPropagation(); closeMenus(); moveEntry(domain, mode, t); });
dd.appendChild(item);
} }
const btnRemove = document.createElement('button'); const rmItem = document.createElement('div');
btnRemove.className = 'btn-remove'; rmItem.className = 'dropdown-item danger';
btnRemove.textContent = '\\u2715'; rmItem.textContent = '\\u0423\\u0434\\u0430\\u043b\\u0438\\u0442\\u044c';
btnRemove.addEventListener('click', () => removeEntry(domain, mode)); rmItem.addEventListener('click', (e) => { e.stopPropagation(); closeMenus(); removeEntry(domain, mode); });
actions.appendChild(btnRemove); dd.appendChild(rmItem);
btnMove.addEventListener('click', (e) => {
e.stopPropagation(); closeMenus();
dd.classList.toggle('open');
});
wrap.appendChild(btnMove);
wrap.appendChild(dd);
actions.appendChild(wrap);
div.appendChild(span); div.appendChild(span);
div.appendChild(actions); div.appendChild(actions);
// Context menu on right-click
div.addEventListener('contextmenu', (e) => {
e.preventDefault(); closeMenus();
let menu = $('ctx-menu');
if (!menu) {
menu = document.createElement('div');
menu.id = 'ctx-menu';
menu.className = 'ctx-menu';
document.body.appendChild(menu);
}
menu.textContent = '';
// Check
const chk = document.createElement('div');
chk.className = 'ctx-menu-item';
chk.textContent = '\\u041f\\u0440\\u043e\\u0432\\u0435\\u0440\\u0438\\u0442\\u044c';
chk.addEventListener('click', () => { closeMenus(); $('domain').value = domain; checkDomain(); });
menu.appendChild(chk);
// Move options
for (const t of TARGETS[mode]) {
const mv = document.createElement('div');
mv.className = 'ctx-menu-item';
mv.textContent = NAMES[t];
mv.addEventListener('click', () => { closeMenus(); moveEntry(domain, mode, t); });
menu.appendChild(mv);
}
// Remove
const rm = document.createElement('div');
rm.className = 'ctx-menu-item danger';
rm.textContent = '\\u0423\\u0434\\u0430\\u043b\\u0438\\u0442\\u044c';
rm.addEventListener('click', () => { closeMenus(); removeEntry(domain, mode); });
menu.appendChild(rm);
menu.style.left = e.clientX + 'px';
menu.style.top = e.clientY + 'px';
menu.classList.add('open');
});
return div; return div;
} }
...@@ -771,7 +859,15 @@ async function checkDomain() { ...@@ -771,7 +859,15 @@ async function checkDomain() {
if (!v) return; if (!v) return;
const btn = $('btn-check'); const btn = $('btn-check');
btn.disabled = true; btn.disabled = true;
btn.textContent = 'Проверка\u2026'; btn.textContent = 'Проверка\\u2026';
// Show block immediately with spinner
const hdr = $('check-domain');
hdr.textContent = v + ' ';
const sp = document.createElement('span');
sp.className = 'spinner';
hdr.appendChild(sp);
$('check-gateways').textContent = '';
$('check-result').className = 'check-result visible';
try { try {
const d = await api('check', {domain: v}); const d = await api('check', {domain: v});
renderCheck(d); renderCheck(d);
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment