diff --git a/internal/server/web/static/account.js b/internal/server/web/static/account.js index df1ff6e..9233800 100644 --- a/internal/server/web/static/account.js +++ b/internal/server/web/static/account.js @@ -99,9 +99,9 @@ li.innerHTML = '' + KEY + '
' + esc(k.name) + '
' + '
добавлен ' + esc(when(k.created)) + ' · вход: ' + esc(when(k.last_used)) + '
' + ''; - li.querySelector('button').onclick = function () { + li.querySelector('button').onclick = async function () { var last = d.keys.length === 1; - if (!confirm('Удалить ключ «' + k.name + '»?' + (last && d.password_off ? ' Это последний ключ: вход по паролю снова включится.' : ''))) return; + if (!await UI.confirm('Удалить ключ «' + k.name + '»?' + (last && d.password_off ? ' Это последний ключ: вход по паролю снова включится.' : ''), {ok: 'Удалить', danger: true})) return; fetch('/api/panel/account/keys/' + k.id + '/delete', {method: 'POST'}).then(function (r) { return r.json(); }) .then(function (r) { if (!r.ok) msg(r.error, true); else { msg('ключ удалён'); load(); } }); }; @@ -190,8 +190,8 @@ } catch (e) { pinMsg('браузер не даёт сохранить настройку', true); return; } pinMsg(min ? 'на этом устройстве PIN не спросят ' + min + ' мин после ввода' : 'PIN спрашивается в каждой новой вкладке'); }; - $('.acct-pindel').onclick = function () { - if (confirm('Убрать PIN-код?')) savePin(''); + $('.acct-pindel').onclick = async function () { + if (await UI.confirm('Убрать PIN-код?', {ok: 'Убрать', danger: true})) savePin(''); }; // The assistant: each user's own switch; the chat button follows it. fetch('/api/panel/ai', {cache: 'no-store'}).then(function (r) { return r.json(); }).then(function (d) { $('.acct-ai .sw').checked = !!d.on; }).catch(function () {}); @@ -201,9 +201,9 @@ .then(function (r) { return r.json(); }) .then(function (r) { if (!r.ok) { ev.target.checked = !on; msg(r.error, true); } else document.dispatchEvent(new CustomEvent('lpra:ai', {detail: on})); }); }; - $('.acct-pw .sw').onchange = function (ev) { + $('.acct-pw .sw').onchange = async function (ev) { const off = !ev.target.checked; - if (off && !confirm('Выключить вход по паролю? Войти можно будет только ключом доступа. Если ключи потеряются, их сможет сбросить другой администратор в «Настройках».')) { + if (off && !await UI.confirm('Выключить вход по паролю? Войти можно будет только ключом доступа. Если ключи потеряются, их сможет сбросить другой администратор в «Настройках».', {ok: 'Выключить', danger: true})) { ev.target.checked = true; return; } diff --git a/internal/server/web/static/ai.js b/internal/server/web/static/ai.js index 607edde..5cb3082 100644 --- a/internal/server/web/static/ai.js +++ b/internal/server/web/static/ai.js @@ -6,6 +6,8 @@ // Off until the user turns it on in the profile. (function () { 'use strict'; + const PLANE = ''; + const STOP = ''; const esc = v => String(v).replace(/[&<>"]/g, c => ({'&': '&', '<': '<', '>': '>', '"': '"'}[c])); const fab = document.createElement('button'); fab.type = 'button'; @@ -22,8 +24,8 @@ '' + '' + '

Спросите о том, что открыто в панели: ассистент видит вкладки и активный терминал или папку. Команды он предложит кнопкой «Выполнить».

' + - '
' + - '
'; + '
' + + '
'; document.body.append(fab, box); const q = s => box.querySelector(s); const list = q('.ai-list'), input = q('textarea'), send = q('.ai-send'); @@ -122,7 +124,7 @@ const el = add('assistant', ''); el.classList.add('wait'); ac = new AbortController(); - send.textContent = 'Стоп'; + send.innerHTML = STOP; send.title = 'Остановить'; let answer = ''; try { const r = await fetch('/api/panel/ai/chat', { @@ -144,7 +146,7 @@ if (e.name !== 'AbortError') answer += (answer ? '\n' : '') + '[ошибка: ' + (e.message || 'нет связи') + ']'; } finally { ac = null; - send.textContent = 'Отправить'; + send.innerHTML = PLANE; send.title = 'Отправить'; el.classList.remove('wait'); render(el, answer || '[нет ответа]', true); talk.push({role: 'assistant', content: answer}); @@ -156,9 +158,13 @@ const text = input.value.trim(); if (!text) return; input.value = ''; + grow(); where(); ask(text); }; + // The field grows with the text, up to a few lines. + function grow() { input.style.height = '40px'; input.style.height = Math.min(140, input.scrollHeight + 2) + 'px'; } + input.addEventListener('input', grow); input.addEventListener('keydown', ev => { if (ev.key === 'Enter' && !ev.shiftKey && !ev.isComposing) { ev.preventDefault(); q('.ai-form-in').requestSubmit(); } }); diff --git a/internal/server/web/static/app.css b/internal/server/web/static/app.css index ad34266..c8b6121 100644 --- a/internal/server/web/static/app.css +++ b/internal/server/web/static/app.css @@ -838,11 +838,17 @@ body.touch.term-active .ai-fab { bottom:104px; } .ai-code { margin:6px 0; } .ai-code pre { margin:0; padding:8px; background:#0c0f13; border:1px solid var(--border); border-radius:7px; overflow:auto; font-size:12.5px; white-space:pre-wrap; } .ai-code .ai-run { margin-top:6px; } -.ai-form-in { display:flex; gap:8px; padding:10px; border-top:1px solid var(--border); } -.ai-form-in textarea { flex:1; resize:none; font:inherit; font-size:14px; padding:8px 10px; color:var(--text); +.ai-form-in { display:flex; align-items:flex-end; gap:8px; padding:10px; border-top:1px solid var(--border); } +.ai-form-in textarea { flex:1; resize:none; font:inherit; font-size:14px; line-height:20px; padding:9px 10px; height:40px; max-height:140px; overflow-y:auto; color:var(--text); background:#0c0f13; border:1px solid var(--border); border-radius:8px; } .ai-form-in textarea:focus { outline:none; border-color:var(--accent); } @media (max-width: 640px) { .ai-box { right:8px; left:8px; width:auto; max-width:none; bottom:78px; height:calc(100dvh - 150px); } body.touch.term-active .ai-box { bottom:164px; height:calc(100dvh - 236px); } } +.ai-send { width:40px; height:40px; padding:0; flex:none; display:inline-flex; align-items:center; justify-content:center; border-radius:8px; } +/* The panel's own confirm and prompt. */ +dialog.uidlg { max-width:420px; width:92vw; } +.uidlg form { padding:18px; display:grid; gap:14px; } +.uidlg .ui-text { margin:0; white-space:pre-wrap; line-height:1.45; } +.uidlg .ui-input[hidden] { display:none; } diff --git a/internal/server/web/static/dbedit.js b/internal/server/web/static/dbedit.js index 12ca26d..3109fbb 100644 --- a/internal/server/web/static/dbedit.js +++ b/internal/server/web/static/dbedit.js @@ -262,10 +262,10 @@ if (ev.key === 'Escape') { ev.preventDefault(); ev.stopPropagation(); done(); } }); }; - $('.dbx-del').onclick = function () { + $('.dbx-del').onclick = async function () { var keys = []; S.sel.forEach(function (i) { keys.push(S.data.rows[i].k); }); - if (!keys.length || !confirm('Удалить строк: ' + keys.length + '?')) return; + if (!keys.length || !await UI.confirm('Удалить строк: ' + keys.length + '?', {ok: 'Удалить', danger: true})) return; req('db.del', {table: S.cur, keys: keys}).then(function (r) { if (!r.ok) { toast(r.error || 'не удалилось', true); return; } toast('удалено строк: ' + r.data.deleted); diff --git a/internal/server/web/static/filepane.js b/internal/server/web/static/filepane.js index 6ad3337..7110a1b 100644 --- a/internal/server/web/static/filepane.js +++ b/internal/server/web/static/filepane.js @@ -320,17 +320,17 @@ this.render(); }); q('.fp-mkdir').onclick = async () => { - const n = prompt('Имя новой папки:'); if (!n) return; + const n = await UI.prompt('Имя новой папки:'); if (!n) return; const r = await this.api('mkdir', {path: join(this.cwd, n)}); r.ok ? (toast('создано'), this.reload()) : toast(r.error || 'ошибка', true); }; q('.fp-newfile').onclick = async () => { - const n = prompt('Имя нового файла:'); if (!n) return; + const n = await UI.prompt('Имя нового файла:'); if (!n) return; const r = await this.api('write', {path: join(this.cwd, n), data: ''}); r.ok ? (toast('создано'), this.reload()) : toast(r.error || 'ошибка', true); }; q('.fp-delsel').onclick = async () => { - if (!confirm('Удалить выбранное (' + this.selected.size + ')?')) return; + if (!await UI.confirm('Удалить выбранное (' + this.selected.size + ')?', {ok: 'Удалить', danger: true})) return; for (const n of this.selected) { const e = this.entries.find(x => x.name === n); await this.api(e && e.is_dir ? 'deltree' : 'delete', {path: join(this.cwd, n)}); @@ -338,7 +338,7 @@ toast('удалено'); this.reload(); }; q('.fp-zip').onclick = async () => { - const name = prompt('Имя архива:', 'archive.zip'); if (!name) return; + const name = await UI.prompt('Имя архива:', 'archive.zip'); if (!name) return; const r = await this.api('zip', {paths: this.selectedPaths(), dest: join(this.cwd, name)}); r.ok ? (toast('архив создан'), this.reload()) : toast(r.error || 'ошибка', true); }; diff --git a/internal/server/web/static/fileviews.js b/internal/server/web/static/fileviews.js index 868bd73..a783c41 100644 --- a/internal/server/web/static/fileviews.js +++ b/internal/server/web/static/fileviews.js @@ -552,8 +552,8 @@ }); // Closing: Esc first closes the search; unsaved changes are asked about. - function closeViewer() { - if (V.dirty && !confirm('Есть несохранённые изменения. Закрыть без сохранения?')) return; + async function closeViewer() { + if (V.dirty && !await UI.confirm('Есть несохранённые изменения. Закрыть без сохранения?', {ok: 'Закрыть', danger: true})) return; stopMedia(); closeFind(); H.data = H.orig = null; V.kind = ''; V.dirty = false; @@ -619,7 +619,7 @@ if (group && (group !== o.group || rec)) args.group = group; if (rec) { args.recursive = true; args.only = $('pp-only').value; } if (Object.keys(args).length === 1) { toast('ничего не изменено'); return; } - if (rec && !confirm('Применить к «' + P.path + '» и всему, что внутри?')) return; + if (rec && !await UI.confirm('Применить к «' + P.path + '» и всему, что внутри?', {ok: 'Применить'})) return; const btn = $('pp-apply'); btn.disabled = true; const r = await api('chmod', args); @@ -634,15 +634,12 @@ $('vprops').onclick = () => { if (V.path) openProps(V.path); }; async function del(e) { - if (e.is_dir) { - if (!confirm('Рекурсивно удалить каталог "'+e.name+'" со всем содержимым?')) return; - const r = await api('deltree', {path: join(cur.cwd, e.name)}); - r.ok ? (toast('удалено'), cur.go(cur.cwd)) : toast(r.error||'ошибка', true); - } else { - if (!confirm('Удалить файл "'+e.name+'"?')) return; - const r = await api('delete', {path: join(cur.cwd, e.name)}); - r.ok ? (toast('удалено'), cur.go(cur.cwd)) : toast(r.error||'ошибка', true); - } + // The pane asked about, whatever another pane does meanwhile. + const pane = cur; + const ok = await UI.confirm(e.is_dir ? 'Удалить папку «' + e.name + '» со всем содержимым?' : 'Удалить файл «' + e.name + '»?', {ok: 'Удалить', danger: true}); + if (!ok) return; + const r = await pane.src.op(e.is_dir ? 'deltree' : 'delete', {path: join(pane.cwd, e.name)}); + r.ok ? (toast('удалено'), pane.go(pane.cwd)) : toast(r.error||'ошибка', true); } // ---------- Move or copy ---------- diff --git a/internal/server/web/static/ui.js b/internal/server/web/static/ui.js new file mode 100644 index 0000000..49b2b70 --- /dev/null +++ b/internal/server/web/static/ui.js @@ -0,0 +1,53 @@ +// The panel's own question and input dialogs in place of the browser's +// confirm() and prompt(): UI.confirm(text, {ok, danger}) and +// UI.prompt(text, value) answer with promises. A form with data-confirm asks +// first and is sent only on «да». +(function () { + 'use strict'; + let dlg = null; + function make() { + dlg = document.createElement('dialog'); + dlg.className = 'uidlg'; + dlg.innerHTML = '

' + + '
'; + document.body.appendChild(dlg); + } + function ask(text, o) { + if (!dlg) make(); + if (dlg.open) dlg.close(''); + const input = dlg.querySelector('.ui-input'), yes = dlg.querySelector('.ui-yes'); + dlg.querySelector('.ui-text').textContent = text; + input.hidden = !o.input; + input.value = o.value || ''; + yes.textContent = o.ok || 'OK'; + yes.className = 'ui-yes' + (o.danger ? ' btn danger' : ''); + return new Promise(done => { + const end = v => { dlg.onclose = null; done(v); }; + dlg.querySelector('.ui-no').onclick = () => dlg.close(''); + dlg.querySelector('form').onsubmit = ev => { ev.preventDefault(); dlg.close('ok'); }; + dlg.onclose = () => end(dlg.returnValue === 'ok' ? (o.input ? input.value : true) : (o.input ? null : false)); + dlg.returnValue = ''; + dlg.showModal(); + if (o.input) { input.focus(); input.select(); } else yes.focus(); + }); + } + const UI = { + confirm: (text, o) => ask(text, Object.assign({ok: 'Да'}, o)), + prompt: (text, value) => ask(text, {input: true, value, ok: 'OK'}) + }; + window.UI = UI; + // Forms that ask first: data-confirm, and data-danger for a red «да». + document.addEventListener('submit', ev => { + const f = ev.target; + if (!f.dataset || !f.dataset.confirm) return; + if (f.dataset.confirmed) { delete f.dataset.confirmed; return; } + ev.preventDefault(); + ev.stopImmediatePropagation(); + const by = ev.submitter; + UI.confirm(f.dataset.confirm, {ok: f.dataset.ok || 'Да', danger: 'danger' in f.dataset}).then(ok => { + if (!ok) return; + f.dataset.confirmed = '1'; + f.requestSubmit(by && f.contains(by) ? by : undefined); + }); + }, true); +})(); diff --git a/internal/server/web/static/workspace.js b/internal/server/web/static/workspace.js index 1e0e952..2502376 100644 --- a/internal/server/web/static/workspace.js +++ b/internal/server/web/static/workspace.js @@ -164,10 +164,10 @@ return t; } function openTerminal(agent, ssh) { return openPane(ssh ? 'ssh' : 'term', agent, {ssh}); } - function closeTab(t) { + async function closeTab(t) { const i = tabs.indexOf(t); if (i < 0) return; - if (t.panes.length > 1 && !confirm('Закрыть вкладку с ' + t.panes.length + ' панелями?')) return; + if (t.panes.length > 1 && !await UI.confirm('Закрыть вкладку с ' + t.panes.length + ' панелями?', {ok: 'Закрыть', danger: true})) return; tabs.splice(i, 1); t.panes.forEach(s => s.close()); t.el.classList.add('leave'); diff --git a/internal/server/web/templates/app.html b/internal/server/web/templates/app.html index e2c1963..a2998cf 100644 --- a/internal/server/web/templates/app.html +++ b/internal/server/web/templates/app.html @@ -195,6 +195,7 @@
+ diff --git a/internal/server/web/templates/dashboard.html b/internal/server/web/templates/dashboard.html index dabe500..97e182f 100644 --- a/internal/server/web/templates/dashboard.html +++ b/internal/server/web/templates/dashboard.html @@ -31,7 +31,7 @@ {{if $.IsAdmin}}
+ data-confirm="Удалить машину {{.Name}}? Сервис на ней продолжит работать, но перестанет быть виден. Чтобы снова подключиться - переустановите агента." data-danger>
@@ -98,7 +98,7 @@

Удаляет машину из панели. Сервис на самой машине продолжит работать, но перестанет быть виден; чтобы подключиться снова - переустановите агента.

+ data-confirm="Удалить машину {{.Agent.Name}}?" data-danger>
diff --git a/internal/server/web/templates/forwards.html b/internal/server/web/templates/forwards.html index a7af6ce..711c036 100644 --- a/internal/server/web/templates/forwards.html +++ b/internal/server/web/templates/forwards.html @@ -49,7 +49,7 @@ {{.Label}}
+ data-confirm="Удалить правило?" data-danger>
diff --git a/internal/server/web/templates/settings.html b/internal/server/web/templates/settings.html index 286d3f2..4fbb65c 100644 --- a/internal/server/web/templates/settings.html +++ b/internal/server/web/templates/settings.html @@ -54,7 +54,7 @@
{{.InstallShort}}
+ data-confirm="Сделать новый код? Старая короткая ссылка перестанет работать." data-danger> @@ -142,7 +142,7 @@
{{.Secret}} + data-confirm="Сменить секрет? Старые one-liner перестанут работать для новых установок." data-danger> @@ -174,14 +174,14 @@ {{if index $.KeyCounts .ID}}
+ data-confirm="Сбросить все ключи доступа {{.Username}}? Вход по паролю снова включится." data-danger>
{{end}}
+ data-confirm="Удалить {{.Username}}?" data-danger> @@ -251,7 +251,7 @@ {{if .Manual}}вручную{{else if .RuleID}}проброс{{else}}туннель{{end}} + data-confirm="Отменить правило ufw {{.Port}}/{{.Proto}}?" data-danger>