From 5216aed237c576555059f99673b029abe2387484 Mon Sep 17 00:00:00 2001 From: iRedTea Date: Fri, 16 Oct 2026 10:17:07 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=90=D0=BA=D0=BA=D0=B0=D1=83=D0=BD?= =?UTF-8?q?=D1=82=20=E2=80=BA=20=D0=92=D0=B8=D0=B4=20-=20tab=20icons=20lef?= =?UTF-8?q?t,=20right=20or=20hidden,=20terminal=20font,=20compact=20file?= =?UTF-8?q?=20lists,=20the=20time=20column,=20animations;=20kept=20per=20u?= =?UTF-8?q?ser=20on=20the=20server.=20Uploads=20show=20a=20ring=20filling?= =?UTF-8?q?=20as=20they=20go?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/server/frag.go | 3 ++ internal/server/frag_test.go | 26 +++++++++++ internal/server/prefs.go | 62 ++++++++++++++++++++++++++ internal/server/server.go | 2 + internal/server/web/static/account.js | 16 +++++++ internal/server/web/static/app.css | 22 +++++++++ internal/server/web/static/filepane.js | 7 ++- internal/server/web/static/prefs.js | 27 +++++++++++ internal/server/web/static/termpane.js | 2 +- internal/server/web/templates/app.html | 3 +- 10 files changed, 167 insertions(+), 3 deletions(-) create mode 100644 internal/server/prefs.go create mode 100644 internal/server/web/static/prefs.js diff --git a/internal/server/frag.go b/internal/server/frag.go index 27658f5..ea797dc 100644 --- a/internal/server/frag.go +++ b/internal/server/frag.go @@ -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) { data["IsAdmin"] = s.currentAdmin(r).IsAdmin() data["EditLimit"] = proto.FSReadCap + if a := s.currentAdmin(r); a != nil { + data["Prefs"] = s.prefs(a.ID) + } if data["View"] == "dashboard" && data["Body"] == nil { // The machines list under a dialog: draw it too. var body bytes.Buffer diff --git a/internal/server/frag_test.go b/internal/server/frag_test.go index 8791e9d..1ed138a 100644 --- a/internal/server/frag_test.go +++ b/internal/server/frag_test.go @@ -115,3 +115,29 @@ func TestSettingsShowAI(t *testing.T) { 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":"","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") + } +} diff --git a/internal/server/prefs.go b/internal/server/prefs.go new file mode 100644 index 0000000..c67a01f --- /dev/null +++ b/internal/server/prefs.go @@ -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}) +} diff --git a/internal/server/server.go b/internal/server/server.go index 3f68297..a1b9377 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -251,6 +251,8 @@ func (s *Server) Handler() http.Handler { mux.Handle("POST /api/panel/users/{id}", s.adminsOnly(s.handleUserEdit)) 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/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/ssh/hosts", s.requireAdmin(s.handleSSHHosts)) mux.Handle("POST /api/panel/ssh/hosts", s.requireAdmin(s.handleSSHHostAdd)) diff --git a/internal/server/web/static/account.js b/internal/server/web/static/account.js index 5991672..ee07cbd 100644 --- a/internal/server/web/static/account.js +++ b/internal/server/web/static/account.js @@ -56,6 +56,13 @@ ' ' + ' ' + '

Спрашивается в каждой новой вкладке; при перезагрузке той же вкладки - нет.

' + + '
Вид
' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + '
' + '
' + '

Ключи доступа

' + '

Вход отпечатком, лицом, PIN-кодом устройства или аппаратным ключом. Ключей может быть несколько - например, телефон и ноутбук.

' + @@ -69,6 +76,15 @@ dlg.querySelector('.acct-out [name=csrf]').value = decodeURIComponent(csrf); var $ = function (sel) { return dlg.querySelector(sel); }; $('[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); function msg(text, bad) { diff --git a/internal/server/web/static/app.css b/internal/server/web/static/app.css index 1a0bc4f..1e7f9e9 100644 --- a/internal/server/web/static/app.css +++ b/internal/server/web/static/app.css @@ -1003,3 +1003,25 @@ details[open]::details-content { block-size: auto; opacity: 1; } .ai-act:hover:not(:disabled) { filter:brightness(1.1); } .ai-act:disabled { background:#1f2733; color:var(--muted); cursor:default; } .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; } diff --git a/internal/server/web/static/filepane.js b/internal/server/web/static/filepane.js index 815fbde..b53fbf0 100644 --- a/internal/server/web/static/filepane.js +++ b/internal/server/web/static/filepane.js @@ -104,7 +104,9 @@ ' ' + '
' + '
' + - ' ' + + ' ' + ' ' + '
' + '
' + @@ -378,6 +380,9 @@ busy.classList.toggle('up', ups.length > 0); busy.title = ups.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); FV.uploadsChanged(this); } diff --git a/internal/server/web/static/prefs.js b/internal/server/web/static/prefs.js new file mode 100644 index 0000000..4b791ea --- /dev/null +++ b/internal/server/web/static/prefs.js @@ -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}; +})(); diff --git a/internal/server/web/static/termpane.js b/internal/server/web/static/termpane.js index c9038d7..48623e4 100644 --- a/internal/server/web/static/termpane.js +++ b/internal/server/web/static/termpane.js @@ -283,7 +283,7 @@ window.TermKeys = (function () { this.el.querySelector('.pane-title').title = ssh ? 'SSH через ' + agent.name : agent.name; if (ssh) this.el.querySelector('.dot').insertAdjacentHTML('afterend', 'SSH'); 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' } }); this.fitter = new FitAddon.FitAddon(); diff --git a/internal/server/web/templates/app.html b/internal/server/web/templates/app.html index a2998cf..5b6b038 100644 --- a/internal/server/web/templates/app.html +++ b/internal/server/web/templates/app.html @@ -193,9 +193,10 @@
- + +