feat: copy buttons for the install commands, icons on the file bar buttons; the path title back on the left, the upload figures sliding, dialogs focused without a ring

This commit is contained in:
redtea committed 2026-10-14 20:12:03 +03:00
1 parent 55c37a0097
commit 89d5bfd3ab
2 files changed
+66 -18

No files matched your search

+29 -15
View File
@@ -13,6 +13,8 @@
display:inline-flex; align-items:center; justify-content:center;
vertical-align:middle; height:34px; line-height:1; margin:0; box-sizing:border-box;
}
/* A button with an icon before its text. */
.fm-bar .btn.ico { gap:6px; }
input.fm-path { font-family:ui-monospace,Menlo,monospace; font-size:13px; color:var(--muted);
background:#0c0f13; border:1px solid var(--border); border-radius:8px; padding:8px 12px;
flex:1; min-width:200px; height:34px; box-sizing:border-box; }
@@ -27,14 +29,16 @@
@keyframes spin { to { transform:rotate(360deg); } }
/* Busy indicator; while files upload it also shows how far they got, and a
click on it lists them. It takes room only while it shows. */
#uppct:empty { display:none; }
#uppct { display:inline-block; max-width:0; margin-left:0; overflow:hidden; white-space:nowrap;
transition:max-width .3s ease, margin-left .3s ease; }
#uppct.on { max-width:110px; margin-left:8px; }
.busy { flex:none; display:none; align-items:center; gap:8px; background:none; border:0; padding:0 2px;
height:34px; color:var(--muted); font-size:13px; font-variant-numeric:tabular-nums; cursor:default; }
/* Folded to nothing when idle (the negative margin takes back the bar's
gap), it opens smoothly, and only for work that lasts a moment. */
.fm-bar .busy { display:inline-flex; box-sizing:border-box; max-width:0; padding:0; margin-left:-8px; opacity:0;
.fm-bar .busy { display:inline-flex; gap:0; box-sizing:border-box; max-width:0; padding:0; margin-left:-8px; opacity:0;
overflow:hidden; white-space:nowrap; transition:max-width .3s ease, margin-left .3s ease, opacity .25s ease; }
.fm-bar .busy.on { max-width:120px; margin-left:0; opacity:1; transition-delay:.15s; }
.fm-bar .busy.on { max-width:140px; margin-left:0; opacity:1; transition-delay:.15s; }
.busy:hover { background:none; }
.busy.up { cursor:pointer; color:var(--text); }
.busy.up:hover { color:var(--accent); }
@@ -75,6 +79,9 @@
dialog { background:var(--panel); color:var(--text); border:1px solid var(--border);
border-radius:12px; padding:0; max-width:900px; width:92vw; }
dialog::backdrop { background:rgba(0,0,0,.6); }
/* A dialog takes the focus itself when it opens, rather than its first
button, which would light up as if tabbed to. */
dialog:focus { outline:none; }
.dlg-h { padding:14px 18px; border-bottom:1px solid var(--border); display:flex; align-items:center; gap:10px; }
.dlg-b { padding:16px 18px; }
#save { display:inline-flex; align-items:center; gap:8px; }
@@ -107,7 +114,9 @@
/* File viewer: the code editor, a picture / video / sound, or the hex editor. */
#viewer { max-width:1100px; }
#viewer .dlg-h { flex-wrap:wrap; }
.vpath { flex:1; min-width:0; overflow:hidden; white-space:nowrap; text-overflow:ellipsis;
/* Right to left so a long path is cut at its start, keeping the name in
sight; still set to the left like any title. */
.vpath { flex:1; min-width:0; overflow:hidden; white-space:nowrap; text-overflow:ellipsis; text-align:left;
font-family:ui-monospace,Menlo,monospace; font-size:13px; font-weight:600; }
.vmsg { width:100%; order:9; font-size:12px; }
.vbody { padding:0; }
@@ -225,7 +234,7 @@
</header>
<div class="wrap" style="max-width:1100px">
<div class="fm-bar">
<button class="btn ghost small" id="up">↑ Вверх</button>
<button class="btn ghost small ico" id="up"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 19V5M5 12l7-7 7 7"/></svg>Вверх</button>
<div class="fm-pathbox">
<input class="fm-path" id="path" value="/" spellcheck="false" autocapitalize="off" autocomplete="off">
<button class="btn ghost small icon sq" id="dirprops" title="Свойства папки" aria-label="Свойства папки"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 4h-7M10 4H3M21 12h-9M8 12H3M21 20h-5M12 20H3M14 2v4M8 10v4M16 18v4"/></svg></button>
@@ -234,9 +243,9 @@
<button class="btn ghost small" id="refresh">Обновить</button>
</div>
<div class="fm-bar">
<button class="btn small" id="mkdir">+ Папка</button>
<button class="btn small" id="newfile">+ Файл</button>
<label class="btn small" style="cursor:pointer">+ Загрузить<input type="file" id="upl" multiple hidden></label>
<button class="btn small ico" id="mkdir"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/><path d="M12 11v6M9 14h6"/></svg>Папка</button>
<button class="btn small ico" id="newfile"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><path d="M14 2v6h6M12 12v6M9 15h6"/></svg>Файл</button>
<label class="btn small ico" style="cursor:pointer"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v12M6 10l6 6 6-6M5 21h14"/></svg>Загрузить<input type="file" id="upl" multiple hidden></label>
<span class="spacer" style="flex:1"></span>
<span id="selinfo" class="muted" style="font-size:13px"></span>
<button class="btn ghost small" id="zipsel" disabled>В zip</button>
@@ -256,7 +265,7 @@
<p class="muted" id="hint" style="font-size:13px">Перетащите файлы сюда для загрузки. Клик по файлу - просмотр и правка.</p>
</div>
<dialog id="viewer">
<dialog id="viewer" tabindex="-1">
<div class="dlg-h">
<strong class="vpath" dir="rtl"><bdi id="vname"></bdi></strong>
<button class="btn ghost small" id="vtext" hidden>Редактировать</button>
@@ -295,7 +304,7 @@
</div>
</dialog>
<dialog id="updlg">
<dialog id="updlg" tabindex="-1">
<div class="dlg-h">
<strong style="flex:1">Загрузка файлов</strong>
<button class="btn ghost small icon close x" id="updlg-close" title="Закрыть" aria-label="Закрыть"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
@@ -303,7 +312,7 @@
<div class="dlg-b" id="updlg-list"></div>
</dialog>
<dialog id="propdlg">
<dialog id="propdlg" tabindex="-1">
<div class="dlg-h">
<strong style="flex:1">Свойства</strong>
<button class="btn ghost small icon close x" id="propdlg-close" title="Закрыть" aria-label="Закрыть"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
@@ -369,6 +378,8 @@
// An open dialog sits in the page's top layer, above any z-index: the toast
// goes inside it then, so "сохранено" or an error shows over the editor.
let toastTimer = null;
// Opens a dialog with the focus on the dialog itself, not its first button.
const openModal = d => { d.showModal(); d.focus(); };
const toast = (msg, err) => {
const t = $('toast'), host = [...document.querySelectorAll('dialog[open]')].pop() || document.body;
if (t.parentNode !== host) host.appendChild(t);
@@ -607,7 +618,7 @@
V.path = path; V.dirty = false;
$('vname').textContent = path;
$('vname').parentElement.title = path;
if (!$('viewer').open) $('viewer').showModal();
if (!$('viewer').open) openModal($('viewer'));
}
async function view(e) {
@@ -1036,7 +1047,7 @@
showMode(d.mode);
$('pp-rec').hidden = !d.is_dir;
$('pp-recursive').checked = false; $('pp-only').value = ''; $('pp-only').disabled = true;
if (!$('propdlg').open) $('propdlg').showModal();
if (!$('propdlg').open) openModal($('propdlg'));
}
$('pp-apply').onclick = async () => {
const o = P.orig, mode = readMode();
@@ -1137,7 +1148,10 @@
const busy = $('busy');
busy.classList.toggle('up', uploads.length > 0);
busy.title = uploads.length ? 'Показать загружаемые файлы' : '';
$('uppct').textContent = pend.length ? Math.floor(100 * sent / total) + '%' + (counted.length > 1 ? ' · ' + (counted.length - pend.length) + '/' + counted.length : '') : '';
// The figures slide in while files go and slide out after, keeping the
// last text as they go.
if (pend.length) $('uppct').textContent = Math.floor(100 * sent / total) + '%' + (counted.length > 1 ? ' · ' + (counted.length - pend.length) + '/' + counted.length : '');
$('uppct').classList.toggle('on', pend.length > 0);
const list = $('updlg-list');
if (!$('updlg').open) return;
list.innerHTML = uploads.length ? '' : '<p class="muted">Сейчас ничего не загружается.</p>';
@@ -1156,7 +1170,7 @@
list.appendChild(el);
}
}
$('busy').onclick = () => { if (!uploads.length) return; $('updlg').showModal(); renderUploads(); };
$('busy').onclick = () => { if (!uploads.length) return; openModal($('updlg')); renderUploads(); };
$('updlg-close').onclick = () => $('updlg').close();
// The pieces go on to the client as they come, so the bar follows the
+37 -3
View File
@@ -5,6 +5,14 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Настройки - LPRA</title>
<link rel="stylesheet" href="{{asset "app.css"}}">
<style>
/* An install command, with a button to copy it at its right. */
.copybox { position:relative; }
.copybox pre { padding-right:48px; }
.copybox .copy { position:absolute; top:7px; right:7px; width:30px; height:30px; padding:0;
display:inline-flex; align-items:center; justify-content:center; background:var(--panel); }
.copybox .copy.done { color:var(--ok); border-color:var(--ok); }
</style>
</head>
<body>
<header class="topbar">
@@ -27,14 +35,14 @@
поставит сервис и зарегистрирует машину.</p>
<label>Короткая команда (секрет уже вшит в ссылку)</label>
<pre id="short">{{.InstallShort}}</pre>
<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>
<details style="margin-top:10px">
<summary class="muted" style="cursor:pointer">Полные варианты (секрет в команде)</summary>
<label>curl</label>
<pre id="curl">{{.InstallCurl}}</pre>
<div class="copybox"><pre id="curl">{{.InstallCurl}}</pre><button type="button" class="btn ghost small icon sq copy" data-copy="curl" 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>
<label>wget</label>
<pre id="wget">{{.InstallWget}}</pre>
<div class="copybox"><pre id="wget">{{.InstallWget}}</pre><button type="button" class="btn ghost small icon sq copy" data-copy="wget" 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>
<p class="muted">Без переменных окружения скрипт спросит секрет интерактивно:
<code>curl -fsSL {{.Host}}/install.sh | sudo sh</code></p>
</details>
@@ -176,5 +184,31 @@
<p class="muted" style="font-size:12px; text-align:center">LPRA сервер · версия {{.ServerVersion}}</p>
</div>
<script>
// Copy an install command: the clipboard API on https, the old way otherwise.
(function () {
var CHECK = '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5"/></svg>';
function copy(text) {
if (navigator.clipboard && window.isSecureContext) return navigator.clipboard.writeText(text);
var ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0';
document.body.appendChild(ta);
ta.select();
var ok = document.execCommand('copy');
ta.remove();
return ok ? Promise.resolve() : Promise.reject(new Error('нет доступа к буферу'));
}
document.querySelectorAll('.copybox .copy').forEach(function (b) {
var icon = b.innerHTML;
b.onclick = function () {
copy(document.getElementById(b.dataset.copy).textContent.trim()).then(function () {
b.innerHTML = CHECK; b.classList.add('done'); b.title = 'Скопировано';
setTimeout(function () { b.innerHTML = icon; b.classList.remove('done'); b.title = 'Скопировать'; }, 1500);
}, function (e) { alert('Не скопировалось: ' + e.message); });
};
});
})();
</script>
</body>
</html>