Gitfed
bastien-mrq/gitfed/ Commits/ 06f1ad3

Move profile to a right-side nav dropdown, redesign dashboard/settings/admin

- Nav: Settings, Admin and Log out move out of the main links into a dropdown behind the avatar+username on the right (click to open, click outside to close). Admin only shows up in the menu for admin accounts. - Dashboard: a stat strip (repos/public/shared-with-you) above the repo list, which is now icon+role+topics rows instead of a bare table; "New repo" is a <details> disclosure instead of a form that's always open. - Settings: split into Profile/SSH keys/Password tabs instead of one long scroll. Tab state survives the redirect after an action (?tab=keys stays selected after adding/removing a key, etc.) — server-rendered, not just client-side JS state. - Admin: the index page is now stat cards with real counts (users by role, trust store total/pending, audit event count) plus a 5-event activity preview, instead of a bare list of three links. Proposed as an HTML mockup first, corrected once (profile dropdown side) and approved before implementing.

bastien-mrq 2026-07-28 15:30 commit 06f1ad3749c1f2b46057ed9f3cb66d5a76f3146d parent c54da694c8e55ad4569f0ac2ec72d71951f22597
4 files changed +304 −85
M cmd/gitfed-web/handlers_admin.go +80 −7
M cmd/gitfed-web/handlers_dashboard.go +41 −24
M cmd/gitfed-web/handlers_settings.go +70 −46
M cmd/gitfed-web/render.go +113 −8
cmd/gitfed-web/handlers_admin.go
diff --git a/cmd/gitfed-web/handlers_admin.go b/cmd/gitfed-web/handlers_admin.go index 00a70c9..5bb3611 100644 --- a/cmd/gitfed-web/handlers_admin.go +++ b/cmd/gitfed-web/handlers_admin.go @@ -4,19 +4,92 @@ import ( "bytes" "html/template" "net/http" + + "gitfed/internal/store" ) var adminIndexTpl = template.Must(template.New("admin-index").Parse(` -<h1>Admin</h1> -<ul> - <li><a href="/admin/users">Users</a> — create/delete accounts, grant admin</li> - <li><a href="/admin/trust">Trust store</a> — approve federated domains</li> - <li><a href="/admin/audit">Audit log</a> — recent auth &amp; access events</li> -</ul> +<div class="gf-page-head"><h1>Admin</h1></div> + +<div class="gf-admin-grid"> + <div class="gf-admin-card"> + <div class="top"><span class="l">Users</span></div> + <div class="n">{{.UserCount}}</div> + <p>{{.AdminCount}} admin{{if ne .AdminCount 1}}s{{end}}, {{.RegularCount}} regular</p> + <a class="go" href="/admin/users">Manage users →</a> + </div> + <div class="gf-admin-card"> + <div class="top"> + <span class="l">Trust store</span> + {{if .PendingCount}}<span class="badge pending">{{.PendingCount}} pending</span>{{end}} + </div> + <div class="n">{{.TrustCount}}</div> + <p>federated domains known</p> + <a class="go" href="/admin/trust">Review trust store →</a> + </div> + <div class="gf-admin-card"> + <div class="top"><span class="l">Audit log</span></div> + <div class="n">{{.AuditCount}}</div> + <p>most recent events on record</p> + <a class="go" href="/admin/audit">View audit log →</a> + </div> +</div> + +{{if .RecentAudit}} +<h3 style="font-size:0.95rem; margin-bottom:0.6rem;">Recent activity</h3> +<div class="gf-card gf-audit-mini"> +{{range .RecentAudit}} + <div class="gf-audit-row"> + <span class="t">{{.Time.Local.Format "15:04:05"}}</span> + <span class="who">{{.Principal}} · {{.Action}}{{if .Repo}} · {{.Repo}}{{end}}</span> + {{if .Allowed}}<span class="badge trusted">allow</span>{{else}}<span class="badge pending">deny</span>{{end}} + </div> +{{end}} +</div> +{{end}} `)) func (s *server) handleAdminIndex(w http.ResponseWriter, r *http.Request) { + users, err := s.ops.ListUsers() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + adminCount := 0 + for _, u := range users { + if u.IsAdmin { + adminCount++ + } + } + + trust, err := s.ops.ListTrustedCAs() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + pendingCount := 0 + for _, t := range trust { + if t.Status == store.TrustPending { + pendingCount++ + } + } + + audit, err := s.ops.ListAudit(200) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + recent := audit + if len(recent) > 5 { + recent = recent[:5] + } + var buf bytes.Buffer - _ = adminIndexTpl.Execute(&buf, nil) + _ = adminIndexTpl.Execute(&buf, struct { + UserCount, AdminCount, RegularCount int + TrustCount, PendingCount int + AuditCount int + RecentAudit []store.AuditEvent + }{len(users), adminCount, len(users) - adminCount, len(trust), pendingCount, len(audit), recent}) s.render(w, r, "Admin", "admin", template.HTML(buf.String())) }
cmd/gitfed-web/handlers_dashboard.go
diff --git a/cmd/gitfed-web/handlers_dashboard.go b/cmd/gitfed-web/handlers_dashboard.go index 24556ea..d3642eb 100644 --- a/cmd/gitfed-web/handlers_dashboard.go +++ b/cmd/gitfed-web/handlers_dashboard.go @@ -10,26 +10,38 @@ import ( var dashboardTpl = template.Must(template.New("dashboard").Parse(` {{.Flash}} -<h1>Your repos</h1> -<table> -<tr><th>Repo</th><th>Role</th><th>Visibility</th></tr> +<div class="gf-stat-row"> + <div class="gf-stat"><div class="n">{{len .Repos}}</div><div class="l">Repos</div></div> + <div class="gf-stat"><div class="n">{{.PublicCount}}</div><div class="l">Public</div></div> + <div class="gf-stat"><div class="n">{{.SharedCount}}</div><div class="l">Shared with you</div></div> +</div> + +<div class="gf-page-head"> + <h1>Your repos</h1> + <details class="gf-new-repo"> + <summary class="gf-btn primary" style="margin:0;">+ New repo</summary> + <form class="card" method="post" action="/repos" style="margin-top:0.75rem;"> + <label>Name</label> + <input name="name" required placeholder="{{.Username}}/my-project"> + <button type="submit">Create</button> + </form> + </details> +</div> + +<div class="gf-card gf-repo-list"> {{range .Repos}} -<tr> - <td><a href="/r/{{.Name}}">{{.Name}}</a></td> - <td class="muted">{{.Role}}</td> - <td>{{if .Public}}<span class="badge trusted">public</span>{{else}}<span class="badge pending">private</span>{{end}}</td> -</tr> + <div class="gf-repo-row"> + <div class="gf-repo-icon">📁</div> + <div class="gf-repo-main"> + <div class="name"><a href="/r/{{.Name}}">{{.Name}}</a></div> + <div class="meta">{{.Role}}{{if .Topics}} · {{end}}{{range $i, $t := .Topics}}{{if $i}}, {{end}}{{$t}}{{end}}</div> + </div> + {{if .Public}}<span class="badge trusted">public</span>{{else}}<span class="badge pending">private</span>{{end}} + </div> {{else}} -<tr><td colspan="3" class="muted">No repos yet — create one below.</td></tr> + <div class="gf-repo-row"><span class="muted">No repos yet — create one above.</span></div> {{end}} -</table> - -<form class="card" method="post" action="/repos"> - <strong>Create repo</strong> - <label>Name</label> - <input name="name" required placeholder="{{.Username}}/my-project"> - <button type="submit">Create</button> -</form> +</div> `)) type dashboardRepo struct { @@ -46,23 +58,28 @@ func (s *server) handleDashboard(w http.ResponseWriter, r *http.Request) { return } var mine []dashboardRepo + publicCount, sharedCount := 0, 0 for _, repo := range all { if repo.Owner == sess.Principal { mine = append(mine, dashboardRepo{Repo: repo, Role: "owner"}) + } else if role, ok, err := s.ops.CheckAccess(repo.Name, sess.Principal, store.RoleRead); err == nil && ok { + mine = append(mine, dashboardRepo{Repo: repo, Role: string(role)}) + sharedCount++ + } else { continue } - role, ok, err := s.ops.CheckAccess(repo.Name, sess.Principal, store.RoleRead) - if err == nil && ok { - mine = append(mine, dashboardRepo{Repo: repo, Role: string(role)}) + if repo.Public { + publicCount++ } } var buf bytes.Buffer _ = dashboardTpl.Execute(&buf, struct { - Repos []dashboardRepo - Username string - Flash template.HTML - }{mine, sess.Username, flash(r)}) + Repos []dashboardRepo + Username string + PublicCount, SharedCount int + Flash template.HTML + }{mine, sess.Username, publicCount, sharedCount, flash(r)}) s.render(w, r, "Dashboard", "dashboard", template.HTML(buf.String())) }
cmd/gitfed-web/handlers_settings.go
diff --git a/cmd/gitfed-web/handlers_settings.go b/cmd/gitfed-web/handlers_settings.go index 61360e7..1495dd2 100644 --- a/cmd/gitfed-web/handlers_settings.go +++ b/cmd/gitfed-web/handlers_settings.go @@ -8,43 +8,67 @@ import ( var settingsTpl = template.Must(template.New("settings").Parse(` {{.Flash}} -<h1>Settings</h1> -<p class="muted">Logged in as <strong>{{.Username}}</strong>{{if .IsAdmin}} (admin){{end}}</p> - -<table> -<tr><th>SSH key</th><th></th></tr> -{{range .Keys}} -<tr> - <td><code>{{.Short}}</code></td> - <td> - <form class="inline" method="post" action="/settings/keys/remove"> - <input type="hidden" name="key" value="{{.Full}}"> - <button class="danger" type="submit">Remove</button> - </form> - </td> -</tr> -{{end}} -</table> - -<form class="card" method="post" action="/settings/keys/add"> - <strong>Add SSH key</strong> - <label>Public key (authorized_keys format)</label> - <input name="pubkey" required placeholder="ssh-ed25519 AAAA... comment"> - <button type="submit">Add</button> -</form> - -<form class="card" method="post" action="/settings/password"> - <strong>Change password</strong> - <label>Current password</label> - <input name="old_password" type="password" required> - <label>New password</label> - <input name="new_password" type="password" required minlength="8"> - <button type="submit">Change password</button> -</form> +<div class="gf-page-head"><h1>Settings</h1></div> + +<div class="gf-tabs"> + <button type="button" data-tab="tab-profile" class="{{if eq .ActiveTab "profile"}}active{{end}}">Profile</button> + <button type="button" data-tab="tab-keys" class="{{if eq .ActiveTab "keys"}}active{{end}}">SSH keys</button> + <button type="button" data-tab="tab-password" class="{{if eq .ActiveTab "password"}}active{{end}}">Password</button> +</div> + +<div id="tab-profile" class="gf-tabpane {{if eq .ActiveTab "profile"}}active{{end}}"> + <p class="muted">Logged in as <strong>{{.Username}}</strong>{{if .IsAdmin}} — admin{{end}}.</p> + <p class="muted">This password only signs you into the web UI. Git push/pull always goes over SSH with a key, independently of it.</p> +</div> + +<div id="tab-keys" class="gf-tabpane {{if eq .ActiveTab "keys"}}active{{end}}"> + <div class="gf-card"> + {{range .Keys}} + <div class="gf-key-row"> + <code>{{.Short}}</code> + <form class="inline" method="post" action="/settings/keys/remove"> + <input type="hidden" name="key" value="{{.Full}}"> + <button class="danger" type="submit">Remove</button> + </form> + </div> + {{else}} + <div class="gf-key-row"><span class="muted">No keys yet.</span></div> + {{end}} + </div> + + <form class="card" method="post" action="/settings/keys/add"> + <strong>Add SSH key</strong> + <label>Public key (authorized_keys format)</label> + <input name="pubkey" required placeholder="ssh-ed25519 AAAA... comment"> + <button type="submit">Add</button> + </form> +</div> + +<div id="tab-password" class="gf-tabpane {{if eq .ActiveTab "password"}}active{{end}}"> + <form class="card" method="post" action="/settings/password"> + <strong>Change password</strong> + <label>Current password</label> + <input name="old_password" type="password" required> + <label>New password</label> + <input name="new_password" type="password" required minlength="8"> + <button type="submit">Change password</button> + </form> +</div> `)) type keyView struct{ Short, Full string } +func settingsTab(r *http.Request) string { + switch r.URL.Query().Get("tab") { + case "keys": + return "keys" + case "password": + return "password" + default: + return "profile" + } +} + func (s *server) handleSettings(w http.ResponseWriter, r *http.Request) { sess, _ := s.currentSession(r) user, err := s.ops.GetUser(sess.Username) @@ -64,30 +88,30 @@ func (s *server) handleSettings(w http.ResponseWriter, r *http.Request) { var buf bytes.Buffer _ = settingsTpl.Execute(&buf, struct { - Username string - IsAdmin bool - Keys []keyView - Flash template.HTML - }{sess.Username, sess.IsAdmin, keys, flash(r)}) + Username, ActiveTab string + IsAdmin bool + Keys []keyView + Flash template.HTML + }{sess.Username, settingsTab(r), sess.IsAdmin, keys, flash(r)}) s.render(w, r, "Settings", "settings", template.HTML(buf.String())) } func (s *server) handleAddOwnKey(w http.ResponseWriter, r *http.Request) { sess, _ := s.currentSession(r) if err := s.ops.AddUserKey(sess.Username, r.FormValue("pubkey")); err != nil { - redirectWithMsg(w, r, "/settings", err.Error(), true) + redirectWithMsg(w, r, "/settings?tab=keys", err.Error(), true) return } - redirectWithMsg(w, r, "/settings", "key added", false) + redirectWithMsg(w, r, "/settings?tab=keys", "key added", false) } func (s *server) handleRemoveOwnKey(w http.ResponseWriter, r *http.Request) { sess, _ := s.currentSession(r) if err := s.ops.RemoveUserKey(sess.Username, r.FormValue("key")); err != nil { - redirectWithMsg(w, r, "/settings", err.Error(), true) + redirectWithMsg(w, r, "/settings?tab=keys", err.Error(), true) return } - redirectWithMsg(w, r, "/settings", "key removed", false) + redirectWithMsg(w, r, "/settings?tab=keys", "key removed", false) } func (s *server) handleChangePassword(w http.ResponseWriter, r *http.Request) { @@ -97,16 +121,16 @@ func (s *server) handleChangePassword(w http.ResponseWriter, r *http.Request) { _, ok, err := s.ops.VerifyPassword(sess.Username, oldPassword) if err != nil { - redirectWithMsg(w, r, "/settings", err.Error(), true) + redirectWithMsg(w, r, "/settings?tab=password", err.Error(), true) return } if !ok { - redirectWithMsg(w, r, "/settings", "current password is incorrect", true) + redirectWithMsg(w, r, "/settings?tab=password", "current password is incorrect", true) return } if err := s.ops.SetPassword(sess.Username, newPassword); err != nil { - redirectWithMsg(w, r, "/settings", err.Error(), true) + redirectWithMsg(w, r, "/settings?tab=password", err.Error(), true) return } - redirectWithMsg(w, r, "/settings", "password changed", false) + redirectWithMsg(w, r, "/settings?tab=password", "password changed", false) }
cmd/gitfed-web/render.go
diff --git a/cmd/gitfed-web/render.go b/cmd/gitfed-web/render.go index 518e719..612fbee 100644 --- a/cmd/gitfed-web/render.go +++ b/cmd/gitfed-web/render.go @@ -76,10 +76,31 @@ const shellSrc = `<!doctype html> .gf-nav-right { display: flex; align-items: center; gap: 0.6rem; flex-shrink: 0; } .gf-nav-right a { color: var(--text-dim); text-decoration: none; font-size: 0.86rem; } .gf-nav-right a:hover { color: var(--text); } - .gf-avatar { width: 26px; height: 26px; border-radius: 50%; background: var(--accent-dim); color: var(--text); display: flex; align-items: center; justify-content: center; font-size: 0.68rem; font-weight: 700; flex-shrink: 0; } .linklike { background: none; border: none; color: var(--text-dim); font-size: 0.86rem; cursor: pointer; padding: 0; font-family: inherit; } .linklike:hover { color: var(--text); } + /* --- profile dropdown --- */ + .gf-avatar { width: 26px; height: 26px; border-radius: 50%; background: var(--accent-dim); color: var(--text); display: flex; align-items: center; justify-content: center; font-size: 0.68rem; font-weight: 700; flex-shrink: 0; } + .gf-profile { position: relative; flex-shrink: 0; } + .gf-profile-trigger { display: flex; align-items: center; gap: 0.5rem; background: none; border: none; color: var(--text); cursor: pointer; padding: 0.3rem 0.4rem; margin: 0; border-radius: 7px; font-size: 0.86rem; font-family: inherit; font-weight: 600; } + .gf-profile-trigger:hover { background: var(--surface-2); } + .gf-profile-trigger .chev { color: var(--text-faint); font-size: 0.65rem; transition: transform 0.12s; } + .gf-profile-trigger[aria-expanded="true"] .chev { transform: rotate(180deg); } + .gf-profile-menu { + position: absolute; top: calc(100% + 6px); right: 0; min-width: 190px; background: var(--surface-2); + border: 1px solid var(--border-strong); border-radius: 9px; padding: 0.4rem; + box-shadow: 0 10px 24px rgba(0,0,0,0.4); display: none; flex-direction: column; gap: 0.1rem; z-index: 20; + } + .gf-profile-menu.open { display: flex; } + .gf-profile-menu .who { padding: 0.5rem 0.7rem 0.4rem; font-size: 0.78rem; color: var(--text-faint); border-bottom: 1px solid var(--border); margin-bottom: 0.3rem; } + .gf-profile-menu a, .gf-profile-menu button { + display: flex; align-items: center; gap: 0.55rem; padding: 0.5rem 0.7rem; border-radius: 6px; font-size: 0.86rem; + color: var(--text); background: none; border: none; text-align: left; width: 100%; cursor: pointer; font-family: inherit; margin: 0; + } + .gf-profile-menu a:hover, .gf-profile-menu button:hover { background: var(--surface-3); } + .gf-profile-menu .divider { height: 1px; background: var(--border); margin: 0.3rem 0; } + .gf-profile-menu .danger { color: var(--danger-fg); } + @media (max-width: 720px) { .gf-nav-burger { display: flex; align-items: center; } .gf-nav-panel { display: none; position: absolute; top: 52px; left: 0; right: 0; background: var(--surface); border-bottom: 1px solid var(--border); flex-direction: column; align-items: stretch; gap: 0.75rem; padding: 0.85rem 1rem; margin: 0; } @@ -129,6 +150,59 @@ const shellSrc = `<!doctype html> .gf-file-table td.meta { display: none; } } + /* ---------- dashboard / settings / admin ---------- */ + .gf-page-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap; margin-bottom: 1rem; } + .gf-page-head h1 { font-size: 1.35rem; margin: 0; } + + .gf-stat-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 1px; background: var(--border); border: 1px solid var(--border); border-radius: 10px; overflow: hidden; margin-bottom: 1.25rem; } + .gf-stat { background: var(--surface); padding: 1rem 1.1rem; } + .gf-stat .n { font-size: 1.5rem; font-weight: 700; } + .gf-stat .l { font-size: 0.78rem; color: var(--text-faint); text-transform: uppercase; letter-spacing: 0.03em; } + + .gf-repo-list { display: flex; flex-direction: column; } + .gf-repo-row { display: flex; align-items: center; gap: 0.9rem; padding: 0.9rem 1.1rem; border-bottom: 1px solid var(--border); } + .gf-repo-row:last-child { border-bottom: none; } + .gf-repo-row:hover { background: var(--surface-2); } + .gf-repo-icon { width: 30px; height: 30px; border-radius: 7px; background: var(--surface-2); display: flex; align-items: center; justify-content: center; flex-shrink: 0; } + .gf-repo-main { flex: 1; min-width: 0; } + .gf-repo-main .name { font-family: var(--mono); font-size: 0.92rem; font-weight: 600; } + .gf-repo-main .name a:hover { color: var(--accent); } + .gf-repo-main .meta { font-size: 0.78rem; color: var(--text-faint); margin-top: 0.15rem; } + .gf-new-repo summary { list-style: none; cursor: pointer; } + .gf-new-repo summary::-webkit-details-marker { display: none; } + .gf-new-repo[open] summary { margin-bottom: 0.5rem; } + + .gf-tabs { display: flex; gap: 0.3rem; border-bottom: 1px solid var(--border); margin-bottom: 1.25rem; } + .gf-tabs button { margin: 0; padding: 0.6rem 0.9rem; font-size: 0.86rem; color: var(--text-dim); border: none; border-bottom: 2px solid transparent; margin-bottom: -1px; background: none; border-radius: 0; cursor: pointer; font-family: inherit; } + .gf-tabs button:hover { background: none; color: var(--text); } + .gf-tabs button.active { color: var(--text); border-bottom-color: var(--accent); font-weight: 600; } + .gf-tabpane { display: none; } + .gf-tabpane.active { display: block; } + + .gf-key-row { display: flex; align-items: center; gap: 0.9rem; padding: 0.8rem 1.1rem; border-bottom: 1px solid var(--border); } + .gf-key-row:last-child { border-bottom: none; } + .gf-key-row code { font-size: 0.82rem; color: var(--text-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; background: none; padding: 0; } + .gf-key-row button { margin: 0; flex-shrink: 0; } + + .gf-admin-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; } + .gf-admin-card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 1.1rem; display: flex; flex-direction: column; gap: 0.5rem; } + .gf-admin-card .top { display: flex; align-items: center; justify-content: space-between; } + .gf-admin-card .n { font-size: 1.6rem; font-weight: 700; } + .gf-admin-card .l { font-size: 0.84rem; color: var(--text-dim); font-weight: 600; } + .gf-admin-card p { margin: 0; font-size: 0.8rem; color: var(--text-faint); } + .gf-admin-card a.go { font-size: 0.82rem; color: var(--accent); margin-top: auto; } + + .gf-audit-mini { display: flex; flex-direction: column; } + .gf-audit-row { display: flex; align-items: center; gap: 0.6rem; padding: 0.55rem 1.1rem; border-bottom: 1px solid var(--border); font-size: 0.82rem; } + .gf-audit-row:last-child { border-bottom: none; } + .gf-audit-row .t { color: var(--text-faint); font-family: var(--mono); font-size: 0.74rem; width: 62px; flex-shrink: 0; } + .gf-audit-row .who { color: var(--text-dim); flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + + @media (max-width: 640px) { + .gf-page-head { flex-direction: column; align-items: stretch; } + .gf-repo-row { flex-wrap: wrap; } + } + /* ---------- tables ---------- */ .table-wrap { overflow-x: auto; } table { border-collapse: collapse; width: 100%; margin: 1rem 0; display: block; overflow-x: auto; max-width: 100%; } @@ -201,11 +275,7 @@ const shellSrc = `<!doctype html> <div class="gf-nav-panel" id="navPanel"> <nav class="gf-nav-links"> <a href="/"{{if eq .Active "home"}} class="active"{{end}}>Explore</a> - {{if .LoggedIn}} - <a href="/dashboard"{{if eq .Active "dashboard"}} class="active"{{end}}>Dashboard</a> - <a href="/settings"{{if eq .Active "settings"}} class="active"{{end}}>Settings</a> - {{if .IsAdmin}}<a href="/admin"{{if eq .Active "admin"}} class="active"{{end}}>Admin</a>{{end}} - {{end}} + {{if .LoggedIn}}<a href="/dashboard"{{if eq .Active "dashboard"}} class="active"{{end}}>Dashboard</a>{{end}} </nav> <form class="gf-nav-search" action="/search" method="get" role="search"> <div class="gf-nav-search-inner"> @@ -216,8 +286,20 @@ const shellSrc = `<!doctype html> </form> <div class="gf-nav-right"> {{if .LoggedIn}} - <span class="gf-avatar" title="{{.Username}}">{{.Initials}}</span> - <form method="post" action="/logout"><button class="linklike" type="submit">Log out</button></form> + <div class="gf-profile"> + <button class="gf-profile-trigger" id="profileTrigger" aria-haspopup="true" aria-expanded="false" aria-controls="profileMenu"> + <span class="gf-avatar" title="{{.Username}}">{{.Initials}}</span> + <span>{{.Username}}</span> + <span class="chev">▾</span> + </button> + <div class="gf-profile-menu" id="profileMenu"> + <div class="who">Signed in as <strong>{{.Username}}</strong></div> + <a href="/settings">⚙ Settings</a> + {{if .IsAdmin}}<a href="/admin">🛡 Admin</a>{{end}} + <div class="divider"></div> + <form method="post" action="/logout"><button class="danger" type="submit">↪ Log out</button></form> + </div> + </div> {{else}} <a href="/login">Log in</a> {{end}} @@ -240,6 +322,19 @@ const shellSrc = `<!doctype html> burger.setAttribute('aria-expanded', open ? 'true' : 'false'); }); } + var profileTrigger = document.getElementById('profileTrigger'); + var profileMenu = document.getElementById('profileMenu'); + if (profileTrigger && profileMenu) { + profileTrigger.addEventListener('click', function (e) { + e.stopPropagation(); + var open = profileMenu.classList.toggle('open'); + profileTrigger.setAttribute('aria-expanded', open ? 'true' : 'false'); + }); + document.addEventListener('click', function () { + profileMenu.classList.remove('open'); + profileTrigger.setAttribute('aria-expanded', 'false'); + }); + } document.addEventListener('keydown', function (e) { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { var s = document.getElementById('navSearch'); @@ -255,6 +350,16 @@ const shellSrc = `<!doctype html> setTimeout(function () { btn.textContent = orig; }, 1200); }); }); + document.addEventListener('click', function (e) { + var tab = e.target.closest('[data-tab]'); + if (!tab) return; + var group = tab.closest('.gf-tabs'); + if (!group) return; + group.querySelectorAll('button').forEach(function (b) { b.classList.remove('active'); }); + tab.classList.add('active'); + var panes = group.parentElement.querySelectorAll('.gf-tabpane'); + panes.forEach(function (p) { p.classList.toggle('active', p.id === tab.getAttribute('data-tab')); }); + }); })(); </script> </body>