Commit 027899f05d

027899f05d625769c881aeac38b7ef6bbfe4fa4f

parent: ba046389a8

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-26 00:47 UTC

web: rail for cross-repo state, repo header once, horizontal tabs

The previous two passes restyled the chrome and left the pages alone: the
repo header, description and topics rendered ahead of content on all
twelve repo pages, the page's own subject was never its h1, and content
sat in 990px with 247px of unused margin beside it.

- the rail now holds what changes as you switch context (pinned repos,
  merge requests waiting on your review) instead of the nine repo links,
  which were a fixed set and belong in tabs. Repo navigation moves to a
  horizontal tab bar with open counts.
- the full repo header — description, topics, website, mirrors — renders
  only on the repo home. Every other page gets identity and tabs.
- each page supplies its own h1; the repo name is the h1 only on the repo
  home. Rail group captions are <p>, not headings, so they no longer sit
  ahead of the page h1 in the outline.
- skip link, authored focus ring, aria-current on the active tab and rail
  entry. Mobile collapses the rail to one strip: chrome ahead of content
  drops from ~600px to 141px on a 375px viewport.
- Harbor palette: black and white ground, blue for what you can do, orange
  for where you are and what wants you. Fills keep pure #0000f0 in both
  schemes; blue text lifts to #5b6bff on dark, where #0000f0 is 2.13:1.
- IBM Plex gives way to Atkinson Hyperlegible, whose letterforms are drawn
  so 0/O and 1/l cannot collapse — this ships a real italic, which the
  three Plex faces never had, so emphasis in READMEs and issue bodies was
  a synthesized oblique.

Tests updated where they assert on display copy that changed case.

Layout: unified · split

e2e/design_test.go +3 −3
@@ -58,7 +58,7 @@ func TestReadmeRelativeLinks(t *testing.T) {
58 } 58 }
59 } 59 }
60 // Branch dropdown lists branches. 60 // Branch dropdown lists branches.
61 if !strings.Contains(body, `class="refmenu"`) || !strings.Contains(body, ">all refs") { 61 if !strings.Contains(body, `class="refmenu"`) || !strings.Contains(body, ">All refs") {
62 t.Error("branch dropdown missing") 62 t.Error("branch dropdown missing")
63 } 63 }
64 // Per-file history: ?path= filters the log; blob pages link to it. 64 // Per-file history: ?path= filters the log; blob pages link to it.
@@ -164,14 +164,14 @@ func TestWebInteractions(t *testing.T) {
164 t.Fatal("pin toggle failed") 164 t.Fatal("pin toggle failed")
165 } 165 }
166 _, body := browserGet(t, browser, inst.base()+"/theorg/webborn") 166 _, body := browserGet(t, browser, inst.base()+"/theorg/webborn")
167 if !strings.Contains(body, "★ pinned") { 167 if !strings.Contains(body, "★ Pinned") {
168 t.Fatal("repo header not pinned") 168 t.Fatal("repo header not pinned")
169 } 169 }
170 if _, body = browserGet(t, browser, inst.base()+"/"); !strings.Contains(body, "theorg<span") { 170 if _, body = browserGet(t, browser, inst.base()+"/"); !strings.Contains(body, "theorg<span") {
171 t.Fatal("dashboard missing pinned repo") 171 t.Fatal("dashboard missing pinned repo")
172 } 172 }
173 browserPost(t, browser, inst.base()+"/theorg/webborn/pin", url.Values{}) 173 browserPost(t, browser, inst.base()+"/theorg/webborn/pin", url.Values{})
174 if _, body = browserGet(t, browser, inst.base()+"/theorg/webborn"); !strings.Contains(body, "☆ pin") { 174 if _, body = browserGet(t, browser, inst.base()+"/theorg/webborn"); !strings.Contains(body, "☆ Pin") {
175 t.Fatal("unpin failed") 175 t.Fatal("unpin failed")
176 } 176 }
177 177
e2e/mirror_test.go +2 −2
@@ -119,10 +119,10 @@ func TestMirrors(t *testing.T) {
119 if status, _ := browserGet(t, browser, inst.base()+loginPath); status != 200 { 119 if status, _ := browserGet(t, browser, inst.base()+loginPath); status != 200 {
120 t.Fatalf("login: %d", status) 120 t.Fatalf("login: %d", status)
121 } 121 }
122 if _, body := browserGet(t, browser, inst.base()+"/alice/app"); !strings.Contains(body, "mirrors to <a href=\""+remoteURL) { 122 if _, body := browserGet(t, browser, inst.base()+"/alice/app"); !strings.Contains(body, "Mirrors to <a href=\""+remoteURL) {
123 t.Fatalf("admin repo page missing mirror line:\n%s", body) 123 t.Fatalf("admin repo page missing mirror line:\n%s", body)
124 } 124 }
125 if _, body := browserGet(t, newBrowser(t), inst.base()+"/alice/app"); strings.Contains(body, "mirrors to") { 125 if _, body := browserGet(t, newBrowser(t), inst.base()+"/alice/app"); strings.Contains(body, "Mirrors to") {
126 t.Fatalf("anonymous repo page shows mirror line:\n%s", body) 126 t.Fatalf("anonymous repo page shows mirror line:\n%s", body)
127 } 127 }
128 128
e2e/web_test.go +1 −1
@@ -88,7 +88,7 @@ func TestWebUI(t *testing.T) {
88 if !strings.Contains(body, "<h1>hello site</h1>") || !strings.Contains(body, "<em>markdown</em>") { 88 if !strings.Contains(body, "<h1>hello site</h1>") || !strings.Contains(body, "<em>markdown</em>") {
89 t.Fatalf("README not rendered:\n%s", body) 89 t.Fatalf("README not rendered:\n%s", body)
90 } 90 }
91 for _, tab := range []string{">issues<", ">merge requests<"} { 91 for _, tab := range []string{">Issues<", ">Merge requests<"} {
92 if !strings.Contains(body, tab) { 92 if !strings.Contains(body, tab) {
93 t.Fatalf("repo header missing %s tab", tab) 93 t.Fatalf("repo header missing %s tab", tab)
94 } 94 }
e2e/wiki_test.go +2 −2
@@ -23,7 +23,7 @@ func TestWikis(t *testing.T) {
23 // No wiki yet: the tab is absent, the page shows the push hint, and 23 // No wiki yet: the tab is absent, the page shows the push hint, and
24 // cloning the companion says so. 24 // cloning the companion says so.
25 _, body := inst.get(t, "/alice/app") 25 _, body := inst.get(t, "/alice/app")
26 if strings.Contains(body, ">wiki<") { 26 if strings.Contains(body, ">Wiki<") {
27 t.Fatal("wiki tab shown with no wiki") 27 t.Fatal("wiki tab shown with no wiki")
28 } 28 }
29 _, body = inst.get(t, "/alice/app/wiki") 29 _, body = inst.get(t, "/alice/app/wiki")
@@ -55,7 +55,7 @@ func TestWikis(t *testing.T) {
55 // Rendering: home resolves, tab appears, links rewrite to wiki pages 55 // Rendering: home resolves, tab appears, links rewrite to wiki pages
56 // and images to the wiki raw route; org pages render too. 56 // and images to the wiki raw route; org pages render too.
57 _, body = inst.get(t, "/alice/app") 57 _, body = inst.get(t, "/alice/app")
58 if !strings.Contains(body, ">wiki<") { 58 if !strings.Contains(body, ">Wiki<") {
59 t.Fatal("wiki tab missing after push") 59 t.Fatal("wiki tab missing after push")
60 } 60 }
61 _, body = inst.get(t, "/alice/app/wiki") 61 _, body = inst.get(t, "/alice/app/wiki")
internal/httpd/accounts.go +18 −23
@@ -64,19 +64,18 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
64 token := r.URL.Query().Get("token") 64 token := r.URL.Query().Get("token")
65 if token == "" { 65 if token == "" {
66 s.render(w, "login.html", struct { 66 s.render(w, "login.html", struct {
67 Site string 67 basePage
68 Viewer string 68 Error string
69 Error string 69 }{basePage{Site: s.siteName()}, ""})
70 }{s.siteName(), "", ""})
71 return 70 return
72 } 71 }
73 userID, err := s.st.ConsumeLoginToken(store.HashToken(token)) 72 userID, err := s.st.ConsumeLoginToken(store.HashToken(token))
74 if err != nil { 73 if err != nil {
75 s.render(w, "login.html", struct { 74 s.render(w, "login.html", struct {
76 Site string 75 basePage
77 Viewer string 76 Error string
78 Error string 77 }{basePage{Site: s.siteName()},
79 }{s.siteName(), "", "that login link is invalid, expired, or already used — mint a new one"}) 78 "that login link is invalid, expired, or already used — mint a new one"})
80 return 79 return
81 } 80 }
82 sessTok, sessHash, err := store.NewToken() 81 sessTok, sessHash, err := store.NewToken()
@@ -120,11 +119,10 @@ func (s *Server) adminOrgs(u store.User) []string {
120 119
121func (s *Server) renderNewRepo(w http.ResponseWriter, u store.User, errMsg string) { 120func (s *Server) renderNewRepo(w http.ResponseWriter, u store.User, errMsg string) {
122 s.render(w, "new.html", struct { 121 s.render(w, "new.html", struct {
123 Site string 122 basePage
124 Viewer string 123 Orgs []string
125 Orgs []string 124 Error string
126 Error string 125 }{s.baseFor(u), s.adminOrgs(u), errMsg})
127 }{s.siteName(), u.Username, s.adminOrgs(u), errMsg})
128} 126}
129 127
130func (s *Server) newRepoForm(w http.ResponseWriter, r *http.Request, u store.User) { 128func (s *Server) newRepoForm(w http.ResponseWriter, r *http.Request, u store.User) {
@@ -223,13 +221,12 @@ func (s *Server) signupForm(w http.ResponseWriter, r *http.Request) {
223 221
224func (s *Server) renderSignup(w http.ResponseWriter, errMsg, username string) { 222func (s *Server) renderSignup(w http.ResponseWriter, errMsg, username string) {
225 s.render(w, "register.html", struct { 223 s.render(w, "register.html", struct {
226 Site string 224 basePage
227 Viewer string
228 Host string 225 Host string
229 Mode string // open | invite 226 Mode string // open | invite
230 Error string 227 Error string
231 Username string 228 Username string
232 }{s.siteName(), "", s.cfg.SiteHost(), s.cfg.Registration.Mode, errMsg, username}) 229 }{basePage{Site: s.siteName()}, s.cfg.SiteHost(), s.cfg.Registration.Mode, errMsg, username})
233} 230}
234 231
235func (s *Server) signupSubmit(w http.ResponseWriter, r *http.Request) { 232func (s *Server) signupSubmit(w http.ResponseWriter, r *http.Request) {
@@ -247,12 +244,11 @@ func (s *Server) signupSubmit(w http.ResponseWriter, r *http.Request) {
247 return 244 return
248 } 245 }
249 s.render(w, "registered.html", struct { 246 s.render(w, "registered.html", struct {
250 Site string 247 basePage
251 Viewer string
252 Username string 248 Username string
253 Message string 249 Message string
254 Host string 250 Host string
255 }{s.siteName(), "", username, msg, s.cfg.SiteHost()}) 251 }{basePage{Site: s.siteName()}, username, msg, s.cfg.SiteHost()})
256} 252}
257 253
258// issueCreateForm renders the new-issue form, prefilled from the repo's 254// issueCreateForm renders the new-issue form, prefilled from the repo's
@@ -441,8 +437,7 @@ func (s *Server) mrCommentSubmit(w http.ResponseWriter, r *http.Request, u store
441} 437}
442 438
443type editPage struct { 439type editPage struct {
444 Site string 440 basePage
445 Viewer string
446 Repo store.Repo 441 Repo store.Repo
447 Ref string 442 Ref string
448 Path string 443 Path string
@@ -467,7 +462,7 @@ func (s *Server) editForm(w http.ResponseWriter, r *http.Request, u store.User)
467 return 462 return
468 } 463 }
469 s.render(w, "edit.html", editPage{ 464 s.render(w, "edit.html", editPage{
470 Site: s.siteName(), Viewer: u.Username, Repo: repo, 465 basePage: s.baseFor(u), Repo: repo,
471 Ref: ref, Path: filePath, Content: string(content), 466 Ref: ref, Path: filePath, Content: string(content),
472 }) 467 })
473} 468}
@@ -481,7 +476,7 @@ func (s *Server) editSubmit(w http.ResponseWriter, r *http.Request, u store.User
481 filePath := strings.Trim(r.PathValue("path"), "/") 476 filePath := strings.Trim(r.PathValue("path"), "/")
482 fail := func(msg string) { 477 fail := func(msg string) {
483 s.render(w, "edit.html", editPage{ 478 s.render(w, "edit.html", editPage{
484 Site: s.siteName(), Viewer: u.Username, Repo: repo, 479 basePage: s.baseFor(u), Repo: repo,
485 Ref: ref, Path: filePath, Content: r.FormValue("content"), Error: msg, 480 Ref: ref, Path: filePath, Content: r.FormValue("content"), Error: msg,
486 }) 481 })
487 } 482 }
internal/httpd/page.go added +85
@@ -0,0 +1,85 @@
1package httpd
2
3import (
4 "net/http"
5
6 "gitbay.org/gitbay/internal/policy"
7 "gitbay.org/gitbay/internal/store"
8)
9
10// railRepo is one pinned repository in the rail.
11type railRepo struct {
12 Owner string
13 Name string
14}
15
16// railItem is one merge request in the rail's review queue.
17type railItem struct {
18 RepoPath string
19 Number int64
20 Title string
21}
22
23// rail is the cross-repo state in the left rail: the repositories and the
24// review work a viewer moves between. A repo's own navigation lives in its
25// header tabs, not here — the rail holds only what changes as you switch
26// context, which is what earns it the width.
27type rail struct {
28 Pinned []railRepo
29 Reviews []railItem
30}
31
32// Empty reports whether the rail has nothing to show beyond the global
33// links, so the template can skip its group headings.
34func (r rail) Empty() bool { return len(r.Pinned) == 0 && len(r.Reviews) == 0 }
35
36// basePage is what the layout needs on every page, repo or not. Page
37// structs embed it so the rail and the site name are always in scope.
38type basePage struct {
39 Site string
40 Viewer string
41 Rail rail
42}
43
44// base builds the layout-wide data for a request that has not already
45// resolved a viewer.
46func (s *Server) base(r *http.Request) basePage {
47 if s.cfg.Web.Mode != "accounts" {
48 return basePage{Site: s.siteName()}
49 }
50 return s.baseFor(s.viewer(r))
51}
52
53// baseFor is base for a handler that already holds the viewer, so the
54// session lookup is not repeated.
55func (s *Server) baseFor(viewer store.User) basePage {
56 b := basePage{Site: s.siteName()}
57 if viewer.ID == 0 {
58 return b
59 }
60 b.Viewer = viewer.Username
61 b.Rail = s.railFor(viewer)
62 return b
63}
64
65// railFor collects the viewer's pinned repositories and review queue,
66// dropping anything they may no longer read.
67func (s *Server) railFor(viewer store.User) rail {
68 var rl rail
69 pinned, _ := s.st.PinnedRepos(viewer.ID)
70 for _, rp := range pinned {
71 grant, _ := s.st.AccessRole(rp.ID, viewer.ID)
72 if policy.CanRead(viewer, rp, grant) {
73 rl.Pinned = append(rl.Pinned, railRepo{Owner: rp.OwnerName, Name: rp.Name})
74 }
75 }
76 queue, _ := s.st.ReviewQueue(viewer.ID)
77 for _, q := range queue {
78 rl.Reviews = append(rl.Reviews, railItem{
79 RepoPath: q.RepoPath,
80 Number: q.Number,
81 Title: q.Title,
82 })
83 }
84 return rl
85}
internal/httpd/web.go +40 −37
@@ -62,8 +62,8 @@ func (s *Server) favicon(w http.ResponseWriter, r *http.Request) {
62 w.Write(web.FaviconSVG) 62 w.Write(web.FaviconSVG)
63} 63}
64 64
65// font serves the embedded IBM Plex subsets. Same-origin, so the CSP's 65// font serves the embedded Atkinson Hyperlegible subsets. Same-origin,
66// default-src 'self' covers it — no font CDN. 66// so the CSP's default-src 'self' covers it — no font CDN.
67func (s *Server) font(w http.ResponseWriter, r *http.Request) { 67func (s *Server) font(w http.ResponseWriter, r *http.Request) {
68 data, err := web.FontFS.ReadFile("static" + r.URL.Path[len("/static"):]) 68 data, err := web.FontFS.ReadFile("static" + r.URL.Path[len("/static"):])
69 if err != nil { 69 if err != nil {
@@ -79,10 +79,9 @@ func (s *Server) font(w http.ResponseWriter, r *http.Request) {
79// the stock plain-text response if the template fails. 79// the stock plain-text response if the template fails.
80func (s *Server) notFound(w http.ResponseWriter, r *http.Request) { 80func (s *Server) notFound(w http.ResponseWriter, r *http.Request) {
81 var buf bytes.Buffer 81 var buf bytes.Buffer
82 if err := web.Render(&buf, "404.html", struct { 82 if err := web.Render(&buf, "404.html", basePage{
83 Site string 83 Site: s.siteName(), Viewer: s.viewerName(r),
84 Viewer string 84 }); err != nil {
85 }{s.siteName(), s.viewerName(r)}); err != nil {
86 http.NotFound(w, r) 85 http.NotFound(w, r)
87 return 86 return
88 } 87 }
@@ -129,12 +128,11 @@ func (s *Server) index(w http.ResponseWriter, r *http.Request) {
129 host := strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix( 128 host := strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix(
130 s.cfg.Server.SiteURL, "https://"), "http://"), "/") 129 s.cfg.Server.SiteURL, "https://"), "http://"), "/")
131 s.render(w, "landing.html", struct { 130 s.render(w, "landing.html", struct {
132 Site string 131 basePage
133 Viewer string
134 Host string 132 Host string
135 Accounts bool 133 Accounts bool
136 Signup bool 134 Signup bool
137 }{s.siteName(), "", host, s.cfg.Web.Mode == "accounts", 135 }{basePage{Site: s.siteName()}, host, s.cfg.Web.Mode == "accounts",
138 s.cfg.Web.Mode == "accounts" && s.cfg.Registration.Mode != "closed"}) 136 s.cfg.Web.Mode == "accounts" && s.cfg.Registration.Mode != "closed"})
139} 137}
140 138
@@ -150,12 +148,11 @@ func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, viewer store.
150 mrs, _ := s.st.DashboardMRs(viewer.ID) 148 mrs, _ := s.st.DashboardMRs(viewer.ID)
151 issues, _ := s.st.DashboardIssues(viewer.ID) 149 issues, _ := s.st.DashboardIssues(viewer.ID)
152 s.render(w, "dashboard.html", struct { 150 s.render(w, "dashboard.html", struct {
153 Site string 151 basePage
154 Viewer string
155 Pinned []store.Repo 152 Pinned []store.Repo
156 MRs []store.DashboardItem 153 MRs []store.DashboardItem
157 Issues []store.DashboardItem 154 Issues []store.DashboardItem
158 }{s.siteName(), viewer.Username, visible, mrs, issues}) 155 }{s.baseFor(viewer), visible, mrs, issues})
159} 156}
160 157
161func (s *Server) explore(w http.ResponseWriter, r *http.Request) { 158func (s *Server) explore(w http.ResponseWriter, r *http.Request) {
@@ -170,11 +167,10 @@ func (s *Server) explore(w http.ResponseWriter, r *http.Request) {
170 } 167 }
171 q := strings.TrimSpace(r.URL.Query().Get("q")) 168 q := strings.TrimSpace(r.URL.Query().Get("q"))
172 s.render(w, "explore.html", struct { 169 s.render(w, "explore.html", struct {
173 Site string 170 basePage
174 Viewer string 171 Query string
175 Query string 172 Repos []describedRepo
176 Repos []describedRepo 173 }{s.baseFor(viewer), q, s.filterRepos(q, s.describeAll(repos))})
177 }{s.siteName(), viewer.Username, q, s.filterRepos(q, s.describeAll(repos))})
178} 174}
179 175
180// viewerName returns the logged-in username for header rendering, or "". 176// viewerName returns the logged-in username for header rendering, or "".
@@ -189,11 +185,10 @@ func (s *Server) viewerName(r *http.Request) string {
189// data, plus this instance's operator-provided notes. 185// data, plus this instance's operator-provided notes.
190func (s *Server) privacy(w http.ResponseWriter, r *http.Request) { 186func (s *Server) privacy(w http.ResponseWriter, r *http.Request) {
191 s.render(w, "privacy.html", struct { 187 s.render(w, "privacy.html", struct {
192 Site string 188 basePage
193 Viewer string
194 Host string 189 Host string
195 Notice string 190 Notice string
196 }{s.siteName(), s.viewerName(r), s.cfg.SiteHost(), s.cfg.Web.PrivacyNotice}) 191 }{s.base(r), s.cfg.SiteHost(), s.cfg.Web.PrivacyNotice})
197} 192}
198 193
199// filterRepos keeps repos whose path, description, or topics contain the 194// filterRepos keeps repos whose path, description, or topics contain the
@@ -222,8 +217,7 @@ func (s *Server) filterRepos(q string, repos []describedRepo) []describedRepo {
222 217
223// repoPage is the shared context for repo-scoped pages. 218// repoPage is the shared context for repo-scoped pages.
224type repoPage struct { 219type repoPage struct {
225 Site string 220 basePage
226 Viewer string
227 Desc string 221 Desc string
228 Repo store.Repo 222 Repo store.Repo
229 Ref string 223 Ref string
@@ -235,6 +229,13 @@ type repoPage struct {
235 HasWiki bool 229 HasWiki bool
236 Host string 230 Host string
237 Mirrors []mirrorLine // repo admins only 231 Mirrors []mirrorLine // repo admins only
232 // OpenIssues and OpenMRs are the counts on the header tabs.
233 OpenIssues int
234 OpenMRs int
235 // RepoHome asks the layout for the full header — description, topics,
236 // website, mirrors. Every other page gets identity and tabs only, so a
237 // repo describes itself once rather than on all twelve of its pages.
238 RepoHome bool
238} 239}
239 240
240// mirrorLine is the admin-only mirror status shown in the repo header. 241// mirrorLine is the admin-only mirror status shown in the repo header.
@@ -300,19 +301,21 @@ func (s *Server) repoFor(w http.ResponseWriter, r *http.Request, ref string) (re
300 }) 301 })
301 } 302 }
302 } 303 }
304 openIssues, openMRs := s.st.OpenCounts(repo.ID)
303 return repoPage{ 305 return repoPage{
304 Mirrors: mirrors, 306 basePage: s.baseFor(viewer),
305 Site: s.siteName(), 307 Mirrors: mirrors,
306 Viewer: viewer.Username, 308 Pinned: pinned,
307 Pinned: pinned, 309 HasWiki: s.wikiDir(repo.OwnerName, repo.Name) != "",
308 HasWiki: s.wikiDir(repo.OwnerName, repo.Name) != "", 310 Host: s.cfg.SiteHost(),
309 Host: s.cfg.SiteHost(), 311 Desc: gitutil.ReadDescription(control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)),
310 Desc: gitutil.ReadDescription(control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)), 312 Repo: repo,
311 Repo: repo, 313 Ref: ref,
312 Ref: ref, 314 CloneURL: s.cfg.Server.SiteURL + "/" + repo.Path() + ".git",
313 CloneURL: s.cfg.Server.SiteURL + "/" + repo.Path() + ".git", 315 Dir: control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name),
314 Dir: control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name), 316 Topics: topics,
315 Topics: topics, 317 OpenIssues: openIssues,
318 OpenMRs: openMRs,
316 }, true 319 }, true
317} 320}
318 321
@@ -385,8 +388,7 @@ func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) {
385 weeks, activityTotal := activityGrid(counts) 388 weeks, activityTotal := activityGrid(counts)
386 389
387 s.render(w, "owner.html", struct { 390 s.render(w, "owner.html", struct {
388 Site string 391 basePage
389 Viewer string
390 Owner string 392 Owner string
391 Kind string 393 Kind string
392 Profile store.Profile 394 Profile store.Profile
@@ -395,7 +397,7 @@ func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) {
395 Orgs []store.OrgMember 397 Orgs []store.OrgMember
396 Activity []activityWeek 398 Activity []activityWeek
397 ActivityTotal int 399 ActivityTotal int
398 }{s.siteName(), viewer.Username, name, kind, profile, s.describeAll(visible), members, orgs, 400 }{s.baseFor(viewer), name, kind, profile, s.describeAll(visible), members, orgs,
399 weeks, activityTotal}) 401 weeks, activityTotal})
400} 402}
401 403
@@ -405,6 +407,7 @@ func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) {
405 return 407 return
406 } 408 }
407 p.Tab = "files" 409 p.Tab = "files"
410 p.RepoHome = true
408 s.renderTree(w, r, p, "") 411 s.renderTree(w, r, p, "")
409} 412}
410 413
internal/store/dashboard.go +31
@@ -118,3 +118,34 @@ func (s *Store) PinnedRepos(userID int64) ([]Repo, error) {
118 } 118 }
119 return out, rows.Err() 119 return out, rows.Err()
120} 120}
121
122// ReviewQueue returns open merge requests the user is involved in, has not
123// authored, and has not reviewed at the current head — what the rail shows
124// as waiting on them. Ordered most recently touched first.
125func (s *Store) ReviewQueue(userID int64) ([]DashboardItem, error) {
126 return s.dashboardQuery(`
127 SELECT COALESCE(u.username, o.name) || '/' || r.name,
128 x.number, x.title, au.username, x.state, x.updated_at
129 FROM merge_requests x
130 JOIN repos r ON r.id = x.repo_id
131 LEFT JOIN users u ON r.owner_kind = 'user' AND u.id = r.owner_id
132 LEFT JOIN orgs o ON r.owner_kind = 'org' AND o.id = r.owner_id
133 JOIN users au ON au.id = x.author_id
134 WHERE x.state IN ('open', 'source_gone')
135 AND x.author_id <> ?1
136 AND NOT EXISTS (SELECT 1 FROM mr_reviews rv
137 WHERE rv.mr_id = x.id AND rv.reviewer_id = ?1
138 AND rv.head_sha = x.head_sha)
139 AND `+involvedCond+`
140 ORDER BY x.updated_at DESC LIMIT 8`, userID)
141}
142
143// OpenCounts returns the repo's open issue and open merge request counts,
144// for the repo tab badges.
145func (s *Store) OpenCounts(repoID int64) (issues, mrs int) {
146 s.DB.QueryRow("SELECT COUNT(*) FROM issues WHERE repo_id = ? AND state = 'open'",
147 repoID).Scan(&issues)
148 s.DB.QueryRow("SELECT COUNT(*) FROM merge_requests WHERE repo_id = ? AND state IN ('open', 'source_gone')",
149 repoID).Scan(&mrs)
150 return
151}
internal/web/static/favicon.svg +1 −1
@@ -1 +1 @@
1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><rect x="1" y="1" width="22" height="22" rx="6" fill="#0000f0"/><path d="M5 9.5c2-2.5 4-2.5 6 0s4 2.5 6 0" stroke="#ffffff" stroke-width="2.2" fill="none" stroke-linecap="round"/><path d="M5 15c2-2.5 4-2.5 6 0s4 2.5 6 0" stroke="#ffffff" stroke-width="2.2" fill="none" stroke-linecap="round"/></svg> 1<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><rect width="24" height="24" fill="#000000"/><path d="M12 2.25 18.75 12H5.25z" fill="#ff6b3d"/><rect x="3" y="13.5" width="18" height="3" fill="#ffffff"/><rect x="7.5" y="18" width="9" height="3" fill="#ffffff"/></svg>
internal/web/static/fonts/atkinson-mono-roman.woff2 added

Binary file not shown.

internal/web/static/fonts/atkinson-next-italic.woff2 added

Binary file not shown.

internal/web/static/fonts/atkinson-next-roman.woff2 added

Binary file not shown.

internal/web/static/fonts/plex-mono-400.woff2 deleted

Binary file not shown.

internal/web/static/fonts/plex-mono-500.woff2 deleted

Binary file not shown.

internal/web/static/fonts/plex-sans.woff2 deleted

Binary file not shown.

internal/web/static/style.css +287 −259
@@ -1,28 +1,31 @@
1/* gitbay stylesheet, Carbon-school: black shell, sharp lines, one blue. 1/* gitbay stylesheet — Harbor: black and white ground, two accents with
2 One file, light and dark via prefers-color-scheme. Tokens first; 2 separate jobs. Blue is what you can do (links, buttons, focus); orange
3 components use tokens only. No external requests: IBM Plex is served 3 is where you are and what wants you (active nav, counts, pending state,
4 from this origin. */ 4 unverified signatures). Orange is never a button, so the two never
5 compete. One file, light and dark via prefers-color-scheme. Tokens
6 first; components use tokens only. No external requests: Atkinson
7 Hyperlegible is served from this origin. */
5 8
6@font-face { 9@font-face {
7 font-family: "IBM Plex Sans"; 10 font-family: "Atkinson Hyperlegible Next";
8 font-style: normal; 11 font-style: normal;
9 font-weight: 400 600; 12 font-weight: 400 700;
10 font-display: swap; 13 font-display: swap;
11 src: url(/static/fonts/plex-sans.woff2) format("woff2"); 14 src: url(/static/fonts/atkinson-next-roman.woff2) format("woff2");
12} 15}
13@font-face { 16@font-face {
14 font-family: "IBM Plex Mono"; 17 font-family: "Atkinson Hyperlegible Next";
15 font-style: normal; 18 font-style: italic;
16 font-weight: 400; 19 font-weight: 400 700;
17 font-display: swap; 20 font-display: swap;
18 src: url(/static/fonts/plex-mono-400.woff2) format("woff2"); 21 src: url(/static/fonts/atkinson-next-italic.woff2) format("woff2");
19} 22}
20@font-face { 23@font-face {
21 font-family: "IBM Plex Mono"; 24 font-family: "Atkinson Hyperlegible Mono";
22 font-style: normal; 25 font-style: normal;
23 font-weight: 500; 26 font-weight: 400 600;
24 font-display: swap; 27 font-display: swap;
25 src: url(/static/fonts/plex-mono-500.woff2) format("woff2"); 28 src: url(/static/fonts/atkinson-mono-roman.woff2) format("woff2");
26} 29}
27 30
28:root { 31:root {
@@ -30,39 +33,50 @@
30 scrollbars per scheme, and opt out of browser auto-darkening */ 33 scrollbars per scheme, and opt out of browser auto-darkening */
31 color-scheme: light dark; 34 color-scheme: light dark;
32 35
33 /* palette (Carbon g10) */ 36 /* ground and ink */
34 --bg: #f4f4f4; 37 --bg: #ffffff;
35 --fg: #161616; 38 --fg: #0d0d0d;
36 --muted: #525252; 39 --muted: #595959; /* 7.00:1 on white */
37 --faint: #ececec; /* hairlines, row separators */ 40 --faint: #eaeaea; /* hairlines, row separators */
38 --line: #e0e0e0; /* control borders, tile edges */ 41 --line: #d4d4d4; /* control borders, table edges */
39 --surface: #ffffff; /* tiles, cards */ 42 --surface: #ffffff;
40 --hover: #f0f2f8; /* row hover on tiles */ 43 --hover: #f5f5f5;
41 --accent: #0f62fe; /* blue-60: links, active nav, primary */ 44 --code-bg: #f5f5f5;
42 --accent-fg: #ffffff; /* text on accent */ 45
43 --ok: #198038; 46 /* blue: what you can do */
44 --warn: #8e6a00; 47 --accent: #0000f0; /* links, focus — 9.30:1 on white */
45 --bad: #da1e28; 48 --accent-fg: #ffffff;
46 --done: #8a3ffc; /* merged */ 49 --fill: #0000f0; /* button grounds; white on it is 9.30:1 */
47 --neutral: #525252; 50
48 --code-bg: #f4f4f4; 51 /* orange: where you are, what wants you */
49 /* the shell is ink in both schemes */ 52 --mark: #e4572e; /* bars and underlines — non-text, 3.0 bar */
50 --shell-bg: #161616; 53 --warn: #c2410c; /* the same orange at text size — 5.18:1 */
51 --shell-fg: #f4f4f4; 54
52 --shell-muted: #c6c6c6; 55 /* semantic */
53 --shell-line: #161616; 56 --ok: #0a7d3c;
57 --bad: #c62828;
58 --done: #6b21a8; /* merged */
59 --neutral: #595959;
60
61 /* the rail is black in both schemes: the one brand constant */
62 --shell-bg: #000000;
63 --shell-fg: #f2f2f2;
64 --shell-muted: #8f8f8f;
65 --shell-line: #1c1c1c;
66 --shell-hover: #141414;
67 --shell-mark: #ff6b3d; /* 7.42:1 on black, both schemes */
54 68
55 /* type */ 69 /* type */
56 --sans: "IBM Plex Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", 70 --sans: "Atkinson Hyperlegible Next", -apple-system, BlinkMacSystemFont,
57 Roboto, "Helvetica Neue", Arial, sans-serif; 71 "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
58 --mono: "IBM Plex Mono", ui-monospace, "SF Mono", SFMono-Regular, Menlo, 72 --mono: "Atkinson Hyperlegible Mono", ui-monospace, "SF Mono",
59 Consolas, "Liberation Mono", monospace; 73 SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
60 --fs-0: 0.8rem; /* 12.8px: chips, fine print */ 74 --fs-0: 0.78rem;
61 --fs-1: 0.866rem; /* ~13px: metadata, mono in tables */ 75 --fs-1: 0.85rem;
62 --fs-2: 0.933rem; /* 14px: dense table text */ 76 --fs-2: 0.92rem;
63 --fs-3: 1rem; /* 15px: body */ 77 --fs-3: 1rem;
64 --fs-4: 1.135rem; /* ~17px: h2 */ 78 --fs-4: 1.2rem;
65 --fs-5: 1.335rem; /* ~20px: h1 */ 79 --fs-5: 1.6rem;
66 80
67 /* spacing */ 81 /* spacing */
68 --sp-1: 0.25rem; 82 --sp-1: 0.25rem;
@@ -72,31 +86,31 @@
72 --sp-5: 1.5rem; 86 --sp-5: 1.5rem;
73 --sp-6: 2.5rem; 87 --sp-6: 2.5rem;
74 88
75 /* radii: sharp everywhere; only tags stay pills */ 89 /* radii: 2px everywhere — chart plates, not pills */
76 --r-sm: 0; 90 --r-sm: 2px;
77 --r-md: 0; 91 --r-md: 2px;
78 --r-lg: 0; 92 --r-lg: 2px;
79 --r-pill: 999px; 93 --r-pill: 2px;
80} 94}
81@media (prefers-color-scheme: dark) { 95@media (prefers-color-scheme: dark) {
82 :root { 96 :root {
83 /* Carbon g100 */ 97 --bg: #0a0a0a;
84 --bg: #161616; 98 --fg: #f2f2f2;
85 --fg: #f4f4f4; 99 --muted: #a1a1a1; /* 7.66:1 on #0a0a0a */
86 --muted: #a8a8a8; 100 --faint: #1c1c1c;
87 --faint: #2a2a2a; 101 --line: #2e2e2e;
88 --line: #393939; 102 --surface: #0f0f0f;
89 --surface: #262626; 103 --hover: #161616;
90 --hover: #2e3440; 104 --code-bg: #050505;
91 --accent: #78a9ff; /* blue-40 reads on dark grounds */ 105 --accent: #5b6bff; /* 4.71:1 — pure #0000f0 is 2.13:1 here */
92 --accent-fg: #161616; 106 --accent-fg: #ffffff;
93 --ok: #42be65; 107 --fill: #0000f0; /* fills stay pure; white on it clears 9:1 */
94 --warn: #d2a106; 108 --mark: #ff6b3d;
95 --bad: #fa4d56; 109 --warn: #ff6b3d;
96 --done: #a56eff; 110 --ok: #3fce7a;
97 --neutral: #a8a8a8; 111 --bad: #ff6b6b;
98 --code-bg: #131313; 112 --done: #c084fc;
99 --shell-line: #393939; 113 --neutral: #a1a1a1;
100 } 114 }
101} 115}
102 116
@@ -108,18 +122,46 @@ body {
108 color: var(--fg); 122 color: var(--fg);
109 font: var(--fs-3)/1.6 var(--sans); 123 font: var(--fs-3)/1.6 var(--sans);
110 min-height: 100vh; 124 min-height: 100vh;
111 display: flex;
112 flex-direction: column;
113} 125}
114main { flex: 1; }
115 126
116a { color: var(--accent); text-decoration: none; } 127a { color: var(--accent); text-decoration: none; }
117a:hover { text-decoration: underline; } 128a:hover { text-decoration: underline; }
118 129
119h1 { font-size: var(--fs-5); font-weight: 600; line-height: 1.3; 130/* one authored focus ring, so keyboard position is never the browser's
120 margin: var(--sp-5) 0 var(--sp-3); letter-spacing: -0.01em; } 131 guess. Blue, because focus is an interactive state. */
121h2 { font-size: var(--fs-4); font-weight: 600; line-height: 1.35; 132:focus-visible {
122 margin: var(--sp-5) 0 var(--sp-3); } 133 outline: 2px solid var(--accent);
134 outline-offset: 2px;
135 border-radius: 1px;
136}
137
138/* visually hidden, still read aloud: for headings a sighted reader gets
139 from the layout but a screen reader would otherwise miss */
140.vh {
141 position: absolute;
142 width: 1px; height: 1px;
143 margin: -1px; padding: 0; border: 0;
144 clip-path: inset(50%);
145 overflow: hidden;
146 white-space: nowrap;
147}
148/* the rail runs ahead of the content in source order, so give keyboard
149 and screen-reader users a way past it */
150a.skip {
151 position: absolute;
152 left: var(--sp-3); top: -3rem;
153 z-index: 50;
154 background: var(--fill);
155 color: #ffffff;
156 padding: var(--sp-2) var(--sp-4);
157 font-weight: 600;
158}
159a.skip:focus { top: var(--sp-3); text-decoration: none; }
160
161h1 { font-size: var(--fs-5); font-weight: 700; line-height: 1.18;
162 margin: 0 0 var(--sp-3); letter-spacing: -0.025em; text-wrap: balance; }
163h2 { font-size: var(--fs-4); font-weight: 700; line-height: 1.3;
164 margin: var(--sp-5) 0 var(--sp-3); letter-spacing: -0.015em; }
123h3 { font-size: var(--fs-3); font-weight: 600; margin: var(--sp-5) 0 var(--sp-2); } 165h3 { font-size: var(--fs-3); font-weight: 600; margin: var(--sp-5) 0 var(--sp-2); }
124 166
125code, pre, .code, td.mode, td.size { 167code, pre, .code, td.mode, td.size {
@@ -127,167 +169,213 @@ code, pre, .code, td.mode, td.size {
127 font-size: var(--fs-1); 169 font-size: var(--fs-1);
128} 170}
129 171
130/* layout: one shared container aligns header, content, and footer */ 172/* ---- shell: black rail, content pane ---- */
131.container { 173.shell { display: flex; align-items: stretch; min-height: 100vh; }
132 max-width: 64rem; 174
133 margin: 0 auto; 175.rail {
134 padding: 0 var(--sp-5); 176 flex: none;
135} 177 width: 13rem;
136/* the shell: an ink bar in both schemes, Carbon UI-shell style. Scoped
137 to the page header so cards with their own <header> stay untouched. */
138body > header {
139 background: var(--shell-bg); 178 background: var(--shell-bg);
140 border-bottom: 1px solid var(--shell-line); 179 color: var(--shell-fg);
141 padding: 0; 180 border-right: 1px solid var(--shell-line);
142 position: sticky; 181 position: sticky;
143 top: 0; 182 top: 0;
144 z-index: 30; 183 height: 100vh;
184 display: flex;
185 flex-direction: column;
145} 186}
146a.site { 187a.brand {
147 font-weight: 600; 188 display: flex;
148 color: var(--shell-fg);
149 letter-spacing: -0.01em;
150 display: inline-flex;
151 align-items: center; 189 align-items: center;
152 gap: var(--sp-2); 190 gap: var(--sp-2);
153 padding: 0 var(--sp-4); 191 padding: var(--sp-3) var(--sp-4);
192 color: var(--shell-fg);
193 font-weight: 700;
194 font-size: var(--fs-3);
195 letter-spacing: -0.02em;
196 border-bottom: 1px solid var(--shell-line);
154} 197}
155a.site:hover { text-decoration: none; color: #ffffff; } 198a.brand:hover { text-decoration: none; color: #ffffff; }
156a.site svg.mark { display: block; } 199a.brand svg.mark { display: block; flex: none; }
157a.site svg.mark rect { fill: #0f62fe; } 200a.brand span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
158a.site svg.mark path { stroke: #ffffff; } 201
159nav.topnav { 202.railbody { flex: 1; overflow-y: auto; padding: var(--sp-3) 0; }
160 display: flex; 203ul.raillist { list-style: none; margin: 0; padding: 0; }
161 align-items: center; 204ul.raillist a {
162 gap: 0; 205 display: block;
163 height: 3rem; 206 padding: 0.28rem var(--sp-4);
164} 207 color: var(--shell-fg);
165nav.topnav .spacer { flex: 1; }
166/* the avatar square is the only nav item in the shell; everything else
167 lives in the rail */
168a.avatar {
169 width: 2rem;
170 height: 2rem;
171 margin-right: var(--sp-3);
172 display: inline-flex;
173 align-items: center;
174 justify-content: center;
175 background: var(--accent);
176 color: #ffffff;
177 font-size: var(--fs-2); 208 font-size: var(--fs-2);
209 border-left: 3px solid transparent;
210 overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
211}
212ul.raillist a:hover { background: var(--shell-hover); text-decoration: none; }
213/* orange marks position — the rail's only accent */
214ul.raillist a[aria-current] {
215 border-left-color: var(--shell-mark);
216 background: var(--shell-hover);
178 font-weight: 600; 217 font-weight: 600;
179 flex: none;
180} 218}
181a.avatar:hover { text-decoration: none; filter: brightness(1.12); } 219ul.raillist .owner { color: var(--shell-muted); }
182a.avatar.guest { background: #393939; color: #f4f4f4; } 220ul.raillist.wide a { white-space: normal; padding-top: var(--sp-1); padding-bottom: var(--sp-1); }
221ul.raillist.wide b { display: block; font-weight: 500; font-size: var(--fs-1); }
222ul.raillist.wide .owner { font-size: var(--fs-0); }
183 223
184/* the rail: persistent navigation, global links first, repo sections 224.raillabel {
185 below them, the signed-in user pinned to the bottom */
186.shell { display: flex; flex: 1; align-items: stretch; }
187.siderail {
188 flex: none;
189 width: 13.5rem;
190 background: var(--surface);
191 border-right: 1px solid var(--line);
192 position: sticky;
193 top: 3rem;
194 height: calc(100vh - 3rem);
195 display: flex; 225 display: flex;
196 flex-direction: column; 226 align-items: center;
227 gap: var(--sp-2);
228 margin: var(--sp-4) 0 var(--sp-1);
229 padding: 0 var(--sp-4);
230 font-family: var(--mono);
231 font-size: var(--fs-0);
232 font-weight: 500;
233 letter-spacing: 0.1em;
234 text-transform: uppercase;
235 color: var(--shell-muted);
236}
237.raillabel .count {
238 font-family: var(--sans);
239 font-size: var(--fs-0);
240 font-weight: 700;
241 letter-spacing: 0;
242 background: var(--shell-mark);
243 color: #000000;
244 padding: 0 0.35rem;
245 border-radius: var(--r-sm);
197} 246}
198.railmain { flex: 1; overflow-y: auto; padding: var(--sp-3) 0; }
199.railfoot { 247.railfoot {
200 flex: none; 248 flex: none;
201 border-top: 1px solid var(--line); 249 border-top: 1px solid var(--shell-line);
202 padding: var(--sp-2) 0; 250 padding: var(--sp-2) var(--sp-4);
203 background: var(--surface);
204}
205.siderail a, .siderail button.linklike {
206 display: flex; 251 display: flex;
207 align-items: center; 252 align-items: center;
208 gap: var(--sp-2); 253 gap: var(--sp-3);
209 width: 100%;
210 text-align: left;
211 color: var(--fg);
212 font-size: var(--fs-2); 254 font-size: var(--fs-2);
213 padding: 0.4rem var(--sp-4);
214 border-left: 3px solid transparent;
215 line-height: 1.35;
216} 255}
217.siderail a:hover, .siderail button.linklike:hover { 256.railfoot a { color: var(--shell-fg); }
218 background: var(--hover); 257a.railuser { display: inline-flex; align-items: center; gap: var(--sp-2); }
219 text-decoration: none; 258.railfoot .linklike {
220 color: var(--fg); 259 background: none; border: 0; padding: 0; cursor: pointer;
260 color: var(--shell-muted); font: inherit; text-decoration: underline;
261}
262.railfoot .linklike:hover { color: var(--shell-fg); }
263.avatar {
264 width: 1.4rem; height: 1.4rem; flex: none;
265 display: inline-grid; place-items: center;
266 background: var(--fill);
267 color: #ffffff;
268 font-size: var(--fs-0);
269 font-weight: 700;
270 border-radius: var(--r-sm);
221} 271}
222.siderail a.active { 272
223 border-left-color: var(--accent); 273.pane { flex: 1; min-width: 0; display: flex; flex-direction: column; }
224 background: var(--hover); 274.content {
275 flex: 1;
276 padding: var(--sp-5) var(--sp-5) var(--sp-6);
277 max-width: 78rem;
278 width: 100%;
279}
280
281/* ---- repo header: identity and tabs on every page, the rest once ---- */
282.repohead {
283 padding: var(--sp-4) var(--sp-5) 0;
284 border-bottom: 1px solid var(--line);
285}
286.repohead .identity { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
287.repohead .identity .grow { flex: 1; }
288.repotitle {
289 margin: 0;
290 font-size: var(--fs-4);
291 font-weight: 700;
292 letter-spacing: -0.02em;
293 line-height: 1.3;
294}
295h1.repotitle { font-size: var(--fs-5); }
296.repotitle a { color: var(--fg); }
297.repotitle a.owner { color: var(--muted); font-weight: 400; }
298.repotitle a:hover { color: var(--accent); text-decoration: none; }
299.repotitle .sep { color: var(--muted); margin: 0 0.12em; font-weight: 400; }
300.repodesc { margin: var(--sp-2) 0 0; color: var(--muted); font-size: var(--fs-2); max-width: 62ch; }
301.repometa { margin: var(--sp-1) 0 0; color: var(--muted); font-size: var(--fs-1); }
302.repohead .topics { margin: var(--sp-2) 0 0; }
303
304button.btn {
305 font: inherit;
306 font-size: var(--fs-1);
225 font-weight: 600; 307 font-weight: 600;
308 padding: 0.15rem 0.7rem;
309 border: 1px solid var(--line);
310 border-radius: var(--r-sm);
311 background: var(--bg);
312 color: var(--fg);
313 cursor: pointer;
226} 314}
227.siderail .glabel { 315button.btn:hover { border-color: var(--accent); color: var(--accent); }
228 display: block; 316button.btn[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); }
229 font-size: var(--fs-0); 317form.inline { display: inline; }
318
319nav.tabs {
320 display: flex;
321 gap: var(--sp-1);
322 margin-top: var(--sp-4);
323 overflow-x: auto;
324 scrollbar-width: none;
325}
326nav.tabs::-webkit-scrollbar { display: none; }
327nav.tabs a {
328 display: inline-flex;
329 align-items: center;
330 gap: var(--sp-2);
331 padding: var(--sp-2) var(--sp-3) 0.6rem;
230 color: var(--muted); 332 color: var(--muted);
231 letter-spacing: 0.04em; 333 font-size: var(--fs-2);
232 padding: 0 var(--sp-4); 334 white-space: nowrap;
233 margin: var(--sp-4) 0 var(--sp-1); 335 border-bottom: 2px solid transparent;
336 margin-bottom: -1px;
234} 337}
235.siderail .glabel:not(.sub) { 338nav.tabs a:hover { color: var(--fg); text-decoration: none; }
339/* orange marks position here too */
340nav.tabs a[aria-current] {
236 color: var(--fg); 341 color: var(--fg);
237 font-weight: 600; 342 font-weight: 700;
238 border-top: 1px solid var(--line); 343 border-bottom-color: var(--mark);
239 padding-top: var(--sp-3);
240 overflow-wrap: anywhere;
241} 344}
242.siderail .glabel.sub { margin-top: var(--sp-2); } 345nav.tabs a i {
243.siderail .railuser { font-weight: 600; } 346 font-style: normal;
244.siderail .avatar.sm { 347 font-family: var(--mono);
245 width: 1.25rem;
246 height: 1.25rem;
247 margin: 0;
248 font-size: var(--fs-0); 348 font-size: var(--fs-0);
249 background: var(--accent); 349 background: var(--hover);
250 color: #ffffff; 350 border-radius: var(--r-sm);
251} 351 padding: 0 0.35rem;
252main.container {
253 width: 100%;
254 min-width: 0;
255 flex: 1;
256 margin: 0;
257 max-width: 66rem;
258 padding-top: var(--sp-4);
259 padding-bottom: var(--sp-6);
260} 352}
353
354/* ---- mobile: the rail becomes a single strip, not a wall of links ---- */
261@media (max-width: 52rem) { 355@media (max-width: 52rem) {
262 /* the rail folds into a wrapping strip above the content */
263 .shell { display: block; } 356 .shell { display: block; }
264 .siderail { 357 .rail {
265 width: auto; 358 width: auto;
266 height: auto; 359 height: auto;
267 position: static; 360 position: static;
268 flex-direction: row; 361 flex-direction: row;
269 flex-wrap: wrap;
270 align-items: center; 362 align-items: center;
271 border-right: none; 363 gap: var(--sp-2);
272 border-bottom: 1px solid var(--line); 364 border-right: 0;
273 } 365 border-bottom: 1px solid var(--shell-line);
274 .railmain, .railfoot { 366 overflow-x: auto;
275 display: flex;
276 flex-wrap: wrap;
277 align-items: center;
278 overflow: visible;
279 padding: var(--sp-1) var(--sp-2);
280 border-top: none;
281 }
282 .railmain { flex: 1 1 auto; }
283 .siderail a, .siderail button.linklike {
284 width: auto;
285 border-left: none;
286 border-bottom: 3px solid transparent;
287 padding: var(--sp-1) var(--sp-2);
288 } 367 }
289 .siderail a.active { border-left: none; border-bottom-color: var(--accent); background: none; } 368 a.brand { border-bottom: 0; padding-right: var(--sp-2); }
290 .siderail .glabel { display: none; } 369 .railbody { flex: 1; overflow: visible; padding: 0; display: flex; align-items: center; }
370 /* pinned repos and the review queue both live on the dashboard, so the
371 strip keeps only what has nowhere else to go */
372 .railgroup { display: none; }
373 ul.raillist { display: flex; }
374 ul.raillist a { border-left: 0; border-bottom: 2px solid transparent; padding: var(--sp-2) var(--sp-3); }
375 ul.raillist a[aria-current] { border-left: 0; border-bottom-color: var(--shell-mark); }
376 .railfoot { border-top: 0; padding-left: var(--sp-2); }
377 .content { padding: var(--sp-4) var(--sp-4) var(--sp-5); }
378 .repohead { padding: var(--sp-3) var(--sp-4) 0; }
291} 379}
292 380
293/* tables */ 381/* tables */
@@ -301,66 +389,6 @@ td {
301td:last-child { padding-right: 0; } 389td:last-child { padding-right: 0; }
302td.size, td.mode { color: var(--muted); white-space: nowrap; } 390td.size, td.mode { color: var(--muted); white-space: nowrap; }
303 391
304/* repo header */
305.repohead { margin-bottom: var(--sp-4); }
306h1.repotitle { margin: var(--sp-4) 0 var(--sp-1); font-weight: 400; }
307h1.repotitle a { color: var(--fg); }
308h1.repotitle a:last-of-type { font-weight: 650; }
309h1.repotitle a:hover { color: var(--accent); text-decoration: none; }
310h1.repotitle .sep { color: var(--muted); margin: 0 0.15em; }
311form.pinform { display: inline; margin-left: var(--sp-2); vertical-align: middle; }
312button.pinbtn {
313 background: none;
314 border: 1px solid var(--line);
315 border-radius: var(--r-pill);
316 color: var(--muted);
317 font-size: var(--fs-0);
318 padding: 0.05rem 0.55rem;
319 cursor: pointer;
320}
321button.pinbtn:hover { border-color: var(--accent); color: var(--accent); filter: none; }
322button.pinbtn.pinned { color: var(--accent); border-color: var(--accent); }
323a.chip.label:hover, a.chip.topic:hover { text-decoration: none; filter: brightness(1.15); }
324.repohead .desc { margin: 0 0 var(--sp-2); }
325
326/* repo rail: grouped vertical navigation on the left */
327nav.tabs { display: flex; flex-direction: column; margin-top: var(--sp-1); }
328nav.tabs .glabel {
329 font-size: var(--fs-0);
330 color: var(--muted);
331 letter-spacing: 0.04em;
332 margin: var(--sp-4) 0 var(--sp-1);
333}
334nav.tabs .glabel:first-child { margin-top: 0; }
335nav.tabs a {
336 color: var(--fg);
337 padding: 0.32rem var(--sp-3);
338 font-size: var(--fs-2);
339 border-left: 3px solid transparent;
340}
341nav.tabs a:hover { background: var(--hover); text-decoration: none; }
342nav.tabs a.active {
343 border-left-color: var(--accent);
344 background: var(--surface);
345 font-weight: 600;
346}
347@media (max-width: 52rem) {
348 /* the rail folds back into a wrapping strip on narrow screens */
349 main.container:has(> nav.tabs) { display: block; }
350 nav.tabs { flex-direction: row; flex-wrap: wrap; gap: 0 var(--sp-1); border-bottom: 1px solid var(--line); padding-bottom: var(--sp-2); position: static; }
351 nav.tabs .glabel { display: none; }
352 nav.tabs a { border-left: none; border-bottom: 3px solid transparent; padding: var(--sp-1) var(--sp-2); }
353 nav.tabs a.active { border-bottom-color: var(--accent); background: none; }
354}
355
356.clone { color: var(--muted); font-size: var(--fs-2); }
357.clone code {
358 background: var(--code-bg);
359 border: 1px solid var(--line);
360 padding: 0.15rem 0.5rem;
361 border-radius: var(--r-sm);
362}
363
364/* path bar on tree and blob pages */ 392/* path bar on tree and blob pages */
365.pathbar { 393.pathbar {
366 display: flex; 394 display: flex;
internal/web/templates/blame.html +1 −1
@@ -1,6 +1,6 @@
1{{define "title"}}blame: {{.Path}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 1{{define "title"}}blame: {{.Path}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}} 2{{define "content"}}
3{{template "repoheader" .}} 3<h1 class="vh">Blame: {{.Path}}</h1>
4<div class="pathbar"> 4<div class="pathbar">
5 <span class="refchip">{{template "branchicon"}} {{.Ref}}</span> 5 <span class="refchip">{{template "branchicon"}} {{.Ref}}</span>
6 <span class="crumbs"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">{{.Repo.Name}}</a>/{{range .Crumbs}}<a href="{{.URL}}">{{.Name}}</a>/{{end}}<strong>{{.Base}}</strong></span> 6 <span class="crumbs"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">{{.Repo.Name}}</a>/{{range .Crumbs}}<a href="{{.URL}}">{{.Name}}</a>/{{end}}<strong>{{.Base}}</strong></span>
internal/web/templates/blob.html +1 −1
@@ -1,6 +1,6 @@
1{{define "title"}}{{.Path}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 1{{define "title"}}{{.Path}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}} 2{{define "content"}}
3{{template "repoheader" .}} 3<h1 class="vh">{{.Path}}</h1>
4<div class="pathbar"> 4<div class="pathbar">
5 {{template "refmenu" .}} 5 {{template "refmenu" .}}
6 <span class="crumbs"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">{{.Repo.Name}}</a>/{{range .Crumbs}}<a href="{{.URL}}">{{.Name}}</a>/{{end}}<strong>{{.Base}}</strong></span> 6 <span class="crumbs"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">{{.Repo.Name}}</a>/{{range .Crumbs}}<a href="{{.URL}}">{{.Name}}</a>/{{end}}<strong>{{.Base}}</strong></span>
internal/web/templates/build.html +1 −2
@@ -1,8 +1,7 @@
1{{define "title"}}build {{.Build.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 1{{define "title"}}build {{.Build.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}} 2{{define "content"}}
3{{template "repoheader" .}}
4<div class="headrow"> 3<div class="headrow">
5<h1>build {{.Build.Number}} <span class="chip check-{{.Build.Status}}">{{.Build.Status}}</span></h1> 4<h1>Build {{.Build.Number}} <span class="chip check-{{.Build.Status}}">{{.Build.Status}}</span></h1>
6</div> 5</div>
7<p class="meta">{{.Build.Job}} on {{.Build.Ref}} · <code><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/commit/{{.Build.SHA}}">{{printf "%.10s" .Build.SHA}}</a></code> · queued {{when .Build.CreatedAt}}{{if .Build.FinishedAt}} · finished {{when .Build.FinishedAt}}{{end}}</p> 6<p class="meta">{{.Build.Job}} on {{.Build.Ref}} · <code><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/commit/{{.Build.SHA}}">{{printf "%.10s" .Build.SHA}}</a></code> · queued {{when .Build.CreatedAt}}{{if .Build.FinishedAt}} · finished {{when .Build.FinishedAt}}{{end}}</p>
8{{if .Log}}<pre class="code buildlog">{{.Log}}</pre>{{else}}<p class="empty-note">no log yet</p>{{end}} 7{{if .Log}}<pre class="code buildlog">{{.Log}}</pre>{{else}}<p class="empty-note">no log yet</p>{{end}}
internal/web/templates/builds.html +1 −1
@@ -1,6 +1,6 @@
1{{define "title"}}builds · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 1{{define "title"}}builds · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}} 2{{define "content"}}
3{{template "repoheader" .}} 3<h1>Builds</h1>
4<ul class="loglist"> 4<ul class="loglist">
5{{range .Builds}}<li> 5{{range .Builds}}<li>
6 <div class="commitmain"> 6 <div class="commitmain">
internal/web/templates/commit.html +1 −2
@@ -1,8 +1,7 @@
1{{define "title"}}{{.ShortSHA}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 1{{define "title"}}{{.ShortSHA}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}} 2{{define "content"}}
3{{template "repoheader" .}}
4<div class="commithead"> 3<div class="commithead">
5 <h2>commit <code>{{.ShortSHA}}</code></h2> 4 <h1>Commit <code>{{.ShortSHA}}</code></h1>
6 <p class="meta"><code class="fullsha">{{.SHA}}</code></p> 5 <p class="meta"><code class="fullsha">{{.SHA}}</code></p>
7 {{if .Parents}}<p class="meta">parent{{if gt (len .Parents) 1}}s{{end}}:{{range .Parents}} <code><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.}}">{{short .}}</a></code>{{end}}</p>{{end}} 6 {{if .Parents}}<p class="meta">parent{{if gt (len .Parents) 1}}s{{end}}:{{range .Parents}} <code><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.}}">{{short .}}</a></code>{{end}}</p>{{end}}
8 <p>{{template "sigbadge" .Sig}}{{range .Checks}} <span class="badge check-{{.State}}">{{.Context}}: {{.State}}</span>{{end}}</p> 7 <p>{{template "sigbadge" .Sig}}{{range .Checks}} <span class="badge check-{{.State}}">{{.Context}}: {{.State}}</span>{{end}}</p>
internal/web/templates/issue.html +1 −2
@@ -1,7 +1,6 @@
1{{define "title"}}#{{.Issue.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 1{{define "title"}}#{{.Issue.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}} 2{{define "content"}}
3{{template "repoheader" .}} 3<h1 class="issuetitle">{{.Issue.Title}} <span class="issuenumber">#{{.Issue.Number}}</span></h1>
4<h2 class="issuetitle">{{.Issue.Title}} <span class="issuenumber">#{{.Issue.Number}}</span></h2>
5<p class="issuemeta"><span class="chip {{if eq .Issue.State "open"}}chip-open{{else}}chip-done{{end}}">{{.Issue.State}}</span> 4<p class="issuemeta"><span class="chip {{if eq .Issue.State "open"}}chip-open{{else}}chip-done{{end}}">{{.Issue.State}}</span>
6<a href="/{{.Issue.Author}}">{{.Issue.Author}}</a> opened this on {{when .Issue.CreatedAt}} 5<a href="/{{.Issue.Author}}">{{.Issue.Author}}</a> opened this on {{when .Issue.CreatedAt}}
7{{if .Issue.Labels}} · {{range .Issue.Labels}}<a class="chip label" style="{{index $.LabelColors .}}" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/issues?label={{.}}">{{.}}</a> {{end}}{{end}} 6{{if .Issue.Labels}} · {{range .Issue.Labels}}<a class="chip label" style="{{index $.LabelColors .}}" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/issues?label={{.}}">{{.}}</a> {{end}}{{end}}
internal/web/templates/issuenew.html +1 −2
@@ -1,7 +1,6 @@
1{{define "title"}}new issue · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 1{{define "title"}}new issue · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}} 2{{define "content"}}
3{{template "repoheader" .}} 3<h1>New issue</h1>
4<h2>new issue</h2>
5{{if gt (len .Templates) 1}}<p class="meta">template: 4{{if gt (len .Templates) 1}}<p class="meta">template:
6{{range .Templates}}{{if eq .Name $.Template}}<strong>{{.Name}}</strong>{{else}}<a href="?template={{.Name}}">{{.Name}}</a>{{end}} {{end}}</p>{{end}} 5{{range .Templates}}{{if eq .Name $.Template}}<strong>{{.Name}}</strong>{{else}}<a href="?template={{.Name}}">{{.Name}}</a>{{end}} {{end}}</p>{{end}}
7<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/new" class="commentform"> 6<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/new" class="commentform">
internal/web/templates/issues.html +1 −2
@@ -1,8 +1,7 @@
1{{define "title"}}issues · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 1{{define "title"}}issues · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}} 2{{define "content"}}
3{{template "repoheader" .}}
4<div class="listhead"> 3<div class="listhead">
5 <h2>issues</h2> 4 <h1>Issues</h1>
6 <nav class="filters"> 5 <nav class="filters">
7 <a {{if eq .State "open"}}class="active" {{end}}href="?state=open">open</a> 6 <a {{if eq .State "open"}}class="active" {{end}}href="?state=open">open</a>
8 <a {{if eq .State "closed"}}class="active" {{end}}href="?state=closed">closed</a> 7 <a {{if eq .State "closed"}}class="active" {{end}}href="?state=closed">closed</a>
internal/web/templates/layout.html +70 −45
@@ -8,65 +8,90 @@
8<link rel="icon" href="/favicon.svg" type="image/svg+xml"> 8<link rel="icon" href="/favicon.svg" type="image/svg+xml">
9</head> 9</head>
10<body> 10<body>
11<header> 11<a class="skip" href="#content">Skip to content</a>
12 <nav class="topnav">
13 <a class="site" href="/"><svg class="mark" width="20" height="20" viewBox="0 0 24 24" aria-hidden="true"><rect x="1" y="1" width="22" height="22" rx="6" fill="var(--accent)"/><path d="M5 9.5c2-2.5 4-2.5 6 0s4 2.5 6 0" stroke="var(--accent-fg)" stroke-width="2.2" fill="none" stroke-linecap="round"/><path d="M5 15c2-2.5 4-2.5 6 0s4 2.5 6 0" stroke="var(--accent-fg)" stroke-width="2.2" fill="none" stroke-linecap="round"/></svg>{{.Site}}</a>
14 <span class="spacer"></span>
15 {{if .Viewer}}<a class="avatar" href="/{{.Viewer}}" title="{{.Viewer}}" aria-label="{{.Viewer}}">{{initial .Viewer}}</a>
16 {{else}}<a class="avatar guest" href="/login" title="sign in" aria-label="sign in">→</a>{{end}}
17 </nav>
18</header>
19<div class="shell"> 12<div class="shell">
20<nav class="siderail" aria-label="site"> 13
21 <div class="railmain"> 14{{$here := ""}}{{with field . "Repo"}}{{$here = printf "%s/%s" .OwnerName .Name}}{{end}}
22 <a {{if eq (str . "Tab") "explore"}}class="active" {{end}}href="/explore">explore</a> 15<nav class="rail" aria-label="Site">
23 {{if .Viewer}}<a href="/new">new repository</a>{{end}} 16 <a class="brand" href="/">{{template "mark"}}<span>{{.Site}}</span></a>
24 {{with field . "Repo"}} 17 <div class="railbody">
25 <span class="glabel">{{.OwnerName}}/{{.Name}}</span> 18 <ul class="raillist">
26 <span class="glabel sub">code</span> 19 {{if .Viewer}}<li><a href="/">Dashboard</a></li>{{end}}
27 <a {{if eq (str $ "Tab") "files"}}class="active" {{end}}href="/{{.OwnerName}}/{{.Name}}">files</a> 20 <li><a {{if eq (str . "Tab") "explore"}}aria-current="page" {{end}}href="/explore">Explore</a></li>
28 <a {{if eq (str $ "Tab") "log"}}class="active" {{end}}href="/{{.OwnerName}}/{{.Name}}/log">log</a> 21 {{if .Viewer}}<li><a href="/new">New repository</a></li>{{end}}
29 <a {{if eq (str $ "Tab") "refs"}}class="active" {{end}}href="/{{.OwnerName}}/{{.Name}}/refs">refs</a> 22 </ul>
30 <span class="glabel sub">work</span> 23 {{with .Rail.Pinned}}
31 <a {{if eq (str $ "Tab") "issues"}}class="active" {{end}}href="/{{.OwnerName}}/{{.Name}}/issues">issues</a> 24 <div class="railgroup">
32 <a {{if eq (str $ "Tab") "merge requests"}}class="active" {{end}}href="/{{.OwnerName}}/{{.Name}}/mrs">merge requests</a> 25 <p class="raillabel" id="rail-pinned">Pinned</p>
33 <a {{if eq (str $ "Tab") "builds"}}class="active" {{end}}href="/{{.OwnerName}}/{{.Name}}/builds">builds</a> 26 <ul class="raillist" aria-labelledby="rail-pinned">
34 <span class="glabel sub">publish</span> 27 {{range .}}<li><a {{if eq $here (printf "%s/%s" .Owner .Name)}}aria-current="page" {{end}}href="/{{.Owner}}/{{.Name}}"><span class="owner">{{.Owner}}/</span>{{.Name}}</a></li>
35 <a {{if eq (str $ "Tab") "releases"}}class="active" {{end}}href="/{{.OwnerName}}/{{.Name}}/releases">releases</a> 28 {{end}}
36 {{if field $ "HasWiki"}}<a {{if eq (str $ "Tab") "wiki"}}class="active" {{end}}href="/{{.OwnerName}}/{{.Name}}/wiki">wiki</a>{{end}} 29 </ul>
37 <span class="glabel sub">tools</span> 30 </div>
38 <a {{if eq (str $ "Tab") "search"}}class="active" {{end}}href="/{{.OwnerName}}/{{.Name}}/search">search</a> 31 {{end}}
39 <a href="/{{.OwnerName}}/{{.Name}}/archive/{{str $ "Ref"}}.tar.gz">archive</a> 32 {{with .Rail.Reviews}}
33 <div class="railgroup">
34 <p class="raillabel" id="rail-reviews">Waiting on you <b class="count">{{len .}}</b></p>
35 <ul class="raillist wide" aria-labelledby="rail-reviews">
36 {{range .}}<li><a href="/{{.RepoPath}}/mrs/{{.Number}}"><b>!{{.Number}} {{.Title}}</b><span class="owner">{{.RepoPath}}</span></a></li>
37 {{end}}
38 </ul>
39 </div>
40 {{end}} 40 {{end}}
41 </div> 41 </div>
42 <div class="railfoot"> 42 <div class="railfoot">
43 {{if .Viewer}}<a class="railuser" href="/{{.Viewer}}"><span class="avatar sm">{{initial .Viewer}}</span>{{.Viewer}}</a> 43 {{if .Viewer}}<a class="railuser" href="/{{.Viewer}}"><span class="avatar">{{initial .Viewer}}</span>{{.Viewer}}</a>
44 <form method="post" action="/logout"><button type="submit" class="linklike">log out</button></form> 44 <form method="post" action="/logout"><button type="submit" class="linklike">Log out</button></form>
45 {{else}}<a href="/login">sign in</a>{{end}} 45 {{else}}<a href="/login">Sign in</a>{{end}}
46 </div> 46 </div>
47</nav> 47</nav>
48<main class="container"> 48
49<div class="pane">
50{{with field . "Repo"}}
51<header class="repohead">
52 <div class="identity">
53 {{if field $ "RepoHome"}}<h1 class="repotitle"><a class="owner" href="/{{.OwnerName}}">{{.OwnerName}}</a><span class="sep">/</span>{{.Name}}</h1>
54 {{else}}<p class="repotitle"><a class="owner" href="/{{.OwnerName}}">{{.OwnerName}}</a><span class="sep">/</span><a href="/{{.OwnerName}}/{{.Name}}">{{.Name}}</a></p>{{end}}
55 {{if eq .Visibility "private"}}<span class="chip">Private</span>{{end}}
56 {{if .Settings.Archived}}<span class="chip">Archived</span>{{end}}
57 <span class="grow"></span>
58 {{if $.Viewer}}<form method="post" action="/{{.OwnerName}}/{{.Name}}/pin" class="inline"><button type="submit" class="btn" aria-pressed="{{if field $ "Pinned"}}true{{else}}false{{end}}">{{if field $ "Pinned"}}★ Pinned{{else}}☆ Pin{{end}}</button></form>{{end}}
59 </div>
60 {{if field $ "RepoHome"}}
61 {{with field $ "Desc"}}<p class="repodesc">{{.}}</p>{{end}}
62 {{with field $ "Topics"}}<p class="topics">{{range .}}<a class="chip topic" href="/explore?q={{.}}">{{.}}</a> {{end}}</p>{{end}}
63 {{with $.Repo.Settings.Website}}<p class="repometa"><a href="{{.}}" rel="nofollow">{{.}}</a></p>{{end}}
64 {{range field $ "Mirrors"}}<p class="repometa">{{if eq .Direction "push"}}Mirrors to{{else}}Mirrors from{{end}} <a href="{{.URL}}" rel="nofollow">{{.Target}}</a>{{if .Error}} · <span class="bad">sync error: {{.Error}}</span>{{else if .Synced}} · synced {{.Synced}}{{end}}</p>
65 {{end}}
66 {{end}}
67 <nav class="tabs" aria-label="Repository">
68 {{$top := topTab (str $ "Tab")}}
69 <a {{if eq $top "code"}}aria-current="page" {{end}}href="/{{.OwnerName}}/{{.Name}}">Code</a>
70 <a {{if eq $top "issues"}}aria-current="page" {{end}}href="/{{.OwnerName}}/{{.Name}}/issues">Issues{{with field $ "OpenIssues"}}{{if .}} <i>{{.}}</i>{{end}}{{end}}</a>
71 <a {{if eq $top "mrs"}}aria-current="page" {{end}}href="/{{.OwnerName}}/{{.Name}}/mrs">Merge requests{{with field $ "OpenMRs"}}{{if .}} <i>{{.}}</i>{{end}}{{end}}</a>
72 <a {{if eq $top "builds"}}aria-current="page" {{end}}href="/{{.OwnerName}}/{{.Name}}/builds">Builds</a>
73 <a {{if eq $top "releases"}}aria-current="page" {{end}}href="/{{.OwnerName}}/{{.Name}}/releases">Releases</a>
74 {{if field $ "HasWiki"}}<a {{if eq $top "wiki"}}aria-current="page" {{end}}href="/{{.OwnerName}}/{{.Name}}/wiki">Wiki</a>{{end}}
75 </nav>
76</header>
77{{end}}
78
79<main id="content" class="content">
49{{template "content" .}} 80{{template "content" .}}
50</main> 81</main>
51</div> 82
52<footer> 83<footer>
53 <div class="container"><p>powered by <a href="https://gitbay.org/krz/gitbay">gitbay</a>{{with gitbayVersion}} · <code><a href="https://gitbay.org/krz/gitbay/commit/{{gitbayCommit}}">{{.}}</a></code>{{end}} · <a href="/privacy">privacy</a></p></div> 84 <p>Powered by <a href="https://gitbay.org/krz/gitbay">gitbay</a>{{with gitbayVersion}} · <code><a href="https://gitbay.org/krz/gitbay/commit/{{gitbayCommit}}">{{.}}</a></code>{{end}} · <a href="/privacy">Privacy</a></p>
54</footer> 85</footer>
86</div>
87</div>
55</body> 88</body>
56</html>{{end}} 89</html>{{end}}
57 90
58{{define "repoheader"}} 91{{define "mark"}}<svg class="mark" width="19" height="19" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2.25 18.75 12H5.25z" fill="#ff6b3d"/><rect x="3" y="13.5" width="18" height="3" fill="currentColor"/><rect x="7.5" y="18" width="9" height="3" fill="currentColor"/></svg>{{end}}
59<div class="repohead">
60<h1 class="repotitle"><a class="owner" href="/{{.Repo.OwnerName}}">{{.Repo.OwnerName}}</a><span class="sep">/</span><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">{{.Repo.Name}}</a>{{if eq .Repo.Visibility "private"}} <span class="chip chip-neutral">private</span>{{end}}{{if .Repo.Settings.Archived}} <span class="chip chip-stale">archived</span>{{end}}{{if .Viewer}}<form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/pin" class="inline pinform"><button type="submit" class="pinbtn{{if .Pinned}} pinned{{end}}" title="{{if .Pinned}}unpin from dashboard{{else}}pin to dashboard{{end}}">{{if .Pinned}}★ pinned{{else}}☆ pin{{end}}</button></form>{{end}}</h1>
61{{if .Desc}}<p class="desc">{{.Desc}}</p>{{end}}
62{{if .Repo.Settings.Website}}<p class="meta repowebsite"><a href="{{.Repo.Settings.Website}}" rel="nofollow">{{.Repo.Settings.Website}}</a></p>{{end}}
63{{if .Topics}}<p class="topics">{{range .Topics}}<span class="chip topic">{{.}}</span> {{end}}</p>{{end}}
64{{range .Mirrors}}<p class="meta mirrorline">{{if eq .Direction "push"}}mirrors to{{else}}mirrors from{{end}} <a href="{{.URL}}" rel="nofollow">{{.Target}}</a>{{if .Error}} · <span class="mirrorerr">sync error: {{.Error}}</span>{{else if .Synced}} · synced {{.Synced}}{{end}}</p>
65{{end}}</div>
66{{end}}
67 92
68{{define "reporow"}}<li> 93{{define "reporow"}}<li>
69 <p class="reponame"><a href="/{{.OwnerName}}/{{.Name}}">{{.OwnerName}}<span class="sep">/</span><strong>{{.Name}}</strong></a>{{if eq .Visibility "private"}} <span class="chip chip-neutral">private</span>{{end}}{{if .Settings.Archived}} <span class="chip chip-stale">archived</span>{{end}}</p> 94 <p class="reponame"><a href="/{{.OwnerName}}/{{.Name}}">{{.OwnerName}}<span class="sep">/</span><strong>{{.Name}}</strong></a>{{if eq .Visibility "private"}} <span class="chip">Private</span>{{end}}{{if .Settings.Archived}} <span class="chip">Archived</span>{{end}}</p>
70 {{if .Desc}}<p class="desc">{{.Desc}}</p>{{end}} 95 {{if .Desc}}<p class="desc">{{.Desc}}</p>{{end}}
71 {{if .Topics}}<p class="topics">{{range .Topics}}<a class="chip topic" href="/explore?q={{.}}">{{.}}</a> {{end}}</p>{{end}} 96 {{if .Topics}}<p class="topics">{{range .Topics}}<a class="chip topic" href="/explore?q={{.}}">{{.}}</a> {{end}}</p>{{end}}
72 <p class="meta">{{template "branchicon"}} {{.DefaultBranch}}{{if .License}} · {{.License}}{{end}}{{if .Updated}} · updated {{.Updated}}{{end}}</p> 97 <p class="meta">{{template "branchicon"}} {{.DefaultBranch}}{{if .License}} · {{.License}}{{end}}{{if .Updated}} · updated {{.Updated}}{{end}}</p>
@@ -76,7 +101,7 @@
76 <summary class="refchip">{{template "branchicon"}} {{.Ref}}</summary> 101 <summary class="refchip">{{template "branchicon"}} {{.Ref}}</summary>
77 <div class="refdrop"> 102 <div class="refdrop">
78 {{range .Branches}}<a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/{{$.RefKind}}/{{.Name}}/{{$.DirPath}}">{{.Name}}</a> 103 {{range .Branches}}<a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/{{$.RefKind}}/{{.Name}}/{{$.DirPath}}">{{.Name}}</a>
79 {{end}}<a class="allrefs" href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/refs">all refs →</a> 104 {{end}}<a class="allrefs" href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/refs">All refs →</a>
80 </div> 105 </div>
81</details>{{else}}<span class="refchip">{{template "branchicon"}} {{.Ref}}</span>{{end}}{{end}} 106</details>{{else}}<span class="refchip">{{template "branchicon"}} {{.Ref}}</span>{{end}}{{end}}
82 107
internal/web/templates/log.html +1 −1
@@ -1,7 +1,7 @@
1{{define "title"}}log · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 1{{define "title"}}log · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}} 2{{define "content"}}
3{{template "repoheader" .}}
4{{if .FilePath}}<p class="meta">history of <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/blob/{{.Ref}}/{{.FilePath}}"><code>{{.FilePath}}</code></a> · <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log">full log</a></p>{{end}} 3{{if .FilePath}}<p class="meta">history of <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/blob/{{.Ref}}/{{.FilePath}}"><code>{{.FilePath}}</code></a> · <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/log">full log</a></p>{{end}}
4<h1 class="vh">Commits</h1>
5<ul class="loglist"> 5<ul class="loglist">
6{{range .Commits}}<li> 6{{range .Commits}}<li>
7 <div class="commitmain"> 7 <div class="commitmain">
internal/web/templates/milestones.html +1 −2
@@ -1,8 +1,7 @@
1{{define "title"}}milestones · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 1{{define "title"}}milestones · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}} 2{{define "content"}}
3{{template "repoheader" .}}
4<div class="listhead"> 3<div class="listhead">
5 <h2>milestones</h2> 4 <h1>Milestones</h1>
6 <nav class="filters"> 5 <nav class="filters">
7 <a {{if eq .State "open"}}class="active" {{end}}href="?state=open">open</a> 6 <a {{if eq .State "open"}}class="active" {{end}}href="?state=open">open</a>
8 <a {{if eq .State "closed"}}class="active" {{end}}href="?state=closed">closed</a> 7 <a {{if eq .State "closed"}}class="active" {{end}}href="?state=closed">closed</a>
internal/web/templates/mr.html +1 −2
@@ -1,7 +1,6 @@
1{{define "title"}}!{{.MR.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 1{{define "title"}}!{{.MR.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}} 2{{define "content"}}
3{{template "repoheader" .}} 3<h1 class="issuetitle">{{.MR.Title}} <span class="issuenumber">!{{.MR.Number}}</span></h1>
4<h2 class="issuetitle">{{.MR.Title}} <span class="issuenumber">!{{.MR.Number}}</span></h2>
5<p class="issuemeta"><span class="chip chip-{{.MR.State}}">{{.MR.State}}</span> 4<p class="issuemeta"><span class="chip chip-{{.MR.State}}">{{.MR.State}}</span>
6<a href="/{{.MR.Author}}">{{.MR.Author}}</a> wants to merge {{if .MR.SourcePath}}{{.MR.SourcePath}}:{{end}}{{.MR.SourceRef}} into {{.MR.TargetRef}} 5<a href="/{{.MR.Author}}">{{.MR.Author}}</a> wants to merge {{if .MR.SourcePath}}{{.MR.SourcePath}}:{{end}}{{.MR.SourceRef}} into {{.MR.TargetRef}}
7 at <code>{{short .MR.HeadSHA}}</code>{{if .MR.Milestone}} · milestone <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/milestones">{{.MR.Milestone}}</a>{{end}}</p> 6 at <code>{{short .MR.HeadSHA}}</code>{{if .MR.Milestone}} · milestone <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/milestones">{{.MR.Milestone}}</a>{{end}}</p>
internal/web/templates/mrs.html +1 −2
@@ -1,8 +1,7 @@
1{{define "title"}}merge requests · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 1{{define "title"}}merge requests · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}} 2{{define "content"}}
3{{template "repoheader" .}}
4<div class="listhead"> 3<div class="listhead">
5 <h2>merge requests</h2> 4 <h1>Merge requests</h1>
6 <nav class="filters"> 5 <nav class="filters">
7 <a {{if eq .State "open"}}class="active" {{end}}href="?state=open">open</a> 6 <a {{if eq .State "open"}}class="active" {{end}}href="?state=open">open</a>
8 <a {{if eq .State "merged"}}class="active" {{end}}href="?state=merged">merged</a> 7 <a {{if eq .State "merged"}}class="active" {{end}}href="?state=merged">merged</a>
internal/web/templates/refs.html +3 −3
@@ -1,8 +1,8 @@
1{{define "title"}}refs · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 1{{define "title"}}refs · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}} 2{{define "content"}}
3{{template "repoheader" .}} 3<h1>Refs</h1>
4<h2>branches</h2> 4<h2>Branches</h2>
5<table class="refs">{{range .Branches}}<tr><td class="name">{{template "branchicon"}} <a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/tree/{{.Name}}/">{{.Name}}</a></td><td class="sha"><code>{{short .SHA}}</code></td></tr>{{else}}<tr><td class="empty">none</td></tr>{{end}}</table> 5<table class="refs">{{range .Branches}}<tr><td class="name">{{template "branchicon"}} <a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/tree/{{.Name}}/">{{.Name}}</a></td><td class="sha"><code>{{short .SHA}}</code></td></tr>{{else}}<tr><td class="empty">none</td></tr>{{end}}</table>
6<h2>tags</h2> 6<h2>Tags</h2>
7<table class="refs">{{range .Tags}}<tr><td class="name"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/tree/{{.Name}}/">{{.Name}}</a></td><td class="sha"><code>{{short .SHA}}</code></td></tr>{{else}}<tr><td class="empty">none</td></tr>{{end}}</table> 7<table class="refs">{{range .Tags}}<tr><td class="name"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/tree/{{.Name}}/">{{.Name}}</a></td><td class="sha"><code>{{short .SHA}}</code></td></tr>{{else}}<tr><td class="empty">none</td></tr>{{end}}</table>
8{{end}} 8{{end}}
internal/web/templates/releases.html +1 −2
@@ -1,7 +1,6 @@
1{{define "title"}}releases · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 1{{define "title"}}releases · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}} 2{{define "content"}}
3{{template "repoheader" .}} 3<h1>Releases</h1>
4<h2>releases</h2>
5{{range $rel := .Releases}} 4{{range $rel := .Releases}}
6<article class="release"> 5<article class="release">
7 <header class="releasehead"> 6 <header class="releasehead">
internal/web/templates/search.html +1 −1
@@ -1,6 +1,6 @@
1{{define "title"}}search · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 1{{define "title"}}search · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}} 2{{define "content"}}
3{{template "repoheader" .}} 3<h1>Search</h1>
4<form method="get" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/search" class="searchform"> 4<form method="get" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/search" class="searchform">
5 <input type="search" name="q" value="{{.Query}}" placeholder="search file contents on {{.Ref}}" autofocus> 5 <input type="search" name="q" value="{{.Query}}" placeholder="search file contents on {{.Ref}}" autofocus>
6 <button type="submit">search</button> 6 <button type="submit">search</button>
internal/web/templates/tree.html +1 −1
@@ -1,6 +1,6 @@
1{{define "title"}}{{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 1{{define "title"}}{{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}} 2{{define "content"}}
3{{template "repoheader" .}} 3{{if .DirPath}}<h1 class="vh">{{.DirPath}}</h1>{{end}}
4<div class="pathbar"> 4<div class="pathbar">
5 {{template "refmenu" .}} 5 {{template "refmenu" .}}
6 <span class="crumbs"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">{{.Repo.Name}}</a>/{{range .Crumbs}}<a href="{{.URL}}">{{.Name}}</a>/{{end}}</span> 6 <span class="crumbs"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}">{{.Repo.Name}}</a>/{{range .Crumbs}}<a href="{{.URL}}">{{.Name}}</a>/{{end}}</span>
internal/web/templates/wiki.html +1 −1
@@ -1,6 +1,6 @@
1{{define "title"}}wiki{{if .Page}}: {{.Page}}{{end}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} 1{{define "title"}}wiki{{if .Page}}: {{.Page}}{{end}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
2{{define "content"}} 2{{define "content"}}
3{{template "repoheader" .}} 3<h1 class="vh">Wiki{{if .Page}}: {{.Page}}{{end}}</h1>
4{{if .Missing}}<p class="empty-note">no wiki yet — create one by pushing pages:<br> 4{{if .Missing}}<p class="empty-note">no wiki yet — create one by pushing pages:<br>
5<code>git clone ssh://git@{{.Host}}/{{.Repo.OwnerName}}/{{.Repo.Name}}.wiki.git</code> then add <code>Home.md</code> (or .org) and push.</p> 5<code>git clone ssh://git@{{.Host}}/{{.Repo.OwnerName}}/{{.Repo.Name}}.wiki.git</code> then add <code>Home.md</code> (or .org) and push.</p>
6{{else}} 6{{else}}
internal/web/web.go +14
@@ -77,6 +77,20 @@ var funcs = template.FuncMap{
77 }, 77 },
78 "add": func(a, b int) int { return a + b }, 78 "add": func(a, b int) int { return a + b },
79 "sub": func(a, b int) int { return a - b }, 79 "sub": func(a, b int) int { return a - b },
80 // topTab maps a page's Tab to the repo header tab that should read as
81 // current. Everything that browses the tree or its history sits under
82 // code, whose own toolbar carries the log, search, and refs links.
83 "topTab": func(tab string) string {
84 switch tab {
85 case "files", "log", "refs", "search":
86 return "code"
87 case "issues":
88 return "issues"
89 case "merge requests":
90 return "mrs"
91 }
92 return tab
93 },
80 // initial is the avatar letter for a username. 94 // initial is the avatar letter for a username.
81 "initial": func(s string) string { 95 "initial": func(s string) string {
82 for _, r := range s { 96 for _, r := range s {