Commit 590c75d99c
Verified · cmc
internal/httpd/web.go +41 −7
| @@ -3,6 +3,7 @@ package httpd | ||
| 3 | 3 | import ( |
| 4 | 4 | "bytes" |
| 5 | 5 | "fmt" |
| 6 | "hash/fnv" | |
| 6 | 7 | |
| 7 | 8 | "gitbay.org/gitbay/internal/policy" |
| 8 | 9 | "html/template" |
| @@ -645,6 +646,32 @@ func (s *Server) commit(w http.ResponseWriter, r *http.Request) { | ||
| 645 | 646 | time.Unix(parsed.AuthorUnix, 0).UTC().Format(time.RFC3339), msg, v, checks, lines}) |
| 646 | 647 | } |
| 647 | 648 | |
| 649 | // labelPalette provides default label chip colors: mid-tone hues that stay | |
| 650 | // legible on light and dark backgrounds. | |
| 651 | var labelPalette = []string{ | |
| 652 | "#0969da", "#1a7f37", "#9a6700", "#cf222e", | |
| 653 | "#8250df", "#b93a86", "#0b6c80", "#bf5b16", | |
| 654 | } | |
| 655 | ||
| 656 | var hexColorPat = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) | |
| 657 | ||
| 658 | // labelColors returns a complete label-name -> chip color map for a repo: | |
| 659 | // the stored labels.color when it is a valid hex color, otherwise a | |
| 660 | // stable default picked from the palette by name hash. | |
| 661 | func (s *Server) labelColors(repoID int64) map[string]template.CSS { | |
| 662 | stored, _ := s.st.LabelColors(repoID) | |
| 663 | out := make(map[string]template.CSS, len(stored)) | |
| 664 | for name, color := range stored { | |
| 665 | if !hexColorPat.MatchString(color) { | |
| 666 | h := fnv.New32a() | |
| 667 | h.Write([]byte(name)) | |
| 668 | color = labelPalette[h.Sum32()%uint32(len(labelPalette))] | |
| 669 | } | |
| 670 | out[name] = template.CSS("--chip:" + color) | |
| 671 | } | |
| 672 | return out | |
| 673 | } | |
| 674 | ||
| 648 | 675 | func (s *Server) issues(w http.ResponseWriter, r *http.Request) { |
| 649 | 676 | p, ok := s.repoFor(w, r, "") |
| 650 | 677 | if !ok { |
| @@ -660,11 +687,17 @@ func (s *Server) issues(w http.ResponseWriter, r *http.Request) { | ||
| 660 | 687 | http.Error(w, "internal error", http.StatusInternalServerError) |
| 661 | 688 | return |
| 662 | 689 | } |
| 690 | if labels, err := s.st.ListIssueLabels(p.Repo.ID); err == nil { | |
| 691 | for i := range issues { | |
| 692 | issues[i].Labels = labels[issues[i].ID] | |
| 693 | } | |
| 694 | } | |
| 663 | 695 | s.render(w, "issues.html", struct { |
| 664 | 696 | repoPage |
| 665 | State string | |
| 666 | Issues []store.Issue | |
| 667 | }{p, state, issues}) | |
| 697 | State string | |
| 698 | Issues []store.Issue | |
| 699 | LabelColors map[string]template.CSS | |
| 700 | }{p, state, issues, s.labelColors(p.Repo.ID)}) | |
| 668 | 701 | } |
| 669 | 702 | |
| 670 | 703 | func (s *Server) issue(w http.ResponseWriter, r *http.Request) { |
| @@ -690,10 +723,11 @@ func (s *Server) issue(w http.ResponseWriter, r *http.Request) { | ||
| 690 | 723 | } |
| 691 | 724 | s.render(w, "issue.html", struct { |
| 692 | 725 | repoPage |
| 693 | Issue store.Issue | |
| 694 | BodyHTML template.HTML | |
| 695 | Comments []renderedComment | |
| 696 | }{p, iss, mdHTML(iss.Body), renderComments(comments)}) | |
| 726 | Issue store.Issue | |
| 727 | BodyHTML template.HTML | |
| 728 | Comments []renderedComment | |
| 729 | LabelColors map[string]template.CSS | |
| 730 | }{p, iss, mdHTML(iss.Body), renderComments(comments), s.labelColors(p.Repo.ID)}) | |
| 697 | 731 | } |
| 698 | 732 | |
| 699 | 733 | func (s *Server) mrs(w http.ResponseWriter, r *http.Request) { |
internal/store/issues.go +43
| @@ -167,6 +167,49 @@ func (s *Store) ListIssueComments(issueID int64) ([]IssueComment, error) { | ||
| 167 | 167 | return out, rows.Err() |
| 168 | 168 | } |
| 169 | 169 | |
| 170 | // ListIssueLabels returns the label names attached to each issue of a | |
| 171 | // repo, keyed by issue id. Used by the web issue listing; ListIssues | |
| 172 | // itself stays label-free for the CLI's lean list output. | |
| 173 | func (s *Store) ListIssueLabels(repoID int64) (map[int64][]string, error) { | |
| 174 | rows, err := s.DB.Query(` | |
| 175 | SELECT il.issue_id, l.name FROM issue_labels il | |
| 176 | JOIN labels l ON l.id = il.label_id | |
| 177 | WHERE l.repo_id = ? ORDER BY l.name`, repoID) | |
| 178 | if err != nil { | |
| 179 | return nil, err | |
| 180 | } | |
| 181 | defer rows.Close() | |
| 182 | out := map[int64][]string{} | |
| 183 | for rows.Next() { | |
| 184 | var id int64 | |
| 185 | var name string | |
| 186 | if err := rows.Scan(&id, &name); err != nil { | |
| 187 | return nil, err | |
| 188 | } | |
| 189 | out[id] = append(out[id], name) | |
| 190 | } | |
| 191 | return out, rows.Err() | |
| 192 | } | |
| 193 | ||
| 194 | // LabelColors returns the repo's label colors keyed by label name. Labels | |
| 195 | // with no stored color map to "". | |
| 196 | func (s *Store) LabelColors(repoID int64) (map[string]string, error) { | |
| 197 | rows, err := s.DB.Query("SELECT name, color FROM labels WHERE repo_id = ?", repoID) | |
| 198 | if err != nil { | |
| 199 | return nil, err | |
| 200 | } | |
| 201 | defer rows.Close() | |
| 202 | out := map[string]string{} | |
| 203 | for rows.Next() { | |
| 204 | var name, color string | |
| 205 | if err := rows.Scan(&name, &color); err != nil { | |
| 206 | return nil, err | |
| 207 | } | |
| 208 | out[name] = color | |
| 209 | } | |
| 210 | return out, rows.Err() | |
| 211 | } | |
| 212 | ||
| 170 | 213 | // SetIssueLabel attaches (add) or detaches a label, creating the repo label |
| 171 | 214 | // on first use. |
| 172 | 215 | func (s *Store) SetIssueLabel(repoID, issueID int64, name string, add bool) error { |
internal/web/static/style.css +65 −2
| @@ -352,8 +352,10 @@ pre.diff .meta { color: var(--muted); } | ||
| 352 | 352 | } |
| 353 | 353 | .chip-open { --chip: var(--ok); } |
| 354 | 354 | .chip-closed { --chip: var(--bad); } |
| 355 | .chip-merged { --chip: var(--done); } | |
| 356 | .chip-neutral { --chip: var(--neutral); } | |
| 355 | .chip-merged, .chip-done { --chip: var(--done); } | |
| 356 | .chip-neutral, .chip-source_gone { --chip: var(--neutral); } | |
| 357 | .chip-stale { --chip: var(--warn); } | |
| 358 | .chip.label { font-weight: 500; } | |
| 357 | 359 | .badge-verified { --chip: var(--ok); } |
| 358 | 360 | .badge-unsigned { --chip: var(--neutral); } |
| 359 | 361 | .badge-signed_unknown_key { --chip: var(--warn); } |
| @@ -368,6 +370,63 @@ span.check-success { color: var(--ok); } | ||
| 368 | 370 | span.check-pending { color: var(--warn); } |
| 369 | 371 | span.check-failure, span.check-error { color: var(--bad); } |
| 370 | 372 | |
| 373 | /* issue and MR listings */ | |
| 374 | .listhead { | |
| 375 | display: flex; | |
| 376 | align-items: baseline; | |
| 377 | gap: var(--sp-4); | |
| 378 | flex-wrap: wrap; | |
| 379 | } | |
| 380 | .listhead h2 { margin-bottom: var(--sp-2); } | |
| 381 | nav.filters { display: flex; gap: var(--sp-1); } | |
| 382 | nav.filters a { | |
| 383 | color: var(--muted); | |
| 384 | font-size: var(--fs-2); | |
| 385 | padding: 0.05rem var(--sp-2); | |
| 386 | border-radius: var(--r-pill); | |
| 387 | } | |
| 388 | nav.filters a:hover { color: var(--fg); background: var(--surface); text-decoration: none; } | |
| 389 | nav.filters a.active { color: var(--accent); background: color-mix(in srgb, var(--accent) 10%, transparent); } | |
| 390 | ||
| 391 | ul.issuelist { list-style: none; margin: var(--sp-2) 0; padding: 0; } | |
| 392 | ul.issuelist li { | |
| 393 | display: flex; | |
| 394 | align-items: baseline; | |
| 395 | gap: var(--sp-3); | |
| 396 | padding: var(--sp-3) 0; | |
| 397 | border-bottom: 1px solid var(--faint); | |
| 398 | } | |
| 399 | ul.issuelist li.empty { display: block; color: var(--muted); } | |
| 400 | ul.issuelist .issuemain { flex: 1; min-width: 0; } | |
| 401 | ul.issuelist p { margin: 0; } | |
| 402 | ul.issuelist .title a { color: var(--fg); font-weight: 550; } | |
| 403 | ul.issuelist .title a:hover { color: var(--accent); } | |
| 404 | ||
| 405 | /* issue / MR detail */ | |
| 406 | h2.issuetitle { margin-bottom: var(--sp-1); font-size: var(--fs-5); } | |
| 407 | .issuenumber { color: var(--muted); font-weight: 400; } | |
| 408 | .issuemeta { color: var(--muted); font-size: var(--fs-2); margin-top: 0; } | |
| 409 | .review { font-size: var(--fs-2); } | |
| 410 | ||
| 411 | /* comment cards */ | |
| 412 | article.comment { | |
| 413 | border: 1px solid var(--faint); | |
| 414 | border-radius: var(--r-lg); | |
| 415 | margin: var(--sp-4) 0; | |
| 416 | } | |
| 417 | article.comment .commenthead { | |
| 418 | background: var(--surface); | |
| 419 | border-bottom: 1px solid var(--faint); | |
| 420 | border-radius: var(--r-lg) var(--r-lg) 0 0; | |
| 421 | padding: var(--sp-2) var(--sp-4); | |
| 422 | font-size: var(--fs-2); | |
| 423 | } | |
| 424 | article.comment .commenthead .when { color: var(--muted); } | |
| 425 | article.comment .rendered { padding: 0 var(--sp-4); } | |
| 426 | article.comment .rendered > :first-child { margin-top: var(--sp-3); } | |
| 427 | article.comment .rendered > :last-child { margin-bottom: var(--sp-3); } | |
| 428 | form.commentform { margin: var(--sp-4) 0; } | |
| 429 | ||
| 371 | 430 | /* review threads under diff lines */ |
| 372 | 431 | .thread { |
| 373 | 432 | border: 1px solid var(--faint); |
| @@ -378,6 +437,10 @@ span.check-failure, span.check-error { color: var(--bad); } | ||
| 378 | 437 | } |
| 379 | 438 | .thread.resolved { border-left-color: var(--ok); opacity: 0.75; } |
| 380 | 439 | .thread.stale { border-left-color: var(--warn); } |
| 440 | .thread p.commenthead { font-size: var(--fs-2); margin: var(--sp-2) 0 0; } | |
| 441 | .thread .when { color: var(--muted); } | |
| 442 | .thread .rendered p { margin: var(--sp-1) 0; } | |
| 443 | .thread .threadstate { color: var(--muted); font-size: var(--fs-1); margin: var(--sp-1) 0 0; } | |
| 381 | 444 | |
| 382 | 445 | /* forms */ |
| 383 | 446 | input, textarea, button, select { font: inherit; color: inherit; } |
internal/web/templates/index.html +1 −1
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | {{define "title"}}{{.Site}}{{end}} |
| 2 | 2 | {{define "content"}} |
| 3 | {{if .Viewer}}<p class="toolbar">logged in as <strong>{{.Viewer}}</strong> · <a href="/new">new repository</a> · | |
| 3 | {{if .Viewer}}<p class="toolbar">logged in as {{.Viewer}} · <a href="/new">new repository</a> · | |
| 4 | 4 | <form method="post" action="/logout" class="inline"><button type="submit" class="linklike">logout</button></form></p>{{end}} |
| 5 | 5 | <h1>repositories</h1> |
| 6 | 6 | <ul class="repolist"> |
internal/web/templates/issue.html +15 −8
| @@ -1,17 +1,24 @@ | ||
| 1 | 1 | {{define "title"}}#{{.Issue.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 2 | 2 | {{define "content"}} |
| 3 | 3 | {{template "repoheader" .}} |
| 4 | <h2>#{{.Issue.Number}} {{.Issue.Title}} <span class="badge badge-unsigned">{{.Issue.State}}</span></h2> | |
| 5 | <p class="crumbs">by {{.Issue.Author}} at {{.Issue.CreatedAt}} | |
| 6 | {{if .Issue.Labels}} · labels: {{range .Issue.Labels}}{{.}} {{end}}{{end}} | |
| 7 | {{if .Issue.Assignees}} · assigned: {{range .Issue.Assignees}}{{.}} {{end}}{{end}}</p> | |
| 8 | {{if .BodyHTML}}<div class="rendered">{{.BodyHTML}}</div>{{end}} | |
| 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> | |
| 6 | {{.Issue.Author}} opened this on {{when .Issue.CreatedAt}} | |
| 7 | {{if .Issue.Labels}} · {{range .Issue.Labels}}<span class="chip label" style="{{index $.LabelColors .}}">{{.}}</span> {{end}}{{end}} | |
| 8 | {{if .Issue.Assignees}} · assigned to {{range .Issue.Assignees}}{{.}} {{end}}{{end}}</p> | |
| 9 | {{if .BodyHTML}}<article class="comment"> | |
| 10 | <header class="commenthead"><strong>{{.Issue.Author}}</strong> <span class="when">{{when .Issue.CreatedAt}}</span></header> | |
| 11 | <div class="rendered">{{.BodyHTML}}</div> | |
| 12 | </article>{{end}} | |
| 9 | 13 | {{range .Comments}} |
| 10 | <div class="readme"><p class="crumbs">{{.Author}} at {{.CreatedAt}}</p><div class="rendered">{{.BodyHTML}}</div></div> | |
| 14 | <article class="comment"> | |
| 15 | <header class="commenthead"><strong>{{.Author}}</strong> <span class="when">{{when .CreatedAt}}</span></header> | |
| 16 | <div class="rendered">{{.BodyHTML}}</div> | |
| 17 | </article> | |
| 11 | 18 | {{end}} |
| 12 | 19 | {{if .Viewer}} |
| 13 | <form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/{{.Issue.Number}}/comment"> | |
| 14 | <p><textarea name="body" rows="4" style="width:100%" placeholder="comment as {{.Viewer}}"></textarea></p> | |
| 20 | <form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/{{.Issue.Number}}/comment" class="commentform"> | |
| 21 | <p><textarea name="body" rows="4" placeholder="comment as {{.Viewer}}"></textarea></p> | |
| 15 | 22 | <p><button type="submit">comment</button></p> |
| 16 | 23 | </form> |
| 17 | 24 | {{end}} |
internal/web/templates/issues.html +19 −12
| @@ -1,16 +1,23 @@ | ||
| 1 | 1 | {{define "title"}}issues · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 2 | 2 | {{define "content"}} |
| 3 | 3 | {{template "repoheader" .}} |
| 4 | <h2>issues ({{.State}})</h2> | |
| 5 | <p class="crumbs"><a href="?state=open">open</a> · <a href="?state=closed">closed</a> · <a href="?state=all">all</a></p> | |
| 6 | <table> | |
| 7 | {{range .Issues}}<tr> | |
| 8 | <td>#{{.Number}}</td> | |
| 9 | <td><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/issues/{{.Number}}">{{.Title}}</a></td> | |
| 10 | <td>{{.State}}</td> | |
| 11 | <td>{{.Author}}</td> | |
| 12 | <td>{{range .Labels}}<span class="badge badge-unsigned">{{.}}</span> {{end}}</td> | |
| 13 | </tr> | |
| 14 | {{else}}<tr><td>no issues</td></tr>{{end}} | |
| 15 | </table> | |
| 4 | <div class="listhead"> | |
| 5 | <h2>issues</h2> | |
| 6 | <nav class="filters"> | |
| 7 | <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> | |
| 9 | <a {{if eq .State "all"}}class="active" {{end}}href="?state=all">all</a> | |
| 10 | </nav> | |
| 11 | </div> | |
| 12 | <ul class="issuelist"> | |
| 13 | {{range .Issues}}<li> | |
| 14 | <div class="issuemain"> | |
| 15 | <p class="title"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/issues/{{.Number}}">{{.Title}}</a> | |
| 16 | {{range .Labels}}<span class="chip label" style="{{index $.LabelColors .}}">{{.}}</span> {{end}}</p> | |
| 17 | <p class="meta">#{{.Number}} opened by {{.Author}}</p> | |
| 18 | </div> | |
| 19 | <span class="chip {{if eq .State "open"}}chip-open{{else}}chip-done{{end}}">{{.State}}</span> | |
| 20 | </li> | |
| 21 | {{else}}<li class="empty">no {{if ne .State "all"}}{{.State}} {{end}}issues — open one with <code>gitbay issue create {{.Repo.OwnerName}}/{{.Repo.Name}} --title "..."</code></li>{{end}} | |
| 22 | </ul> | |
| 16 | 23 | {{end}} |
internal/web/templates/mr.html +21 −12
| @@ -1,25 +1,34 @@ | ||
| 1 | 1 | {{define "title"}}!{{.MR.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 2 | 2 | {{define "content"}} |
| 3 | 3 | {{template "repoheader" .}} |
| 4 | <h2>!{{.MR.Number}} {{.MR.Title}} <span class="badge badge-unsigned">{{.MR.State}}</span></h2> | |
| 5 | <p class="crumbs">by {{.MR.Author}} · {{if .MR.SourcePath}}{{.MR.SourcePath}}:{{end}}{{.MR.SourceRef}} → {{.MR.TargetRef}} | |
| 6 | @ <code>{{.MR.HeadSHA}}</code></p> | |
| 7 | {{if .BodyHTML}}<div class="rendered">{{.BodyHTML}}</div>{{end}} | |
| 8 | {{if .Checks}}<p>checks: <span class="badge check-{{.Combined}}">{{.Combined}}</span> | |
| 9 | {{range .Checks}} · {{if .TargetURL}}<a href="{{.TargetURL}}" rel="nofollow">{{.Context}}</a>{{else}}{{.Context}}{{end}} <span class="check-{{.State}}">{{.State}}</span>{{end}}</p>{{end}} | |
| 10 | {{range .Reviews}}<p>review: {{.Reviewer}} — {{.Verdict}}{{if .Stale}} <span class="badge badge-signed_key_expired">stale</span>{{end}}</p>{{end}} | |
| 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> | |
| 6 | {{.MR.Author}} wants to merge {{if .MR.SourcePath}}{{.MR.SourcePath}}:{{end}}{{.MR.SourceRef}} into {{.MR.TargetRef}} | |
| 7 | at <code>{{short .MR.HeadSHA}}</code></p> | |
| 8 | {{if .BodyHTML}}<article class="comment"> | |
| 9 | <header class="commenthead"><strong>{{.MR.Author}}</strong></header> | |
| 10 | <div class="rendered">{{.BodyHTML}}</div> | |
| 11 | </article>{{end}} | |
| 12 | {{if .Checks}}<div class="checks"> | |
| 13 | <p>checks: <span class="badge check-{{.Combined}}">{{.Combined}}</span> | |
| 14 | {{range .Checks}} · {{if .TargetURL}}<a href="{{.TargetURL}}" rel="nofollow">{{.Context}}</a>{{else}}{{.Context}}{{end}} <span class="check-{{.State}}">{{.State}}</span>{{end}}</p> | |
| 15 | </div>{{end}} | |
| 16 | {{range .Reviews}}<p class="review">review: <strong>{{.Reviewer}}</strong> — {{.Verdict}}{{if .Stale}} <span class="chip chip-stale">stale</span>{{end}}</p>{{end}} | |
| 11 | 17 | {{range .Comments}} |
| 12 | <div class="readme"><p class="crumbs">{{.Author}} at {{.CreatedAt}}</p><div class="rendered">{{.BodyHTML}}</div></div> | |
| 18 | <article class="comment"> | |
| 19 | <header class="commenthead"><strong>{{.Author}}</strong> <span class="when">{{when .CreatedAt}}</span></header> | |
| 20 | <div class="rendered">{{.BodyHTML}}</div> | |
| 21 | </article> | |
| 13 | 22 | {{end}} |
| 14 | 23 | {{if .Viewer}} |
| 15 | <form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/{{.MR.Number}}/comment"> | |
| 16 | <p><textarea name="body" rows="4" style="width:100%" placeholder="comment as {{.Viewer}}"></textarea></p> | |
| 24 | <form method="post" action="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/{{.MR.Number}}/comment" class="commentform"> | |
| 25 | <p><textarea name="body" rows="4" placeholder="comment as {{.Viewer}}"></textarea></p> | |
| 17 | 26 | <p><button type="submit">comment</button></p> |
| 18 | 27 | </form> |
| 19 | 28 | {{end}} |
| 20 | 29 | <h3>diff</h3> |
| 21 | 30 | <pre class="diff">{{range .DiffLines}}<span class="{{.Class}}">{{.Text}}</span> |
| 22 | {{range .Threads}}</pre><div class="thread{{if .Resolved}} resolved{{end}}">{{if .Resolved}}<p class="crumbs">resolved by {{.Resolved}}</p>{{end}}{{range .Comments}}<p class="crumbs">{{.Author}} at {{.CreatedAt}}</p><div class="rendered">{{.BodyHTML}}</div>{{end}}</div><pre class="diff">{{end}}{{end}}</pre> | |
| 31 | {{range .Threads}}</pre><div class="thread{{if .Resolved}} resolved{{end}}">{{if .Resolved}}<p class="threadstate">resolved by {{.Resolved}}</p>{{end}}{{range .Comments}}<p class="commenthead"><strong>{{.Author}}</strong> <span class="when">{{when .CreatedAt}}</span></p><div class="rendered">{{.BodyHTML}}</div>{{end}}</div><pre class="diff">{{end}}{{end}}</pre> | |
| 23 | 32 | {{if .DetachedThreads}}<h3>threads on earlier revisions</h3> |
| 24 | {{range .DetachedThreads}}<div class="thread stale"><p class="crumbs">{{if .Stale}}stale · {{end}}{{if .Resolved}}resolved by {{.Resolved}}{{end}}</p>{{range .Comments}}<p class="crumbs">{{.Author}} at {{.CreatedAt}}</p><div class="rendered">{{.BodyHTML}}</div>{{end}}</div>{{end}}{{end}} | |
| 33 | {{range .DetachedThreads}}<div class="thread stale"><p class="threadstate">{{if .Stale}}stale{{end}}{{if .Resolved}}{{if .Stale}} · {{end}}resolved by {{.Resolved}}{{end}}</p>{{range .Comments}}<p class="commenthead"><strong>{{.Author}}</strong> <span class="when">{{when .CreatedAt}}</span></p><div class="rendered">{{.BodyHTML}}</div>{{end}}</div>{{end}}{{end}} | |
| 25 | 34 | {{end}} |
internal/web/templates/mrs.html +19 −12
| @@ -1,16 +1,23 @@ | ||
| 1 | 1 | {{define "title"}}merge requests · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} |
| 2 | 2 | {{define "content"}} |
| 3 | 3 | {{template "repoheader" .}} |
| 4 | <h2>merge requests ({{.State}})</h2> | |
| 5 | <p class="crumbs"><a href="?state=open">open</a> · <a href="?state=merged">merged</a> · <a href="?state=closed">closed</a> · <a href="?state=all">all</a></p> | |
| 6 | <table> | |
| 7 | {{range .MRs}}<tr> | |
| 8 | <td>!{{.Number}}</td> | |
| 9 | <td><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/mrs/{{.Number}}">{{.Title}}</a></td> | |
| 10 | <td>{{.State}}</td> | |
| 11 | <td>{{.Author}}</td> | |
| 12 | <td>{{if .SourcePath}}{{.SourcePath}}:{{end}}{{.SourceRef}} → {{.TargetRef}}</td> | |
| 13 | </tr> | |
| 14 | {{else}}<tr><td>no merge requests</td></tr>{{end}} | |
| 15 | </table> | |
| 4 | <div class="listhead"> | |
| 5 | <h2>merge requests</h2> | |
| 6 | <nav class="filters"> | |
| 7 | <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> | |
| 9 | <a {{if eq .State "closed"}}class="active" {{end}}href="?state=closed">closed</a> | |
| 10 | <a {{if eq .State "all"}}class="active" {{end}}href="?state=all">all</a> | |
| 11 | </nav> | |
| 12 | </div> | |
| 13 | <ul class="issuelist"> | |
| 14 | {{range .MRs}}<li> | |
| 15 | <div class="issuemain"> | |
| 16 | <p class="title"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/mrs/{{.Number}}">{{.Title}}</a></p> | |
| 17 | <p class="meta">!{{.Number}} by {{.Author}} · {{if .SourcePath}}{{.SourcePath}}:{{end}}{{.SourceRef}} → {{.TargetRef}}</p> | |
| 18 | </div> | |
| 19 | <span class="chip chip-{{.State}}">{{.State}}</span> | |
| 20 | </li> | |
| 21 | {{else}}<li class="empty">no {{if ne .State "all"}}{{.State}} {{end}}merge requests — open one with <code>gitbay mr create {{.Repo.OwnerName}}/{{.Repo.Name}} --source ... --target {{.Repo.DefaultBranch}}</code></li>{{end}} | |
| 22 | </ul> | |
| 16 | 23 | {{end}} |
internal/web/web.go +10
| @@ -8,6 +8,7 @@ import ( | ||
| 8 | 8 | "io" |
| 9 | 9 | "runtime/debug" |
| 10 | 10 | "sync" |
| 11 | "time" | |
| 11 | 12 | ) |
| 12 | 13 | |
| 13 | 14 | //go:embed templates/*.html |
| @@ -43,6 +44,15 @@ var funcs = template.FuncMap{ | ||
| 43 | 44 | } |
| 44 | 45 | return s |
| 45 | 46 | }, |
| 47 | // when formats a stored RFC3339 timestamp for display; unparseable | |
| 48 | // values pass through unchanged. | |
| 49 | "when": func(s string) string { | |
| 50 | t, err := time.Parse(time.RFC3339Nano, s) | |
| 51 | if err != nil { | |
| 52 | return s | |
| 53 | } | |
| 54 | return t.UTC().Format("2006-01-02 15:04") | |
| 55 | }, | |
| 46 | 56 | } |
| 47 | 57 | |
| 48 | 58 | // Render executes the named page template with the shared layout. |