Commit a75d0485bc
Verified · cmc
Layout: unified · split
internal/httpd/issuecreate_test.go +2 −1
| @@ -31,7 +31,8 @@ func TestIssuePageHasDiscussionHeading(t *testing.T) { | |||
| 31 | Notice string | 31 | Notice string |
| 32 | LabelColors map[string]template.CSS | 32 | LabelColors map[string]template.CSS |
| 33 | Draft *draft | 33 | Draft *draft |
| 34 | }{repoPage: testRepoPage(), Issue: store.Issue{Number: 1, Title: "bug", Author: "cmc", State: "open"}}); err != nil { | 34 | Reactions map[int64]reactionBar |
| 35 | }{repoPage: testRepoPage(), Issue: store.Issue{Number: 1, Title: "bug", Author: "cmc", State: "open"}, Reactions: map[int64]reactionBar{0: {}}}); err != nil { | ||
| 35 | t.Fatalf("render: %v", err) | 36 | t.Fatalf("render: %v", err) |
| 36 | } | 37 | } |
| 37 | if !strings.Contains(sb.String(), "<h2>Discussion</h2>") { | 38 | if !strings.Contains(sb.String(), "<h2>Discussion</h2>") { |
internal/httpd/mrpage_test.go +1
| @@ -40,6 +40,7 @@ type mrPageData struct { | |||
| 40 | Base string | 40 | Base string |
| 41 | Draft *draft | 41 | Draft *draft |
| 42 | Layout diffLayout | 42 | Layout diffLayout |
| 43 | Reactions map[int64]reactionBar | ||
| 43 | } | 44 | } |
| 44 | 45 | ||
| 45 | // A pruned head has no diff to show; the page must say the head is gone | 46 | // A pruned head has no diff to show; the page must say the head is gone |
internal/httpd/reactions.go added +84
| @@ -0,0 +1,84 @@ | |||
| 1 | package httpd | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "net/http" | ||
| 5 | "strings" | ||
| 6 | |||
| 7 | "gitbay.org/gitbay/internal/store" | ||
| 8 | ) | ||
| 9 | |||
| 10 | // reactionView is one reaction button (or count) under an item. | ||
| 11 | type reactionView struct { | ||
| 12 | Name string | ||
| 13 | Emoji string | ||
| 14 | Count int | ||
| 15 | Me bool | ||
| 16 | Label string | ||
| 17 | } | ||
| 18 | |||
| 19 | // reactionBar is what the reactions partial renders for one item: | ||
| 20 | // Comment is 0 for the issue or merge request body. | ||
| 21 | type reactionBar struct { | ||
| 22 | Action string | ||
| 23 | Comment int64 | ||
| 24 | Signed bool | ||
| 25 | Items []reactionView | ||
| 26 | } | ||
| 27 | |||
| 28 | // reactionBars builds the bars for a thread body (key 0) and its | ||
| 29 | // comments. A signed-in viewer is offered every reaction; anyone else | ||
| 30 | // sees only the ones somebody gave. | ||
| 31 | func (s *Server) reactionBars(r *http.Request, noun string, threadID int64, comments []store.IssueComment, action string) map[int64]reactionBar { | ||
| 32 | viewer := s.webViewer(r) | ||
| 33 | counts, _ := s.st.ReactionCounts(noun, threadID, viewer.ID) | ||
| 34 | bar := func(id int64) reactionBar { | ||
| 35 | b := reactionBar{Action: action, Comment: id, Signed: viewer.ID != 0} | ||
| 36 | given := map[string]store.ReactionCount{} | ||
| 37 | for _, rc := range counts[id] { | ||
| 38 | given[rc.Reaction] = rc | ||
| 39 | } | ||
| 40 | for _, r := range store.Reactions { | ||
| 41 | rc := given[r.Name] | ||
| 42 | if rc.Count == 0 && !b.Signed { | ||
| 43 | continue | ||
| 44 | } | ||
| 45 | label := r.Name | ||
| 46 | if rc.Me { | ||
| 47 | label = "remove " + r.Name | ||
| 48 | } | ||
| 49 | b.Items = append(b.Items, reactionView{r.Name, r.Emoji, rc.Count, rc.Me, label}) | ||
| 50 | } | ||
| 51 | return b | ||
| 52 | } | ||
| 53 | out := map[int64]reactionBar{0: bar(0)} | ||
| 54 | for _, c := range comments { | ||
| 55 | if c.Kind != "system" { | ||
| 56 | out[c.ID] = bar(c.ID) | ||
| 57 | } | ||
| 58 | } | ||
| 59 | return out | ||
| 60 | } | ||
| 61 | |||
| 62 | // reactArgs turns the form into the react command's arguments. The | ||
| 63 | // button pressed is named add or remove and carries the reaction. | ||
| 64 | func reactArgs(r *http.Request, noun string) []string { | ||
| 65 | repo := r.PathValue("owner") + "/" + r.PathValue("repo") | ||
| 66 | args := []string{noun, "react", repo, r.PathValue("n")} | ||
| 67 | if c := strings.TrimSpace(r.FormValue("comment")); c != "" { | ||
| 68 | args = append(args, "--comment", c) | ||
| 69 | } | ||
| 70 | if v := r.FormValue("remove"); v != "" { | ||
| 71 | return append(args, "--remove", v) | ||
| 72 | } | ||
| 73 | return append(args, r.FormValue("add")) | ||
| 74 | } | ||
| 75 | |||
| 76 | func (s *Server) issueReactSubmit(w http.ResponseWriter, r *http.Request, u store.User) { | ||
| 77 | _, msg, code := s.runControlCode(u, reactArgs(r, "issue")) | ||
| 78 | s.done(w, r, code, msg, s.issueRedirect) | ||
| 79 | } | ||
| 80 | |||
| 81 | func (s *Server) mrReactSubmit(w http.ResponseWriter, r *http.Request, u store.User) { | ||
| 82 | _, msg, code := s.runControlCode(u, reactArgs(r, "mr")) | ||
| 83 | s.done(w, r, code, msg, s.mrRedirect) | ||
| 84 | } | ||
internal/httpd/reactions_test.go added +106
| @@ -0,0 +1,106 @@ | |||
| 1 | package httpd | ||
| 2 | |||
| 3 | import ( | ||
| 4 | "net/http" | ||
| 5 | "net/http/httptest" | ||
| 6 | "net/url" | ||
| 7 | "strings" | ||
| 8 | "testing" | ||
| 9 | |||
| 10 | "gitbay.org/gitbay/internal/config" | ||
| 11 | "gitbay.org/gitbay/internal/store" | ||
| 12 | ) | ||
| 13 | |||
| 14 | func reactServer(t *testing.T) (*Server, *store.Store, store.User, store.User) { | ||
| 15 | t.Helper() | ||
| 16 | st, err := store.Open(":memory:") | ||
| 17 | if err != nil { | ||
| 18 | t.Fatal(err) | ||
| 19 | } | ||
| 20 | t.Cleanup(func() { st.Close() }) | ||
| 21 | if err := st.MigrateUp(); err != nil { | ||
| 22 | t.Fatal(err) | ||
| 23 | } | ||
| 24 | aid, _ := st.CreateUser("alice", false) | ||
| 25 | bid, _ := st.CreateUser("bob", false) | ||
| 26 | rid, err := st.CreateRepo("user", aid, "app", "public") | ||
| 27 | if err != nil { | ||
| 28 | t.Fatal(err) | ||
| 29 | } | ||
| 30 | n, _ := st.CreateIssue(rid, aid, "bug", "body", "md") | ||
| 31 | iss, _ := st.IssueByNumber(rid, n) | ||
| 32 | st.AddIssueComment(iss.ID, aid, "a comment", "md") | ||
| 33 | cfg := config.Default() | ||
| 34 | cfg.Web.Mode = "accounts" | ||
| 35 | return New(cfg, st, nil), st, store.User{ID: aid, Username: "alice"}, store.User{ID: bid, Username: "bob"} | ||
| 36 | } | ||
| 37 | |||
| 38 | func issueReq(method, target string, form url.Values) *http.Request { | ||
| 39 | var r *http.Request | ||
| 40 | if form != nil { | ||
| 41 | r = httptest.NewRequest(method, target, strings.NewReader(form.Encode())) | ||
| 42 | r.Header.Set("Content-Type", "application/x-www-form-urlencoded") | ||
| 43 | } else { | ||
| 44 | r = httptest.NewRequest(method, target, nil) | ||
| 45 | } | ||
| 46 | r.SetPathValue("owner", "alice") | ||
| 47 | r.SetPathValue("repo", "app") | ||
| 48 | r.SetPathValue("n", "1") | ||
| 49 | return r | ||
| 50 | } | ||
| 51 | |||
| 52 | func TestIssueReactionsOnPage(t *testing.T) { | ||
| 53 | s, st, _, bob := reactServer(t) | ||
| 54 | |||
| 55 | // Bob reacts to the body and to the comment through the form. | ||
| 56 | for _, form := range []url.Values{ | ||
| 57 | {"add": {"+1"}}, | ||
| 58 | {"add": {"rocket"}, "comment": {"1"}}, | ||
| 59 | } { | ||
| 60 | rr := httptest.NewRecorder() | ||
| 61 | s.issueReactSubmit(rr, issueReq("POST", "/alice/app/issues/1/react", form), bob) | ||
| 62 | if rr.Code != http.StatusSeeOther { | ||
| 63 | t.Fatalf("%v: status %d: %s", form, rr.Code, rr.Body) | ||
| 64 | } | ||
| 65 | } | ||
| 66 | |||
| 67 | // Signed in: a button for every reaction, his own marked pressed. | ||
| 68 | req := issueReq("GET", "/alice/app/issues/1", nil) | ||
| 69 | req.AddCookie(sessionCookieFor(t, s, st, bob.ID)) | ||
| 70 | rr := httptest.NewRecorder() | ||
| 71 | s.issue(rr, req) | ||
| 72 | body := rr.Body.String() | ||
| 73 | if n := strings.Count(body, `class="react"`)+strings.Count(body, `class="react mine"`); n != 16 { | ||
| 74 | t.Errorf("%d reaction buttons, want 16 (8 on the body, 8 on the comment)", n) | ||
| 75 | } | ||
| 76 | if !strings.Contains(body, `name="remove" value="+1" class="react mine" aria-pressed="true"`) { | ||
| 77 | t.Errorf("own reaction not marked:\n%s", body) | ||
| 78 | } | ||
| 79 | if !strings.Contains(body, `name="add" value="eyes"`) || !strings.Contains(body, `<input type="hidden" name="comment" value="1">`) { | ||
| 80 | t.Error("add buttons or comment field missing") | ||
| 81 | } | ||
| 82 | |||
| 83 | // Signed out: counts only, no forms or buttons. | ||
| 84 | rr = httptest.NewRecorder() | ||
| 85 | s.issue(rr, issueReq("GET", "/alice/app/issues/1", nil)) | ||
| 86 | body = rr.Body.String() | ||
| 87 | if strings.Contains(body, `class="react"`) && strings.Contains(body, "<button type=\"submit\" name=\"add\"") { | ||
| 88 | t.Error("buttons shown to a signed-out viewer") | ||
| 89 | } | ||
| 90 | if strings.Contains(body, `/react"`) { | ||
| 91 | t.Error("react form shown to a signed-out viewer") | ||
| 92 | } | ||
| 93 | if n := strings.Count(body, `<span class="react"`); n != 2 { | ||
| 94 | t.Errorf("%d counts, want 2:\n%s", n, body) | ||
| 95 | } | ||
| 96 | if !strings.Contains(body, "👍 1") || !strings.Contains(body, "🚀 1") { | ||
| 97 | t.Error("counts missing") | ||
| 98 | } | ||
| 99 | |||
| 100 | // Pressing a pressed button takes it back. | ||
| 101 | rr = httptest.NewRecorder() | ||
| 102 | s.issueReactSubmit(rr, issueReq("POST", "/alice/app/issues/1/react", url.Values{"remove": {"+1"}}), bob) | ||
| 103 | if got, _ := st.ReactionCounts("issue", 1, bob.ID); len(got[0]) != 0 { | ||
| 104 | t.Errorf("reaction not removed: %+v", got) | ||
| 105 | } | ||
| 106 | } | ||
internal/httpd/routes.go +4
| @@ -160,6 +160,8 @@ func (s *Server) Routes() []Route { | |||
| 160 | Handler: s.checkOrigin(s.requireUser(s.issueCommentSubmit))}, | 160 | Handler: s.checkOrigin(s.requireUser(s.issueCommentSubmit))}, |
| 161 | Route{Method: "POST", Pattern: "/{owner}/{repo}/issues/{n}/edit", Mutating: true, | 161 | Route{Method: "POST", Pattern: "/{owner}/{repo}/issues/{n}/edit", Mutating: true, |
| 162 | Handler: s.checkOrigin(s.requireUser(s.issueEditSubmit))}, | 162 | Handler: s.checkOrigin(s.requireUser(s.issueEditSubmit))}, |
| 163 | Route{Method: "POST", Pattern: "/{owner}/{repo}/issues/{n}/react", Mutating: true, | ||
| 164 | Handler: s.checkOrigin(s.requireUser(s.issueReactSubmit))}, | ||
| 163 | // Triage: each runs the matching issue command. | 165 | // Triage: each runs the matching issue command. |
| 164 | Route{Method: "POST", Pattern: "/{owner}/{repo}/issues/{n}/state", Mutating: true, | 166 | Route{Method: "POST", Pattern: "/{owner}/{repo}/issues/{n}/state", Mutating: true, |
| 165 | Handler: s.checkOrigin(s.requireUser(s.issueStateSubmit))}, | 167 | Handler: s.checkOrigin(s.requireUser(s.issueStateSubmit))}, |
| @@ -206,6 +208,8 @@ func (s *Server) Routes() []Route { | |||
| 206 | Handler: s.checkOrigin(s.requireUser(s.mrEditSubmit))}, | 208 | Handler: s.checkOrigin(s.requireUser(s.mrEditSubmit))}, |
| 207 | Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/comment", Mutating: true, | 209 | Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/comment", Mutating: true, |
| 208 | Handler: s.checkOrigin(s.requireUser(s.mrCommentSubmit))}, | 210 | Handler: s.checkOrigin(s.requireUser(s.mrCommentSubmit))}, |
| 211 | Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/react", Mutating: true, | ||
| 212 | Handler: s.checkOrigin(s.requireUser(s.mrReactSubmit))}, | ||
| 209 | // Review loop: each runs the matching mr command. | 213 | // Review loop: each runs the matching mr command. |
| 210 | Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/review", Mutating: true, | 214 | Route{Method: "POST", Pattern: "/{owner}/{repo}/mrs/{n}/review", Mutating: true, |
| 211 | Handler: s.checkOrigin(s.requireUser(s.mrReviewSubmit))}, | 215 | Handler: s.checkOrigin(s.requireUser(s.mrReviewSubmit))}, |
internal/httpd/web.go +8 −3
| @@ -1324,6 +1324,7 @@ func (s *Server) ugcFor(r *http.Request, repo store.Repo) ugcRenderer { | |||
| 1324 | 1324 | ||
| 1325 | // renderedComment pairs a comment with its rendered body for templates. | 1325 | // renderedComment pairs a comment with its rendered body for templates. |
| 1326 | type renderedComment struct { | 1326 | type renderedComment struct { |
| 1327 | ID int64 | ||
| 1327 | Author string | 1328 | Author string |
| 1328 | CreatedAt string | 1329 | CreatedAt string |
| 1329 | Kind string | 1330 | Kind string |
| @@ -1333,7 +1334,7 @@ type renderedComment struct { | |||
| 1333 | func renderComments(cs []store.IssueComment, ugc ugcRenderer) []renderedComment { | 1334 | func renderComments(cs []store.IssueComment, ugc ugcRenderer) []renderedComment { |
| 1334 | var out []renderedComment | 1335 | var out []renderedComment |
| 1335 | for _, c := range cs { | 1336 | for _, c := range cs { |
| 1336 | out = append(out, renderedComment{c.Author, c.CreatedAt, c.Kind, ugc(c.Body, c.BodyFormat)}) | 1337 | out = append(out, renderedComment{ID: c.ID, Author: c.Author, CreatedAt: c.CreatedAt, Kind: c.Kind, BodyHTML: ugc(c.Body, c.BodyFormat)}) |
| 1337 | } | 1338 | } |
| 1338 | return out | 1339 | return out |
| 1339 | } | 1340 | } |
| @@ -2024,6 +2025,7 @@ func (s *Server) issuePage(w http.ResponseWriter, r *http.Request, previewForm s | |||
| 2024 | } | 2025 | } |
| 2025 | // nil readable: the picker lists titles, never the progress counts. | 2026 | // nil readable: the picker lists titles, never the progress counts. |
| 2026 | milestones, _ := s.st.ListMilestones(p.Repo, "open", nil) | 2027 | milestones, _ := s.st.ListMilestones(p.Repo, "open", nil) |
| 2028 | bars := s.reactionBars(r, "issue", iss.ID, comments, fmt.Sprintf("/%s/%s/issues/%d/react", p.Repo.OwnerName, p.Repo.Name, iss.Number)) | ||
| 2027 | s.render(w, "issue.html", struct { | 2029 | s.render(w, "issue.html", struct { |
| 2028 | repoPage | 2030 | repoPage |
| 2029 | Issue store.Issue | 2031 | Issue store.Issue |
| @@ -2035,9 +2037,10 @@ func (s *Server) issuePage(w http.ResponseWriter, r *http.Request, previewForm s | |||
| 2035 | Notice string | 2037 | Notice string |
| 2036 | LabelColors map[string]template.CSS | 2038 | LabelColors map[string]template.CSS |
| 2037 | Draft *draft | 2039 | Draft *draft |
| 2040 | Reactions map[int64]reactionBar | ||
| 2038 | }{p, iss, md(iss.Body, iss.BodyFormat), renderComments(comments, md), | 2041 | }{p, iss, md(iss.Body, iss.BodyFormat), renderComments(comments, md), |
| 2039 | s.canEditItem(r, p.Repo, iss.Author), s.canWriteRepo(r, p.Repo), | 2042 | s.canEditItem(r, p.Repo, iss.Author), s.canWriteRepo(r, p.Repo), |
| 2040 | milestones, s.takeFlash(w, r), s.labelColors(p.Repo), d}) | 2043 | milestones, s.takeFlash(w, r), s.labelColors(p.Repo), d, bars}) |
| 2041 | } | 2044 | } |
| 2042 | 2045 | ||
| 2043 | // canEditItem: the author or anyone with write access may edit. | 2046 | // canEditItem: the author or anyone with write access may edit. |
| @@ -2328,6 +2331,7 @@ func (s *Server) mrPage(w http.ResponseWriter, r *http.Request, previewForm stri | |||
| 2328 | } | 2331 | } |
| 2329 | d = s.draftFor(r, p.Repo, previewForm, "body", format) | 2332 | d = s.draftFor(r, p.Repo, previewForm, "body", format) |
| 2330 | } | 2333 | } |
| 2334 | bars := s.reactionBars(r, "mr", m.ID, comments, fmt.Sprintf("/%s/%s/mrs/%d/react", p.Repo.OwnerName, p.Repo.Name, m.Number)) | ||
| 2331 | s.render(w, "mr.html", struct { | 2335 | s.render(w, "mr.html", struct { |
| 2332 | repoPage | 2336 | repoPage |
| 2333 | MR store.MR | 2337 | MR store.MR |
| @@ -2360,10 +2364,11 @@ func (s *Server) mrPage(w http.ResponseWriter, r *http.Request, previewForm stri | |||
| 2360 | LabelColors map[string]template.CSS | 2364 | LabelColors map[string]template.CSS |
| 2361 | Draft *draft | 2365 | Draft *draft |
| 2362 | Layout diffLayout | 2366 | Layout diffLayout |
| 2367 | Reactions map[int64]reactionBar | ||
| 2363 | }{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md), | 2368 | }{p, m, view, md(m.Body, m.BodyFormat), checks, combined, renderComments(comments, md), |
| 2364 | reviewRows, files, diffTruncated, stat, commits, commitsTotal, branches, s.canEditItem(r, p.Repo, m.Author), | 2369 | reviewRows, files, diffTruncated, stat, commits, commitsTotal, branches, s.canEditItem(r, p.Repo, m.Author), |
| 2365 | canWrite, unresolved, revisions, s.takeFlash(w, r), detachedThreads, stackedOn, stacked, supersedes, gates, | 2370 | canWrite, unresolved, revisions, s.takeFlash(w, r), detachedThreads, stackedOn, stacked, supersedes, gates, |
| 2366 | sourceGone(p, m), headMerged, headPruned, base, s.labelColors(p.Repo), d, layout}) | 2371 | sourceGone(p, m), headMerged, headPruned, base, s.labelColors(p.Repo), d, layout, bars}) |
| 2367 | } | 2372 | } |
| 2368 | 2373 | ||
| 2369 | // sourceGone reports whether an MR's source branch no longer exists: the | 2374 | // sourceGone reports whether an MR's source branch no longer exists: the |
internal/web/static/style.css +18
| @@ -1917,3 +1917,21 @@ svg.icon { vertical-align: -0.125em; } | |||
| 1917 | /* the mark is a link home too, so it is a target like the rest */ | 1917 | /* the mark is a link home too, so it is a target like the rest */ |
| 1918 | a.brand { width: var(--rail-hit); } | 1918 | a.brand { width: var(--rail-hit); } |
| 1919 | } | 1919 | } |
| 1920 | |||
| 1921 | /* ---- reactions ---- */ | ||
| 1922 | .reactions { display: flex; flex-wrap: wrap; gap: var(--sp-1); margin: 0; padding: var(--sp-2) var(--sp-4); } | ||
| 1923 | p.reactions { color: var(--muted); font-size: var(--fs-1); } | ||
| 1924 | article.comment .reactions { border-top: 1px solid var(--faint); } | ||
| 1925 | button.react, span.react { | ||
| 1926 | font: inherit; | ||
| 1927 | font-size: var(--fs-1); | ||
| 1928 | padding: 0 var(--sp-2); | ||
| 1929 | min-height: 24px; | ||
| 1930 | border: 1px solid var(--line); | ||
| 1931 | border-radius: var(--r-sm); | ||
| 1932 | background: transparent; | ||
| 1933 | color: var(--muted); | ||
| 1934 | } | ||
| 1935 | button.react { cursor: pointer; } | ||
| 1936 | button.react:hover { background: var(--surface); } | ||
| 1937 | button.react.mine { border-color: var(--accent); background: var(--surface); color: var(--fg); } | ||
internal/web/templates/issue.html +3 −1
| @@ -22,12 +22,14 @@ | |||
| 22 | {{if .BodyHTML}}<article class="comment"> | 22 | {{if .BodyHTML}}<article class="comment"> |
| 23 | <header class="commenthead"><strong><a href="/{{.Issue.Author}}">{{.Issue.Author}}</a></strong> <span class="when">{{when .Issue.CreatedAt}}</span></header> | 23 | <header class="commenthead"><strong><a href="/{{.Issue.Author}}">{{.Issue.Author}}</a></strong> <span class="when">{{when .Issue.CreatedAt}}</span></header> |
| 24 | <div class="rendered">{{.BodyHTML}}</div> | 24 | <div class="rendered">{{.BodyHTML}}</div> |
| 25 | </article>{{end}} | 25 | {{template "reactions" index .Reactions 0}} |
| 26 | </article>{{else}}{{template "reactions" index .Reactions 0}}{{end}} | ||
| 26 | {{range .Comments}} | 27 | {{range .Comments}} |
| 27 | {{if eq .Kind "system"}}<div class="syscomment">{{.BodyHTML}} <span class="when">{{when .CreatedAt}}</span></div> | 28 | {{if eq .Kind "system"}}<div class="syscomment">{{.BodyHTML}} <span class="when">{{when .CreatedAt}}</span></div> |
| 28 | {{else}}<article class="comment"> | 29 | {{else}}<article class="comment"> |
| 29 | <header class="commenthead"><strong><a href="/{{.Author}}">{{.Author}}</a></strong> <span class="when">{{when .CreatedAt}}</span></header> | 30 | <header class="commenthead"><strong><a href="/{{.Author}}">{{.Author}}</a></strong> <span class="when">{{when .CreatedAt}}</span></header> |
| 30 | <div class="rendered">{{.BodyHTML}}</div> | 31 | <div class="rendered">{{.BodyHTML}}</div> |
| 32 | {{template "reactions" index $.Reactions .ID}} | ||
| 31 | </article>{{end}} | 33 | </article>{{end}} |
| 32 | {{end}} | 34 | {{end}} |
| 33 | {{if .Viewer}} | 35 | {{if .Viewer}} |
internal/web/templates/layout.html +6
| @@ -182,6 +182,12 @@ | |||
| 182 | {{/* previewbtn is the Preview submit beside a markup form's own submit. | 182 | {{/* previewbtn is the Preview submit beside a markup form's own submit. |
| 183 | It posts the form to its usual action, which renders the body and | 183 | It posts the form to its usual action, which renders the body and |
| 184 | hands the page back instead of writing (#235). */}} | 184 | hands the page back instead of writing (#235). */}} |
| 185 | {{/* reactions is one item's reaction bar (reactionBar in httpd). A | ||
| 186 | signed-in viewer gets a button per reaction; anyone else the counts. */}} | ||
| 187 | {{define "reactions"}}{{if .Items}}{{if .Signed}}<form method="post" action="{{.Action}}" class="reactions">{{if .Comment}}<input type="hidden" name="comment" value="{{.Comment}}">{{end}} | ||
| 188 | {{range .Items}}<button type="submit" name="{{if .Me}}remove{{else}}add{{end}}" value="{{.Name}}" class="react{{if .Me}} mine{{end}}" aria-pressed="{{.Me}}" aria-label="{{.Label}}">{{.Emoji}}{{if .Count}} <span>{{.Count}}</span>{{end}}</button> | ||
| 189 | {{end}}</form>{{else}}<p class="reactions">{{range .Items}}<span class="react" aria-label="{{.Label}}">{{.Emoji}} {{.Count}}</span> {{end}}</p>{{end}}{{end}}{{end}} | ||
| 190 | |||
| 185 | {{define "previewbtn"}}<button type="submit" name="preview" value="1" class="btn">Preview</button>{{end}} | 191 | {{define "previewbtn"}}<button type="submit" name="preview" value="1" class="btn">Preview</button>{{end}} |
| 186 | 192 | ||
| 187 | {{/* previewblock shows a draft as it will read. The argument is the | 193 | {{/* previewblock shows a draft as it will read. The argument is the |
internal/web/templates/mr.html +3 −1
| @@ -37,12 +37,14 @@ | |||
| 37 | {{if .BodyHTML}}<article class="comment"> | 37 | {{if .BodyHTML}}<article class="comment"> |
| 38 | <header class="commenthead"><strong><a href="/{{.MR.Author}}">{{.MR.Author}}</a></strong></header> | 38 | <header class="commenthead"><strong><a href="/{{.MR.Author}}">{{.MR.Author}}</a></strong></header> |
| 39 | <div class="rendered">{{.BodyHTML}}</div> | 39 | <div class="rendered">{{.BodyHTML}}</div> |
| 40 | </article>{{end}} | 40 | {{template "reactions" index .Reactions 0}} |
| 41 | </article>{{else}}{{template "reactions" index .Reactions 0}}{{end}} | ||
| 41 | {{range .Comments}} | 42 | {{range .Comments}} |
| 42 | {{if eq .Kind "system"}}<div class="syscomment">{{.BodyHTML}} <span class="when">{{when .CreatedAt}}</span></div> | 43 | {{if eq .Kind "system"}}<div class="syscomment">{{.BodyHTML}} <span class="when">{{when .CreatedAt}}</span></div> |
| 43 | {{else}}<article class="comment"> | 44 | {{else}}<article class="comment"> |
| 44 | <header class="commenthead"><strong><a href="/{{.Author}}">{{.Author}}</a></strong> <span class="when">{{when .CreatedAt}}</span></header> | 45 | <header class="commenthead"><strong><a href="/{{.Author}}">{{.Author}}</a></strong> <span class="when">{{when .CreatedAt}}</span></header> |
| 45 | <div class="rendered">{{.BodyHTML}}</div> | 46 | <div class="rendered">{{.BodyHTML}}</div> |
| 47 | {{template "reactions" index $.Reactions .ID}} | ||
| 46 | </article>{{end}} | 48 | </article>{{end}} |
| 47 | {{end}} | 49 | {{end}} |
| 48 | {{if .DetachedThreads}}<h2>Threads on earlier revisions</h2> | 50 | {{if .DetachedThreads}}<h2>Threads on earlier revisions</h2> |