profile: reorder the tabs, fold in bookmarks and snippets !455

merged merged by cmc on 2026-09-21 23:46 UTC · krz/gitbay:profile-tabs into main

20 files changed, +483 −221

Layout: unified · split

.gitbay/wiki/Parity.org +12 −6
@@ -180,6 +180,7 @@ sit inside the diff, where a page-level preview has nowhere to go.
180| pin | yes | yes | yes | 180| pin | yes | yes | yes |
181| bookmark | yes | yes | yes | 181| bookmark | yes | yes | yes |
182| bookmark list | yes | yes | yes | 182| bookmark list | yes | yes | yes |
183| bookmarks on your profile | n/a | yes | no |
183| watch, unwatch | yes | yes | yes | 184| watch, unwatch | yes | yes | yes |
184| mute | yes | no | yes | 185| mute | yes | no | yes |
185| settings, protection | yes | yes | yes | 186| settings, protection | yes | yes | yes |
@@ -310,12 +311,17 @@ it as =about=, =about_format= and =about_path=. It reads with the
310repository's own access, so a private =.gitbay= is a profile with no 311repository's own access, so a private =.gitbay= is a profile with no
311about text to anyone but its owner and the admins. A repository whose 312about text to anyone but its owner and the admins. A repository whose
312name starts with a dot stays out of =explore= and off the profile's 313name starts with a dot stays out of =explore= and off the profile's
313repository list. On the web the about text is its own tab: =/<owner>= 314repository list. On the web a profile is tabs: =/<owner>= is About —
314is the repository list, =/<owner>/-/about= the text, =/<owner>/-/activity= 315the text, the year of squares and a log of the newest thirty events —
315the year of squares, and =/<owner>/-/people= an organization's members 316then =/<owner>/-/repositories=, =/<owner>/-/bookmarks=,
316and teams. A tab with nothing on it is not offered and its URL is a 317=/<owner>/-/snippets=, and =/<owner>/-/people= for an organization's
317404. The CLI's =profile show= is unchanged and still returns all of it 318members and teams. A tab nobody may open is not offered and its URL is
318at once. The iOS client decodes and renders both formats, 319a 404: bookmarks are the viewer's own, only a user has snippets, and
320people is the org admin panel. The log counts what the graph over it
321counts — a user's own events, an organization's repositories' — on
322public repositories only; =activity.atom= is the feed that goes back
323further. The CLI's =profile show= is unchanged and still returns all of
324it at once. The iOS client decodes and renders both formats,
319through the same OrgSwift path a README takes. 325through the same OrgSwift path a README takes.
320 326
321=help= lists the command registry. Bare it is an index, one line per 327=help= lists the command registry. Bare it is an index, one line per
CHANGELOG.org +21
@@ -4,6 +4,27 @@ Versioning follows semver from v0.1.0. Database migrations run
4automatically on daemon start; upgrade notes appear per release when 4automatically on daemon start; upgrade notes appear per release when
5anything beyond "replace the binary and restart" is needed. 5anything beyond "replace the binary and restart" is needed.
6 6
7* Unreleased
8
9The profile's tabs, reworked.
10
11- The bar reads About, Repositories, Bookmarks, Snippets, and People
12 for an organization's admins. =/{owner}= is About; the rest hang off
13 =/-/=. A tab nobody may open is not offered and its URL is a 404.
14- The activity graph moved inside About, with a log of the newest
15 thirty events under it. The log counts what the graph over it counts:
16 =UserPublicEvents= keys on the actor for a user, matching
17 =ActivityByDay=, while an organization keeps =OwnerPublicEvents=. Both
18 are public repositories only. =activity.atom= is still the feed that
19 goes back further.
20- Bookmarks are a tab on your own profile. =repo bookmarks= takes no
21 owner and lists the caller's, so the tab is not offered on anyone
22 else's and its URL is a 404 there. =/bookmarks= redirects to it, and
23 the rail points at the tab.
24- The snippet list renders inside the profile rather than on a page of
25 its own, so it carries the header and the tab bar like every other
26 section. A snippet itself is still its own page.
27
7* v1.33.0 — 2026-09-21 28* v1.33.0 — 2026-09-21
8 29
9Five findings from the outside review of the web UI (#241–#245). 30Five findings from the outside review of the web UI (#241–#245).
e2e/activity_test.go +4 −4
@@ -50,7 +50,7 @@ func TestActivityGraph(t *testing.T) {
50 if _, _, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/app", "--title", "'x'"); code != 0 { 50 if _, _, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/app", "--title", "'x'"); code != 0 {
51 t.Fatal("issue create failed") 51 t.Fatal("issue create failed")
52 } 52 }
53 status, body := inst.get(t, "/alice/-/activity") 53 status, body := inst.get(t, "/alice")
54 if status != 200 || !strings.Contains(body, `class="actgraph"`) { 54 if status != 200 || !strings.Contains(body, `class="actgraph"`) {
55 t.Fatalf("graph missing: %d", status) 55 t.Fatalf("graph missing: %d", status)
56 } 56 }
@@ -62,14 +62,14 @@ func TestActivityGraph(t *testing.T) {
62 t.Fatalf("alice total = %d, want >= 2", total) 62 t.Fatalf("alice total = %d, want >= 2", total)
63 } 63 }
64 // bob authored a commit but his email is unverified: zero activity. 64 // bob authored a commit but his email is unverified: zero activity.
65 _, bobBody := inst.get(t, "/bob/-/activity") 65 _, bobBody := inst.get(t, "/bob")
66 if bt := activityTotal(t, bobBody); bt != 0 { 66 if bt := activityTotal(t, bobBody); bt != 0 {
67 t.Fatalf("unverified author got credit: total %d", bt) 67 t.Fatalf("unverified author got credit: total %d", bt)
68 } 68 }
69 69
70 // Re-pushing the same history (force) does not double-count. 70 // Re-pushing the same history (force) does not double-count.
71 mustGit(t, dir, env, "push", "-q", "--force", "origin", "main") 71 mustGit(t, dir, env, "push", "-q", "--force", "origin", "main")
72 _, body2 := inst.get(t, "/alice/-/activity") 72 _, body2 := inst.get(t, "/alice")
73 if body2 != body { 73 if body2 != body {
74 // Counts must be identical; compare just the graph cells. 74 // Counts must be identical; compare just the graph cells.
75 if excerpt(body, "actgraph") != excerpt(body2, "actgraph") { 75 if excerpt(body, "actgraph") != excerpt(body2, "actgraph") {
@@ -84,7 +84,7 @@ func TestActivityGraph(t *testing.T) {
84 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "transfer", "alice/app", "theorg"); code != 0 { 84 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "transfer", "alice/app", "theorg"); code != 0 {
85 t.Fatal("transfer failed") 85 t.Fatal("transfer failed")
86 } 86 }
87 _, orgBody := inst.get(t, "/theorg/-/activity") 87 _, orgBody := inst.get(t, "/theorg")
88 if !strings.Contains(orgBody, `class="actgraph"`) || strings.Contains(orgBody, "0 in the last year") { 88 if !strings.Contains(orgBody, `class="actgraph"`) || strings.Contains(orgBody, "0 in the last year") {
89 t.Fatalf("org graph empty:\n%s", excerpt(orgBody, "activity")) 89 t.Fatalf("org graph empty:\n%s", excerpt(orgBody, "activity"))
90 } 90 }
e2e/description_test.go +3 −2
@@ -48,8 +48,9 @@ func TestRepoDescriptions(t *testing.T) {
48 t.Fatalf("non-admin set: exit %d, want 4", code) 48 t.Fatalf("non-admin set: exit %d, want 4", code)
49 } 49 }
50 50
51 // Web: explore, owner page, and repo header all show it. 51 // Web: explore, the profile's repositories tab, and the repo header
52 for _, path := range []string{"/explore", "/alice", "/alice/tool"} { 52 // all show it.
53 for _, path := range []string{"/explore", "/alice/-/repositories", "/alice/tool"} {
53 status, body := inst.get(t, path) 54 status, body := inst.get(t, path)
54 if status != 200 || !strings.Contains(body, "better now") { 55 if status != 200 || !strings.Contains(body, "better now") {
55 t.Fatalf("description missing at %s (%d)", path, status) 56 t.Fatalf("description missing at %s (%d)", path, status)
e2e/org_test.go +1 −1
@@ -179,7 +179,7 @@ func TestOrganizations(t *testing.T) {
179 if status, _ := inst.get(t, "/puborg/site"); status != 200 { 179 if status, _ := inst.get(t, "/puborg/site"); status != 200 {
180 t.Fatalf("org repo page: %d", status) 180 t.Fatalf("org repo page: %d", status)
181 } 181 }
182 status, body = inst.get(t, "/puborg") 182 status, body = inst.get(t, "/puborg/-/repositories")
183 if status != 200 || !strings.Contains(body, "org") || !strings.Contains(body, "alice") || !strings.Contains(body, ">site<") { 183 if status != 200 || !strings.Contains(body, "org") || !strings.Contains(body, "alice") || !strings.Contains(body, ">site<") {
184 t.Fatalf("org owner page: %d\n%s", status, body) 184 t.Fatalf("org owner page: %d\n%s", status, body)
185 } 185 }
e2e/profile_test.go +13 −14
@@ -76,7 +76,7 @@ func TestOwnerProfiles(t *testing.T) {
76 } 76 }
77 // The owner page renders those rows, having dispatched the same 77 // The owner page renders those rows, having dispatched the same
78 // command rather than assembling them from the store again. 78 // command rather than assembling them from the store again.
79 status, body := inst.get(t, "/alice") 79 status, body := inst.get(t, "/alice/-/repositories")
80 if status != 200 { 80 if status != 200 {
81 t.Fatalf("owner page: %d", status) 81 t.Fatalf("owner page: %d", status)
82 } 82 }
@@ -158,7 +158,7 @@ func TestOwnerProfiles(t *testing.T) {
158 } 158 }
159 // Org emphasis parsed, not left as literal slashes the way the 159 // Org emphasis parsed, not left as literal slashes the way the
160 // markdown renderer would. 160 // markdown renderer would.
161 _, body = inst.get(t, "/alice/-/about") 161 _, body = inst.get(t, "/alice")
162 if !strings.Contains(body, "<em>small tools</em>") || strings.Contains(body, "/small tools/") { 162 if !strings.Contains(body, "<em>small tools</em>") || strings.Contains(body, "/small tools/") {
163 t.Fatalf("org about not rendered as org: %s", body) 163 t.Fatalf("org about not rendered as org: %s", body)
164 } 164 }
@@ -197,8 +197,9 @@ func TestOwnerProfiles(t *testing.T) {
197 t.Fatal("more than five links accepted") 197 t.Fatal("more than five links accepted")
198 } 198 }
199 199
200 // The bare owner page is the repository list, with the description 200 // The bare owner page is About: the text, the graph and the log,
201 // and the link chips in the header above the tabs (#242). 201 // with the description and the link chips in the header above the
202 // tabs (#242). The repositories are one tab along.
202 status, body = inst.get(t, "/alice") 203 status, body = inst.get(t, "/alice")
203 if status != 200 || !strings.Contains(body, "tinkerer") { 204 if status != 200 || !strings.Contains(body, "tinkerer") {
204 t.Fatalf("user page profile: %d", status) 205 t.Fatalf("user page profile: %d", status)
@@ -207,19 +208,17 @@ func TestOwnerProfiles(t *testing.T) {
207 !strings.Contains(body, ">Mastodon<") { 208 !strings.Contains(body, ">Mastodon<") {
208 t.Fatalf("links not rendered: %s", body) 209 t.Fatalf("links not rendered: %s", body)
209 } 210 }
210 if !strings.Contains(body, `<ul class="repolist"`) { 211 if !strings.Contains(body, "<em>small tools</em>") {
211 t.Error("the owner page is not the repository list") 212 t.Fatalf("about not rendered: %s", body)
212 } 213 }
213 if strings.Contains(body, `class="activity"`) || strings.Contains(body, "<em>small tools</em>") { 214 if !strings.Contains(body, `class="activity"`) {
214 t.Error("the about text or the activity graph still sits on the repository page") 215 t.Error("the about tab has no activity graph")
215 } 216 }
216 // Each of them is one tab along, and the markdown renders there. 217 if strings.Contains(body, `<ul class="repolist"`) {
217 _, aboutBody := inst.get(t, "/alice/-/about") 218 t.Error("the repository list still sits under the about text")
218 if !strings.Contains(aboutBody, "<em>small tools</em>") {
219 t.Fatalf("about not rendered: %s", aboutBody)
220 } 219 }
221 if _, g := inst.get(t, "/alice/-/activity"); !strings.Contains(g, `class="activity"`) { 220 if _, repos := inst.get(t, "/alice/-/repositories"); !strings.Contains(repos, `<ul class="repolist"`) {
222 t.Error("the activity tab has no graph") 221 t.Error("the repositories tab has no repository list")
223 } 222 }
224 223
225 // Clearing works the same way as the other fields. The about is not 224 // Clearing works the same way as the other fields. The about is not
e2e/profileabout_test.go +2 −2
@@ -40,7 +40,7 @@ func TestProfileAboutFromRepo(t *testing.T) {
40 t.Errorf("about_path missing: %s", out) 40 t.Errorf("about_path missing: %s", out)
41 } 41 }
42 42
43 _, body := inst.get(t, "/alice/-/about") 43 _, body := inst.get(t, "/alice")
44 if !strings.Contains(body, "hello from a file") { 44 if !strings.Contains(body, "hello from a file") {
45 t.Error("web profile does not render the about") 45 t.Error("web profile does not render the about")
46 } 46 }
@@ -54,7 +54,7 @@ func TestProfileAboutFromRepo(t *testing.T) {
54 "--ref", "main", "--file", "-"); code != 0 { 54 "--ref", "main", "--file", "-"); code != 0 {
55 t.Fatalf("committing bob's org about: %s", errOut) 55 t.Fatalf("committing bob's org about: %s", errOut)
56 } 56 }
57 if _, page := inst.get(t, "/bob/-/about"); !strings.Contains(page, "<em>note</em>") { 57 if _, page := inst.get(t, "/bob"); !strings.Contains(page, "<em>note</em>") {
58 t.Errorf("about did not render as org:\n%s", page) 58 t.Errorf("about did not render as org:\n%s", page)
59 } 59 }
60} 60}
e2e/web_test.go +3 −2
@@ -201,8 +201,9 @@ func TestWebUI(t *testing.T) {
201 t.Fatalf("txt README not escaped-plaintext:\n%s", body) 201 t.Fatalf("txt README not escaped-plaintext:\n%s", body)
202 } 202 }
203 203
204 // Owner page: lists visible repos only; unknown owners 404. 204 // Owner page: the repositories tab lists visible repos only;
205 status, body = inst.get(t, "/alice") 205 // unknown owners 404.
206 status, body = inst.get(t, "/alice/-/repositories")
206 if status != 200 || !strings.Contains(body, ">site<") || !strings.Contains(body, "user") { 207 if status != 200 || !strings.Contains(body, ">site<") || !strings.Contains(body, "user") {
207 t.Fatalf("owner page: %d", status) 208 t.Fatalf("owner page: %d", status)
208 } 209 }
internal/httpd/accounts.go +4 −7
@@ -262,14 +262,11 @@ func (s *Server) bookmarkToggle(w http.ResponseWriter, r *http.Request, u store.
262} 262}
263 263
264// bookmarksPage lists what the viewer has saved. 264// bookmarksPage lists what the viewer has saved.
265// bookmarksPage keeps /bookmarks working: the list is a tab on the
266// viewer's own profile now, so there is one page of it rather than two
267// showing the same rows.
265func (s *Server) bookmarksPage(w http.ResponseWriter, r *http.Request, u store.User) { 268func (s *Server) bookmarksPage(w http.ResponseWriter, r *http.Request, u store.User) {
266 var rows []control.BookmarkOut 269 http.Redirect(w, r, "/"+u.Username+"/-/bookmarks", http.StatusSeeOther)
267 s.runControlInto(u, []string{"repo", "bookmarks"}, &rows)
268 s.render(w, "bookmarks.html", struct {
269 basePage
270 Tab string
271 Bookmarks []control.BookmarkOut
272 }{s.baseFor(u), "bookmarks", rows})
273} 270}
274 271
275// renderFork draws the fork form: where the copy lands and what it is 272// renderFork draws the fork form: where the copy lands and what it is
internal/httpd/ownerpage_test.go +120 −71
@@ -12,12 +12,13 @@ import (
12 12
13func TestProfileTabFromPath(t *testing.T) { 13func TestProfileTabFromPath(t *testing.T) {
14 for path, want := range map[string]string{ 14 for path, want := range map[string]string{
15 "/cmc": "repos", 15 "/cmc": "about",
16 "/cmc/-/about": "about", 16 "/cmc/-/repositories": "repos",
17 "/cmc/-/activity": "activity", 17 "/cmc/-/bookmarks": "bookmarks",
18 "/cmc/-/people": "people", 18 "/cmc/-/snippets": "snippets",
19 "/krz": "repos", 19 "/cmc/-/people": "people",
20 "/cmc/-/snippets": "repos", 20 "/krz": "about",
21 "/cmc/-/snippets/abcd": "about",
21 } { 22 } {
22 if got := profileTab(path); got != want { 23 if got := profileTab(path); got != want {
23 t.Errorf("profileTab(%q) = %q, want %q", path, got, want) 24 t.Errorf("profileTab(%q) = %q, want %q", path, got, want)
@@ -25,48 +26,94 @@ func TestProfileTabFromPath(t *testing.T) {
25 } 26 }
26} 27}
27 28
28// The About text and the year of squares sat above the repository list 29// The profile is sections rather than one stack (#242): the bar reads
29// and pushed it below the fold (#242). Repositories are the bare 30// About, Repositories, Bookmarks, Snippets, and each holds one thing.
30// /{owner} now and the rest are tabs beside them. 31func TestOwnerPageTabOrder(t *testing.T) {
31func TestOwnerPageLeadsWithRepositories(t *testing.T) { 32 d := ownerFixture()
32 out := renderOwner(t, "repos", ownerFixture()) 33 d.Self, d.Snippets = true, 2
33 if !strings.Contains(out, "reminiscecleberg.com") { 34 out := renderOwner(t, "about", d)
34 t.Errorf("the repository list is not on the default tab:\n%s", out) 35 order := []string{
35 } 36 `href="/cmc">About`,
36 for _, unwanted := range []string{"actgraph", "Christian Cleberg"} { 37 `href="/cmc/-/repositories">Repositories`,
37 if strings.Contains(out, unwanted) { 38 `href="/cmc/-/bookmarks">Bookmarks`,
38 t.Errorf("the default tab still carries %q:\n%s", unwanted, out) 39 `href="/cmc/-/snippets">Snippets`,
40 }
41 at := -1
42 for _, want := range order {
43 i := strings.Index(out, want)
44 if i < 0 {
45 t.Fatalf("tab bar missing %q:\n%s", want, out)
46 }
47 if i < at {
48 t.Errorf("tab %q is out of order", want)
39 } 49 }
50 at = i
40 } 51 }
41 for _, want := range []string{ 52}
42 `aria-current="page" href="/cmc"`, 53
43 `href="/cmc/-/about"`, 54// The graph moved inside About, with a log of the newest events under
44 `href="/cmc/-/activity"`, 55// it — not the whole history, which is what the atom feed is for.
45 } { 56func TestOwnerPageAboutCarriesTheGraphAndLog(t *testing.T) {
57 d := ownerFixture()
58 d.Log = []feedLine{{Actor: "cmc", Verb: "opened issue", Ref: "#12", Repo: "krz/gitbay", URL: "/krz/gitbay/issues/12"}}
59 out := renderOwner(t, "about", d)
60 for _, want := range []string{"Christian Cleberg", "actgraph", "opened issue", "#12", "/cmc/activity.atom"} {
46 if !strings.Contains(out, want) { 61 if !strings.Contains(out, want) {
47 t.Errorf("tab bar missing %q:\n%s", want, out) 62 t.Errorf("about tab missing %q:\n%s", want, out)
48 } 63 }
49 } 64 }
50 // Nobody administers this profile, so it offers no people tab. 65 if strings.Contains(out, "reminiscecleberg.com") {
51 if strings.Contains(out, "/-/people") { 66 t.Errorf("the about tab carries the repository list:\n%s", out)
52 t.Errorf("a profile nobody admins offers a people tab:\n%s", out) 67 }
68 // A profile with no About file still has the graph, so the tab is
69 // never empty and never a 404.
70 d.AboutHTML = ""
71 if out := renderOwner(t, "about", d); !strings.Contains(out, "actgraph") {
72 t.Errorf("a profile with no About file loses its graph:\n%s", out)
73 }
74 // Nothing to log reads as nothing, not as an empty block.
75 d.Log = nil
76 if out := renderOwner(t, "about", d); !strings.Contains(out, "Nothing yet.") {
77 t.Errorf("an empty log says nothing:\n%s", out)
53 } 78 }
54} 79}
55 80
56func TestOwnerPageTabsCarryOneSectionEach(t *testing.T) { 81func TestOwnerPageSectionsAreSeparate(t *testing.T) {
57 if out := renderOwner(t, "about", ownerFixture()); !strings.Contains(out, "Christian Cleberg") || 82 d := ownerFixture()
58 strings.Contains(out, "actgraph") || strings.Contains(out, "reminiscecleberg.com") { 83 d.Self, d.Snippets = true, 1
59 t.Errorf("about tab is not the About file alone:\n%s", out) 84 d.Bookmarks = []control.BookmarkOut{{Path: "krz/hutch", Description: "a SourceHut client", Visibility: "public"}}
85 d.SnippetRows = []snippetRow{{store.Snippet{PublicID: "ab12", Description: "a shell one-liner", Visibility: "public"}, "run.sh"}}
86
87 repos := renderOwner(t, "repos", d)
88 if !strings.Contains(repos, "reminiscecleberg.com") || strings.Contains(repos, "actgraph") {
89 t.Errorf("repos tab is not the repository list alone:\n%s", repos)
90 }
91 marks := renderOwner(t, "bookmarks", d)
92 if !strings.Contains(marks, "krz/hutch") || strings.Contains(marks, "reminiscecleberg.com") {
93 t.Errorf("bookmarks tab is not the bookmark list alone:\n%s", marks)
60 } 94 }
61 if out := renderOwner(t, "activity", ownerFixture()); !strings.Contains(out, "actgraph") || 95 // The snippet list renders here rather than on a page of its own.
62 strings.Contains(out, "reminiscecleberg.com") { 96 snips := renderOwner(t, "snippets", d)
63 t.Errorf("activity tab is not the graph alone:\n%s", out) 97 for _, want := range []string{"a shell one-liner", "run.sh", `href="/cmc/-/snippets/ab12"`, "new snippet"} {
98 if !strings.Contains(snips, want) {
99 t.Errorf("snippets tab missing %q:\n%s", want, snips)
100 }
101 }
102 if !strings.Contains(snips, `href="/cmc/-/repositories">Repositories`) {
103 t.Errorf("the snippets tab lost the profile's tab bar:\n%s", snips)
104 }
105}
106
107// Bookmarks are the viewer's own: repo bookmarks takes no owner and
108// lists the caller's. The tab is not offered on anyone else's profile.
109func TestOwnerPageBookmarksTabIsSelfOnly(t *testing.T) {
110 if out := renderOwner(t, "about", ownerFixture()); strings.Contains(out, "/-/bookmarks") {
111 t.Errorf("a stranger's profile offers a bookmarks tab:\n%s", out)
64 } 112 }
65 // A profile with no About file does not offer the tab.
66 d := ownerFixture() 113 d := ownerFixture()
67 d.AboutHTML = "" 114 d.Self = true
68 if out := renderOwner(t, "repos", d); strings.Contains(out, "/-/about") { 115 if out := renderOwner(t, "about", d); !strings.Contains(out, "/-/bookmarks") {
69 t.Errorf("a profile with no About file offers the tab:\n%s", out) 116 t.Errorf("own profile has no bookmarks tab:\n%s", out)
70 } 117 }
71} 118}
72 119
@@ -76,12 +123,16 @@ func TestOwnerPagePeopleTabHoldsTheAdminPanel(t *testing.T) {
76 d.CanAdmin = true 123 d.CanAdmin = true
77 d.Members = []control.ProfileMember{{Name: "cmc", Role: "admin"}} 124 d.Members = []control.ProfileMember{{Name: "cmc", Role: "admin"}}
78 125
79 repos := renderOwner(t, "repos", d) 126 about := renderOwner(t, "about", d)
80 if !strings.Contains(repos, `href="/cmc/-/people"`) { 127 if !strings.Contains(about, `href="/cmc/-/people"`) {
81 t.Errorf("an admin gets no people tab:\n%s", repos) 128 t.Errorf("an admin gets no people tab:\n%s", about)
129 }
130 if strings.Contains(about, "Create a team") {
131 t.Errorf("the admin forms still sit on another tab:\n%s", about)
82 } 132 }
83 if strings.Contains(repos, "Create a team") { 133 // An org has no snippets, so it is not offered the tab.
84 t.Errorf("the admin forms still sit under the repository list:\n%s", repos) 134 if strings.Contains(about, "/-/snippets") {
135 t.Errorf("an org offers a snippets tab:\n%s", about)
85 } 136 }
86 people := renderOwner(t, "people", d) 137 people := renderOwner(t, "people", d)
87 for _, want := range []string{"Create a team", "member-add", "org-rename"} { 138 for _, want := range []string{"Create a team", "member-add", "org-rename"} {
@@ -92,10 +143,15 @@ func TestOwnerPagePeopleTabHoldsTheAdminPanel(t *testing.T) {
92} 143}
93 144
94type ownerFixtureData struct { 145type ownerFixtureData struct {
95 Kind string 146 Kind string
96 AboutHTML template.HTML 147 AboutHTML template.HTML
97 CanAdmin bool 148 CanAdmin bool
98 Members []control.ProfileMember 149 Self bool
150 Snippets int
151 Members []control.ProfileMember
152 Log []feedLine
153 Bookmarks []control.BookmarkOut
154 SnippetRows []snippetRow
99} 155}
100 156
101func ownerFixture() ownerFixtureData { 157func ownerFixture() ownerFixtureData {
@@ -108,31 +164,24 @@ func ownerFixture() ownerFixtureData {
108func renderOwner(t *testing.T, tab string, d ownerFixtureData) string { 164func renderOwner(t *testing.T, tab string, d ownerFixtureData) string {
109 t.Helper() 165 t.Helper()
110 var sb strings.Builder 166 var sb strings.Builder
111 err := web.Render(&sb, "owner.html", struct { 167 err := web.Render(&sb, "owner.html", ownerPage{
112 basePage 168 basePage: basePage{Site: "gitbay"},
113 Owner string 169 Owner: "cmc",
114 Kind string 170 Kind: d.Kind,
115 Tab string 171 Tab: tab,
116 Profile store.Profile 172 Profile: store.Profile{Description: "Org-Mode · Self-Hosting · Privacy"},
117 AboutHTML template.HTML 173 AboutHTML: d.AboutHTML,
118 Repos []profileRepoRow 174 Repos: []profileRepoRow{{control.ProfileRepo{Path: "cmc/reminiscecleberg.com", Description: "Personal placeholder site."}}},
119 Members []control.ProfileMember 175 Members: d.Members,
120 Orgs []control.ProfileMember 176 Activity: []activityWeek{{Month: "Sep", Days: []activityDay{{Date: "2026-09-20", Count: 3, Level: 2}}}},
121 Activity []activityWeek 177 ActivityTotal: 6088,
122 ActivityTotal int 178 Log: d.Log,
123 Teams []teamView 179 Bookmarks: d.Bookmarks,
124 CanAdmin bool 180 SnippetRows: d.SnippetRows,
125 Self bool 181 CanAdmin: d.CanAdmin,
126 Snippets int 182 Self: d.Self,
127 Notice string 183 Snippets: d.Snippets,
128 Feed string 184 Feed: "/cmc/activity.atom",
129 }{
130 basePage{Site: "gitbay"}, "cmc", d.Kind, tab,
131 store.Profile{Description: "Org-Mode · Self-Hosting · Privacy"}, d.AboutHTML,
132 []profileRepoRow{{control.ProfileRepo{Path: "cmc/reminiscecleberg.com", Description: "Personal placeholder site."}}},
133 d.Members, nil,
134 []activityWeek{{Month: "Sep", Days: []activityDay{{Date: "2026-09-20", Count: 3, Level: 2}}}}, 6088,
135 nil, d.CanAdmin, false, 0, "", "/cmc/activity.atom",
136 }) 185 })
137 if err != nil { 186 if err != nil {
138 t.Fatalf("render: %v", err) 187 t.Fatalf("render: %v", err)
internal/httpd/routes.go +5 −5
@@ -59,7 +59,7 @@ func (s *Server) Routes() []Route {
59 } 59 }
60 routes = append(routes, 60 routes = append(routes,
61 Route{Method: "GET", Pattern: "/favicon.svg", Handler: s.favicon}, 61 Route{Method: "GET", Pattern: "/favicon.svg", Handler: s.favicon},
62 Route{Method: "GET", Pattern: "/{owner}", Handler: s.ownerPage}, 62 Route{Method: "GET", Pattern: "/{owner}", Handler: s.ownerProfile},
63 Route{Method: "GET", Pattern: "/{owner}/{repo}", Handler: s.repoHome}, 63 Route{Method: "GET", Pattern: "/{owner}/{repo}", Handler: s.repoHome},
64 Route{Method: "GET", Pattern: "/{owner}/{repo}/tree/{ref}/{path...}", Handler: s.tree}, 64 Route{Method: "GET", Pattern: "/{owner}/{repo}/tree/{ref}/{path...}", Handler: s.tree},
65 Route{Method: "GET", Pattern: "/{owner}/{repo}/blob/{ref}/{path...}", Handler: s.blob}, 65 Route{Method: "GET", Pattern: "/{owner}/{repo}/blob/{ref}/{path...}", Handler: s.blob},
@@ -75,12 +75,12 @@ func (s *Server) Routes() []Route {
75 Route{Method: "GET", Pattern: "/{owner}/{repo}/log.atom", Handler: s.logAtom}, 75 Route{Method: "GET", Pattern: "/{owner}/{repo}/log.atom", Handler: s.logAtom},
76 Route{Method: "GET", Pattern: "/{owner}/{repo}/log.atom/{ref...}", Handler: s.logAtom}, 76 Route{Method: "GET", Pattern: "/{owner}/{repo}/log.atom/{ref...}", Handler: s.logAtom},
77 Route{Method: "GET", Pattern: "/{owner}/activity.atom", Handler: s.ownerAtom}, 77 Route{Method: "GET", Pattern: "/{owner}/activity.atom", Handler: s.ownerAtom},
78 Route{Method: "GET", Pattern: "/{owner}/-/about", Handler: s.ownerPage}, 78 Route{Method: "GET", Pattern: "/{owner}/-/repositories", Handler: s.ownerProfile},
79 Route{Method: "GET", Pattern: "/{owner}/-/activity", Handler: s.ownerPage}, 79 Route{Method: "GET", Pattern: "/{owner}/-/bookmarks", Handler: s.ownerProfile},
80 Route{Method: "GET", Pattern: "/{owner}/-/people", Handler: s.ownerPage}, 80 Route{Method: "GET", Pattern: "/{owner}/-/people", Handler: s.ownerProfile},
81 Route{Method: "GET", Pattern: "/{owner}/-/labels", Handler: s.orgLabels}, 81 Route{Method: "GET", Pattern: "/{owner}/-/labels", Handler: s.orgLabels},
82 Route{Method: "GET", Pattern: "/{owner}/-/milestones", Handler: s.orgMilestones}, 82 Route{Method: "GET", Pattern: "/{owner}/-/milestones", Handler: s.orgMilestones},
83 Route{Method: "GET", Pattern: "/{owner}/-/snippets", Handler: s.snippetsPage}, 83 Route{Method: "GET", Pattern: "/{owner}/-/snippets", Handler: s.ownerProfile},
84 Route{Method: "GET", Pattern: "/{owner}/-/snippets/{id}", Handler: s.snippetPage}, 84 Route{Method: "GET", Pattern: "/{owner}/-/snippets/{id}", Handler: s.snippetPage},
85 Route{Method: "GET", Pattern: "/{owner}/-/snippets/{id}/raw/{name}", Handler: s.snippetRaw}, 85 Route{Method: "GET", Pattern: "/{owner}/-/snippets/{id}/raw/{name}", Handler: s.snippetRaw},
86 Route{Method: "GET", Pattern: "/{owner}/{repo}/builds", Handler: s.builds}, 86 Route{Method: "GET", Pattern: "/{owner}/{repo}/builds", Handler: s.builds},
internal/httpd/snippets.go +10 −15
@@ -30,19 +30,21 @@ type snippetRow struct {
30 Names string 30 Names string
31} 31}
32 32
33func (s *Server) snippetsPage(w http.ResponseWriter, r *http.Request) { 33// ownerSnippets lists an owner's snippets for the profile's Snippets
34 viewer := s.viewer(r) 34// tab. The list is a section of the profile like the repositories are,
35 owner, err := s.st.UserByUsername(r.PathValue("owner")) 35// not a page of its own (a snippet itself still is). A private snippet
36// is in the list only for its owner and the admins.
37func (s *Server) ownerSnippets(w http.ResponseWriter, r *http.Request, viewer store.User, name string) ([]snippetRow, bool) {
38 owner, err := s.st.UserByUsername(name)
36 if err != nil { 39 if err != nil {
37 s.notFound(w, r) 40 s.notFound(w, r)
38 return 41 return nil, false
39 } 42 }
40 self := viewer.ID != 0 && viewer.ID == owner.ID 43 all := viewer.IsAdmin || (viewer.ID != 0 && viewer.ID == owner.ID)
41 all := self || viewer.IsAdmin
42 list, err := s.st.ListSnippets(owner.ID, all, 0, 0) 44 list, err := s.st.ListSnippets(owner.ID, all, 0, 0)
43 if err != nil { 45 if err != nil {
44 http.Error(w, "internal error", http.StatusInternalServerError) 46 http.Error(w, "internal error", http.StatusInternalServerError)
45 return 47 return nil, false
46 } 48 }
47 rows := make([]snippetRow, 0, len(list)) 49 rows := make([]snippetRow, 0, len(list))
48 for _, sn := range list { 50 for _, sn := range list {
@@ -55,14 +57,7 @@ func (s *Server) snippetsPage(w http.ResponseWriter, r *http.Request) {
55 } 57 }
56 rows = append(rows, snippetRow{sn, names.String()}) 58 rows = append(rows, snippetRow{sn, names.String()})
57 } 59 }
58 s.render(w, "snippets.html", struct { 60 return rows, true
59 basePage
60 Owner string
61 Self bool
62 All bool
63 Snippets []snippetRow
64 Notice string
65 }{s.baseFor(viewer), owner.Username, self, all, rows, s.takeFlash(w, r)})
66} 61}
67 62
68type snippetFileView struct { 63type snippetFileView struct {
internal/httpd/web.go +121 −40
@@ -427,23 +427,92 @@ func (p profileRepoRow) Desc() string { return p.Description }
427// viewer may see, org membership either direction. Owner names are not 427// viewer may see, org membership either direction. Owner names are not
428// secret (they are on every commit); repository visibility rules hold. 428// secret (they are on every commit); repository visibility rules hold.
429// profileTab is which section of a profile a URL asks for. The bare 429// profileTab is which section of a profile a URL asks for. The bare
430// /{owner} is the repository list, because a profile's job is to lead to 430// /{owner} is About, the first tab; the rest hang off the /-/ namespace
431// the projects and the About text used to push them below the fold 431// the labels and milestones pages already use. What #242 asked for is
432// (#242). The rest hang off the /-/ namespace the labels, milestones and 432// that the sections be separate pages rather than one stack a long
433// snippet pages already use. 433// About pushes the repositories off the bottom of — not that any one of
434// them be the landing page.
434func profileTab(path string) string { 435func profileTab(path string) string {
435 switch { 436 switch {
436 case strings.HasSuffix(path, "/-/about"): 437 case strings.HasSuffix(path, "/-/repositories"):
437 return "about" 438 return "repos"
438 case strings.HasSuffix(path, "/-/activity"): 439 case strings.HasSuffix(path, "/-/bookmarks"):
439 return "activity" 440 return "bookmarks"
441 case strings.HasSuffix(path, "/-/snippets"):
442 return "snippets"
440 case strings.HasSuffix(path, "/-/people"): 443 case strings.HasSuffix(path, "/-/people"):
441 return "people" 444 return "people"
442 } 445 }
443 return "repos" 446 return "about"
444} 447}
445 448
446func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) { 449// profileEvents is how many activity lines the About tab lists under the
450// graph. The graph is a year at a glance; the log is what happened
451// lately, and a fixed count keeps the page the same length whatever the
452// account's pace.
453const profileEvents = 30
454
455// ownerFeed is the activity log under the graph on the About tab: the
456// newest of whatever the graph above it counts, on public repositories
457// only. That is the actor's own events for a user and the
458// organization's repositories' events for an org, matching
459// ActivityByDay and OrgActivityByDay respectively — a log that counted
460// something else would contradict the total printed over it. Only the
461// About tab renders it, so no other tab pays for the query.
462func (s *Server) ownerFeed(tab, kind, name string) []feedLine {
463 if tab != "about" {
464 return nil
465 }
466 var events []store.FeedEvent
467 var err error
468 switch kind {
469 case "user":
470 u, uerr := s.st.UserByUsername(name)
471 if uerr != nil {
472 return nil
473 }
474 events, err = s.st.UserPublicEvents(u.ID, profileEvents)
475 case "org":
476 o, oerr := s.st.OrgByName(name)
477 if oerr != nil {
478 return nil
479 }
480 events, err = s.st.OwnerPublicEvents("org", o.ID, profileEvents)
481 }
482 if err != nil {
483 return nil
484 }
485 return feedLines(events)
486}
487
488// ownerPage is what owner.html renders against. It is a named type
489// because the handler and the tests must agree on it field for field,
490// and an anonymous struct in two places drifts.
491type ownerPage struct {
492 basePage
493 Owner string
494 Kind string
495 Tab string
496 Profile store.Profile
497 AboutHTML template.HTML
498 Repos []profileRepoRow
499 Members []control.ProfileMember
500 Orgs []control.ProfileMember
501 Activity []activityWeek
502 ActivityTotal int
503 Log []feedLine
504 Bookmarks []control.BookmarkOut
505 SnippetRows []snippetRow
506 SnippetsAll bool
507 Teams []teamView
508 CanAdmin bool
509 Self bool
510 Snippets int
511 Notice string
512 Feed string
513}
514
515func (s *Server) ownerProfile(w http.ResponseWriter, r *http.Request) {
447 name := r.PathValue("owner") 516 name := r.PathValue("owner")
448 var viewer store.User 517 var viewer store.User
449 if s.cfg.Web.Mode == "accounts" { 518 if s.cfg.Web.Mode == "accounts" {
@@ -472,40 +541,52 @@ func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) {
472 weeks, activityTotal := activityGrid(counts) 541 weeks, activityTotal := activityGrid(counts)
473 542
474 teams, canAdmin := s.orgAdminView(viewer, d.Kind, name) 543 teams, canAdmin := s.orgAdminView(viewer, d.Kind, name)
544 self := d.Kind == "user" && viewer.ID != 0 && strings.EqualFold(viewer.Username, name)
475 tab := profileTab(r.URL.Path) 545 tab := profileTab(r.URL.Path)
476 // Neither tab is offered when there is nothing on it: the people tab 546 // A tab nobody may open is not a page: the people tab is the
477 // is the organization admin panel, and the About tab is a file the 547 // organization admin panel, bookmarks are the viewer's own and
478 // owner may not have written. Both answer the way a missing page does 548 // nobody else's, and only a user has snippets. Each answers the way
479 // rather than rendering empty. 549 // a missing page does rather than rendering empty.
480 if (tab == "people" && !canAdmin) || (tab == "about" && d.About == "") { 550 if (tab == "people" && !canAdmin) || (tab == "bookmarks" && !self) ||
551 (tab == "snippets" && d.Kind != "user") {
481 s.notFound(w, r) 552 s.notFound(w, r)
482 return 553 return
483 } 554 }
484 profile := store.Profile{Description: d.Description, Website: d.Website, Links: d.Links} 555
485 s.render(w, "owner.html", struct { 556 var bookmarks []control.BookmarkOut
486 basePage 557 if tab == "bookmarks" {
487 Owner string 558 s.runControlInto(viewer, []string{"repo", "bookmarks"}, &bookmarks)
488 Kind string 559 }
489 Tab string 560 var snippets []snippetRow
490 Profile store.Profile 561 if tab == "snippets" {
491 AboutHTML template.HTML 562 var ok bool
492 Repos []profileRepoRow 563 if snippets, ok = s.ownerSnippets(w, r, viewer, name); !ok {
493 Members []control.ProfileMember 564 return
494 Orgs []control.ProfileMember 565 }
495 Activity []activityWeek 566 }
496 ActivityTotal int 567 s.render(w, "owner.html", ownerPage{
497 Teams []teamView 568 basePage: s.baseFor(viewer),
498 CanAdmin bool 569 Owner: name,
499 Self bool 570 Kind: d.Kind,
500 Snippets int 571 Tab: tab,
501 Notice string 572 Profile: store.Profile{Description: d.Description, Website: d.Website, Links: d.Links},
502 Feed string 573 AboutHTML: aboutHTML(d.About, d.AboutFormat),
503 }{s.baseFor(viewer), name, d.Kind, tab, profile, aboutHTML(d.About, d.AboutFormat), 574 Repos: d.Repos,
504 d.Repos, d.Members, d.Orgs, 575 Members: d.Members,
505 weeks, activityTotal, teams, canAdmin, 576 Orgs: d.Orgs,
506 d.Kind == "user" && viewer.ID != 0 && strings.EqualFold(viewer.Username, name), 577 Activity: weeks,
507 d.Snippets, 578 ActivityTotal: activityTotal,
508 s.takeFlash(w, r), "/" + name + "/activity.atom"}) 579 Log: s.ownerFeed(tab, d.Kind, name),
580 Bookmarks: bookmarks,
581 SnippetRows: snippets,
582 SnippetsAll: self || viewer.IsAdmin,
583 Teams: teams,
584 CanAdmin: canAdmin,
585 Self: self,
586 Snippets: d.Snippets,
587 Notice: s.takeFlash(w, r),
588 Feed: "/" + name + "/activity.atom",
589 })
509} 590}
510 591
511func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) { 592func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) {
internal/store/dashboard.go +31
@@ -323,6 +323,37 @@ func (s *Store) OwnerPublicEvents(ownerKind string, ownerID int64, limit int) ([
323 return out, rows.Err() 323 return out, rows.Err()
324} 324}
325 325
326// UserPublicEvents returns what a user did on public repositories,
327// newest first. It keys on the actor, not the repository's owner, which
328// is what ActivityByDay counts for a user: the profile's log has to
329// agree with the total printed above its graph. Push events are
330// excluded as in RecentEvents.
331func (s *Store) UserPublicEvents(userID int64, limit int) ([]FeedEvent, error) {
332 rows, err := s.DB.Query(`
333 SELECT e.id, COALESCE(u.username, o.name) || '/' || r.name,
334 COALESCE(ac.username, ''), e.kind, e.data_json, e.created_at
335 FROM events e
336 JOIN repos r ON r.id = e.repo_id
337 LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id
338 LEFT JOIN orgs o ON r.owner_kind = 'org' AND o.id = r.owner_id
339 LEFT JOIN users ac ON ac.id = e.actor_id
340 WHERE e.kind <> 'push' AND r.visibility = 'public' AND e.actor_id = ?
341 ORDER BY e.id DESC LIMIT ?`, userID, limit)
342 if err != nil {
343 return nil, err
344 }
345 defer rows.Close()
346 var out []FeedEvent
347 for rows.Next() {
348 var e FeedEvent
349 if err := rows.Scan(&e.ID, &e.RepoPath, &e.Actor, &e.Kind, &e.Data, &e.CreatedAt); err != nil {
350 return nil, err
351 }
352 out = append(out, e)
353 }
354 return out, rows.Err()
355}
356
326// RecentEvents returns activity on repositories the user can reach. Push 357// RecentEvents returns activity on repositories the user can reach. Push
327// events are excluded: they repeat what the commit lists already show. 358// events are excluded: they repeat what the commit lists already show.
328// before (an event id) starts the page strictly below it, matching the 359// before (an event id) starts the page strictly below it, matching the
internal/store/ownerevents_test.go added +71
@@ -0,0 +1,71 @@
1package store
2
3import "testing"
4
5// The profile's activity log has to count what the graph over it counts.
6// For a user that is what they did, wherever they did it — not what
7// happened on the repositories they happen to own.
8func TestUserPublicEventsKeyOnTheActor(t *testing.T) {
9 s := open(t)
10 if err := s.MigrateUp(); err != nil {
11 t.Fatal(err)
12 }
13
14 alice, err := s.CreateUser("alice", false)
15 if err != nil {
16 t.Fatal(err)
17 }
18 bob, err := s.CreateUser("bob", false)
19 if err != nil {
20 t.Fatal(err)
21 }
22 // alice owns one public and one private repository; bob owns one.
23 aliceRepo, err := s.CreateRepo("user", alice, "app", "public")
24 if err != nil {
25 t.Fatal(err)
26 }
27 secret, err := s.CreateRepo("user", alice, "secret", "private")
28 if err != nil {
29 t.Fatal(err)
30 }
31 bobRepo, err := s.CreateRepo("user", bob, "tool", "public")
32 if err != nil {
33 t.Fatal(err)
34 }
35
36 for _, e := range []struct {
37 repo, actor int64
38 kind string
39 }{
40 {aliceRepo, alice, "issue.opened"}, // hers, on her repo
41 {bobRepo, alice, "issue.opened"}, // hers, on someone else's
42 {aliceRepo, bob, "issue.opened"}, // his, on her repo
43 {secret, alice, "issue.opened"}, // hers, but private
44 {aliceRepo, alice, "push"}, // pushes never appear
45 } {
46 if err := s.RecordEvent(e.repo, e.actor, e.kind, "{}"); err != nil {
47 t.Fatal(err)
48 }
49 }
50
51 got, err := s.UserPublicEvents(alice, 10)
52 if err != nil {
53 t.Fatal(err)
54 }
55 if len(got) != 2 {
56 t.Fatalf("want alice's two public events, got %d: %+v", len(got), got)
57 }
58 // Newest first, and both are hers.
59 if got[0].RepoPath != "bob/tool" || got[1].RepoPath != "alice/app" {
60 t.Errorf("wrong rows or order: %+v", got)
61 }
62 for _, e := range got {
63 if e.Actor != "alice" {
64 t.Errorf("an event alice did not do: %+v", e)
65 }
66 }
67
68 if got, err := s.UserPublicEvents(alice, 1); err != nil || len(got) != 1 {
69 t.Fatalf("limit not applied: %d %v", len(got), err)
70 }
71}
internal/web/static/style.css +4
@@ -1677,6 +1677,10 @@ h2 .count { background: none; color: var(--muted); font-weight: 400; font-size:
1677.actlegend { display: flex; align-items: center; gap: 3px; margin-top: var(--sp-2); font-size: var(--fs-0); color: var(--muted); } 1677.actlegend { display: flex; align-items: center; gap: 3px; margin-top: var(--sp-2); font-size: var(--fs-0); color: var(--muted); }
1678.actlegend span:first-child { margin-right: var(--sp-1); } 1678.actlegend span:first-child { margin-right: var(--sp-1); }
1679.actlegend span:last-child { margin-left: var(--sp-1); } 1679.actlegend span:last-child { margin-left: var(--sp-1); }
1680/* the activity log under the graph: the newest events, not the year */
1681.logsub { margin: var(--sp-5) 0 var(--sp-2); font-size: var(--fs-2); color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; }
1682.ownerlog .feedline { font-size: var(--fs-2); }
1683.ownerlog .feedline .none { font-size: var(--fs-1); }
1680 1684
1681/* ---- wiki ---- */ 1685/* ---- wiki ---- */
1682.wikilayout { display: flex; gap: var(--sp-5); align-items: flex-start; } 1686.wikilayout { display: flex; gap: var(--sp-5); align-items: flex-start; }
internal/web/templates/bookmarks.html deleted −16
@@ -1,16 +0,0 @@
1{{define "title"}}bookmarks · {{.Site}}{{end}}
2{{define "content"}}
3<h1>Bookmarks</h1>
4<p class="meta">Repositories you saved to come back to. A pin is private quick
5 access to your own work; a bookmark is a public vote to save access to another
6 repo.</p>
7<ul class="repolist">
8{{range .Bookmarks}}<li>
9 <p class="title"><a href="/{{.Path}}">{{.Path}}</a>
10 {{if eq .Visibility "private"}}<span class="chip">Private</span>{{end}}
11 <span class="count">{{.Bookmarks}}</span></p>
12 {{if .Description}}<p class="desc">{{.Description}}</p>{{end}}
13</li>
14{{else}}<li class="empty">nothing bookmarked yet. open a repository and press Bookmark</li>{{end}}
15</ul>
16{{end}}
internal/web/templates/layout.html +2 −2
@@ -22,7 +22,7 @@
22 <li class="railopt">{{template "raillink" dict "Href" "/explore" "Icon" "compass" "Name" "Explore" "Current" (eq (str . "Tab") "explore")}}</li> 22 <li class="railopt">{{template "raillink" dict "Href" "/explore" "Icon" "compass" "Name" "Explore" "Current" (eq (str . "Tab") "explore")}}</li>
23 <li>{{template "raillink" dict "Href" "/search" "Icon" "search" "Name" "Search" "Current" (eq (str . "Tab") "sitesearch")}}</li> 23 <li>{{template "raillink" dict "Href" "/search" "Icon" "search" "Name" "Search" "Current" (eq (str . "Tab") "sitesearch")}}</li>
24 {{if .Viewer}}<li>{{template "raillink" dict "Href" "/notifications" "Icon" "bell" "Name" "Notifications" "Current" (eq (str . "Tab") "notifications") "Count" .Rail.Unread}}</li> 24 {{if .Viewer}}<li>{{template "raillink" dict "Href" "/notifications" "Icon" "bell" "Name" "Notifications" "Current" (eq (str . "Tab") "notifications") "Count" .Rail.Unread}}</li>
25 <li class="railopt">{{template "raillink" dict "Href" "/bookmarks" "Icon" "bookmark" "Name" "Bookmarks" "Current" (eq (str . "Tab") "bookmarks")}}</li> 25 <li class="railopt">{{template "raillink" dict "Href" (printf "/%s/-/bookmarks" .Viewer) "Icon" "bookmark" "Name" "Bookmarks" "Current" (eq (str . "Tab") "bookmarks")}}</li>
26 <li class="railopt">{{template "raillink" dict "Href" (printf "/%s/-/snippets" .Viewer) "Icon" "snippet" "Name" "Snippets" "Current" (eq (str . "Tab") "snippets")}}</li> 26 <li class="railopt">{{template "raillink" dict "Href" (printf "/%s/-/snippets" .Viewer) "Icon" "snippet" "Name" "Snippets" "Current" (eq (str . "Tab") "snippets")}}</li>
27 <li class="railopt">{{template "raillink" dict "Href" "/new" "Icon" "plus" "Name" "New repository"}}</li>{{end}} 27 <li class="railopt">{{template "raillink" dict "Href" "/new" "Icon" "plus" "Name" "New repository"}}</li>{{end}}
28 </ul> 28 </ul>
@@ -36,7 +36,7 @@
36 <summary class="railicon" aria-label="More" title="More">{{template "icon" "ellipsis"}}<span class="vh">More</span></summary> 36 <summary class="railicon" aria-label="More" title="More">{{template "icon" "ellipsis"}}<span class="vh">More</span></summary>
37 <div class="raildrop"> 37 <div class="raildrop">
38 <a href="/explore">{{template "icon" "compass"}} Explore</a> 38 <a href="/explore">{{template "icon" "compass"}} Explore</a>
39 <a href="/bookmarks">{{template "icon" "bookmark"}} Bookmarks</a> 39 <a href="/{{.Viewer}}/-/bookmarks">{{template "icon" "bookmark"}} Bookmarks</a>
40 <a href="/{{.Viewer}}/-/snippets">{{template "icon" "snippet"}} Snippets</a> 40 <a href="/{{.Viewer}}/-/snippets">{{template "icon" "snippet"}} Snippets</a>
41 <a href="/new">{{template "icon" "plus"}} New repository</a> 41 <a href="/new">{{template "icon" "plus"}} New repository</a>
42 <a href="/settings">{{template "icon" "gear"}} Settings</a> 42 <a href="/settings">{{template "icon" "gear"}} Settings</a>
internal/web/templates/owner.html +56 −18
@@ -9,29 +9,20 @@
9{{if .Members}}<p class="meta">members {{range .Members}}<a class="memberchip" href="/{{.Name}}">{{.Name}} <span class="role">{{.Role}}</span></a> {{end}}</p>{{end}} 9{{if .Members}}<p class="meta">members {{range .Members}}<a class="memberchip" href="/{{.Name}}">{{.Name}} <span class="role">{{.Role}}</span></a> {{end}}</p>{{end}}
10{{if and (eq .Kind "org") .Repos}}<p class="meta"><a href="/{{.Owner}}/-/labels">labels</a> · <a href="/{{.Owner}}/-/milestones">milestones</a></p>{{end}} 10{{if and (eq .Kind "org") .Repos}}<p class="meta"><a href="/{{.Owner}}/-/labels">labels</a> · <a href="/{{.Owner}}/-/milestones">milestones</a></p>{{end}}
11</section> 11</section>
12{{/* The repository list is the bare /{owner}, because a profile's job is 12{{/* The profile is sections rather than one stack: a long About used to
13 to lead to the projects: the About text and the year of squares used 13 push the repositories off the bottom of the page (#242). About is
14 to sit above the list and push it below the fold (#242). */}} 14 the bare /{owner}; the rest hang off /-/. A tab nobody may open is
15 not offered and its URL is a 404. */}}
15<nav class="tabs" aria-label="Profile"> 16<nav class="tabs" aria-label="Profile">
16 <a {{if eq .Tab "repos"}}aria-current="page" {{end}}href="/{{.Owner}}">Repositories{{if .Repos}} <i>{{len .Repos}}</i>{{end}}</a> 17 <a {{if eq .Tab "about"}}aria-current="page" {{end}}href="/{{.Owner}}">About</a>
17 {{if .AboutHTML}}<a {{if eq .Tab "about"}}aria-current="page" {{end}}href="/{{.Owner}}/-/about">About</a>{{end}} 18 <a {{if eq .Tab "repos"}}aria-current="page" {{end}}href="/{{.Owner}}/-/repositories">Repositories{{if .Repos}} <i>{{len .Repos}}</i>{{end}}</a>
18 <a {{if eq .Tab "activity"}}aria-current="page" {{end}}href="/{{.Owner}}/-/activity">Activity{{if .ActivityTotal}} <i>{{.ActivityTotal}}</i>{{end}}</a> 19 {{if .Self}}<a {{if eq .Tab "bookmarks"}}aria-current="page" {{end}}href="/{{.Owner}}/-/bookmarks">Bookmarks</a>{{end}}
19 {{if or .Snippets .Self}}<a href="/{{.Owner}}/-/snippets">Snippets{{if .Snippets}} <i>{{.Snippets}}</i>{{end}}</a>{{end}} 20 {{if and (eq .Kind "user") (or .Snippets .Self)}}<a {{if eq .Tab "snippets"}}aria-current="page" {{end}}href="/{{.Owner}}/-/snippets">Snippets{{if .Snippets}} <i>{{.Snippets}}</i>{{end}}</a>{{end}}
20 {{if .CanAdmin}}<a {{if eq .Tab "people"}}aria-current="page" {{end}}href="/{{.Owner}}/-/people">People{{if .Members}} <i>{{len .Members}}</i>{{end}}</a>{{end}} 21 {{if .CanAdmin}}<a {{if eq .Tab "people"}}aria-current="page" {{end}}href="/{{.Owner}}/-/people">People{{if .Members}} <i>{{len .Members}}</i>{{end}}</a>{{end}}
21</nav> 22</nav>
22 23
23{{if eq .Tab "repos"}}
24<ul class="repolist">
25{{range .Repos}}{{template "reporow" .}}
26{{else}}<li class="empty">no visible repositories</li>{{end}}
27</ul>
28{{end}}
29
30{{if eq .Tab "about"}} 24{{if eq .Tab "about"}}
31<section class="readme"><div class="rendered">{{.AboutHTML}}</div></section> 25{{if .AboutHTML}}<section class="readme"><div class="rendered">{{.AboutHTML}}</div></section>{{end}}
32{{end}}
33
34{{if eq .Tab "activity"}}
35<section class="activity"> 26<section class="activity">
36<h2>activity <span class="count">{{.ActivityTotal}} in the last year</span></h2> 27<h2>activity <span class="count">{{.ActivityTotal}} in the last year</span></h2>
37<div class="actgraph-scroll"> 28<div class="actgraph-scroll">
@@ -40,9 +31,56 @@
40</div> 31</div>
41</div> 32</div>
42<p class="actlegend"><span>Less</span><span class="actday l0"></span><span class="actday l1"></span><span class="actday l2"></span><span class="actday l3"></span><span class="actday l4"></span><span>More</span></p> 33<p class="actlegend"><span>Less</span><span class="actday l0"></span><span class="actday l1"></span><span class="actday l2"></span><span class="actday l3"></span><span class="actday l4"></span><span>More</span></p>
34{{/* The graph is the year at a glance; the log is what happened lately.
35 It is the newest events, not all of them — the atom feed is the one
36 that goes back. */}}
37{{if .Log}}<h3 class="logsub">recent</h3>
38<div class="ownerlog">
39{{range .Log}}<p class="feedline"><a href="/{{.Actor}}">{{.Actor}}</a> {{.Verb}} <a href="{{.URL}}"{{if .Jobs}} title="{{join .Jobs ", "}}"{{end}}>{{.Ref}}</a><br><span class="none">{{.Repo}} · <span title="{{whenT .WhenT}}">{{ago .WhenT}}</span></span></p>
40{{end}}
41</div>
42<p class="meta"><a href="{{.Feed}}">activity feed</a></p>
43{{else}}<p class="none">Nothing yet.</p>{{end}}
43</section> 44</section>
44{{end}} 45{{end}}
45 46
47{{if eq .Tab "repos"}}
48<ul class="repolist">
49{{range .Repos}}{{template "reporow" .}}
50{{else}}<li class="empty">no visible repositories</li>{{end}}
51</ul>
52{{end}}
53
54{{if eq .Tab "bookmarks"}}
55<p class="meta">Repositories you saved to come back to. A pin is private quick
56 access to your own work; a bookmark is a public vote to save access to another
57 repo.</p>
58<ul class="repolist">
59{{range .Bookmarks}}<li>
60 <p class="title"><a href="/{{.Path}}">{{.Path}}</a>
61 {{if eq .Visibility "private"}}<span class="chip">Private</span>{{end}}
62 <span class="count">{{.Bookmarks}}</span></p>
63 {{if .Description}}<p class="desc">{{.Description}}</p>{{end}}
64</li>
65{{else}}<li class="empty">nothing bookmarked yet. open a repository and press Bookmark</li>{{end}}
66</ul>
67{{end}}
68
69{{if eq .Tab "snippets"}}
70{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
71{{if .Self}}<p class="meta"><a href="/{{.Owner}}/-/snippets/new">new snippet</a> · or <code>gitbay snippet create &lt;file&gt; &lt; file</code></p>{{end}}
72{{if .SnippetRows}}<div class="tablewrap"><table class="keys">
73<tr class="cols"><th scope="col">snippet</th><th scope="col">files</th>{{if .SnippetsAll}}<th scope="col">visibility</th>{{end}}<th scope="col">updated</th></tr>
74{{range .SnippetRows}}<tr>
75 <td><a href="/{{$.Owner}}/-/snippets/{{.PublicID}}">{{if .Description}}{{.Description}}{{else}}{{.PublicID}}{{end}}</a></td>
76 <td><span class="mono">{{.Names}}</span></td>
77 {{if $.SnippetsAll}}<td><span class="chip chip-neutral">{{.Visibility}}</span></td>{{end}}
78 <td>{{when .UpdatedAt}}</td>
79</tr>
80{{end}}</table></div>
81{{else}}<p class="none">No snippets yet.</p>{{end}}
82{{end}}
83
46{{if eq .Tab "people"}}{{$org := .Owner}} 84{{if eq .Tab "people"}}{{$org := .Owner}}
47<h2>people <span class="count">{{len .Members}}</span></h2> 85<h2>people <span class="count">{{len .Members}}</span></h2>
48{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} 86{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
internal/web/templates/snippets.html deleted −16
@@ -1,16 +0,0 @@
1{{define "title"}}snippets · {{.Owner}}{{end}}
2{{define "content"}}
3<h1><a href="/{{.Owner}}">{{.Owner}}</a> snippets</h1>
4{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
5{{if .Self}}<p class="meta"><a href="/{{.Owner}}/-/snippets/new">new snippet</a> · or <code>gitbay snippet create &lt;file&gt; &lt; file</code></p>{{end}}
6{{if .Snippets}}<div class="tablewrap"><table class="keys">
7<tr class="cols"><th scope="col">snippet</th><th scope="col">files</th>{{if .All}}<th scope="col">visibility</th>{{end}}<th scope="col">updated</th></tr>
8{{range .Snippets}}<tr>
9 <td><a href="/{{$.Owner}}/-/snippets/{{.PublicID}}">{{if .Description}}{{.Description}}{{else}}{{.PublicID}}{{end}}</a></td>
10 <td><span class="mono">{{.Names}}</span></td>
11 {{if $.All}}<td><span class="chip chip-neutral">{{.Visibility}}</span></td>{{end}}
12 <td>{{when .UpdatedAt}}</td>
13</tr>
14{{end}}</table></div>
15{{else}}<p class="none">No snippets yet.</p>{{end}}
16{{end}}