feat: the panel's own confirm and prompt dialogs everywhere; the assistant's field is one line with a square send button

This commit is contained in:
redtea committed 2026-10-15 22:16:02 +03:00
1 parent 3f113ce8d6
commit 1f930e5aea
13 files changed
+104 -41

No files matched your search

+6 -6
View File
@@ -99,9 +99,9 @@
li.innerHTML = '<span class="acct-kicon">' + KEY + '</span><div class="acct-kinfo"><div>' + esc(k.name) + '</div>' +
'<div class="muted">добавлен ' + esc(when(k.created)) + ' · вход: ' + esc(when(k.last_used)) + '</div></div>' +
'<button type="button" class="btn danger small" title="Удалить ключ">Удалить</button>';
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;
}
+10 -4
View File
@@ -6,6 +6,8 @@
// Off until the user turns it on in the profile.
(function () {
'use strict';
const PLANE = '<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><path d="M2.4 10.6 20.6 3.3c.8-.3 1.5.4 1.2 1.2l-7.3 18.2c-.3.8-1.5.8-1.8 0l-2.6-6.9-6.9-2.6c-.8-.3-.8-1.5 0-1.8zm7.3 4.4 2 5.2L18 6 9.7 15z"/></svg>';
const STOP = '<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><rect x="5" y="5" width="14" height="14" rx="2"/></svg>';
const esc = v => String(v).replace(/[&<>"]/g, c => ({'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;'}[c]));
const fab = document.createElement('button');
fab.type = 'button';
@@ -22,8 +24,8 @@
'<button type="button" class="btn ghost small ai-clear" title="Новый разговор">Очистить</button>' +
'<button type="button" class="btn ghost small icon close x ai-close" aria-label="Закрыть"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button></div>' +
'<div class="ai-list"><p class="muted ai-hello">Спросите о том, что открыто в панели: ассистент видит вкладки и активный терминал или папку. Команды он предложит кнопкой «Выполнить».</p></div>' +
'<form class="ai-form-in"><textarea rows="2" placeholder="Сообщение… (Enter - отправить, Shift+Enter - строка)"></textarea>' +
'<button type="submit" class="ai-send">Отправить</button></form>';
'<form class="ai-form-in"><textarea rows="1" placeholder="Сообщение…"></textarea>' +
'<button type="submit" class="ai-send" title="Отправить" aria-label="Отправить">' + PLANE + '</button></form>';
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(); }
});
+8 -2
View File
@@ -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; }
+2 -2
View File
@@ -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);
+4 -4
View File
@@ -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);
};
+9 -12
View File
@@ -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 ----------
+53
View File
@@ -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 = '<form method="dialog"><p class="ui-text"></p><input class="ui-input" autocomplete="off" spellcheck="false">' +
'<div class="row end"><button type="button" class="btn ghost ui-no">Отмена</button><button type="submit" class="ui-yes">OK</button></div></form>';
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);
})();
+2 -2
View File
@@ -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');
+1
View File
@@ -195,6 +195,7 @@
<div class="toast" id="toast"></div>
<script>window.APP = {admin: {{.IsAdmin}}, open: {{.Open}}, dialog: {{.Dialog}}, editLimit: {{.EditLimit}}};</script>
<script src="{{asset "wsutil.js"}}"></script>
<script src="{{asset "ui.js"}}"></script>
<script src="{{asset "xterm.js"}}"></script>
<script src="{{asset "addon-fit.js"}}"></script>
<script src="{{asset "app.js"}}"></script>
+1 -1
View File
@@ -31,7 +31,7 @@
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/></svg>
</a>
{{if $.IsAdmin}}<form method="post" action="/agents/{{.ID}}/delete" style="display:inline"
onsubmit="return confirm('Удалить машину {{.Name}}? Сервис на ней продолжит работать, но перестанет быть виден. Чтобы снова подключиться - переустановите агента.')">
data-confirm="Удалить машину {{.Name}}? Сервис на ней продолжит работать, но перестанет быть виден. Чтобы снова подключиться - переустановите агента." data-danger>
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<button class="btn danger small icon" type="submit" data-tip="Удалить" aria-label="Удалить">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>
+2 -2
View File
@@ -84,7 +84,7 @@
</p>
{{if .Outdated}}
<form method="post" action="/agents/{{.Agent.ID}}/update"
onsubmit="return confirm('Обновить клиента до версии {{.ServerVersion}}? Бинарник будет заменён, сервис перезапущен (без удаления).')">
data-confirm="Обновить клиента до версии {{.ServerVersion}}? Бинарник будет заменён, сервис перезапущен (без удаления)." data-danger>
<input type="hidden" name="csrf" value="{{.CSRF}}">
<button type="submit">Обновить клиента</button>
</form>
@@ -98,7 +98,7 @@
<p class="muted">Удаляет машину из панели. Сервис на самой машине продолжит работать,
но перестанет быть виден; чтобы подключиться снова - переустановите агента.</p>
<form method="post" action="/agents/{{.Agent.ID}}/delete"
onsubmit="return confirm('Удалить машину {{.Agent.Name}}?')">
data-confirm="Удалить машину {{.Agent.Name}}?" data-danger>
<input type="hidden" name="csrf" value="{{.CSRF}}">
<button class="btn danger" type="submit">Удалить машину</button>
</form>
+1 -1
View File
@@ -49,7 +49,7 @@
<td class="lead">{{.Label}}</td>
<td class="actions" style="text-align:right">
<form method="post" action="/forwards/{{.ID}}/delete" style="display:inline"
onsubmit="return confirm('Удалить правило?')">
data-confirm="Удалить правило?" data-danger>
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<button class="btn danger small" type="submit">Удалить</button>
</form>
+5 -5
View File
@@ -54,7 +54,7 @@
<div class="copybox"><pre id="short">{{.InstallShort}}</pre><button type="button" class="btn ghost small icon sq copy" data-copy="short" title="Скопировать" aria-label="Скопировать"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/></svg></button></div>
<form method="post" action="/settings/install/code" class="codeform"
onsubmit="return confirm('Сделать новый код? Старая короткая ссылка перестанет работать.')">
data-confirm="Сделать новый код? Старая короткая ссылка перестанет работать." data-danger>
<input type="hidden" name="csrf" value="{{.CSRF}}">
<label for="codelen">Символов в коде</label>
<input type="number" id="codelen" name="len" min="3" max="24" value="{{.CodeLen}}">
@@ -142,7 +142,7 @@
<div class="row">
<code>{{.Secret}}</code>
<form method="post" action="/settings/secret/rotate" style="display:inline"
onsubmit="return confirm('Сменить секрет? Старые one-liner перестанут работать для новых установок.')">
data-confirm="Сменить секрет? Старые one-liner перестанут работать для новых установок." data-danger>
<input type="hidden" name="csrf" value="{{.CSRF}}">
<button class="btn ghost small" type="submit">Сменить</button>
</form>
@@ -174,14 +174,14 @@
<td style="text-align:right" class="nowrap">
{{if index $.KeyCounts .ID}}
<form method="post" action="/settings/admins/keys/reset" style="display:inline"
onsubmit="return confirm('Сбросить все ключи доступа {{.Username}}? Вход по паролю снова включится.')">
data-confirm="Сбросить все ключи доступа {{.Username}}? Вход по паролю снова включится." data-danger>
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="id" value="{{.ID}}">
<button class="btn ghost small" type="submit" title="Удалить все ключи доступа и снова включить вход по паролю">Сбросить ключи</button>
</form>
{{end}}
<form method="post" action="/settings/admins/delete" style="display:inline"
onsubmit="return confirm('Удалить {{.Username}}?')">
data-confirm="Удалить {{.Username}}?" data-danger>
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="id" value="{{.ID}}">
<button class="btn danger small" type="submit">Удалить</button>
@@ -251,7 +251,7 @@
<td class="muted">{{if .Manual}}вручную{{else if .RuleID}}проброс{{else}}туннель{{end}}</td>
<td style="text-align:right">
<form method="post" action="/settings/ufw/revoke" style="display:inline"
onsubmit="return confirm('Отменить правило ufw {{.Port}}/{{.Proto}}?')">
data-confirm="Отменить правило ufw {{.Port}}/{{.Proto}}?" data-danger>
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="id" value="{{.ID}}">
<button class="btn danger small" type="submit">Отменить</button>