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.
180180| pin | yes | yes | yes |
181181| bookmark | yes | yes | yes |
182182| bookmark list | yes | yes | yes |
183| bookmarks on your profile | n/a | yes | no |
183184| watch, unwatch | yes | yes | yes |
184185| mute | yes | no | yes |
185186| settings, protection | yes | yes | yes |
@@ -310,12 +311,17 @@ it as =about=, =about_format= and =about_path=. It reads with the
310311repository's own access, so a private =.gitbay= is a profile with no
311312about text to anyone but its owner and the admins. A repository whose
312313name 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>=
314is the repository list, =/<owner>/-/about= the text, =/<owner>/-/activity=
315the year of squares, and =/<owner>/-/people= an organization's members
316and teams. A tab with nothing on it is not offered and its URL is a
317404. The CLI's =profile show= is unchanged and still returns all of it
318at once. The iOS client decodes and renders both formats,
314repository list. On the web a profile is tabs: =/<owner>= is About —
315the text, the year of squares and a log of the newest thirty events —
316then =/<owner>/-/repositories=, =/<owner>/-/bookmarks=,
317=/<owner>/-/snippets=, and =/<owner>/-/people= for an organization's
318members and teams. A tab nobody may open is not offered and its URL is
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,
319325through the same OrgSwift path a README takes.
320326
321327=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
44automatically on daemon start; upgrade notes appear per release when
55anything beyond "replace the binary and restart" is needed.
66
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
728* v1.33.0 — 2026-09-21
829
930Five 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) {
5050 if _, _, code := inst.ssh(t, aliceKey, "", "issue", "create", "alice/app", "--title", "'x'"); code != 0 {
5151 t.Fatal("issue create failed")
5252 }
53 status, body := inst.get(t, "/alice/-/activity")
53 status, body := inst.get(t, "/alice")
5454 if status != 200 || !strings.Contains(body, `class="actgraph"`) {
5555 t.Fatalf("graph missing: %d", status)
5656 }
@@ -62,14 +62,14 @@ func TestActivityGraph(t *testing.T) {
6262 t.Fatalf("alice total = %d, want >= 2", total)
6363 }
6464 // bob authored a commit but his email is unverified: zero activity.
65 _, bobBody := inst.get(t, "/bob/-/activity")
65 _, bobBody := inst.get(t, "/bob")
6666 if bt := activityTotal(t, bobBody); bt != 0 {
6767 t.Fatalf("unverified author got credit: total %d", bt)
6868 }
6969
7070 // Re-pushing the same history (force) does not double-count.
7171 mustGit(t, dir, env, "push", "-q", "--force", "origin", "main")
72 _, body2 := inst.get(t, "/alice/-/activity")
72 _, body2 := inst.get(t, "/alice")
7373 if body2 != body {
7474 // Counts must be identical; compare just the graph cells.
7575 if excerpt(body, "actgraph") != excerpt(body2, "actgraph") {
@@ -84,7 +84,7 @@ func TestActivityGraph(t *testing.T) {
8484 if _, _, code := inst.ssh(t, aliceKey, "", "repo", "transfer", "alice/app", "theorg"); code != 0 {
8585 t.Fatal("transfer failed")
8686 }
87 _, orgBody := inst.get(t, "/theorg/-/activity")
87 _, orgBody := inst.get(t, "/theorg")
8888 if !strings.Contains(orgBody, `class="actgraph"`) || strings.Contains(orgBody, "0 in the last year") {
8989 t.Fatalf("org graph empty:\n%s", excerpt(orgBody, "activity"))
9090 }
e2e/description_test.go +3 −2
@@ -48,8 +48,9 @@ func TestRepoDescriptions(t *testing.T) {
4848 t.Fatalf("non-admin set: exit %d, want 4", code)
4949 }
5050
51 // Web: explore, owner page, and repo header all show it.
52 for _, path := range []string{"/explore", "/alice", "/alice/tool"} {
51 // Web: explore, the profile's repositories tab, and the repo header
52 // all show it.
53 for _, path := range []string{"/explore", "/alice/-/repositories", "/alice/tool"} {
5354 status, body := inst.get(t, path)
5455 if status != 200 || !strings.Contains(body, "better now") {
5556 t.Fatalf("description missing at %s (%d)", path, status)
e2e/org_test.go +1 −1
@@ -179,7 +179,7 @@ func TestOrganizations(t *testing.T) {
179179 if status, _ := inst.get(t, "/puborg/site"); status != 200 {
180180 t.Fatalf("org repo page: %d", status)
181181 }
182 status, body = inst.get(t, "/puborg")
182 status, body = inst.get(t, "/puborg/-/repositories")
183183 if status != 200 || !strings.Contains(body, "org") || !strings.Contains(body, "alice") || !strings.Contains(body, ">site<") {
184184 t.Fatalf("org owner page: %d\n%s", status, body)
185185 }
e2e/profile_test.go +13 −14
@@ -76,7 +76,7 @@ func TestOwnerProfiles(t *testing.T) {
7676 }
7777 // The owner page renders those rows, having dispatched the same
7878 // command rather than assembling them from the store again.
79 status, body := inst.get(t, "/alice")
79 status, body := inst.get(t, "/alice/-/repositories")
8080 if status != 200 {
8181 t.Fatalf("owner page: %d", status)
8282 }
@@ -158,7 +158,7 @@ func TestOwnerProfiles(t *testing.T) {
158158 }
159159 // Org emphasis parsed, not left as literal slashes the way the
160160 // markdown renderer would.
161 _, body = inst.get(t, "/alice/-/about")
161 _, body = inst.get(t, "/alice")
162162 if !strings.Contains(body, "<em>small tools</em>") || strings.Contains(body, "/small tools/") {
163163 t.Fatalf("org about not rendered as org: %s", body)
164164 }
@@ -197,8 +197,9 @@ func TestOwnerProfiles(t *testing.T) {
197197 t.Fatal("more than five links accepted")
198198 }
199199
200 // The bare owner page is the repository list, with the description
201 // and the link chips in the header above the tabs (#242).
200 // The bare owner page is About: the text, the graph and the log,
201 // with the description and the link chips in the header above the
202 // tabs (#242). The repositories are one tab along.
202203 status, body = inst.get(t, "/alice")
203204 if status != 200 || !strings.Contains(body, "tinkerer") {
204205 t.Fatalf("user page profile: %d", status)
@@ -207,19 +208,17 @@ func TestOwnerProfiles(t *testing.T) {
207208 !strings.Contains(body, ">Mastodon<") {
208209 t.Fatalf("links not rendered: %s", body)
209210 }
210 if !strings.Contains(body, `<ul class="repolist"`) {
211 t.Error("the owner page is not the repository list")
211 if !strings.Contains(body, "<em>small tools</em>") {
212 t.Fatalf("about not rendered: %s", body)
212213 }
213 if strings.Contains(body, `class="activity"`) || strings.Contains(body, "<em>small tools</em>") {
214 t.Error("the about text or the activity graph still sits on the repository page")
214 if !strings.Contains(body, `class="activity"`) {
215 t.Error("the about tab has no activity graph")
215216 }
216 // Each of them is one tab along, and the markdown renders there.
217 _, aboutBody := inst.get(t, "/alice/-/about")
218 if !strings.Contains(aboutBody, "<em>small tools</em>") {
219 t.Fatalf("about not rendered: %s", aboutBody)
217 if strings.Contains(body, `<ul class="repolist"`) {
218 t.Error("the repository list still sits under the about text")
220219 }
221 if _, g := inst.get(t, "/alice/-/activity"); !strings.Contains(g, `class="activity"`) {
222 t.Error("the activity tab has no graph")
220 if _, repos := inst.get(t, "/alice/-/repositories"); !strings.Contains(repos, `<ul class="repolist"`) {
221 t.Error("the repositories tab has no repository list")
223222 }
224223
225224 // 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) {
4040 t.Errorf("about_path missing: %s", out)
4141 }
4242
43 _, body := inst.get(t, "/alice/-/about")
43 _, body := inst.get(t, "/alice")
4444 if !strings.Contains(body, "hello from a file") {
4545 t.Error("web profile does not render the about")
4646 }
@@ -54,7 +54,7 @@ func TestProfileAboutFromRepo(t *testing.T) {
5454 "--ref", "main", "--file", "-"); code != 0 {
5555 t.Fatalf("committing bob's org about: %s", errOut)
5656 }
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>") {
5858 t.Errorf("about did not render as org:\n%s", page)
5959 }
6060}
e2e/web_test.go +3 −2
@@ -201,8 +201,9 @@ func TestWebUI(t *testing.T) {
201201 t.Fatalf("txt README not escaped-plaintext:\n%s", body)
202202 }
203203
204 // Owner page: lists visible repos only; unknown owners 404.
205 status, body = inst.get(t, "/alice")
204 // Owner page: the repositories tab lists visible repos only;
205 // unknown owners 404.
206 status, body = inst.get(t, "/alice/-/repositories")
206207 if status != 200 || !strings.Contains(body, ">site<") || !strings.Contains(body, "user") {
207208 t.Fatalf("owner page: %d", status)
208209 }
internal/httpd/accounts.go +4 −7
@@ -262,14 +262,11 @@ func (s *Server) bookmarkToggle(w http.ResponseWriter, r *http.Request, u store.
262262}
263263
264264// 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.
265268func (s *Server) bookmarksPage(w http.ResponseWriter, r *http.Request, u store.User) {
266 var rows []control.BookmarkOut
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})
269 http.Redirect(w, r, "/"+u.Username+"/-/bookmarks", http.StatusSeeOther)
273270}
274271
275272// 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 (
1212
1313func TestProfileTabFromPath(t *testing.T) {
1414 for path, want := range map[string]string{
15 "/cmc": "repos",
16 "/cmc/-/about": "about",
17 "/cmc/-/activity": "activity",
18 "/cmc/-/people": "people",
19 "/krz": "repos",
20 "/cmc/-/snippets": "repos",
15 "/cmc": "about",
16 "/cmc/-/repositories": "repos",
17 "/cmc/-/bookmarks": "bookmarks",
18 "/cmc/-/snippets": "snippets",
19 "/cmc/-/people": "people",
20 "/krz": "about",
21 "/cmc/-/snippets/abcd": "about",
2122 } {
2223 if got := profileTab(path); got != want {
2324 t.Errorf("profileTab(%q) = %q, want %q", path, got, want)
@@ -25,48 +26,94 @@ func TestProfileTabFromPath(t *testing.T) {
2526 }
2627}
2728
28// The About text and the year of squares sat above the repository list
29// and pushed it below the fold (#242). Repositories are the bare
30// /{owner} now and the rest are tabs beside them.
31func TestOwnerPageLeadsWithRepositories(t *testing.T) {
32 out := renderOwner(t, "repos", ownerFixture())
33 if !strings.Contains(out, "reminiscecleberg.com") {
34 t.Errorf("the repository list is not on the default tab:\n%s", out)
35 }
36 for _, unwanted := range []string{"actgraph", "Christian Cleberg"} {
37 if strings.Contains(out, unwanted) {
38 t.Errorf("the default tab still carries %q:\n%s", unwanted, out)
29// The profile is sections rather than one stack (#242): the bar reads
30// About, Repositories, Bookmarks, Snippets, and each holds one thing.
31func TestOwnerPageTabOrder(t *testing.T) {
32 d := ownerFixture()
33 d.Self, d.Snippets = true, 2
34 out := renderOwner(t, "about", d)
35 order := []string{
36 `href="/cmc">About`,
37 `href="/cmc/-/repositories">Repositories`,
38 `href="/cmc/-/bookmarks">Bookmarks`,
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)
3949 }
50 at = i
4051 }
41 for _, want := range []string{
42 `aria-current="page" href="/cmc"`,
43 `href="/cmc/-/about"`,
44 `href="/cmc/-/activity"`,
45 } {
52}
53
54// The graph moved inside About, with a log of the newest events under
55// it — not the whole history, which is what the atom feed is for.
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"} {
4661 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)
4863 }
4964 }
50 // Nobody administers this profile, so it offers no people tab.
51 if strings.Contains(out, "/-/people") {
52 t.Errorf("a profile nobody admins offers a people tab:\n%s", out)
65 if strings.Contains(out, "reminiscecleberg.com") {
66 t.Errorf("the about tab carries the repository list:\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)
5378 }
5479}
5580
56func TestOwnerPageTabsCarryOneSectionEach(t *testing.T) {
57 if out := renderOwner(t, "about", ownerFixture()); !strings.Contains(out, "Christian Cleberg") ||
58 strings.Contains(out, "actgraph") || strings.Contains(out, "reminiscecleberg.com") {
59 t.Errorf("about tab is not the About file alone:\n%s", out)
81func TestOwnerPageSectionsAreSeparate(t *testing.T) {
82 d := ownerFixture()
83 d.Self, d.Snippets = true, 1
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)
6094 }
61 if out := renderOwner(t, "activity", ownerFixture()); !strings.Contains(out, "actgraph") ||
62 strings.Contains(out, "reminiscecleberg.com") {
63 t.Errorf("activity tab is not the graph alone:\n%s", out)
95 // The snippet list renders here rather than on a page of its own.
96 snips := renderOwner(t, "snippets", d)
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)
64112 }
65 // A profile with no About file does not offer the tab.
66113 d := ownerFixture()
67 d.AboutHTML = ""
68 if out := renderOwner(t, "repos", d); strings.Contains(out, "/-/about") {
69 t.Errorf("a profile with no About file offers the tab:\n%s", out)
114 d.Self = true
115 if out := renderOwner(t, "about", d); !strings.Contains(out, "/-/bookmarks") {
116 t.Errorf("own profile has no bookmarks tab:\n%s", out)
70117 }
71118}
72119
@@ -76,12 +123,16 @@ func TestOwnerPagePeopleTabHoldsTheAdminPanel(t *testing.T) {
76123 d.CanAdmin = true
77124 d.Members = []control.ProfileMember{{Name: "cmc", Role: "admin"}}
78125
79 repos := renderOwner(t, "repos", d)
80 if !strings.Contains(repos, `href="/cmc/-/people"`) {
81 t.Errorf("an admin gets no people tab:\n%s", repos)
126 about := renderOwner(t, "about", d)
127 if !strings.Contains(about, `href="/cmc/-/people"`) {
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)
82132 }
83 if strings.Contains(repos, "Create a team") {
84 t.Errorf("the admin forms still sit under the repository list:\n%s", repos)
133 // An org has no snippets, so it is not offered the tab.
134 if strings.Contains(about, "/-/snippets") {
135 t.Errorf("an org offers a snippets tab:\n%s", about)
85136 }
86137 people := renderOwner(t, "people", d)
87138 for _, want := range []string{"Create a team", "member-add", "org-rename"} {
@@ -92,10 +143,15 @@ func TestOwnerPagePeopleTabHoldsTheAdminPanel(t *testing.T) {
92143}
93144
94145type ownerFixtureData struct {
95 Kind string
96 AboutHTML template.HTML
97 CanAdmin bool
98 Members []control.ProfileMember
146 Kind string
147 AboutHTML template.HTML
148 CanAdmin bool
149 Self bool
150 Snippets int
151 Members []control.ProfileMember
152 Log []feedLine
153 Bookmarks []control.BookmarkOut
154 SnippetRows []snippetRow
99155}
100156
101157func ownerFixture() ownerFixtureData {
@@ -108,31 +164,24 @@ func ownerFixture() ownerFixtureData {
108164func renderOwner(t *testing.T, tab string, d ownerFixtureData) string {
109165 t.Helper()
110166 var sb strings.Builder
111 err := web.Render(&sb, "owner.html", struct {
112 basePage
113 Owner string
114 Kind string
115 Tab string
116 Profile store.Profile
117 AboutHTML template.HTML
118 Repos []profileRepoRow
119 Members []control.ProfileMember
120 Orgs []control.ProfileMember
121 Activity []activityWeek
122 ActivityTotal int
123 Teams []teamView
124 CanAdmin bool
125 Self bool
126 Snippets int
127 Notice string
128 Feed string
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",
167 err := web.Render(&sb, "owner.html", ownerPage{
168 basePage: basePage{Site: "gitbay"},
169 Owner: "cmc",
170 Kind: d.Kind,
171 Tab: tab,
172 Profile: store.Profile{Description: "Org-Mode · Self-Hosting · Privacy"},
173 AboutHTML: d.AboutHTML,
174 Repos: []profileRepoRow{{control.ProfileRepo{Path: "cmc/reminiscecleberg.com", Description: "Personal placeholder site."}}},
175 Members: d.Members,
176 Activity: []activityWeek{{Month: "Sep", Days: []activityDay{{Date: "2026-09-20", Count: 3, Level: 2}}}},
177 ActivityTotal: 6088,
178 Log: d.Log,
179 Bookmarks: d.Bookmarks,
180 SnippetRows: d.SnippetRows,
181 CanAdmin: d.CanAdmin,
182 Self: d.Self,
183 Snippets: d.Snippets,
184 Feed: "/cmc/activity.atom",
136185 })
137186 if err != nil {
138187 t.Fatalf("render: %v", err)
internal/httpd/routes.go +5 −5
@@ -59,7 +59,7 @@ func (s *Server) Routes() []Route {
5959 }
6060 routes = append(routes,
6161 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},
6363 Route{Method: "GET", Pattern: "/{owner}/{repo}", Handler: s.repoHome},
6464 Route{Method: "GET", Pattern: "/{owner}/{repo}/tree/{ref}/{path...}", Handler: s.tree},
6565 Route{Method: "GET", Pattern: "/{owner}/{repo}/blob/{ref}/{path...}", Handler: s.blob},
@@ -75,12 +75,12 @@ func (s *Server) Routes() []Route {
7575 Route{Method: "GET", Pattern: "/{owner}/{repo}/log.atom", Handler: s.logAtom},
7676 Route{Method: "GET", Pattern: "/{owner}/{repo}/log.atom/{ref...}", Handler: s.logAtom},
7777 Route{Method: "GET", Pattern: "/{owner}/activity.atom", Handler: s.ownerAtom},
78 Route{Method: "GET", Pattern: "/{owner}/-/about", Handler: s.ownerPage},
79 Route{Method: "GET", Pattern: "/{owner}/-/activity", Handler: s.ownerPage},
80 Route{Method: "GET", Pattern: "/{owner}/-/people", Handler: s.ownerPage},
78 Route{Method: "GET", Pattern: "/{owner}/-/repositories", Handler: s.ownerProfile},
79 Route{Method: "GET", Pattern: "/{owner}/-/bookmarks", Handler: s.ownerProfile},
80 Route{Method: "GET", Pattern: "/{owner}/-/people", Handler: s.ownerProfile},
8181 Route{Method: "GET", Pattern: "/{owner}/-/labels", Handler: s.orgLabels},
8282 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},
8484 Route{Method: "GET", Pattern: "/{owner}/-/snippets/{id}", Handler: s.snippetPage},
8585 Route{Method: "GET", Pattern: "/{owner}/-/snippets/{id}/raw/{name}", Handler: s.snippetRaw},
8686 Route{Method: "GET", Pattern: "/{owner}/{repo}/builds", Handler: s.builds},
internal/httpd/snippets.go +10 −15
@@ -30,19 +30,21 @@ type snippetRow struct {
3030 Names string
3131}
3232
33func (s *Server) snippetsPage(w http.ResponseWriter, r *http.Request) {
34 viewer := s.viewer(r)
35 owner, err := s.st.UserByUsername(r.PathValue("owner"))
33// ownerSnippets lists an owner's snippets for the profile's Snippets
34// tab. The list is a section of the profile like the repositories are,
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)
3639 if err != nil {
3740 s.notFound(w, r)
38 return
41 return nil, false
3942 }
40 self := viewer.ID != 0 && viewer.ID == owner.ID
41 all := self || viewer.IsAdmin
43 all := viewer.IsAdmin || (viewer.ID != 0 && viewer.ID == owner.ID)
4244 list, err := s.st.ListSnippets(owner.ID, all, 0, 0)
4345 if err != nil {
4446 http.Error(w, "internal error", http.StatusInternalServerError)
45 return
47 return nil, false
4648 }
4749 rows := make([]snippetRow, 0, len(list))
4850 for _, sn := range list {
@@ -55,14 +57,7 @@ func (s *Server) snippetsPage(w http.ResponseWriter, r *http.Request) {
5557 }
5658 rows = append(rows, snippetRow{sn, names.String()})
5759 }
58 s.render(w, "snippets.html", struct {
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)})
60 return rows, true
6661}
6762
6863type snippetFileView struct {
internal/httpd/web.go +121 −40
@@ -427,23 +427,92 @@ func (p profileRepoRow) Desc() string { return p.Description }
427427// viewer may see, org membership either direction. Owner names are not
428428// secret (they are on every commit); repository visibility rules hold.
429429// 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
431// the projects and the About text used to push them below the fold
432// (#242). The rest hang off the /-/ namespace the labels, milestones and
433// snippet pages already use.
430// /{owner} is About, the first tab; the rest hang off the /-/ namespace
431// the labels and milestones pages already use. What #242 asked for is
432// that the sections be separate pages rather than one stack a long
433// About pushes the repositories off the bottom of — not that any one of
434// them be the landing page.
434435func profileTab(path string) string {
435436 switch {
436 case strings.HasSuffix(path, "/-/about"):
437 return "about"
438 case strings.HasSuffix(path, "/-/activity"):
439 return "activity"
437 case strings.HasSuffix(path, "/-/repositories"):
438 return "repos"
439 case strings.HasSuffix(path, "/-/bookmarks"):
440 return "bookmarks"
441 case strings.HasSuffix(path, "/-/snippets"):
442 return "snippets"
440443 case strings.HasSuffix(path, "/-/people"):
441444 return "people"
442445 }
443 return "repos"
446 return "about"
444447}
445448
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) {
447516 name := r.PathValue("owner")
448517 var viewer store.User
449518 if s.cfg.Web.Mode == "accounts" {
@@ -472,40 +541,52 @@ func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) {
472541 weeks, activityTotal := activityGrid(counts)
473542
474543 teams, canAdmin := s.orgAdminView(viewer, d.Kind, name)
544 self := d.Kind == "user" && viewer.ID != 0 && strings.EqualFold(viewer.Username, name)
475545 tab := profileTab(r.URL.Path)
476 // Neither tab is offered when there is nothing on it: the people tab
477 // is the organization admin panel, and the About tab is a file the
478 // owner may not have written. Both answer the way a missing page does
479 // rather than rendering empty.
480 if (tab == "people" && !canAdmin) || (tab == "about" && d.About == "") {
546 // A tab nobody may open is not a page: the people tab is the
547 // organization admin panel, bookmarks are the viewer's own and
548 // nobody else's, and only a user has snippets. Each answers the way
549 // a missing page does rather than rendering empty.
550 if (tab == "people" && !canAdmin) || (tab == "bookmarks" && !self) ||
551 (tab == "snippets" && d.Kind != "user") {
481552 s.notFound(w, r)
482553 return
483554 }
484 profile := store.Profile{Description: d.Description, Website: d.Website, Links: d.Links}
485 s.render(w, "owner.html", struct {
486 basePage
487 Owner string
488 Kind string
489 Tab string
490 Profile store.Profile
491 AboutHTML template.HTML
492 Repos []profileRepoRow
493 Members []control.ProfileMember
494 Orgs []control.ProfileMember
495 Activity []activityWeek
496 ActivityTotal int
497 Teams []teamView
498 CanAdmin bool
499 Self bool
500 Snippets int
501 Notice string
502 Feed string
503 }{s.baseFor(viewer), name, d.Kind, tab, profile, aboutHTML(d.About, d.AboutFormat),
504 d.Repos, d.Members, d.Orgs,
505 weeks, activityTotal, teams, canAdmin,
506 d.Kind == "user" && viewer.ID != 0 && strings.EqualFold(viewer.Username, name),
507 d.Snippets,
508 s.takeFlash(w, r), "/" + name + "/activity.atom"})
555
556 var bookmarks []control.BookmarkOut
557 if tab == "bookmarks" {
558 s.runControlInto(viewer, []string{"repo", "bookmarks"}, &bookmarks)
559 }
560 var snippets []snippetRow
561 if tab == "snippets" {
562 var ok bool
563 if snippets, ok = s.ownerSnippets(w, r, viewer, name); !ok {
564 return
565 }
566 }
567 s.render(w, "owner.html", ownerPage{
568 basePage: s.baseFor(viewer),
569 Owner: name,
570 Kind: d.Kind,
571 Tab: tab,
572 Profile: store.Profile{Description: d.Description, Website: d.Website, Links: d.Links},
573 AboutHTML: aboutHTML(d.About, d.AboutFormat),
574 Repos: d.Repos,
575 Members: d.Members,
576 Orgs: d.Orgs,
577 Activity: weeks,
578 ActivityTotal: activityTotal,
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 })
509590}
510591
511592func (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) ([
323323 return out, rows.Err()
324324}
325325
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
326357// RecentEvents returns activity on repositories the user can reach. Push
327358// events are excluded: they repeat what the commit lists already show.
328359// 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:
16771677.actlegend { display: flex; align-items: center; gap: 3px; margin-top: var(--sp-2); font-size: var(--fs-0); color: var(--muted); }
16781678.actlegend span:first-child { margin-right: var(--sp-1); }
16791679.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); }
16801684
16811685/* ---- wiki ---- */
16821686.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 @@
2222 <li class="railopt">{{template "raillink" dict "Href" "/explore" "Icon" "compass" "Name" "Explore" "Current" (eq (str . "Tab") "explore")}}</li>
2323 <li>{{template "raillink" dict "Href" "/search" "Icon" "search" "Name" "Search" "Current" (eq (str . "Tab") "sitesearch")}}</li>
2424 {{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>
2626 <li class="railopt">{{template "raillink" dict "Href" (printf "/%s/-/snippets" .Viewer) "Icon" "snippet" "Name" "Snippets" "Current" (eq (str . "Tab") "snippets")}}</li>
2727 <li class="railopt">{{template "raillink" dict "Href" "/new" "Icon" "plus" "Name" "New repository"}}</li>{{end}}
2828 </ul>
@@ -36,7 +36,7 @@
3636 <summary class="railicon" aria-label="More" title="More">{{template "icon" "ellipsis"}}<span class="vh">More</span></summary>
3737 <div class="raildrop">
3838 <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>
4040 <a href="/{{.Viewer}}/-/snippets">{{template "icon" "snippet"}} Snippets</a>
4141 <a href="/new">{{template "icon" "plus"}} New repository</a>
4242 <a href="/settings">{{template "icon" "gear"}} Settings</a>
internal/web/templates/owner.html +56 −18
@@ -9,29 +9,20 @@
99{{if .Members}}<p class="meta">members {{range .Members}}<a class="memberchip" href="/{{.Name}}">{{.Name}} <span class="role">{{.Role}}</span></a> {{end}}</p>{{end}}
1010{{if and (eq .Kind "org") .Repos}}<p class="meta"><a href="/{{.Owner}}/-/labels">labels</a> · <a href="/{{.Owner}}/-/milestones">milestones</a></p>{{end}}
1111</section>
12{{/* The repository list is the bare /{owner}, because a profile's job is
13 to lead to the projects: the About text and the year of squares used
14 to sit above the list and push it below the fold (#242). */}}
12{{/* The profile is sections rather than one stack: a long About used to
13 push the repositories off the bottom of the page (#242). About is
14 the bare /{owner}; the rest hang off /-/. A tab nobody may open is
15 not offered and its URL is a 404. */}}
1516<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 {{if .AboutHTML}}<a {{if eq .Tab "about"}}aria-current="page" {{end}}href="/{{.Owner}}/-/about">About</a>{{end}}
18 <a {{if eq .Tab "activity"}}aria-current="page" {{end}}href="/{{.Owner}}/-/activity">Activity{{if .ActivityTotal}} <i>{{.ActivityTotal}}</i>{{end}}</a>
19 {{if or .Snippets .Self}}<a href="/{{.Owner}}/-/snippets">Snippets{{if .Snippets}} <i>{{.Snippets}}</i>{{end}}</a>{{end}}
17 <a {{if eq .Tab "about"}}aria-current="page" {{end}}href="/{{.Owner}}">About</a>
18 <a {{if eq .Tab "repos"}}aria-current="page" {{end}}href="/{{.Owner}}/-/repositories">Repositories{{if .Repos}} <i>{{len .Repos}}</i>{{end}}</a>
19 {{if .Self}}<a {{if eq .Tab "bookmarks"}}aria-current="page" {{end}}href="/{{.Owner}}/-/bookmarks">Bookmarks</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}}
2021 {{if .CanAdmin}}<a {{if eq .Tab "people"}}aria-current="page" {{end}}href="/{{.Owner}}/-/people">People{{if .Members}} <i>{{len .Members}}</i>{{end}}</a>{{end}}
2122</nav>
2223
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
3024{{if eq .Tab "about"}}
31<section class="readme"><div class="rendered">{{.AboutHTML}}</div></section>
32{{end}}
33
34{{if eq .Tab "activity"}}
25{{if .AboutHTML}}<section class="readme"><div class="rendered">{{.AboutHTML}}</div></section>{{end}}
3526<section class="activity">
3627<h2>activity <span class="count">{{.ActivityTotal}} in the last year</span></h2>
3728<div class="actgraph-scroll">
@@ -40,9 +31,56 @@
4031</div>
4132</div>
4233<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}}
4344</section>
4445{{end}}
4546
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
4684{{if eq .Tab "people"}}{{$org := .Owner}}
4785<h2>people <span class="count">{{len .Members}}</span></h2>
4886{{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}}