issues.go
⎇
Raw
1package views
2
3import (
4 "strconv"
5 "strings"
6
7 "hearthforge/internal/config"
8 "hearthforge/internal/db"
9 "hearthforge/internal/util"
10
11 g "maragu.dev/gomponents"
12 . "maragu.dev/gomponents/html"
13)
14
15// LabelsQueryParam builds the "&labels=1,2" suffix the issue tabs reuse.
16func LabelsQueryParam(ids []int64) string {
17 if len(ids) == 0 {
18 return ""
19 }
20 parts := make([]string, len(ids))
21 for i, id := range ids {
22 parts[i] = strconv.FormatInt(id, 10)
23 }
24 return "&labels=" + strings.Join(parts, ",")
25}
26
27func labelBadge(label db.Label) g.Node {
28 return Span(Class("label-badge"),
29 Style("background:"+label.Color+";color:"+util.LabelTextColor(label.Color)),
30 g.Text(label.Name))
31}
32
33// IssueList renders the issue index with status tabs, label filter and pages.
34func IssueList(cfg *config.Config, user *db.SessionUser, repo *db.Repo, issues []db.Issue,
35 status string, counts map[string]int, page PageInfo, repoLabels []db.Label,
36 selectedLabelIDs []int64, labelsByIssueID map[int64][]db.Label,
37) g.Node {
38 labelsParam := LabelsQueryParam(selectedLabelIDs)
39 tab := func(key, label string) g.Node {
40 class := "list-tab"
41 if status == key {
42 class += " active"
43 }
44 return A(Href("/"+repo.Name+"/issues?status="+key+labelsParam), Class(class),
45 g.Text(label+" "),
46 g.If(counts[key] > 0, Span(Class("tab-count"), g.Text(strconv.Itoa(counts[key])))),
47 )
48 }
49 selected := make(map[int64]bool, len(selectedLabelIDs))
50 for _, id := range selectedLabelIDs {
51 selected[id] = true
52 }
53 return Layout(Page{Title: "Issues — " + repo.Name, User: user, Cfg: cfg},
54 Div(Class("container"),
55 RepoHeader(repo),
56 RepoNav(cfg, repo, "issues", user),
57 Div(Class("list-header"),
58 Div(Class("list-header-tabs"),
59 tab("open", "Open"),
60 tab("completed", "Completed"),
61 tab("closed", "Closed"),
62 ),
63 Div(Class("list-header-actions"),
64 g.If(len(repoLabels) > 0, Details(Class("label-filter"),
65 Summary(Class("btn btn-secondary btn-sm"),
66 g.Text("Filter by label"),
67 g.If(len(selectedLabelIDs) > 0,
68 Span(Class("tab-count"), g.Text(strconv.Itoa(len(selectedLabelIDs))))),
69 ),
70 Div(Class("label-filter-popup"),
71 Form(Method("GET"), Action("/"+repo.Name+"/issues"),
72 Input(Type("hidden"), Name("status"), Value(status)),
73 Div(Class("label-filter-list"),
74 g.Map(repoLabels, func(label db.Label) g.Node {
75 return Label(Class("label-filter-item"),
76 Input(Type("checkbox"), Name("labels"),
77 Value(strconv.FormatInt(label.ID, 10)),
78 g.If(selected[label.ID], Checked())),
79 labelBadge(label),
80 )
81 }),
82 ),
83 Div(Class("label-filter-actions"),
84 Button(Type("submit"), Class("btn btn-primary btn-sm"), g.Text("Apply")),
85 A(Href("/"+repo.Name+"/issues?status="+status),
86 Class("btn btn-secondary btn-sm"), g.Text("Clear")),
87 ),
88 ),
89 ),
90 )),
91 g.If(user != nil, A(Href("/"+repo.Name+"/issues/new"),
92 Class("btn btn-primary btn-sm"), g.Text("New issue"))),
93 ),
94 ),
95 g.If(len(issues) == 0, Div(Class("empty-state"),
96 P(g.Text("No "+status+" issues.")))),
97 g.If(len(issues) > 0, Ul(Class("issue-list"),
98 g.Map(issues, func(issue db.Issue) g.Node {
99 labels := labelsByIssueID[issue.ID]
100 return Li(Class("issue-item"),
101 Div(Class("issue-main"),
102 Span(Class("issue-status-dot "+issue.Status)),
103 A(Href("/"+repo.Name+"/issues/"+strconv.FormatInt(issue.Number, 10)),
104 Class("issue-title"), g.Text(issue.Title)),
105 Span(Class("issue-number"), g.Text("#"+strconv.FormatInt(issue.Number, 10))),
106 ),
107 g.If(len(labels) > 0, Div(Class("issue-labels"),
108 g.Map(labels, labelBadge))),
109 Div(Class("issue-meta"),
110 Avatar(issue.AuthorID, issue.AuthorAvatarVersion, 20),
111 Span(Class("issue-author"),
112 g.Text("opened by "+util.DisplayName(deref(issue.AuthorUsername), cfg.OwnerDisplayName))),
113 g.El("time", DateTime(issue.CreatedAt), g.Text(util.FormatDate(issue.CreatedAt))),
114 ),
115 )
116 }),
117 )),
118 Pagination(page),
119 ),
120 )
121}
122
123// NewIssue renders the issue creation form.
124func NewIssue(cfg *config.Config, user *db.SessionUser, repo *db.Repo, errMsg, template string,
125 labels []db.Label,
126) g.Node {
127 showLabels := len(labels) > 0 && (user.IsAdmin || repo.AllowUserLabels)
128 return Layout(Page{Title: "New issue — " + repo.Name, User: user, Cfg: cfg},
129 Div(Class("container"),
130 RepoHeader(repo),
131 RepoNav(cfg, repo, "issues", user),
132 H2(Class("section-title"), g.Text("New issue")),
133 g.If(errMsg != "", P(Class("form-error"), g.Text(errMsg))),
134 Form(Method("POST"), Action("/"+repo.Name+"/issues"), Class("form-card"),
135 Div(Class("form-group"),
136 Label(For("title"), g.Text("Title")),
137 Input(ID("title"), Name("title"), Type("text"), Required(),
138 MaxLength(strconv.Itoa(cfg.MaxTitleBytes)),
139 Placeholder("Short, descriptive title")),
140 ),
141 Div(Class("form-group"),
142 Label(For("body"), g.Text("Description "),
143 Span(Class("text-muted"), g.Text("(Markdown supported, optional)"))),
144 Textarea(ID("body"), Name("body"), Rows("10"),
145 MaxLength(strconv.Itoa(cfg.MaxTextBodyBytes)),
146 Placeholder("Describe the issue..."), g.Text(template)),
147 ),
148 g.If(showLabels, Div(Class("form-group"),
149 Span(Class("form-group-label"), g.Text("Labels")),
150 Div(Class("label-checkbox-list"),
151 g.Map(labels, func(label db.Label) g.Node {
152 return Label(Class("label-checkbox-item"),
153 Input(Type("checkbox"), Name("label_ids"),
154 Value(strconv.FormatInt(label.ID, 10))),
155 labelBadge(label),
156 )
157 }),
158 ),
159 )),
160 Div(Class("form-actions"),
161 Button(Type("submit"), Class("btn btn-primary"), g.Text("Submit issue")),
162 A(Href("/"+repo.Name+"/issues"), Class("btn"), g.Text("Cancel")),
163 ),
164 ),
165 ),
166 )
167}
168
169// IssueDetail renders one issue with its labels, body, reactions and comments.
170func IssueDetail(cfg *config.Config, user *db.SessionUser, repo *db.Repo, issue *db.Issue,
171 bodyHTML string, comments []ThreadComment, reactions []ReactionCount,
172 commentReactions map[int64][]ReactionCount, issueLabels, repoLabels []db.Label,
173) g.Node {
174 baseURL := "/" + repo.Name + "/issues/" + strconv.FormatInt(issue.Number, 10)
175 isAuthor := user != nil && issue.AuthorID != nil && user.ID == *issue.AuthorID
176 canEditIssue := user != nil && (user.IsAdmin || (isAuthor && issue.Status == "open"))
177 canManageLabels := user != nil && (user.IsAdmin || (repo.AllowUserLabels && isAuthor))
178 isAdmin := user != nil && user.IsAdmin
179 closeLabel := "Reopen issue"
180 if issue.Status == "open" {
181 closeLabel = "Close issue"
182 }
183 return Layout(Page{Title: issue.Title + " — " + repo.Name, User: user, Cfg: cfg},
184 Div(Class("container"),
185 RepoHeader(repo),
186 RepoNav(cfg, repo, "issues", user),
187 Div(Class("issue-detail"),
188 Div(Class("issue-detail-header"),
189 Span(Class("issue-number"), g.Text("#"+strconv.FormatInt(issue.Number, 10))),
190 EditableTitle(cfg, issue.Title, canEditIssue, baseURL+"/edit", "edit_body", issue.Body),
191 Span(Class("issue-badge "+issue.Status), g.Text(issue.Status)),
192 g.If(canEditIssue, Div(Class("issue-detail-meta-actions"),
193 g.If(isAdmin && issue.Status == "open",
194 Form(Method("POST"), Action(baseURL+"/complete"), Class("inline-form"),
195 Button(Type("submit"), Class("btn btn-sm btn-primary"), g.Text("Completed")),
196 )),
197 g.If(isAdmin,
198 Form(Method("POST"), Action(baseURL+"/close"), Class("inline-form"),
199 Button(Type("submit"), Class("btn btn-sm"), g.Text(closeLabel)),
200 )),
201 Details(Class("confirm-details"),
202 Summary(Class("btn btn-sm btn-danger"), g.Text("Delete")),
203 Div(Class("confirm-popup"),
204 g.Text("Permanently delete this issue?"),
205 Form(Method("POST"), Action(baseURL+"/delete"), Class("inline-form"),
206 Button(Type("submit"), Class("btn btn-sm btn-danger"), g.Text("Yes, delete")),
207 ),
208 ),
209 ),
210 )),
211 ),
212
213 LabelBadges(issueLabels, repoLabels, canManageLabels, baseURL),
214
215 Div(Class("timeline-item"),
216 Div(Class("timeline-author"),
217 Avatar(issue.AuthorID, issue.AuthorAvatarVersion, 24),
218 Strong(g.Text(util.DisplayName(deref(issue.AuthorUsername), cfg.OwnerDisplayName))),
219 Div(Class("timeline-author-right"),
220 g.If(canEditIssue, Details(Class("inline-edit-details"),
221 Summary(Class("btn btn-xs"),
222 Span(Class("when-closed"), g.Text("Edit")),
223 Span(Class("when-open"), g.Text("Stop editing")),
224 ),
225 )),
226 DateWithEdited(issue.CreatedAt, issue.EditedAt),
227 ),
228 ),
229 g.If(canEditIssue, Div(Class("inline-edit-form-area"),
230 Form(Method("POST"), Action(baseURL+"/edit"), Class("inline-edit-form"),
231 Input(Type("hidden"), Name("title"), Value(issue.Title)),
232 Div(Class("form-group"),
233 Textarea(Class("form-input"), ID("edit-issue-body"), Name("edit_body"),
234 Rows("6"), MaxLength(strconv.Itoa(cfg.MaxTextBodyBytes)),
235 g.Text(issue.Body)),
236 ),
237 Div(Class("form-actions"),
238 Button(Type("submit"), Class("btn btn-sm btn-primary"), g.Text("Save")),
239 ),
240 ),
241 )),
242 // The markdown renderer already sanitised this HTML.
243 Div(Class("timeline-body markdown-body"),
244 g.If(bodyHTML != "", g.Raw(bodyHTML)),
245 g.If(bodyHTML == "", Em(Class("text-muted"), g.Text("No description provided."))),
246 ),
247 ReactionBar(reactions, baseURL+"/react", 0, user),
248 ),
249
250 CommentThread(cfg, user, comments, commentReactions, baseURL, issue.Status),
251 ),
252 ),
253 )
254}
255