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 & 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>