Commit 4e509e8b58

4e509e8b58bcea1786fb110befcec502e64d4311

parent: abe9818bdd

Verified · cmc ci/build: success ci/test: success ci/vuln: success

cmc <hello@cleberg.net> · 2026-09-21 22:07 UTC

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

The bar reads About, Repositories, Bookmarks, Snippets, and People for
an organization's admins. /{owner} is About; the rest hang off /-/. A
tab nobody may open is not offered and its URL is a 404: bookmarks are
the viewer's own, only a user has snippets, and people is the org admin
panel. What #242 asked for is that the sections be separate pages
rather than one stack a long About pushes the repositories off the
bottom of, which holds whichever one the bare URL lands on.

The activity graph moved inside About with a log of the newest thirty
events under it. The log counts what the graph over it counts:
UserPublicEvents keys on the actor for a user, matching ActivityByDay,
while an organization keeps OwnerPublicEvents, matching
OrgActivityByDay. Both are public repositories only. Keying the user's
log on their repositories instead would have printed "3 in the last
year" over "Nothing yet."

Bookmarks and the snippet list were pages of their own that did not
carry the profile's header or tabs. Both render as sections now.
/bookmarks redirects to the tab and the rail points at it; a snippet
itself is still its own page.

The page struct owner.html renders against is a named type: the handler
and the tests have to agree on it field for field, and an anonymous
struct in two places drifted three times while this was written.
.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}}