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