package views import ( "strconv" "strings" "hearthforge/internal/config" "hearthforge/internal/db" "hearthforge/internal/util" g "maragu.dev/gomponents" . "maragu.dev/gomponents/html" ) // LabelsQueryParam builds the "&labels=1,2" suffix the issue tabs reuse. func LabelsQueryParam(ids []int64) string { if len(ids) == 0 { return "" } parts := make([]string, len(ids)) for i, id := range ids { parts[i] = strconv.FormatInt(id, 10) } return "&labels=" + strings.Join(parts, ",") } // CanEdit reports whether user may edit or delete an issue, a patch or one // of their comments. Admins always may. Authors may only while it is open. func CanEdit(user *db.SessionUser, authorID *int64, status string) bool { if user == nil { return false } return user.IsAdmin || (authorID != nil && *authorID == user.ID && status == "open") } func labelBadge(label db.Label) g.Node { return Span(Class("label-badge"), Style("background:"+label.Color+";color:"+util.LabelTextColor(label.Color)), g.Text(label.Name)) } // IssueList renders the issue index with status tabs, label filter and pages. func IssueList(cfg *config.Config, user *db.SessionUser, repo *db.Repo, issues []db.Issue, status string, counts map[string]int, page PageInfo, repoLabels []db.Label, selectedLabelIDs []int64, labelsByIssueID map[int64][]db.Label, ) g.Node { labelsParam := LabelsQueryParam(selectedLabelIDs) tab := func(key, label string) g.Node { class := "list-tab" if status == key { class += " active" } return A(Href("/"+repo.Name+"/issues?status="+key+labelsParam), Class(class), g.Text(label+" "), g.If(counts[key] > 0, Span(Class("tab-count"), g.Text(strconv.Itoa(counts[key])))), ) } selected := make(map[int64]bool, len(selectedLabelIDs)) for _, id := range selectedLabelIDs { selected[id] = true } return Layout(Page{Title: "Issues — " + repo.Name, User: user, Cfg: cfg}, Div(Class("container"), RepoHeader(repo), RepoNav(cfg, repo, "issues", user), Div(Class("list-header"), Div(Class("list-header-tabs"), tab("open", "Open"), tab("completed", "Completed"), tab("closed", "Closed"), ), Div(Class("list-header-actions"), g.If(len(repoLabels) > 0, Details(Class("label-filter"), Summary(Class("btn btn-secondary btn-sm"), g.Text("Filter by label"), g.If(len(selectedLabelIDs) > 0, Span(Class("tab-count"), g.Text(strconv.Itoa(len(selectedLabelIDs))))), ), Div(Class("label-filter-popup"), Form(Method("GET"), Action("/"+repo.Name+"/issues"), Input(Type("hidden"), Name("status"), Value(status)), Div(Class("label-filter-list"), g.Map(repoLabels, func(label db.Label) g.Node { return Label(Class("label-filter-item"), Input(Type("checkbox"), Name("labels"), Value(strconv.FormatInt(label.ID, 10)), g.If(selected[label.ID], Checked())), labelBadge(label), ) }), ), Div(Class("label-filter-actions"), Button(Type("submit"), Class("btn btn-primary btn-sm"), g.Text("Apply")), A(Href("/"+repo.Name+"/issues?status="+status), Class("btn btn-secondary btn-sm"), g.Text("Clear")), ), ), ), )), g.If(user != nil, A(Href("/"+repo.Name+"/issues/new"), Class("btn btn-primary btn-sm"), g.Text("New issue"))), ), ), g.If(len(issues) == 0, Div(Class("empty-state"), P(g.Text("No "+status+" issues.")))), g.If(len(issues) > 0, Ul(Class("issue-list"), g.Map(issues, func(issue db.Issue) g.Node { labels := labelsByIssueID[issue.ID] return Li(Class("issue-item"), Div(Class("issue-main"), Span(Class("issue-status-dot "+issue.Status)), A(Href("/"+repo.Name+"/issues/"+strconv.FormatInt(issue.Number, 10)), Class("issue-title"), g.Text(issue.Title)), Span(Class("issue-number"), g.Text("#"+strconv.FormatInt(issue.Number, 10))), ), g.If(len(labels) > 0, Div(Class("issue-labels"), g.Map(labels, labelBadge))), Div(Class("issue-meta"), Avatar(issue.AuthorID, issue.AuthorAvatarVersion, 20), Span(Class("issue-author"), g.Text("opened by "+util.DisplayName(deref(issue.AuthorUsername), cfg.OwnerDisplayName))), g.El("time", DateTime(issue.CreatedAt), g.Text(util.FormatDate(issue.CreatedAt))), ), ) }), )), Pagination(page), ), ) } // NewIssue renders the issue creation form. func NewIssue(cfg *config.Config, user *db.SessionUser, repo *db.Repo, errMsg, template string, labels []db.Label, ) g.Node { showLabels := len(labels) > 0 && (user.IsAdmin || repo.AllowUserLabels) return Layout(Page{Title: "New issue — " + repo.Name, User: user, Cfg: cfg}, Div(Class("container"), RepoHeader(repo), RepoNav(cfg, repo, "issues", user), H2(Class("section-title"), g.Text("New issue")), g.If(errMsg != "", P(Class("form-error"), g.Text(errMsg))), Form(Method("POST"), Action("/"+repo.Name+"/issues"), Class("form-card"), Div(Class("form-group"), Label(For("title"), g.Text("Title")), Input(ID("title"), Name("title"), Type("text"), Required(), MaxLength(strconv.Itoa(cfg.MaxTitleBytes)), Placeholder("Short, descriptive title")), ), Div(Class("form-group"), Label(For("body"), g.Text("Description "), Span(Class("text-muted"), g.Text("(Markdown supported, optional)"))), Textarea(ID("body"), Name("body"), Rows("10"), MaxLength(strconv.Itoa(cfg.MaxTextBodyBytes)), Placeholder("Describe the issue..."), g.Text(template)), ), g.If(showLabels, Div(Class("form-group"), Span(Class("form-group-label"), g.Text("Labels")), Div(Class("label-checkbox-list"), g.Map(labels, func(label db.Label) g.Node { return Label(Class("label-checkbox-item"), Input(Type("checkbox"), Name("label_ids"), Value(strconv.FormatInt(label.ID, 10))), labelBadge(label), ) }), ), )), Div(Class("form-actions"), Button(Type("submit"), Class("btn btn-primary"), g.Text("Submit issue")), A(Href("/"+repo.Name+"/issues"), Class("btn"), g.Text("Cancel")), ), ), ), ) } // IssueDetail renders one issue with its labels, body, reactions and comments. func IssueDetail(cfg *config.Config, user *db.SessionUser, repo *db.Repo, issue *db.Issue, bodyHTML string, comments []ThreadComment, reactions []ReactionCount, commentReactions map[int64][]ReactionCount, issueLabels, repoLabels []db.Label, ) g.Node { baseURL := "/" + repo.Name + "/issues/" + strconv.FormatInt(issue.Number, 10) isAuthor := user != nil && issue.AuthorID != nil && user.ID == *issue.AuthorID canEditIssue := CanEdit(user, issue.AuthorID, issue.Status) canManageLabels := user != nil && (user.IsAdmin || (repo.AllowUserLabels && isAuthor)) isAdmin := user != nil && user.IsAdmin closeLabel := "Reopen issue" if issue.Status == "open" { closeLabel = "Close issue" } return Layout(Page{Title: issue.Title + " — " + repo.Name, User: user, Cfg: cfg}, Div(Class("container"), RepoHeader(repo), RepoNav(cfg, repo, "issues", user), Div(Class("issue-detail"), Div(Class("issue-detail-header"), Span(Class("issue-number"), g.Text("#"+strconv.FormatInt(issue.Number, 10))), EditableTitle(cfg, issue.Title, canEditIssue, baseURL+"/edit", "edit_body", issue.Body), Span(Class("issue-badge "+issue.Status), g.Text(issue.Status)), g.If(canEditIssue, Div(Class("issue-detail-meta-actions"), g.If(isAdmin && issue.Status == "open", Form(Method("POST"), Action(baseURL+"/complete"), Class("inline-form"), Button(Type("submit"), Class("btn btn-sm btn-primary"), g.Text("Completed")), )), g.If(isAdmin, Form(Method("POST"), Action(baseURL+"/close"), Class("inline-form"), Button(Type("submit"), Class("btn btn-sm"), g.Text(closeLabel)), )), Details(Class("confirm-details"), Summary(Class("btn btn-sm btn-danger"), g.Text("Delete")), Div(Class("confirm-popup"), g.Text("Permanently delete this issue?"), Form(Method("POST"), Action(baseURL+"/delete"), Class("inline-form"), Button(Type("submit"), Class("btn btn-sm btn-danger"), g.Text("Yes, delete")), ), ), ), )), ), LabelBadges(issueLabels, repoLabels, canManageLabels, baseURL), Div(Class("timeline-item"), Div(Class("timeline-author"), Avatar(issue.AuthorID, issue.AuthorAvatarVersion, 24), Strong(g.Text(util.DisplayName(deref(issue.AuthorUsername), cfg.OwnerDisplayName))), Div(Class("timeline-author-right"), g.If(canEditIssue, Details(Class("inline-edit-details"), Summary(Class("btn btn-xs"), Span(Class("when-closed"), g.Text("Edit")), Span(Class("when-open"), g.Text("Stop editing")), ), )), DateWithEdited(issue.CreatedAt, issue.EditedAt), ), ), g.If(canEditIssue, Div(Class("inline-edit-form-area"), Form(Method("POST"), Action(baseURL+"/edit"), Class("inline-edit-form"), Input(Type("hidden"), Name("title"), Value(issue.Title)), Div(Class("form-group"), Textarea(Class("form-input"), ID("edit-issue-body"), Name("edit_body"), Rows("6"), MaxLength(strconv.Itoa(cfg.MaxTextBodyBytes)), g.Text(issue.Body)), ), Div(Class("form-actions"), Button(Type("submit"), Class("btn btn-sm btn-primary"), g.Text("Save")), ), ), )), // The markdown renderer already sanitised this HTML. Div(Class("timeline-body markdown-body"), g.If(bodyHTML != "", g.Raw(bodyHTML)), g.If(bodyHTML == "", Em(Class("text-muted"), g.Text("No description provided."))), ), ReactionBar(reactions, baseURL+"/react", 0, user), ), CommentThread(cfg, user, comments, commentReactions, baseURL, issue.Status), ), ), ) }