From e8beb7d4f35885b5217a7d4ed857499bd141492d Mon Sep 17 00:00:00 2001 From: etalon Date: Sat, 13 Jun 2026 11:05:33 +0200 Subject: [PATCH] feat: sidebar nav (default), group member management, per-customer ticketing identity mapping - Navigation: new per-user setting (default "side") renders the page links as a left sidebar while keeping theme/bell/avatar/logout in the top-right; falls back to a top bar under 760px or when set to "top". Profile selector added. - Group conversations: record a creatorId; the creator gets a "Manage members" button to add/remove participants (new GET/POST/DELETE members endpoints, creator-only). Existing groups backfilled. - Customer ticketing: admin can map each assigned consultant to their username in that customer's ticketing system. Per-customer mapping takes precedence over the consultant's global ticketingIdentities during sync. Co-Authored-By: Claude Fable 5 --- internal/domain/customer.go | 11 ++-- internal/domain/user.go | 1 + internal/http/admin_customers.go | 45 +++++++++++----- internal/http/messages.go | 92 ++++++++++++++++++++++++++++++++ internal/http/profile.go | 9 ++++ internal/http/web.go | 7 +++ internal/store/conversations.go | 23 ++++++++ internal/sync/manager.go | 7 ++- web/static/css/app.css | 46 ++++++++++++++++ web/static/js/admin.js | 36 +++++++++++++ web/static/js/messages.js | 69 ++++++++++++++++++++++++ web/static/js/profile.js | 6 ++- web/templates/admin.html | 5 ++ web/templates/layout.html | 2 +- web/templates/messages.html | 20 ++++++- web/templates/profile.html | 7 +++ 16 files changed, 367 insertions(+), 19 deletions(-) diff --git a/internal/domain/customer.go b/internal/domain/customer.go index 9dbd88b..9843c62 100644 --- a/internal/domain/customer.go +++ b/internal/domain/customer.go @@ -28,9 +28,14 @@ type Ticketing struct { CredentialsEnc string `bson:"credentialsEnc" json:"-"` ProjectKey string `bson:"projectKey" json:"projectKey"` PollIntervalSec int `bson:"pollIntervalSec" json:"pollIntervalSec"` - LastSyncAt time.Time `bson:"lastSyncAt" json:"lastSyncAt"` - LastSyncStatus string `bson:"lastSyncStatus" json:"lastSyncStatus"` // "", ok, error - LastSyncError string `bson:"lastSyncError" json:"lastSyncError"` + // ConsultantIdentities maps a bounty-board consultant id to that + // consultant's username in THIS customer's ticketing system. It takes + // precedence over the consultant's global extra.ticketingIdentities so an + // admin can map accounts per project (§5.3). + ConsultantIdentities map[string]string `bson:"consultantIdentities,omitempty" json:"consultantIdentities,omitempty"` + LastSyncAt time.Time `bson:"lastSyncAt" json:"lastSyncAt"` + LastSyncStatus string `bson:"lastSyncStatus" json:"lastSyncStatus"` // "", ok, error + LastSyncError string `bson:"lastSyncError" json:"lastSyncError"` } // Customer == "project" in the UI (§4.2). diff --git a/internal/domain/user.go b/internal/domain/user.go index 0a01442..cc2a815 100644 --- a/internal/domain/user.go +++ b/internal/domain/user.go @@ -54,6 +54,7 @@ type NotificationPrefs struct { type UserSettings struct { Theme string `bson:"theme" json:"theme"` + NavLayout string `bson:"navLayout" json:"navLayout"` // "side" (default) | "top" Notifications NotificationPrefs `bson:"notifications" json:"notifications"` LeaderboardOptOut bool `bson:"leaderboardOptOut" json:"leaderboardOptOut"` } diff --git a/internal/http/admin_customers.go b/internal/http/admin_customers.go index 1b7d712..6dd3116 100644 --- a/internal/http/admin_customers.go +++ b/internal/http/admin_customers.go @@ -19,16 +19,17 @@ import ( // customerPayload is the create/update body. Credentials are write-only: // they are encrypted at rest and never returned. type customerPayload struct { - Name *string `json:"name"` - Type *domain.TicketingType `json:"type"` - BaseURL *string `json:"baseUrl"` - ProjectKey *string `json:"projectKey"` - Credentials *syncpkg.Credentials `json:"credentials"` - PollInterval *int `json:"pollIntervalSec"` - ConsultantIDs *[]string `json:"consultantIds"` - DefaultBudget *float64 `json:"defaultBudget"` - Archived *bool `json:"archived"` - Version *int64 `json:"version"` + Name *string `json:"name"` + Type *domain.TicketingType `json:"type"` + BaseURL *string `json:"baseUrl"` + ProjectKey *string `json:"projectKey"` + Credentials *syncpkg.Credentials `json:"credentials"` + PollInterval *int `json:"pollIntervalSec"` + ConsultantIDs *[]string `json:"consultantIds"` + ConsultantIdentities *map[string]string `json:"consultantIdentities"` + DefaultBudget *float64 `json:"defaultBudget"` + Archived *bool `json:"archived"` + Version *int64 `json:"version"` } func (s *Server) sealCredentials(creds syncpkg.Credentials) (string, error) { @@ -63,8 +64,9 @@ func customerJSON(c *domain.Customer) map[string]any { "type": c.Ticketing.Type, "baseUrl": c.Ticketing.BaseURL, "projectKey": c.Ticketing.ProjectKey, "pollIntervalSec": c.Ticketing.PollIntervalSec, "lastSyncAt": c.Ticketing.LastSyncAt, "lastSyncStatus": c.Ticketing.LastSyncStatus, - "lastSyncError": c.Ticketing.LastSyncError, - "hasCredentials": c.Ticketing.CredentialsEnc != "", + "lastSyncError": c.Ticketing.LastSyncError, + "hasCredentials": c.Ticketing.CredentialsEnc != "", + "consultantIdentities": c.Ticketing.ConsultantIdentities, }, "consultantIds": c.ConsultantIDs, "defaultBudget": c.DefaultBudget, "archived": c.Archived, "createdAt": c.CreatedAt, "updatedAt": c.UpdatedAt, @@ -99,6 +101,18 @@ func (s *Server) handleAdminGetCustomer(w http.ResponseWriter, r *http.Request) writeJSON(w, http.StatusOK, map[string]any{"customer": customerJSON(c)}) } +// cleanIdentities trims values and drops empty mappings so we don't persist +// blank usernames for consultants the admin left unmapped. +func cleanIdentities(in map[string]string) map[string]string { + out := map[string]string{} + for k, v := range in { + if t := strings.TrimSpace(v); t != "" { + out[k] = t + } + } + return out +} + func (s *Server) validateConsultants(ctx context.Context, ids []string) (string, bool) { for _, id := range ids { u, err := s.store.UserByID(ctx, id) @@ -156,6 +170,9 @@ func (s *Server) handleAdminCreateCustomer(w http.ResponseWriter, r *http.Reques } c.ConsultantIDs = *req.ConsultantIDs } + if req.ConsultantIdentities != nil { + c.Ticketing.ConsultantIdentities = cleanIdentities(*req.ConsultantIdentities) + } creds := syncpkg.Credentials{} if req.Credentials != nil { creds = *req.Credentials @@ -236,6 +253,10 @@ func (s *Server) handleAdminPatchCustomer(w http.ResponseWriter, r *http.Request set["consultantIds"] = *req.ConsultantIDs diff["consultantIds"] = *req.ConsultantIDs } + if req.ConsultantIdentities != nil { + set["ticketing.consultantIdentities"] = cleanIdentities(*req.ConsultantIdentities) + diff["consultantIdentities"] = "updated" + } if req.DefaultBudget != nil { set["defaultBudget"] = *req.DefaultBudget diff["defaultBudget"] = *req.DefaultBudget diff --git a/internal/http/messages.go b/internal/http/messages.go index cdca030..9b376ab 100644 --- a/internal/http/messages.go +++ b/internal/http/messages.go @@ -24,6 +24,9 @@ func (s *Server) routesMessages(mux *http.ServeMux) { mux.Handle("GET /api/v1/conversations/{id}/messages", s.requireAuth(http.HandlerFunc(s.handleListMessages))) mux.Handle("POST /api/v1/conversations/{id}/messages", s.authed(s.handleSendMessage)) mux.Handle("POST /api/v1/conversations/{id}/read", s.authed(s.handleMarkRead)) + mux.Handle("GET /api/v1/conversations/{id}/members", s.requireAuth(http.HandlerFunc(s.handleListMembers))) + mux.Handle("POST /api/v1/conversations/{id}/members", s.authed(s.handleAddMember)) + mux.Handle("DELETE /api/v1/conversations/{id}/members/{userId}", s.authed(s.handleRemoveMember)) mux.Handle("POST /api/v1/files", s.authed(s.handleUploadFile)) mux.Handle("GET /api/v1/users", s.requireAuth(http.HandlerFunc(s.handleSearchUsers))) } @@ -81,6 +84,7 @@ func (s *Server) handleListConversations(w http.ResponseWriter, r *http.Request) out[i] = map[string]any{ "id": c.ID, "kind": c.Kind, "title": title, "customerId": c.CustomerID, "participantIds": c.ParticipantIDs, + "creatorId": c.CreatorID, "lastMessageAt": c.LastMessageAt, "unread": unread[c.ID], } } @@ -135,6 +139,7 @@ func (s *Server) handleCreateConversation(w http.ResponseWriter, r *http.Request conv := &store.Conversation{ Kind: req.Kind, Title: strings.TrimSpace(req.Title), + CreatorID: me.ID, ParticipantIDs: participants, } if req.Kind == "project" { @@ -256,6 +261,93 @@ func (s *Server) handleMarkRead(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"marked": n}) } +// groupOwner loads a group/project conversation and authorizes the creator. +func (s *Server) groupOwner(w http.ResponseWriter, r *http.Request, id string) (*store.Conversation, bool) { + conv, ok := s.conversation(w, r, id) + if !ok { + return nil, false + } + if conv.Kind == "dm" { + writeError(w, http.StatusBadRequest, "bad_request", "direct messages have no managed members") + return nil, false + } + if conv.CreatorID != CurrentUser(r.Context()).ID { + writeError(w, http.StatusForbidden, "forbidden", "only the group creator can manage members") + return nil, false + } + return conv, true +} + +// handleListMembers returns the participants; any member may view. +func (s *Server) handleListMembers(w http.ResponseWriter, r *http.Request) { + conv, ok := s.conversation(w, r, r.PathValue("id")) + if !ok { + return + } + me := CurrentUser(r.Context()) + out := make([]map[string]any, 0, len(conv.ParticipantIDs)) + for _, id := range conv.ParticipantIDs { + u, err := s.store.UserByID(r.Context(), id) + if err != nil { + continue + } + out = append(out, map[string]any{ + "id": u.ID, "name": u.Name, "email": u.Email, + "avatarFileId": u.AvatarFileID, "isCreator": id == conv.CreatorID, + }) + } + writeJSON(w, http.StatusOK, map[string]any{ + "members": out, + "creatorId": conv.CreatorID, + "canManage": conv.Kind != "dm" && conv.CreatorID == me.ID, + }) +} + +// handleAddMember adds a participant to a group (creator only). +func (s *Server) handleAddMember(w http.ResponseWriter, r *http.Request) { + conv, ok := s.groupOwner(w, r, r.PathValue("id")) + if !ok { + return + } + var req struct { + UserID string `json:"userId"` + } + if !decodeJSON(w, r, &req) { + return + } + if req.UserID == "" { + writeError(w, http.StatusBadRequest, "bad_request", "userId is required") + return + } + if _, err := s.store.UserByID(r.Context(), req.UserID); err != nil { + writeError(w, http.StatusBadRequest, "bad_user", "unknown user") + return + } + if err := s.store.AddParticipant(r.Context(), conv.ID, req.UserID); err != nil { + s.internalError(w, r, "add member", err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + +// handleRemoveMember removes a participant from a group (creator only). +func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request) { + conv, ok := s.groupOwner(w, r, r.PathValue("id")) + if !ok { + return + } + target := r.PathValue("userId") + if target == conv.CreatorID { + writeError(w, http.StatusBadRequest, "bad_request", "the creator cannot be removed") + return + } + if err := s.store.RemoveParticipant(r.Context(), conv.ID, target); err != nil { + s.internalError(w, r, "remove member", err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) +} + // handleUploadFile is the generic upload endpoint (POST /files, §6) used by // chat; scope=chat unless the caller passes scope=task (consultants). func (s *Server) handleUploadFile(w http.ResponseWriter, r *http.Request) { diff --git a/internal/http/profile.go b/internal/http/profile.go index 4a446a6..ee5045b 100644 --- a/internal/http/profile.go +++ b/internal/http/profile.go @@ -45,6 +45,7 @@ func (s *Server) handlePatchProfile(w http.ResponseWriter, r *http.Request) { Extra *map[string]any `json:"extra"` Settings *struct { Theme *string `json:"theme"` + NavLayout *string `json:"navLayout"` Notifications *domain.NotificationPrefs `json:"notifications"` LeaderboardOptOut *bool `json:"leaderboardOptOut"` } `json:"settings"` @@ -83,6 +84,14 @@ func (s *Server) handlePatchProfile(w http.ResponseWriter, r *http.Request) { } set["settings.theme"] = theme } + if req.Settings.NavLayout != nil { + nav := *req.Settings.NavLayout + if nav != "side" && nav != "top" { + writeError(w, http.StatusBadRequest, "invalid_nav", "navLayout must be side or top") + return + } + set["settings.navLayout"] = nav + } if req.Settings.Notifications != nil { set["settings.notifications"] = *req.Settings.Notifications } diff --git a/internal/http/web.go b/internal/http/web.go index 3a43a83..aa4b0b7 100644 --- a/internal/http/web.go +++ b/internal/http/web.go @@ -22,6 +22,7 @@ type pageData struct { Active string // nav highlight key User *domain.User DefaultTheme string + NavLayout string Narrow bool OIDCEnabled bool Error string @@ -81,6 +82,12 @@ func (s *Server) render(w http.ResponseWriter, r *http.Request, page string, dat data.DefaultTheme = data.User.Settings.Theme } } + if data.NavLayout == "" { + data.NavLayout = "side" // sidebar by default + if data.User != nil && data.User.Settings.NavLayout == "top" { + data.NavLayout = "top" + } + } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := t.ExecuteTemplate(w, "layout", data); err != nil { s.log.Error("execute template", "page", page, "err", err) diff --git a/internal/store/conversations.go b/internal/store/conversations.go index 16dc1df..369751b 100644 --- a/internal/store/conversations.go +++ b/internal/store/conversations.go @@ -19,6 +19,7 @@ type Conversation struct { Kind string `bson:"kind" json:"kind"` // dm | group | project CustomerID string `bson:"customerId,omitempty" json:"customerId,omitempty"` Title string `bson:"title,omitempty" json:"title,omitempty"` + CreatorID string `bson:"creatorId,omitempty" json:"creatorId,omitempty"` ParticipantIDs []string `bson:"participantIds" json:"participantIds"` LastMessageAt time.Time `bson:"lastMessageAt" json:"lastMessageAt"` CreatedAt time.Time `bson:"createdAt" json:"createdAt"` @@ -104,6 +105,28 @@ func (s *Store) ConversationByID(ctx context.Context, id string) (*Conversation, return &c, nil } +// AddParticipant adds a user to a conversation (idempotent via $addToSet). +func (s *Store) AddParticipant(ctx context.Context, convID, userID string) error { + _, err := s.DB.Collection("conversations").UpdateOne(ctx, + bson.M{"_id": convID}, + bson.M{"$addToSet": bson.M{"participantIds": userID}}) + if err != nil { + return fmt.Errorf("add participant: %w", err) + } + return nil +} + +// RemoveParticipant removes a user from a conversation. +func (s *Store) RemoveParticipant(ctx context.Context, convID, userID string) error { + _, err := s.DB.Collection("conversations").UpdateOne(ctx, + bson.M{"_id": convID}, + bson.M{"$pull": bson.M{"participantIds": userID}}) + if err != nil { + return fmt.Errorf("remove participant: %w", err) + } + return nil +} + func (s *Store) ListConversations(ctx context.Context, userID string) ([]Conversation, error) { cur, err := s.DB.Collection("conversations").Find(ctx, bson.M{"participantIds": userID}, diff --git a/internal/sync/manager.go b/internal/sync/manager.go index e672286..3c26bfc 100644 --- a/internal/sync/manager.go +++ b/internal/sync/manager.go @@ -257,7 +257,12 @@ func (m *Manager) SyncCustomer(ctx context.Context, c *domain.Customer) error { if err != nil { continue // consultant deleted; reconcile will catch up } - identity := ticketingIdentity(consultant, c.Ticketing.Type) + // Per-customer mapping (set by the admin on the customer) wins over + // the consultant's global identity; fall back to the global one. + identity := c.Ticketing.ConsultantIdentities[consultantID] + if identity == "" { + identity = ticketingIdentity(consultant, c.Ticketing.Type) + } if identity == "" { continue // §5.3: consultant has no linked account in this system } diff --git a/web/static/css/app.css b/web/static/css/app.css index 130bef6..2502819 100644 --- a/web/static/css/app.css +++ b/web/static/css/app.css @@ -303,6 +303,52 @@ table, .badge, input[type=number] { font-variant-numeric: tabular-nums; } } .topnav .right { display: flex; align-items: center; gap: 8px; } +/* ---- sidebar navigation (default; user can switch to top bar) ---- */ +body[data-nav=side] .topnav { + justify-content: flex-end; /* only the right controls remain in the flow */ + padding-left: 232px; +} +body[data-nav=side] .topnav .brand { + position: fixed; top: 14px; left: 18px; z-index: 41; + font-size: 1.05rem; +} +body[data-nav=side] .topnav .links { + position: fixed; top: 0; left: 0; bottom: 0; + width: 214px; + flex: none; flex-direction: column; flex-wrap: nowrap; align-items: stretch; + gap: 4px; + background-color: var(--surface2); + background-image: var(--dither); + border-right: 2px solid var(--border); + padding: 58px 12px 18px; + overflow-y: auto; + z-index: 40; +} +:root[data-theme=light] body[data-nav=side] .topnav .links { background-image: none; } +body[data-nav=side] .topnav .links a { + border-bottom: none; + border-left: 3px solid transparent; + border-radius: 0; + padding: 9px 12px; +} +body[data-nav=side] .topnav .links a[aria-current=page] { + border-left-color: var(--accent); + background: var(--surface); +} +body[data-nav=side] main.container { margin-left: 214px; } +@media (max-width: 760px) { + /* collapse the sidebar back into a normal top bar on small screens */ + body[data-nav=side] .topnav { padding-left: 20px; justify-content: flex-start; flex-wrap: wrap; } + body[data-nav=side] .topnav .brand { position: static; } + body[data-nav=side] .topnav .links { + position: static; width: auto; flex: 1; flex-direction: row; flex-wrap: wrap; + background: none; border-right: none; padding: 0; overflow: visible; + } + body[data-nav=side] .topnav .links a { border-left: none; border-bottom: 2px solid transparent; } + body[data-nav=side] .topnav .links a[aria-current=page] { border-bottom-color: var(--accent); background: none; } + body[data-nav=side] main.container { margin-left: auto; } +} + /* ---- cards: printed stock with hard Y2K shadows ---- */ .card { background: var(--surface); diff --git a/web/static/js/admin.js b/web/static/js/admin.js index a5ecbc4..2f5a0e9 100644 --- a/web/static/js/admin.js +++ b/web/static/js/admin.js @@ -57,6 +57,36 @@ async function loadConsultants() { })); } +// Render one username input per selected consultant, prefilled from the +// customer's saved per-consultant ticketing identity map. +function renderIdentities() { + const wrap = document.getElementById('c-identities-wrap'); + const host = document.getElementById('c-identities'); + const selectedIds = [...document.getElementById('c-consultants').selectedOptions].map((o) => o.value); + const existing = (editingCustomer && editingCustomer.ticketing.consultantIdentities) || {}; + if (!selectedIds.length) { wrap.hidden = true; host.replaceChildren(); return; } + wrap.hidden = false; + host.replaceChildren(...selectedIds.map((id) => { + const u = consultants.find((c) => c.id === id) || { name: id, email: '' }; + const row = document.createElement('div'); + row.className = 'spread'; + row.style.marginTop = '6px'; + const label = document.createElement('span'); + label.className = 'muted'; + label.style.flex = '1'; + label.textContent = u.name; + const inp = document.createElement('input'); + inp.type = 'text'; + inp.dataset.identity = id; + inp.placeholder = 'username in ticketing system'; + inp.value = existing[id] || ''; + inp.style.flex = '1'; + row.append(label, inp); + return row; + })); +} +document.getElementById('c-consultants').addEventListener('change', renderIdentities); + function openCustomerDialog(customer) { editingCustomer = customer || null; document.getElementById('customer-dialog-title').textContent = @@ -73,6 +103,7 @@ function openCustomerDialog(customer) { [...sel.options].forEach((o) => { o.selected = customer ? customer.consultantIds.includes(o.value) : false; }); + renderIdentities(); document.getElementById('c-test-result').textContent = customer && customer.ticketing.hasCredentials ? 'Stored credentials are kept unless you enter new ones.' : ''; dlg.showModal(); @@ -114,6 +145,11 @@ document.getElementById('customer-form').addEventListener('submit', async (e) => defaultBudget: parseFloat(val('c-budget')) || 0, consultantIds: [...document.getElementById('c-consultants').selectedOptions].map((o) => o.value), }; + const identities = {}; + document.querySelectorAll('#c-identities input[data-identity]').forEach((inp) => { + if (inp.value.trim()) identities[inp.dataset.identity] = inp.value.trim(); + }); + body.consultantIdentities = identities; if (!editingCustomer || credsEntered(type)) body.credentials = credsFromForm(type); try { if (editingCustomer) { diff --git a/web/static/js/messages.js b/web/static/js/messages.js index 1c522cd..1cd8ddf 100644 --- a/web/static/js/messages.js +++ b/web/static/js/messages.js @@ -62,6 +62,8 @@ async function openConversation(c) { current = c; oldestCursor = ''; document.getElementById('chat-title').textContent = c.title; + const mbtn = document.getElementById('chat-members'); + mbtn.hidden = !(c.kind !== 'dm' && c.creatorId && c.creatorId === meId); form.hidden = false; msgHost.replaceChildren(); renderConvList(); @@ -297,6 +299,73 @@ document.getElementById('newconv-form').addEventListener('submit', async (e) => } catch (err) { toast(err.message, 'err'); } }); +// ---- manage members (group creator only) ---- +const membersDlg = document.getElementById('members-dialog'); +let mmMemberIds = new Set(); + +async function renderMembers() { + const res = await api('GET', `/api/v1/conversations/${current.id}/members`); + mmMemberIds = new Set(res.members.map((u) => u.id)); + const host = document.getElementById('mm-list'); + host.replaceChildren(...res.members.map((u) => { + const li = document.createElement('li'); + li.className = 'conv-item'; + li.style.justifyContent = 'space-between'; + li.innerHTML = `${esc(u.name)} ${u.isCreator ? 'creator' : ''} +
${esc(u.email)}
`; + if (res.canManage && !u.isCreator) { + const rm = document.createElement('button'); + rm.className = 'btn small'; + rm.textContent = 'Remove'; + rm.addEventListener('click', async () => { + try { + await api('DELETE', `/api/v1/conversations/${current.id}/members/${u.id}`); + await renderMembers(); + } catch (e) { toast(e.message, 'err'); } + }); + li.appendChild(rm); + } + return li; + })); + document.getElementById('mm-add-wrap').hidden = !res.canManage; +} + +let mmTimer = null; +async function mmSearch() { + const q = document.getElementById('mm-search').value.trim(); + const res = await api('GET', `/api/v1/users?q=${encodeURIComponent(q)}`); + const host = document.getElementById('mm-results'); + const rows = res.users.filter((u) => !mmMemberIds.has(u.id)).map((u) => { + const b = document.createElement('button'); + b.type = 'button'; + b.className = 'nc-row'; + b.textContent = `${u.name} — ${u.email}`; + b.addEventListener('click', async () => { + try { + await api('POST', `/api/v1/conversations/${current.id}/members`, { userId: u.id }); + document.getElementById('mm-search').value = ''; + host.replaceChildren(); + await renderMembers(); + } catch (e) { toast(e.message, 'err'); } + }); + return b; + }); + host.replaceChildren(...rows); +} + +document.getElementById('chat-members').addEventListener('click', async () => { + if (!current) return; + document.getElementById('mm-search').value = ''; + document.getElementById('mm-results').replaceChildren(); + try { await renderMembers(); membersDlg.showModal(); } + catch (e) { toast(e.message, 'err'); } +}); +document.getElementById('mm-close').addEventListener('click', () => membersDlg.close()); +document.getElementById('mm-search').addEventListener('input', () => { + clearTimeout(mmTimer); + mmTimer = setTimeout(mmSearch, 250); +}); + (async () => { try { const me = await api('GET', '/api/v1/auth/me'); diff --git a/web/static/js/profile.js b/web/static/js/profile.js index 142c24f..9f67a97 100644 --- a/web/static/js/profile.js +++ b/web/static/js/profile.js @@ -56,12 +56,16 @@ form.addEventListener('submit', async (e) => { links, }, extra, - settings: { theme: document.getElementById('p-theme').value }, + settings: { + theme: document.getElementById('p-theme').value, + navLayout: document.getElementById('p-nav').value, + }, }); version = res.user.version; const theme = res.user.settings.theme; document.documentElement.dataset.theme = theme; try { localStorage.setItem('theme', theme); } catch (err) { /* ignore */ } + document.body.dataset.nav = res.user.settings.navLayout || 'side'; okBox.textContent = 'Profile saved.'; } catch (err) { if (err.status === 409) { diff --git a/web/templates/admin.html b/web/templates/admin.html index bf7384d..f97e08f 100644 --- a/web/templates/admin.html +++ b/web/templates/admin.html @@ -84,6 +84,11 @@

Hold Ctrl/Cmd to select multiple. At least one is needed for syncing.

+
diff --git a/web/templates/layout.html b/web/templates/layout.html index 59e00bd..b1c87b4 100644 --- a/web/templates/layout.html +++ b/web/templates/layout.html @@ -7,7 +7,7 @@ - +