Commit 027899f05d
Verified · cmc
e2e/design_test.go +3 −3
| @@ -58,7 +58,7 @@ func TestReadmeRelativeLinks(t *testing.T) { | ||
| 58 | 58 | } |
| 59 | 59 | } |
| 60 | 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 | 62 | t.Error("branch dropdown missing") |
| 63 | 63 | } |
| 64 | 64 | // Per-file history: ?path= filters the log; blob pages link to it. |
| @@ -164,14 +164,14 @@ func TestWebInteractions(t *testing.T) { | ||
| 164 | 164 | t.Fatal("pin toggle failed") |
| 165 | 165 | } |
| 166 | 166 | _, body := browserGet(t, browser, inst.base()+"/theorg/webborn") |
| 167 | if !strings.Contains(body, "★ pinned") { | |
| 167 | if !strings.Contains(body, "★ Pinned") { | |
| 168 | 168 | t.Fatal("repo header not pinned") |
| 169 | 169 | } |
| 170 | 170 | if _, body = browserGet(t, browser, inst.base()+"/"); !strings.Contains(body, "theorg<span") { |
| 171 | 171 | t.Fatal("dashboard missing pinned repo") |
| 172 | 172 | } |
| 173 | 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 | 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 | 119 | if status, _ := browserGet(t, browser, inst.base()+loginPath); status != 200 { |
| 120 | 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 | 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 | 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 | 88 | if !strings.Contains(body, "<h1>hello site</h1>") || !strings.Contains(body, "<em>markdown</em>") { |
| 89 | 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 | 92 | if !strings.Contains(body, tab) { |
| 93 | 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 | 23 | // No wiki yet: the tab is absent, the page shows the push hint, and |
| 24 | 24 | // cloning the companion says so. |
| 25 | 25 | _, body := inst.get(t, "/alice/app") |
| 26 | if strings.Contains(body, ">wiki<") { | |
| 26 | if strings.Contains(body, ">Wiki<") { | |
| 27 | 27 | t.Fatal("wiki tab shown with no wiki") |
| 28 | 28 | } |
| 29 | 29 | _, body = inst.get(t, "/alice/app/wiki") |
| @@ -55,7 +55,7 @@ func TestWikis(t *testing.T) { | ||
| 55 | 55 | // Rendering: home resolves, tab appears, links rewrite to wiki pages |
| 56 | 56 | // and images to the wiki raw route; org pages render too. |
| 57 | 57 | _, body = inst.get(t, "/alice/app") |
| 58 | if !strings.Contains(body, ">wiki<") { | |
| 58 | if !strings.Contains(body, ">Wiki<") { | |
| 59 | 59 | t.Fatal("wiki tab missing after push") |
| 60 | 60 | } |
| 61 | 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 | 64 | token := r.URL.Query().Get("token") |
| 65 | 65 | if token == "" { |
| 66 | 66 | s.render(w, "login.html", struct { |
| 67 | Site string | |
| 68 | Viewer string | |
| 69 | Error string | |
| 70 | }{s.siteName(), "", ""}) | |
| 67 | basePage | |
| 68 | Error string | |
| 69 | }{basePage{Site: s.siteName()}, ""}) | |
| 71 | 70 | return |
| 72 | 71 | } |
| 73 | 72 | userID, err := s.st.ConsumeLoginToken(store.HashToken(token)) |
| 74 | 73 | if err != nil { |
| 75 | 74 | s.render(w, "login.html", struct { |
| 76 | Site string | |
| 77 | Viewer string | |
| 78 | Error string | |
| 79 | }{s.siteName(), "", "that login link is invalid, expired, or already used — mint a new one"}) | |
| 75 | basePage | |
| 76 | Error string | |
| 77 | }{basePage{Site: s.siteName()}, | |
| 78 | "that login link is invalid, expired, or already used — mint a new one"}) | |
| 80 | 79 | return |
| 81 | 80 | } |
| 82 | 81 | sessTok, sessHash, err := store.NewToken() |
| @@ -120,11 +119,10 @@ func (s *Server) adminOrgs(u store.User) []string { | ||
| 120 | 119 | |
| 121 | 120 | func (s *Server) renderNewRepo(w http.ResponseWriter, u store.User, errMsg string) { |
| 122 | 121 | s.render(w, "new.html", struct { |
| 123 | Site string | |
| 124 | Viewer string | |
| 125 | Orgs []string | |
| 126 | Error string | |
| 127 | }{s.siteName(), u.Username, s.adminOrgs(u), errMsg}) | |
| 122 | basePage | |
| 123 | Orgs []string | |
| 124 | Error string | |
| 125 | }{s.baseFor(u), s.adminOrgs(u), errMsg}) | |
| 128 | 126 | } |
| 129 | 127 | |
| 130 | 128 | func (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 | |
| 224 | 222 | func (s *Server) renderSignup(w http.ResponseWriter, errMsg, username string) { |
| 225 | 223 | s.render(w, "register.html", struct { |
| 226 | Site string | |
| 227 | Viewer string | |
| 224 | basePage | |
| 228 | 225 | Host string |
| 229 | 226 | Mode string // open | invite |
| 230 | 227 | Error string |
| 231 | 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 | |
| 235 | 232 | func (s *Server) signupSubmit(w http.ResponseWriter, r *http.Request) { |
| @@ -247,12 +244,11 @@ func (s *Server) signupSubmit(w http.ResponseWriter, r *http.Request) { | ||
| 247 | 244 | return |
| 248 | 245 | } |
| 249 | 246 | s.render(w, "registered.html", struct { |
| 250 | Site string | |
| 251 | Viewer string | |
| 247 | basePage | |
| 252 | 248 | Username string |
| 253 | 249 | Message string |
| 254 | 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 | 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 | |
| 443 | 439 | type editPage struct { |
| 444 | Site string | |
| 445 | Viewer string | |
| 440 | basePage | |
| 446 | 441 | Repo store.Repo |
| 447 | 442 | Ref string |
| 448 | 443 | Path string |
| @@ -467,7 +462,7 @@ func (s *Server) editForm(w http.ResponseWriter, r *http.Request, u store.User) | ||
| 467 | 462 | return |
| 468 | 463 | } |
| 469 | 464 | s.render(w, "edit.html", editPage{ |
| 470 | Site: s.siteName(), Viewer: u.Username, Repo: repo, | |
| 465 | basePage: s.baseFor(u), Repo: repo, | |
| 471 | 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 | 476 | filePath := strings.Trim(r.PathValue("path"), "/") |
| 482 | 477 | fail := func(msg string) { |
| 483 | 478 | s.render(w, "edit.html", editPage{ |
| 484 | Site: s.siteName(), Viewer: u.Username, Repo: repo, | |
| 479 | basePage: s.baseFor(u), Repo: repo, | |
| 485 | 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 @@ | ||
| 1 | package httpd | |
| 2 | ||
| 3 | import ( | |
| 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. | |
| 11 | type railRepo struct { | |
| 12 | Owner string | |
| 13 | Name string | |
| 14 | } | |
| 15 | ||
| 16 | // railItem is one merge request in the rail's review queue. | |
| 17 | type 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. | |
| 27 | type 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. | |
| 34 | func (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. | |
| 38 | type 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. | |
| 46 | func (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. | |
| 55 | func (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. | |
| 67 | func (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 | 62 | w.Write(web.FaviconSVG) |
| 63 | 63 | } |
| 64 | 64 | |
| 65 | // font serves the embedded IBM Plex subsets. Same-origin, so the CSP's | |
| 66 | // default-src 'self' covers it — no font CDN. | |
| 65 | // font serves the embedded Atkinson Hyperlegible subsets. Same-origin, | |
| 66 | // so the CSP's default-src 'self' covers it — no font CDN. | |
| 67 | 67 | func (s *Server) font(w http.ResponseWriter, r *http.Request) { |
| 68 | 68 | data, err := web.FontFS.ReadFile("static" + r.URL.Path[len("/static"):]) |
| 69 | 69 | if err != nil { |
| @@ -79,10 +79,9 @@ func (s *Server) font(w http.ResponseWriter, r *http.Request) { | ||
| 79 | 79 | // the stock plain-text response if the template fails. |
| 80 | 80 | func (s *Server) notFound(w http.ResponseWriter, r *http.Request) { |
| 81 | 81 | var buf bytes.Buffer |
| 82 | if err := web.Render(&buf, "404.html", struct { | |
| 83 | Site string | |
| 84 | Viewer string | |
| 85 | }{s.siteName(), s.viewerName(r)}); err != nil { | |
| 82 | if err := web.Render(&buf, "404.html", basePage{ | |
| 83 | Site: s.siteName(), Viewer: s.viewerName(r), | |
| 84 | }); err != nil { | |
| 86 | 85 | http.NotFound(w, r) |
| 87 | 86 | return |
| 88 | 87 | } |
| @@ -129,12 +128,11 @@ func (s *Server) index(w http.ResponseWriter, r *http.Request) { | ||
| 129 | 128 | host := strings.TrimSuffix(strings.TrimPrefix(strings.TrimPrefix( |
| 130 | 129 | s.cfg.Server.SiteURL, "https://"), "http://"), "/") |
| 131 | 130 | s.render(w, "landing.html", struct { |
| 132 | Site string | |
| 133 | Viewer string | |
| 131 | basePage | |
| 134 | 132 | Host string |
| 135 | 133 | Accounts bool |
| 136 | 134 | Signup bool |
| 137 | }{s.siteName(), "", host, s.cfg.Web.Mode == "accounts", | |
| 135 | }{basePage{Site: s.siteName()}, host, s.cfg.Web.Mode == "accounts", | |
| 138 | 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 | 148 | mrs, _ := s.st.DashboardMRs(viewer.ID) |
| 151 | 149 | issues, _ := s.st.DashboardIssues(viewer.ID) |
| 152 | 150 | s.render(w, "dashboard.html", struct { |
| 153 | Site string | |
| 154 | Viewer string | |
| 151 | basePage | |
| 155 | 152 | Pinned []store.Repo |
| 156 | 153 | MRs []store.DashboardItem |
| 157 | 154 | Issues []store.DashboardItem |
| 158 | }{s.siteName(), viewer.Username, visible, mrs, issues}) | |
| 155 | }{s.baseFor(viewer), visible, mrs, issues}) | |
| 159 | 156 | } |
| 160 | 157 | |
| 161 | 158 | func (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 | 168 | q := strings.TrimSpace(r.URL.Query().Get("q")) |
| 172 | 169 | s.render(w, "explore.html", struct { |
| 173 | Site string | |
| 174 | Viewer string | |
| 175 | Query string | |
| 176 | Repos []describedRepo | |
| 177 | }{s.siteName(), viewer.Username, q, s.filterRepos(q, s.describeAll(repos))}) | |
| 170 | basePage | |
| 171 | Query string | |
| 172 | Repos []describedRepo | |
| 173 | }{s.baseFor(viewer), q, s.filterRepos(q, s.describeAll(repos))}) | |
| 178 | 174 | } |
| 179 | 175 | |
| 180 | 176 | // viewerName returns the logged-in username for header rendering, or "". |
| @@ -189,11 +185,10 @@ func (s *Server) viewerName(r *http.Request) string { | ||
| 189 | 185 | // data, plus this instance's operator-provided notes. |
| 190 | 186 | func (s *Server) privacy(w http.ResponseWriter, r *http.Request) { |
| 191 | 187 | s.render(w, "privacy.html", struct { |
| 192 | Site string | |
| 193 | Viewer string | |
| 188 | basePage | |
| 194 | 189 | Host string |
| 195 | 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 | 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 | 218 | // repoPage is the shared context for repo-scoped pages. |
| 224 | 219 | type repoPage struct { |
| 225 | Site string | |
| 226 | Viewer string | |
| 220 | basePage | |
| 227 | 221 | Desc string |
| 228 | 222 | Repo store.Repo |
| 229 | 223 | Ref string |
| @@ -235,6 +229,13 @@ type repoPage struct { | ||
| 235 | 229 | HasWiki bool |
| 236 | 230 | Host string |
| 237 | 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 | 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 | 305 | return repoPage{ |
| 304 | Mirrors: mirrors, | |
| 305 | Site: s.siteName(), | |
| 306 | Viewer: viewer.Username, | |
| 307 | Pinned: pinned, | |
| 308 | HasWiki: s.wikiDir(repo.OwnerName, repo.Name) != "", | |
| 309 | Host: s.cfg.SiteHost(), | |
| 310 | Desc: gitutil.ReadDescription(control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)), | |
| 311 | Repo: repo, | |
| 312 | Ref: ref, | |
| 313 | CloneURL: s.cfg.Server.SiteURL + "/" + repo.Path() + ".git", | |
| 314 | Dir: control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name), | |
| 315 | Topics: topics, | |
| 306 | basePage: s.baseFor(viewer), | |
| 307 | Mirrors: mirrors, | |
| 308 | Pinned: pinned, | |
| 309 | HasWiki: s.wikiDir(repo.OwnerName, repo.Name) != "", | |
| 310 | Host: s.cfg.SiteHost(), | |
| 311 | Desc: gitutil.ReadDescription(control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name)), | |
| 312 | Repo: repo, | |
| 313 | Ref: ref, | |
| 314 | CloneURL: s.cfg.Server.SiteURL + "/" + repo.Path() + ".git", | |
| 315 | Dir: control.RepoDir(s.cfg.Server.Root, repo.OwnerName, repo.Name), | |
| 316 | Topics: topics, | |
| 317 | OpenIssues: openIssues, | |
| 318 | OpenMRs: openMRs, | |
| 316 | 319 | }, true |
| 317 | 320 | } |
| 318 | 321 | |
| @@ -385,8 +388,7 @@ func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) { | ||
| 385 | 388 | weeks, activityTotal := activityGrid(counts) |
| 386 | 389 | |
| 387 | 390 | s.render(w, "owner.html", struct { |
| 388 | Site string | |
| 389 | Viewer string | |
| 391 | basePage | |
| 390 | 392 | Owner string |
| 391 | 393 | Kind string |
| 392 | 394 | Profile store.Profile |
| @@ -395,7 +397,7 @@ func (s *Server) ownerPage(w http.ResponseWriter, r *http.Request) { | ||
| 395 | 397 | Orgs []store.OrgMember |
| 396 | 398 | Activity []activityWeek |
| 397 | 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 | 401 | weeks, activityTotal}) |
| 400 | 402 | } |
| 401 | 403 | |
| @@ -405,6 +407,7 @@ func (s *Server) repoHome(w http.ResponseWriter, r *http.Request) { | ||
| 405 | 407 | return |
| 406 | 408 | } |
| 407 | 409 | p.Tab = "files" |
| 410 | p.RepoHome = true | |
| 408 | 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 | 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. | |
| 125 | func (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. | |
| 145 | func (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. | |
| 2 | One file, light and dark via prefers-color-scheme. Tokens first; | |
| 3 | components use tokens only. No external requests: IBM Plex is served | |
| 4 | from this origin. */ | |
| 1 | /* gitbay stylesheet — Harbor: black and white ground, two accents with | |
| 2 | separate jobs. Blue is what you can do (links, buttons, focus); orange | |
| 3 | is where you are and what wants you (active nav, counts, pending state, | |
| 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 | 9 | @font-face { |
| 7 | font-family: "IBM Plex Sans"; | |
| 10 | font-family: "Atkinson Hyperlegible Next"; | |
| 8 | 11 | font-style: normal; |
| 9 | font-weight: 400 600; | |
| 12 | font-weight: 400 700; | |
| 10 | 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 | 16 | @font-face { |
| 14 | font-family: "IBM Plex Mono"; | |
| 15 | font-style: normal; | |
| 16 | font-weight: 400; | |
| 17 | font-family: "Atkinson Hyperlegible Next"; | |
| 18 | font-style: italic; | |
| 19 | font-weight: 400 700; | |
| 17 | 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 | 23 | @font-face { |
| 21 | font-family: "IBM Plex Mono"; | |
| 24 | font-family: "Atkinson Hyperlegible Mono"; | |
| 22 | 25 | font-style: normal; |
| 23 | font-weight: 500; | |
| 26 | font-weight: 400 600; | |
| 24 | 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 | 31 | :root { |
| @@ -30,39 +33,50 @@ | ||
| 30 | 33 | scrollbars per scheme, and opt out of browser auto-darkening */ |
| 31 | 34 | color-scheme: light dark; |
| 32 | 35 | |
| 33 | /* palette (Carbon g10) */ | |
| 34 | --bg: #f4f4f4; | |
| 35 | --fg: #161616; | |
| 36 | --muted: #525252; | |
| 37 | --faint: #ececec; /* hairlines, row separators */ | |
| 38 | --line: #e0e0e0; /* control borders, tile edges */ | |
| 39 | --surface: #ffffff; /* tiles, cards */ | |
| 40 | --hover: #f0f2f8; /* row hover on tiles */ | |
| 41 | --accent: #0f62fe; /* blue-60: links, active nav, primary */ | |
| 42 | --accent-fg: #ffffff; /* text on accent */ | |
| 43 | --ok: #198038; | |
| 44 | --warn: #8e6a00; | |
| 45 | --bad: #da1e28; | |
| 46 | --done: #8a3ffc; /* merged */ | |
| 47 | --neutral: #525252; | |
| 48 | --code-bg: #f4f4f4; | |
| 49 | /* the shell is ink in both schemes */ | |
| 50 | --shell-bg: #161616; | |
| 51 | --shell-fg: #f4f4f4; | |
| 52 | --shell-muted: #c6c6c6; | |
| 53 | --shell-line: #161616; | |
| 36 | /* ground and ink */ | |
| 37 | --bg: #ffffff; | |
| 38 | --fg: #0d0d0d; | |
| 39 | --muted: #595959; /* 7.00:1 on white */ | |
| 40 | --faint: #eaeaea; /* hairlines, row separators */ | |
| 41 | --line: #d4d4d4; /* control borders, table edges */ | |
| 42 | --surface: #ffffff; | |
| 43 | --hover: #f5f5f5; | |
| 44 | --code-bg: #f5f5f5; | |
| 45 | ||
| 46 | /* blue: what you can do */ | |
| 47 | --accent: #0000f0; /* links, focus — 9.30:1 on white */ | |
| 48 | --accent-fg: #ffffff; | |
| 49 | --fill: #0000f0; /* button grounds; white on it is 9.30:1 */ | |
| 50 | ||
| 51 | /* orange: where you are, what wants you */ | |
| 52 | --mark: #e4572e; /* bars and underlines — non-text, 3.0 bar */ | |
| 53 | --warn: #c2410c; /* the same orange at text size — 5.18:1 */ | |
| 54 | ||
| 55 | /* semantic */ | |
| 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 | 69 | /* type */ |
| 56 | --sans: "IBM Plex Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", | |
| 57 | Roboto, "Helvetica Neue", Arial, sans-serif; | |
| 58 | --mono: "IBM Plex Mono", ui-monospace, "SF Mono", SFMono-Regular, Menlo, | |
| 59 | Consolas, "Liberation Mono", monospace; | |
| 60 | --fs-0: 0.8rem; /* 12.8px: chips, fine print */ | |
| 61 | --fs-1: 0.866rem; /* ~13px: metadata, mono in tables */ | |
| 62 | --fs-2: 0.933rem; /* 14px: dense table text */ | |
| 63 | --fs-3: 1rem; /* 15px: body */ | |
| 64 | --fs-4: 1.135rem; /* ~17px: h2 */ | |
| 65 | --fs-5: 1.335rem; /* ~20px: h1 */ | |
| 70 | --sans: "Atkinson Hyperlegible Next", -apple-system, BlinkMacSystemFont, | |
| 71 | "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; | |
| 72 | --mono: "Atkinson Hyperlegible Mono", ui-monospace, "SF Mono", | |
| 73 | SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; | |
| 74 | --fs-0: 0.78rem; | |
| 75 | --fs-1: 0.85rem; | |
| 76 | --fs-2: 0.92rem; | |
| 77 | --fs-3: 1rem; | |
| 78 | --fs-4: 1.2rem; | |
| 79 | --fs-5: 1.6rem; | |
| 66 | 80 | |
| 67 | 81 | /* spacing */ |
| 68 | 82 | --sp-1: 0.25rem; |
| @@ -72,31 +86,31 @@ | ||
| 72 | 86 | --sp-5: 1.5rem; |
| 73 | 87 | --sp-6: 2.5rem; |
| 74 | 88 | |
| 75 | /* radii: sharp everywhere; only tags stay pills */ | |
| 76 | --r-sm: 0; | |
| 77 | --r-md: 0; | |
| 78 | --r-lg: 0; | |
| 79 | --r-pill: 999px; | |
| 89 | /* radii: 2px everywhere — chart plates, not pills */ | |
| 90 | --r-sm: 2px; | |
| 91 | --r-md: 2px; | |
| 92 | --r-lg: 2px; | |
| 93 | --r-pill: 2px; | |
| 80 | 94 | } |
| 81 | 95 | @media (prefers-color-scheme: dark) { |
| 82 | 96 | :root { |
| 83 | /* Carbon g100 */ | |
| 84 | --bg: #161616; | |
| 85 | --fg: #f4f4f4; | |
| 86 | --muted: #a8a8a8; | |
| 87 | --faint: #2a2a2a; | |
| 88 | --line: #393939; | |
| 89 | --surface: #262626; | |
| 90 | --hover: #2e3440; | |
| 91 | --accent: #78a9ff; /* blue-40 reads on dark grounds */ | |
| 92 | --accent-fg: #161616; | |
| 93 | --ok: #42be65; | |
| 94 | --warn: #d2a106; | |
| 95 | --bad: #fa4d56; | |
| 96 | --done: #a56eff; | |
| 97 | --neutral: #a8a8a8; | |
| 98 | --code-bg: #131313; | |
| 99 | --shell-line: #393939; | |
| 97 | --bg: #0a0a0a; | |
| 98 | --fg: #f2f2f2; | |
| 99 | --muted: #a1a1a1; /* 7.66:1 on #0a0a0a */ | |
| 100 | --faint: #1c1c1c; | |
| 101 | --line: #2e2e2e; | |
| 102 | --surface: #0f0f0f; | |
| 103 | --hover: #161616; | |
| 104 | --code-bg: #050505; | |
| 105 | --accent: #5b6bff; /* 4.71:1 — pure #0000f0 is 2.13:1 here */ | |
| 106 | --accent-fg: #ffffff; | |
| 107 | --fill: #0000f0; /* fills stay pure; white on it clears 9:1 */ | |
| 108 | --mark: #ff6b3d; | |
| 109 | --warn: #ff6b3d; | |
| 110 | --ok: #3fce7a; | |
| 111 | --bad: #ff6b6b; | |
| 112 | --done: #c084fc; | |
| 113 | --neutral: #a1a1a1; | |
| 100 | 114 | } |
| 101 | 115 | } |
| 102 | 116 | |
| @@ -108,18 +122,46 @@ body { | ||
| 108 | 122 | color: var(--fg); |
| 109 | 123 | font: var(--fs-3)/1.6 var(--sans); |
| 110 | 124 | min-height: 100vh; |
| 111 | display: flex; | |
| 112 | flex-direction: column; | |
| 113 | 125 | } |
| 114 | main { flex: 1; } | |
| 115 | 126 | |
| 116 | 127 | a { color: var(--accent); text-decoration: none; } |
| 117 | 128 | a:hover { text-decoration: underline; } |
| 118 | 129 | |
| 119 | h1 { font-size: var(--fs-5); font-weight: 600; line-height: 1.3; | |
| 120 | margin: var(--sp-5) 0 var(--sp-3); letter-spacing: -0.01em; } | |
| 121 | h2 { font-size: var(--fs-4); font-weight: 600; line-height: 1.35; | |
| 122 | margin: var(--sp-5) 0 var(--sp-3); } | |
| 130 | /* one authored focus ring, so keyboard position is never the browser's | |
| 131 | guess. Blue, because focus is an interactive state. */ | |
| 132 | :focus-visible { | |
| 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 */ | |
| 150 | a.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 | } | |
| 159 | a.skip:focus { top: var(--sp-3); text-decoration: none; } | |
| 160 | ||
| 161 | h1 { 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; } | |
| 163 | h2 { 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; } | |
| 123 | 165 | h3 { font-size: var(--fs-3); font-weight: 600; margin: var(--sp-5) 0 var(--sp-2); } |
| 124 | 166 | |
| 125 | 167 | code, pre, .code, td.mode, td.size { |
| @@ -127,167 +169,213 @@ code, pre, .code, td.mode, td.size { | ||
| 127 | 169 | font-size: var(--fs-1); |
| 128 | 170 | } |
| 129 | 171 | |
| 130 | /* layout: one shared container aligns header, content, and footer */ | |
| 131 | .container { | |
| 132 | max-width: 64rem; | |
| 133 | margin: 0 auto; | |
| 134 | padding: 0 var(--sp-5); | |
| 135 | } | |
| 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. */ | |
| 138 | body > header { | |
| 172 | /* ---- shell: black rail, content pane ---- */ | |
| 173 | .shell { display: flex; align-items: stretch; min-height: 100vh; } | |
| 174 | ||
| 175 | .rail { | |
| 176 | flex: none; | |
| 177 | width: 13rem; | |
| 139 | 178 | background: var(--shell-bg); |
| 140 | border-bottom: 1px solid var(--shell-line); | |
| 141 | padding: 0; | |
| 179 | color: var(--shell-fg); | |
| 180 | border-right: 1px solid var(--shell-line); | |
| 142 | 181 | position: sticky; |
| 143 | 182 | top: 0; |
| 144 | z-index: 30; | |
| 183 | height: 100vh; | |
| 184 | display: flex; | |
| 185 | flex-direction: column; | |
| 145 | 186 | } |
| 146 | a.site { | |
| 147 | font-weight: 600; | |
| 148 | color: var(--shell-fg); | |
| 149 | letter-spacing: -0.01em; | |
| 150 | display: inline-flex; | |
| 187 | a.brand { | |
| 188 | display: flex; | |
| 151 | 189 | align-items: center; |
| 152 | 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 | } |
| 155 | a.site:hover { text-decoration: none; color: #ffffff; } | |
| 156 | a.site svg.mark { display: block; } | |
| 157 | a.site svg.mark rect { fill: #0f62fe; } | |
| 158 | a.site svg.mark path { stroke: #ffffff; } | |
| 159 | nav.topnav { | |
| 160 | display: flex; | |
| 161 | align-items: center; | |
| 162 | gap: 0; | |
| 163 | height: 3rem; | |
| 164 | } | |
| 165 | nav.topnav .spacer { flex: 1; } | |
| 166 | /* the avatar square is the only nav item in the shell; everything else | |
| 167 | lives in the rail */ | |
| 168 | a.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; | |
| 198 | a.brand:hover { text-decoration: none; color: #ffffff; } | |
| 199 | a.brand svg.mark { display: block; flex: none; } | |
| 200 | a.brand span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 201 | ||
| 202 | .railbody { flex: 1; overflow-y: auto; padding: var(--sp-3) 0; } | |
| 203 | ul.raillist { list-style: none; margin: 0; padding: 0; } | |
| 204 | ul.raillist a { | |
| 205 | display: block; | |
| 206 | padding: 0.28rem var(--sp-4); | |
| 207 | color: var(--shell-fg); | |
| 177 | 208 | font-size: var(--fs-2); |
| 209 | border-left: 3px solid transparent; | |
| 210 | overflow: hidden; text-overflow: ellipsis; white-space: nowrap; | |
| 211 | } | |
| 212 | ul.raillist a:hover { background: var(--shell-hover); text-decoration: none; } | |
| 213 | /* orange marks position — the rail's only accent */ | |
| 214 | ul.raillist a[aria-current] { | |
| 215 | border-left-color: var(--shell-mark); | |
| 216 | background: var(--shell-hover); | |
| 178 | 217 | font-weight: 600; |
| 179 | flex: none; | |
| 180 | 218 | } |
| 181 | a.avatar:hover { text-decoration: none; filter: brightness(1.12); } | |
| 182 | a.avatar.guest { background: #393939; color: #f4f4f4; } | |
| 219 | ul.raillist .owner { color: var(--shell-muted); } | |
| 220 | ul.raillist.wide a { white-space: normal; padding-top: var(--sp-1); padding-bottom: var(--sp-1); } | |
| 221 | ul.raillist.wide b { display: block; font-weight: 500; font-size: var(--fs-1); } | |
| 222 | ul.raillist.wide .owner { font-size: var(--fs-0); } | |
| 183 | 223 | |
| 184 | /* the rail: persistent navigation, global links first, repo sections | |
| 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); | |
| 224 | .raillabel { | |
| 195 | 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 | 247 | .railfoot { |
| 200 | 248 | flex: none; |
| 201 | border-top: 1px solid var(--line); | |
| 202 | padding: var(--sp-2) 0; | |
| 203 | background: var(--surface); | |
| 204 | } | |
| 205 | .siderail a, .siderail button.linklike { | |
| 249 | border-top: 1px solid var(--shell-line); | |
| 250 | padding: var(--sp-2) var(--sp-4); | |
| 206 | 251 | display: flex; |
| 207 | 252 | align-items: center; |
| 208 | gap: var(--sp-2); | |
| 209 | width: 100%; | |
| 210 | text-align: left; | |
| 211 | color: var(--fg); | |
| 253 | gap: var(--sp-3); | |
| 212 | 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 { | |
| 218 | background: var(--hover); | |
| 219 | text-decoration: none; | |
| 220 | color: var(--fg); | |
| 256 | .railfoot a { color: var(--shell-fg); } | |
| 257 | a.railuser { display: inline-flex; align-items: center; gap: var(--sp-2); } | |
| 258 | .railfoot .linklike { | |
| 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 { | |
| 223 | border-left-color: var(--accent); | |
| 224 | background: var(--hover); | |
| 272 | ||
| 273 | .pane { flex: 1; min-width: 0; display: flex; flex-direction: column; } | |
| 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 | } | |
| 295 | h1.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 | ||
| 304 | button.btn { | |
| 305 | font: inherit; | |
| 306 | font-size: var(--fs-1); | |
| 225 | 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 { | |
| 228 | display: block; | |
| 229 | font-size: var(--fs-0); | |
| 315 | button.btn:hover { border-color: var(--accent); color: var(--accent); } | |
| 316 | button.btn[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); } | |
| 317 | form.inline { display: inline; } | |
| 318 | ||
| 319 | nav.tabs { | |
| 320 | display: flex; | |
| 321 | gap: var(--sp-1); | |
| 322 | margin-top: var(--sp-4); | |
| 323 | overflow-x: auto; | |
| 324 | scrollbar-width: none; | |
| 325 | } | |
| 326 | nav.tabs::-webkit-scrollbar { display: none; } | |
| 327 | nav.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 | 332 | color: var(--muted); |
| 231 | letter-spacing: 0.04em; | |
| 232 | padding: 0 var(--sp-4); | |
| 233 | margin: var(--sp-4) 0 var(--sp-1); | |
| 333 | font-size: var(--fs-2); | |
| 334 | white-space: nowrap; | |
| 335 | border-bottom: 2px solid transparent; | |
| 336 | margin-bottom: -1px; | |
| 234 | 337 | } |
| 235 | .siderail .glabel:not(.sub) { | |
| 338 | nav.tabs a:hover { color: var(--fg); text-decoration: none; } | |
| 339 | /* orange marks position here too */ | |
| 340 | nav.tabs a[aria-current] { | |
| 236 | 341 | color: var(--fg); |
| 237 | font-weight: 600; | |
| 238 | border-top: 1px solid var(--line); | |
| 239 | padding-top: var(--sp-3); | |
| 240 | overflow-wrap: anywhere; | |
| 342 | font-weight: 700; | |
| 343 | border-bottom-color: var(--mark); | |
| 241 | 344 | } |
| 242 | .siderail .glabel.sub { margin-top: var(--sp-2); } | |
| 243 | .siderail .railuser { font-weight: 600; } | |
| 244 | .siderail .avatar.sm { | |
| 245 | width: 1.25rem; | |
| 246 | height: 1.25rem; | |
| 247 | margin: 0; | |
| 345 | nav.tabs a i { | |
| 346 | font-style: normal; | |
| 347 | font-family: var(--mono); | |
| 248 | 348 | font-size: var(--fs-0); |
| 249 | background: var(--accent); | |
| 250 | color: #ffffff; | |
| 251 | } | |
| 252 | main.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); | |
| 349 | background: var(--hover); | |
| 350 | border-radius: var(--r-sm); | |
| 351 | padding: 0 0.35rem; | |
| 260 | 352 | } |
| 353 | ||
| 354 | /* ---- mobile: the rail becomes a single strip, not a wall of links ---- */ | |
| 261 | 355 | @media (max-width: 52rem) { |
| 262 | /* the rail folds into a wrapping strip above the content */ | |
| 263 | 356 | .shell { display: block; } |
| 264 | .siderail { | |
| 357 | .rail { | |
| 265 | 358 | width: auto; |
| 266 | 359 | height: auto; |
| 267 | 360 | position: static; |
| 268 | 361 | flex-direction: row; |
| 269 | flex-wrap: wrap; | |
| 270 | 362 | align-items: center; |
| 271 | border-right: none; | |
| 272 | border-bottom: 1px solid var(--line); | |
| 273 | } | |
| 274 | .railmain, .railfoot { | |
| 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); | |
| 363 | gap: var(--sp-2); | |
| 364 | border-right: 0; | |
| 365 | border-bottom: 1px solid var(--shell-line); | |
| 366 | overflow-x: auto; | |
| 288 | 367 | } |
| 289 | .siderail a.active { border-left: none; border-bottom-color: var(--accent); background: none; } | |
| 290 | .siderail .glabel { display: none; } | |
| 368 | a.brand { border-bottom: 0; padding-right: var(--sp-2); } | |
| 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 | 381 | /* tables */ |
| @@ -301,66 +389,6 @@ td { | ||
| 301 | 389 | td:last-child { padding-right: 0; } |
| 302 | 390 | td.size, td.mode { color: var(--muted); white-space: nowrap; } |
| 303 | 391 | |
| 304 | /* repo header */ | |
| 305 | .repohead { margin-bottom: var(--sp-4); } | |
| 306 | h1.repotitle { margin: var(--sp-4) 0 var(--sp-1); font-weight: 400; } | |
| 307 | h1.repotitle a { color: var(--fg); } | |
| 308 | h1.repotitle a:last-of-type { font-weight: 650; } | |
| 309 | h1.repotitle a:hover { color: var(--accent); text-decoration: none; } | |
| 310 | h1.repotitle .sep { color: var(--muted); margin: 0 0.15em; } | |
| 311 | form.pinform { display: inline; margin-left: var(--sp-2); vertical-align: middle; } | |
| 312 | button.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 | } | |
| 321 | button.pinbtn:hover { border-color: var(--accent); color: var(--accent); filter: none; } | |
| 322 | button.pinbtn.pinned { color: var(--accent); border-color: var(--accent); } | |
| 323 | a.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 */ | |
| 327 | nav.tabs { display: flex; flex-direction: column; margin-top: var(--sp-1); } | |
| 328 | nav.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 | } | |
| 334 | nav.tabs .glabel:first-child { margin-top: 0; } | |
| 335 | nav.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 | } | |
| 341 | nav.tabs a:hover { background: var(--hover); text-decoration: none; } | |
| 342 | nav.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 | 392 | /* path bar on tree and blob pages */ |
| 365 | 393 | .pathbar { |
| 366 | 394 | display: flex; |
internal/web/templates/blame.html +1 −1
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | {{define "title"}}blame: {{.Path}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 2 | 2 | {{define "content"}} |
| 3 | {{template "repoheader" .}} | |
| 3 | <h1 class="vh">Blame: {{.Path}}</h1> | |
| 4 | 4 | <div class="pathbar"> |
| 5 | 5 | <span class="refchip">{{template "branchicon"}} {{.Ref}}</span> |
| 6 | 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 | 1 | {{define "title"}}{{.Path}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 2 | 2 | {{define "content"}} |
| 3 | {{template "repoheader" .}} | |
| 3 | <h1 class="vh">{{.Path}}</h1> | |
| 4 | 4 | <div class="pathbar"> |
| 5 | 5 | {{template "refmenu" .}} |
| 6 | 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 | 1 | {{define "title"}}build {{.Build.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 2 | 2 | {{define "content"}} |
| 3 | {{template "repoheader" .}} | |
| 4 | 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 | 5 | </div> |
| 7 | 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 | 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 | 1 | {{define "title"}}builds · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 2 | 2 | {{define "content"}} |
| 3 | {{template "repoheader" .}} | |
| 3 | <h1>Builds</h1> | |
| 4 | 4 | <ul class="loglist"> |
| 5 | 5 | {{range .Builds}}<li> |
| 6 | 6 | <div class="commitmain"> |
internal/web/templates/commit.html +1 −2
| @@ -1,8 +1,7 @@ | ||
| 1 | 1 | {{define "title"}}{{.ShortSHA}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 2 | 2 | {{define "content"}} |
| 3 | {{template "repoheader" .}} | |
| 4 | 3 | <div class="commithead"> |
| 5 | <h2>commit <code>{{.ShortSHA}}</code></h2> | |
| 4 | <h1>Commit <code>{{.ShortSHA}}</code></h1> | |
| 6 | 5 | <p class="meta"><code class="fullsha">{{.SHA}}</code></p> |
| 7 | 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 | 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 | 1 | {{define "title"}}#{{.Issue.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 2 | 2 | {{define "content"}} |
| 3 | {{template "repoheader" .}} | |
| 4 | <h2 class="issuetitle">{{.Issue.Title}} <span class="issuenumber">#{{.Issue.Number}}</span></h2> | |
| 3 | <h1 class="issuetitle">{{.Issue.Title}} <span class="issuenumber">#{{.Issue.Number}}</span></h1> | |
| 5 | 4 | <p class="issuemeta"><span class="chip {{if eq .Issue.State "open"}}chip-open{{else}}chip-done{{end}}">{{.Issue.State}}</span> |
| 6 | 5 | <a href="/{{.Issue.Author}}">{{.Issue.Author}}</a> opened this on {{when .Issue.CreatedAt}} |
| 7 | 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 | 1 | {{define "title"}}new issue · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 2 | 2 | {{define "content"}} |
| 3 | {{template "repoheader" .}} | |
| 4 | <h2>new issue</h2> | |
| 3 | <h1>New issue</h1> | |
| 5 | 4 | {{if gt (len .Templates) 1}}<p class="meta">template: |
| 6 | 5 | {{range .Templates}}{{if eq .Name $.Template}}<strong>{{.Name}}</strong>{{else}}<a href="?template={{.Name}}">{{.Name}}</a>{{end}} {{end}}</p>{{end}} |
| 7 | 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 | 1 | {{define "title"}}issues · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 2 | 2 | {{define "content"}} |
| 3 | {{template "repoheader" .}} | |
| 4 | 3 | <div class="listhead"> |
| 5 | <h2>issues</h2> | |
| 4 | <h1>Issues</h1> | |
| 6 | 5 | <nav class="filters"> |
| 7 | 6 | <a {{if eq .State "open"}}class="active" {{end}}href="?state=open">open</a> |
| 8 | 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 | 8 | <link rel="icon" href="/favicon.svg" type="image/svg+xml"> |
| 9 | 9 | </head> |
| 10 | 10 | <body> |
| 11 | <header> | |
| 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> | |
| 11 | <a class="skip" href="#content">Skip to content</a> | |
| 19 | 12 | <div class="shell"> |
| 20 | <nav class="siderail" aria-label="site"> | |
| 21 | <div class="railmain"> | |
| 22 | <a {{if eq (str . "Tab") "explore"}}class="active" {{end}}href="/explore">explore</a> | |
| 23 | {{if .Viewer}}<a href="/new">new repository</a>{{end}} | |
| 24 | {{with field . "Repo"}} | |
| 25 | <span class="glabel">{{.OwnerName}}/{{.Name}}</span> | |
| 26 | <span class="glabel sub">code</span> | |
| 27 | <a {{if eq (str $ "Tab") "files"}}class="active" {{end}}href="/{{.OwnerName}}/{{.Name}}">files</a> | |
| 28 | <a {{if eq (str $ "Tab") "log"}}class="active" {{end}}href="/{{.OwnerName}}/{{.Name}}/log">log</a> | |
| 29 | <a {{if eq (str $ "Tab") "refs"}}class="active" {{end}}href="/{{.OwnerName}}/{{.Name}}/refs">refs</a> | |
| 30 | <span class="glabel sub">work</span> | |
| 31 | <a {{if eq (str $ "Tab") "issues"}}class="active" {{end}}href="/{{.OwnerName}}/{{.Name}}/issues">issues</a> | |
| 32 | <a {{if eq (str $ "Tab") "merge requests"}}class="active" {{end}}href="/{{.OwnerName}}/{{.Name}}/mrs">merge requests</a> | |
| 33 | <a {{if eq (str $ "Tab") "builds"}}class="active" {{end}}href="/{{.OwnerName}}/{{.Name}}/builds">builds</a> | |
| 34 | <span class="glabel sub">publish</span> | |
| 35 | <a {{if eq (str $ "Tab") "releases"}}class="active" {{end}}href="/{{.OwnerName}}/{{.Name}}/releases">releases</a> | |
| 36 | {{if field $ "HasWiki"}}<a {{if eq (str $ "Tab") "wiki"}}class="active" {{end}}href="/{{.OwnerName}}/{{.Name}}/wiki">wiki</a>{{end}} | |
| 37 | <span class="glabel sub">tools</span> | |
| 38 | <a {{if eq (str $ "Tab") "search"}}class="active" {{end}}href="/{{.OwnerName}}/{{.Name}}/search">search</a> | |
| 39 | <a href="/{{.OwnerName}}/{{.Name}}/archive/{{str $ "Ref"}}.tar.gz">archive</a> | |
| 13 | ||
| 14 | {{$here := ""}}{{with field . "Repo"}}{{$here = printf "%s/%s" .OwnerName .Name}}{{end}} | |
| 15 | <nav class="rail" aria-label="Site"> | |
| 16 | <a class="brand" href="/">{{template "mark"}}<span>{{.Site}}</span></a> | |
| 17 | <div class="railbody"> | |
| 18 | <ul class="raillist"> | |
| 19 | {{if .Viewer}}<li><a href="/">Dashboard</a></li>{{end}} | |
| 20 | <li><a {{if eq (str . "Tab") "explore"}}aria-current="page" {{end}}href="/explore">Explore</a></li> | |
| 21 | {{if .Viewer}}<li><a href="/new">New repository</a></li>{{end}} | |
| 22 | </ul> | |
| 23 | {{with .Rail.Pinned}} | |
| 24 | <div class="railgroup"> | |
| 25 | <p class="raillabel" id="rail-pinned">Pinned</p> | |
| 26 | <ul class="raillist" aria-labelledby="rail-pinned"> | |
| 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> | |
| 28 | {{end}} | |
| 29 | </ul> | |
| 30 | </div> | |
| 31 | {{end}} | |
| 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 | 40 | {{end}} |
| 41 | 41 | </div> |
| 42 | 42 | <div class="railfoot"> |
| 43 | {{if .Viewer}}<a class="railuser" href="/{{.Viewer}}"><span class="avatar sm">{{initial .Viewer}}</span>{{.Viewer}}</a> | |
| 44 | <form method="post" action="/logout"><button type="submit" class="linklike">log out</button></form> | |
| 45 | {{else}}<a href="/login">sign in</a>{{end}} | |
| 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> | |
| 45 | {{else}}<a href="/login">Sign in</a>{{end}} | |
| 46 | 46 | </div> |
| 47 | 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 | 80 | {{template "content" .}} |
| 50 | 81 | </main> |
| 51 | </div> | |
| 82 | ||
| 52 | 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 | 85 | </footer> |
| 86 | </div> | |
| 87 | </div> | |
| 55 | 88 | </body> |
| 56 | 89 | </html>{{end}} |
| 57 | 90 | |
| 58 | {{define "repoheader"}} | |
| 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}} | |
| 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}} | |
| 67 | 92 | |
| 68 | 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 | 95 | {{if .Desc}}<p class="desc">{{.Desc}}</p>{{end}} |
| 71 | 96 | {{if .Topics}}<p class="topics">{{range .Topics}}<a class="chip topic" href="/explore?q={{.}}">{{.}}</a> {{end}}</p>{{end}} |
| 72 | 97 | <p class="meta">{{template "branchicon"}} {{.DefaultBranch}}{{if .License}} · {{.License}}{{end}}{{if .Updated}} · updated {{.Updated}}{{end}}</p> |
| @@ -76,7 +101,7 @@ | ||
| 76 | 101 | <summary class="refchip">{{template "branchicon"}} {{.Ref}}</summary> |
| 77 | 102 | <div class="refdrop"> |
| 78 | 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 | 105 | </div> |
| 81 | 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 | 1 | {{define "title"}}log · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 2 | 2 | {{define "content"}} |
| 3 | {{template "repoheader" .}} | |
| 4 | 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 | 5 | <ul class="loglist"> |
| 6 | 6 | {{range .Commits}}<li> |
| 7 | 7 | <div class="commitmain"> |
internal/web/templates/milestones.html +1 −2
| @@ -1,8 +1,7 @@ | ||
| 1 | 1 | {{define "title"}}milestones · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 2 | 2 | {{define "content"}} |
| 3 | {{template "repoheader" .}} | |
| 4 | 3 | <div class="listhead"> |
| 5 | <h2>milestones</h2> | |
| 4 | <h1>Milestones</h1> | |
| 6 | 5 | <nav class="filters"> |
| 7 | 6 | <a {{if eq .State "open"}}class="active" {{end}}href="?state=open">open</a> |
| 8 | 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 | 1 | {{define "title"}}!{{.MR.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 2 | 2 | {{define "content"}} |
| 3 | {{template "repoheader" .}} | |
| 4 | <h2 class="issuetitle">{{.MR.Title}} <span class="issuenumber">!{{.MR.Number}}</span></h2> | |
| 3 | <h1 class="issuetitle">{{.MR.Title}} <span class="issuenumber">!{{.MR.Number}}</span></h1> | |
| 5 | 4 | <p class="issuemeta"><span class="chip chip-{{.MR.State}}">{{.MR.State}}</span> |
| 6 | 5 | <a href="/{{.MR.Author}}">{{.MR.Author}}</a> wants to merge {{if .MR.SourcePath}}{{.MR.SourcePath}}:{{end}}{{.MR.SourceRef}} into {{.MR.TargetRef}} |
| 7 | 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 | 1 | {{define "title"}}merge requests · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 2 | 2 | {{define "content"}} |
| 3 | {{template "repoheader" .}} | |
| 4 | 3 | <div class="listhead"> |
| 5 | <h2>merge requests</h2> | |
| 4 | <h1>Merge requests</h1> | |
| 6 | 5 | <nav class="filters"> |
| 7 | 6 | <a {{if eq .State "open"}}class="active" {{end}}href="?state=open">open</a> |
| 8 | 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 | 1 | {{define "title"}}refs · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 2 | 2 | {{define "content"}} |
| 3 | {{template "repoheader" .}} | |
| 4 | <h2>branches</h2> | |
| 3 | <h1>Refs</h1> | |
| 4 | <h2>Branches</h2> | |
| 5 | 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 | 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 | 8 | {{end}} |
internal/web/templates/releases.html +1 −2
| @@ -1,7 +1,6 @@ | ||
| 1 | 1 | {{define "title"}}releases · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 2 | 2 | {{define "content"}} |
| 3 | {{template "repoheader" .}} | |
| 4 | <h2>releases</h2> | |
| 3 | <h1>Releases</h1> | |
| 5 | 4 | {{range $rel := .Releases}} |
| 6 | 5 | <article class="release"> |
| 7 | 6 | <header class="releasehead"> |
internal/web/templates/search.html +1 −1
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | {{define "title"}}search · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 2 | 2 | {{define "content"}} |
| 3 | {{template "repoheader" .}} | |
| 3 | <h1>Search</h1> | |
| 4 | 4 | <form method="get" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/search" class="searchform"> |
| 5 | 5 | <input type="search" name="q" value="{{.Query}}" placeholder="search file contents on {{.Ref}}" autofocus> |
| 6 | 6 | <button type="submit">search</button> |
internal/web/templates/tree.html +1 −1
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | {{define "title"}}{{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 2 | 2 | {{define "content"}} |
| 3 | {{template "repoheader" .}} | |
| 3 | {{if .DirPath}}<h1 class="vh">{{.DirPath}}</h1>{{end}} | |
| 4 | 4 | <div class="pathbar"> |
| 5 | 5 | {{template "refmenu" .}} |
| 6 | 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 | 1 | {{define "title"}}wiki{{if .Page}}: {{.Page}}{{end}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 2 | 2 | {{define "content"}} |
| 3 | {{template "repoheader" .}} | |
| 3 | <h1 class="vh">Wiki{{if .Page}}: {{.Page}}{{end}}</h1> | |
| 4 | 4 | {{if .Missing}}<p class="empty-note">no wiki yet — create one by pushing pages:<br> |
| 5 | 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 | 6 | {{else}} |
internal/web/web.go +14
| @@ -77,6 +77,20 @@ var funcs = template.FuncMap{ | ||
| 77 | 77 | }, |
| 78 | 78 | "add": func(a, b int) int { return a + b }, |
| 79 | 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 | 94 | // initial is the avatar letter for a username. |
| 81 | 95 | "initial": func(s string) string { |
| 82 | 96 | for _, r := range s { |