feat: Аккаунт › Вид - tab icons left, right or hidden, terminal font, compact file lists, the time column, animations; kept per user on the server. Uploads show a ring filling as they go

This commit is contained in:
redtea committed 2026-10-16 10:17:07 +03:00
1 parent c003b51b4c
commit 5216aed237
10 files changed
+167 -3

No files matched your search

+3
View File
@@ -52,6 +52,9 @@ func (s *Server) renderPage(w http.ResponseWriter, r *http.Request, name string,
func (s *Server) shell(w http.ResponseWriter, r *http.Request, data map[string]any) { func (s *Server) shell(w http.ResponseWriter, r *http.Request, data map[string]any) {
data["IsAdmin"] = s.currentAdmin(r).IsAdmin() data["IsAdmin"] = s.currentAdmin(r).IsAdmin()
data["EditLimit"] = proto.FSReadCap data["EditLimit"] = proto.FSReadCap
if a := s.currentAdmin(r); a != nil {
data["Prefs"] = s.prefs(a.ID)
}
if data["View"] == "dashboard" && data["Body"] == nil { if data["View"] == "dashboard" && data["Body"] == nil {
// The machines list under a dialog: draw it too. // The machines list under a dialog: draw it too.
var body bytes.Buffer var body bytes.Buffer
+26
View File
@@ -115,3 +115,29 @@ func TestSettingsShowAI(t *testing.T) {
t.Fatal("the AI card is empty") t.Fatal("the AI card is empty")
} }
} }
// The look of the panel is each user's: kept on the server, only known
// settings with known values, and given to the page at once.
func TestViewPrefs(t *testing.T) {
s, c := newTestServer(t)
post := func(body string) string {
req := httptest.NewRequest(http.MethodPost, "/api/panel/account/prefs", strings.NewReader(body))
req.AddCookie(c)
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, req)
return w.Body.String()
}
if got := post(`{"tabIcons":"right","termFont":16,"compact":true,"motion":"off","evil":"<x>","fileTime":"maybe"}`); !strings.Contains(got, `"ok":true`) {
t.Fatal(got)
}
got := get(t, s, c, "/api/panel/account/prefs").Body.String()
if !strings.Contains(got, `"tabIcons":"right"`) || !strings.Contains(got, `"termFont":16`) || strings.Contains(got, "evil") || strings.Contains(got, "maybe") {
t.Fatalf("prefs: %s", got)
}
if body := get(t, s, c, "/workspace").Body.String(); !strings.Contains(body, `"tabIcons":"right"`) {
t.Fatal("the page does not get the prefs")
}
if got := post(`{"termFont":99}`); !strings.Contains(got, `"ok":true`) || strings.Contains(get(t, s, c, "/api/panel/account/prefs").Body.String(), "99") {
t.Fatal("a font size out of range was kept")
}
}
+62
View File
@@ -0,0 +1,62 @@
package server
import (
"encoding/json"
"net/http"
"strconv"
)
// Each user's look of the panel (Аккаунт › Вид), kept on the server so it
// is the same on every device: where a tab's icons go, the terminal's font
// size, a compact file list, the time column, animations.
type viewPrefs struct {
TabIcons string `json:"tabIcons,omitempty"` // left (default), right, hide
TermFont int `json:"termFont,omitempty"` // 11-20; 0: the default
Compact bool `json:"compact,omitempty"` // denser file lists
FileTime string `json:"fileTime,omitempty"` // show (default), hide
Motion string `json:"motion,omitempty"` // on (default), off
}
func prefsKey(id int64) string { return "prefs_" + strconv.FormatInt(id, 10) }
// clean keeps known values only.
func (p viewPrefs) clean() viewPrefs {
if p.TabIcons != "right" && p.TabIcons != "hide" {
p.TabIcons = ""
}
if p.TermFont < 11 || p.TermFont > 20 {
p.TermFont = 0
}
if p.FileTime != "hide" {
p.FileTime = ""
}
if p.Motion != "off" {
p.Motion = ""
}
return p
}
func (s *Server) prefs(id int64) viewPrefs {
var p viewPrefs
if v, _ := s.db.GetConfig(prefsKey(id)); v != "" {
_ = json.Unmarshal([]byte(v), &p)
}
return p.clean()
}
// GET /api/panel/account/prefs
func (s *Server) handlePrefs(w http.ResponseWriter, r *http.Request) {
writeJSON(w, s.prefs(s.currentAdmin(r).ID))
}
// POST /api/panel/account/prefs - the whole set.
func (s *Server) handlePrefsSet(w http.ResponseWriter, r *http.Request) {
var p viewPrefs
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4<<10)).Decode(&p); err != nil {
writeJSON(w, map[string]any{"ok": false, "error": "плохой запрос"})
return
}
b, _ := json.Marshal(p.clean())
_ = s.db.SetConfig(prefsKey(s.currentAdmin(r).ID), string(b))
writeJSON(w, map[string]any{"ok": true})
}
+2
View File
@@ -251,6 +251,8 @@ func (s *Server) Handler() http.Handler {
mux.Handle("POST /api/panel/users/{id}", s.adminsOnly(s.handleUserEdit)) mux.Handle("POST /api/panel/users/{id}", s.adminsOnly(s.handleUserEdit))
mux.Handle("POST /settings/admins/keys/reset", s.adminsOnly(s.handleResetAdminKeys)) mux.Handle("POST /settings/admins/keys/reset", s.adminsOnly(s.handleResetAdminKeys))
mux.Handle("GET /api/panel/account", s.requireAdmin(s.handleAccount)) mux.Handle("GET /api/panel/account", s.requireAdmin(s.handleAccount))
mux.Handle("GET /api/panel/account/prefs", s.requireAdmin(s.handlePrefs))
mux.Handle("POST /api/panel/account/prefs", s.requireAdmin(s.handlePrefsSet))
mux.Handle("GET /api/panel/ai", s.requireAdmin(s.handleAIState)) mux.Handle("GET /api/panel/ai", s.requireAdmin(s.handleAIState))
mux.Handle("GET /api/panel/ssh/hosts", s.requireAdmin(s.handleSSHHosts)) mux.Handle("GET /api/panel/ssh/hosts", s.requireAdmin(s.handleSSHHosts))
mux.Handle("POST /api/panel/ssh/hosts", s.requireAdmin(s.handleSSHHostAdd)) mux.Handle("POST /api/panel/ssh/hosts", s.requireAdmin(s.handleSSHHostAdd))
+16
View File
@@ -56,6 +56,13 @@
' <option value="0">каждый раз</option><option value="5">5 минут</option><option value="15">15 минут</option>' + ' <option value="0">каждый раз</option><option value="5">5 минут</option><option value="15">15 минут</option>' +
' <option value="30">30 минут</option><option value="60">60 минут</option></select></label>' + ' <option value="30">30 минут</option><option value="60">60 минут</option></select></label>' +
' <p class="acct-pinmsg" aria-live="polite"></p><p class="muted" style="font-size:12px;margin:8px 0 0">Спрашивается в каждой новой вкладке; при перезагрузке той же вкладки - нет.</p></details>' + ' <p class="acct-pinmsg" aria-live="polite"></p><p class="muted" style="font-size:12px;margin:8px 0 0">Спрашивается в каждой новой вкладке; при перезагрузке той же вкладки - нет.</p></details>' +
' <details class="acct-view"><summary>Вид</summary><div class="acct-viewin">' +
' <label><span>Значки вкладок</span><select data-pref="tabIcons"><option value="">слева от названия</option><option value="right">справа от названия</option><option value="hide">не показывать</option></select></label>' +
' <label><span>Шрифт терминала</span><select data-pref="termFont" data-num><option value="0">обычный</option><option value="12">12</option><option value="13">13</option><option value="14">14</option><option value="15">15</option><option value="16">16</option><option value="18">18</option><option value="20">20</option></select></label>' +
' <label><span>Списки файлов</span><select data-pref="compact" data-bool><option value="">обычные</option><option value="1">компактные</option></select></label>' +
' <label><span>Время изменения файлов</span><select data-pref="fileTime"><option value="">показывать</option><option value="hide">скрыть</option></select></label>' +
' <label><span>Анимации</span><select data-pref="motion"><option value="">включены</option><option value="off">выключены</option></select></label>' +
' </div></details>' +
' <div class="acct-keysec">' + ' <div class="acct-keysec">' +
' <h3>Ключи доступа</h3>' + ' <h3>Ключи доступа</h3>' +
' <p class="muted acct-hint">Вход отпечатком, лицом, PIN-кодом устройства или аппаратным ключом. Ключей может быть несколько - например, телефон и ноутбук.</p>' + ' <p class="muted acct-hint">Вход отпечатком, лицом, PIN-кодом устройства или аппаратным ключом. Ключей может быть несколько - например, телефон и ноутбук.</p>' +
@@ -69,6 +76,15 @@
dlg.querySelector('.acct-out [name=csrf]').value = decodeURIComponent(csrf); dlg.querySelector('.acct-out [name=csrf]').value = decodeURIComponent(csrf);
var $ = function (sel) { return dlg.querySelector(sel); }; var $ = function (sel) { return dlg.querySelector(sel); };
$('[data-close]').onclick = function () { dlg.close(); }; $('[data-close]').onclick = function () { dlg.close(); };
// Вид: each change applies at once and is kept on the server.
dlg.querySelectorAll('[data-pref]').forEach(function (sel) {
var k = sel.dataset.pref, cur = window.Prefs ? Prefs.get()[k] : '';
sel.value = sel.hasAttribute('data-bool') ? (cur ? '1' : '') : String(cur || (sel.hasAttribute('data-num') ? 0 : ''));
sel.onchange = function () {
var v = sel.hasAttribute('data-bool') ? sel.value === '1' : sel.hasAttribute('data-num') ? Number(sel.value) : sel.value;
if (window.Prefs) Prefs.set(k, v).then(function (ok) { if (!ok) msg('не сохранилось', true); });
};
});
var supported = !!(window.PublicKeyCredential && navigator.credentials && window.isSecureContext); var supported = !!(window.PublicKeyCredential && navigator.credentials && window.isSecureContext);
function msg(text, bad) { function msg(text, bad) {
+22
View File
@@ -1003,3 +1003,25 @@ details[open]::details-content { block-size: auto; opacity: 1; }
.ai-act:hover:not(:disabled) { filter:brightness(1.1); } .ai-act:hover:not(:disabled) { filter:brightness(1.1); }
.ai-act:disabled { background:#1f2733; color:var(--muted); cursor:default; } .ai-act:disabled { background:#1f2733; color:var(--muted); cursor:default; }
.ai-code.ai-action pre { margin:0; border:0; border-radius:0; background:transparent; } .ai-code.ai-action pre { margin:0; border:0; border-radius:0; background:transparent; }
/* Аккаунт › Вид. */
.acct-view { margin-top:12px; padding-top:12px; border-top:1px solid var(--border); }
.acct-view summary { font-size:14px; color:var(--text); }
.acct-viewin { display:grid; gap:8px; margin-top:10px; }
.acct-viewin label { display:flex; align-items:center; justify-content:space-between; gap:10px; font-size:13px; color:var(--muted); }
.acct-viewin select { width:auto; flex:none; min-width:170px; }
/* The look chosen. */
body[data-tabicons="right"] .tab-kinds { order:2; }
body[data-tabicons="right"] .tab-title { order:1; }
body[data-tabicons="right"] .tab-x { order:3; }
body[data-tabicons="hide"] .tab-kinds { display:none; }
body.compact table.fm td { padding-top:3px; padding-bottom:3px; }
body.compact table.fm td .btn.icon { width:28px; height:28px; }
body.no-ftime table.fm .c-mt, body.no-ftime table.fm td.mt { display:none; }
body.no-motion *, body.no-motion *::before, body.no-motion *::after, body.no-motion::backdrop { transition:none !important; animation:none !important; }
/* Uploading: a small ring that fills as the files go up. */
.fp-ring { display:none; transform:rotate(-90deg); flex:none; }
.busy.uploading .spinner { display:none; }
.busy.uploading .fp-ring { display:block; }
.fp-ring circle { fill:none; stroke-width:2.6; }
.fp-ring .bg { stroke:var(--border); }
.fp-ring .fg { stroke:var(--accent); stroke-linecap:round; stroke-dasharray:100; stroke-dashoffset:calc(100 - var(--up, 0)); transition:stroke-dashoffset .25s ease; }
+6 -1
View File
@@ -104,7 +104,9 @@
' <button class="btn ghost small icon sq fp-up" type="button" title="Вверх" aria-label="Вверх"><svg width="16" height="16" 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>' + ' <button class="btn ghost small icon sq fp-up" type="button" title="Вверх" aria-label="Вверх"><svg width="16" height="16" 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 fp-path" value="/" spellcheck="false" autocapitalize="off" autocomplete="off" aria-label="Папка">' + ' <div class="fm-pathbox"><input class="fm-path fp-path" value="/" spellcheck="false" autocapitalize="off" autocomplete="off" aria-label="Папка">' +
' <button type="button" class="btn ghost small icon sq fp-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></div>' + ' <button type="button" class="btn ghost small icon sq fp-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></div>' +
' <button type="button" class="busy fp-busy" title=""><span class="spinner"></span><span class="fp-uppct"></span></button>' + ' <button type="button" class="busy fp-busy" title=""><span class="spinner"></span>' +
'<svg class="fp-ring" width="18" height="18" viewBox="0 0 20 20" aria-hidden="true"><circle class="bg" cx="10" cy="10" r="8"/><circle class="fg" cx="10" cy="10" r="8" pathLength="100"/></svg>' +
'<span class="fp-uppct"></span></button>' +
' <button class="btn ghost small icon sq fp-refresh" type="button" title="Обновить" aria-label="Обновить"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-3-6.7L21 8"/><path d="M21 3v5h-5"/></svg></button>' + ' <button class="btn ghost small icon sq fp-refresh" type="button" title="Обновить" aria-label="Обновить"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-3-6.7L21 8"/><path d="M21 3v5h-5"/></svg></button>' +
'</div>' + '</div>' +
'<div class="fm-bar">' + '<div class="fm-bar">' +
@@ -378,6 +380,9 @@
busy.classList.toggle('up', ups.length > 0); busy.classList.toggle('up', ups.length > 0);
busy.title = ups.length ? 'Показать загружаемые файлы' : ''; busy.title = ups.length ? 'Показать загружаемые файлы' : '';
if (pend.length) pct.textContent = Math.floor(100 * sent / total) + '%' + (counted.length > 1 ? ' · ' + (counted.length - pend.length) + '/' + counted.length : ''); if (pend.length) pct.textContent = Math.floor(100 * sent / total) + '%' + (counted.length > 1 ? ' · ' + (counted.length - pend.length) + '/' + counted.length : '');
// While files go up, the round mark fills as they do.
busy.classList.toggle('uploading', pend.length > 0);
busy.style.setProperty('--up', String(pend.length ? 100 * sent / total : 0));
pct.classList.toggle('on', pend.length > 0); pct.classList.toggle('on', pend.length > 0);
FV.uploadsChanged(this); FV.uploadsChanged(this);
} }
+27
View File
@@ -0,0 +1,27 @@
// The look of the panel, as each user set it (Аккаунт › Вид): applied at
// once on load from what the page came with, and again on every change.
(function () {
'use strict';
let p = Object.assign({}, (window.APP && window.APP.prefs) || {});
function apply() {
const b = document.body;
b.dataset.tabicons = p.tabIcons || 'left';
b.classList.toggle('compact', !!p.compact);
b.classList.toggle('no-ftime', p.fileTime === 'hide');
b.classList.toggle('no-motion', p.motion === 'off');
// Open terminals take the new size at once.
if (window.Workspace) Workspace.panes().forEach(t => {
if (t.term && t.term.options.fontSize !== termFont()) { t.term.options.fontSize = termFont(); t.fit(); }
});
}
const termFont = () => p.termFont || (matchMedia('(pointer: coarse)').matches ? 13 : 14);
async function set(k, v) {
p[k] = v;
apply();
const r = await fetch('/api/panel/account/prefs', {method: 'POST', headers: {'Content-Type': 'application/json'},
credentials: 'same-origin', body: JSON.stringify(p)}).then(x => x.json()).catch(() => ({ok: false}));
return r.ok;
}
apply();
window.Prefs = {get: () => Object.assign({}, p), set, apply, termFont};
})();
+1 -1
View File
@@ -283,7 +283,7 @@ window.TermKeys = (function () {
this.el.querySelector('.pane-title').title = ssh ? 'SSH через ' + agent.name : agent.name; this.el.querySelector('.pane-title').title = ssh ? 'SSH через ' + agent.name : agent.name;
if (ssh) this.el.querySelector('.dot').insertAdjacentHTML('afterend', '<span class="badge-ssh">SSH</span>'); if (ssh) this.el.querySelector('.dot').insertAdjacentHTML('afterend', '<span class="badge-ssh">SSH</span>');
this.term = new Terminal({ this.term = new Terminal({
cursorBlink: true, fontSize: touch ? 13 : 14, cursorBlink: true, fontSize: window.Prefs ? Prefs.termFont() : (touch ? 13 : 14),
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', theme: { background: '#0c0f13' } fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', theme: { background: '#0c0f13' }
}); });
this.fitter = new FitAddon.FitAddon(); this.fitter = new FitAddon.FitAddon();
+2 -1
View File
@@ -193,9 +193,10 @@
<button type="button" class="btn ghost small icon close x md-close" aria-label="Закрыть"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button></div> <button type="button" class="btn ghost small icon close x md-close" aria-label="Закрыть"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button></div>
<div class="frag-area"></div></dialog> <div class="frag-area"></div></dialog>
<div class="toast" id="toast"></div> <div class="toast" id="toast"></div>
<script>window.APP = {admin: {{.IsAdmin}}, open: {{.Open}}, dialog: {{.Dialog}}, editLimit: {{.EditLimit}}};</script> <script>window.APP = {admin: {{.IsAdmin}}, open: {{.Open}}, dialog: {{.Dialog}}, editLimit: {{.EditLimit}}, prefs: {{.Prefs}}};</script>
<script src="{{asset "wsutil.js"}}"></script> <script src="{{asset "wsutil.js"}}"></script>
<script src="{{asset "ui.js"}}"></script> <script src="{{asset "ui.js"}}"></script>
<script src="{{asset "prefs.js"}}"></script>
<script src="{{asset "xterm.js"}}"></script> <script src="{{asset "xterm.js"}}"></script>
<script src="{{asset "addon-fit.js"}}"></script> <script src="{{asset "addon-fit.js"}}"></script>
<script src="{{asset "app.js"}}"></script> <script src="{{asset "app.js"}}"></script>