package views import ( "strconv" "strings" "hearthforge/internal/config" "hearthforge/internal/db" "hearthforge/internal/util" g "maragu.dev/gomponents" . "maragu.dev/gomponents/html" ) // ReleaseListItem is one row of the release list. The handler renders the // notes, so the views package stays free of the markdown renderer. type ReleaseListItem struct { Release db.Release AssetCount int NotesPlain string NotesHTML string } // ReleaseFormValues repopulates the new release form after a validation error. type ReleaseFormValues struct { CreateTag bool TagName string Revision string Name string Notes string IncludeSourceCode bool } // SourceArchive is one generated source code archive on disk. type SourceArchive struct { Format string Filename string Size int64 } // ReleaseList renders the releases of one repository. func ReleaseList(cfg *config.Config, user *db.SessionUser, repo *db.Repo, releases []ReleaseListItem, page PageInfo, ) g.Node { return Layout(Page{Title: "Releases — " + repo.Name, User: user, Cfg: cfg}, Div(Class("container"), RepoHeader(repo), RepoNav(cfg, repo, "releases", user), Div(Class("list-header"), H2(Class("list-heading"), g.Text("Releases")), g.If(user != nil && user.IsAdmin, A(Href("/"+repo.Name+"/releases/new"), Class("btn btn-primary btn-sm"), g.Text("New release"))), ), g.If(len(releases) == 0, Div(Class("empty-state"), P(g.Text("No releases yet.")))), g.If(len(releases) > 0, Ul(Class("issue-list"), g.Map(releases, func(item ReleaseListItem) g.Node { rel := item.Release id := strconv.FormatInt(rel.ID, 10) return Li(Class("issue-item"), Div(Class("release-item-header"), Div(Class("release-item-main"), A(Href("/"+repo.Name+"/releases/"+id), Class("release-item-title"), Style("view-transition-name: release-title-"+id), g.Text(rel.Name)), Div(Class("release-item-meta"), g.If(deref(rel.TagName) != "", A(Href("/"+repo.Name+"/tree/"+EscapePath(deref(rel.TagName))), Class("badge"), g.Text(deref(rel.TagName)))), g.If(item.AssetCount > 0, Span(g.Text(strconv.Itoa(item.AssetCount)+" "+assetWord(item.AssetCount)))), g.If(rel.IncludeSourceCode, Span(g.Text("source archives"))), ), ), Div(Class("release-item-date"), g.El("time", DateTime(rel.CreatedAt), g.Text(util.FormatDate(rel.CreatedAt))), ), ), g.If(deref(rel.Notes) != "", releaseNotes(item)), ) }), )), Pagination(page), ), ) } // releaseNotes renders the notes of a list row. Short notes show in full. // Longer notes show a five-line preview behind a "Show more" toggle. func releaseNotes(item ReleaseListItem) g.Node { long := strings.Count(item.NotesPlain, "\n") >= 5 || len(item.NotesPlain) > 320 body := Div(Class("markdown-body"), g.Raw(item.NotesHTML)) if long { body = Details(Class("notes-expand"), Summary(Class("notes-toggle"), Div(Class("notes-preview"), g.Text(item.NotesPlain)), Span(Class("notes-toggle-label")), ), Div(Class("notes-full markdown-body"), g.Raw(item.NotesHTML)), ) } return Div(Class("release-notes-section"), P(Class("release-notes-label"), g.Text("Release Notes")), body, ) } // assetWord pluralizes the asset count label. func assetWord(n int) string { if n == 1 { return "asset" } return "assets" } // NewRelease renders the release creation form. errMsg is empty on first load. func NewRelease(cfg *config.Config, user *db.SessionUser, repo *db.Repo, errMsg string, values *ReleaseFormValues, ) g.Node { v := ReleaseFormValues{Revision: repo.DefaultBranch} if values != nil { v = *values } tagFieldsStyle := "display:none" if v.CreateTag { tagFieldsStyle = "" } return Layout(Page{Title: "New Release — " + repo.Name, User: user, Cfg: cfg}, Div(Class("container"), RepoHeader(repo), RepoNav(cfg, repo, "releases", user), Div(Class("form-page"), H2(Class("page-title"), g.Text("New Release")), g.If(errMsg != "", Div(Class("form-error"), g.Text(errMsg))), Form(Method("post"), Action("/"+repo.Name+"/releases"), EncType("multipart/form-data"), Class("form"), Div(Class("form-group"), Label(Class("form-label"), For("name"), g.Text("Release title "), Span(Class("form-required"), g.Text("*"))), Input(Type("text"), ID("name"), Name("name"), Class("form-input"), Required(), MaxLength(strconv.Itoa(cfg.MaxTitleBytes)), Value(v.Name), Placeholder("e.g. Version 1.0 — Initial Release")), ), Div(Class("form-group"), Label(Class("checkbox-label"), Input(Type("checkbox"), ID("create_tag"), Name("create_tag"), Value("on"), g.If(v.CreateTag, Checked()), g.Attr("data-toggle-target", "tag-fields-group")), Span(g.Text("Create a git tag for this release")), ), ), Div(ID("tag-fields-group"), Style(tagFieldsStyle), Div(Class("form-group"), Label(Class("form-label"), For("tag_name"), g.Text("Tag name "), Span(Class("form-required"), g.Text("*"))), Input(Type("text"), ID("tag_name"), Name("tag_name"), Class("form-input"), MaxLength(strconv.Itoa(cfg.MaxTitleBytes)), Pattern(`[a-zA-Z0-9._\-+]+`), Title("Letters, digits, dots, hyphens, underscores, and plus signs only"), Value(v.TagName), Placeholder("v1.0.0")), ), Div(Class("form-group"), Label(Class("form-label"), For("revision"), g.Text("Revision "), Span(Class("form-required"), g.Text("*"))), Input(Type("text"), ID("revision"), Name("revision"), Class("form-input monospace"), Value(v.Revision), Placeholder("branch, tag, or commit")), P(Class("form-hint"), g.Text("Branch, tag, or commit to tag.")), ), ), Div(Class("form-group"), Label(Class("form-label"), For("notes"), g.Text("Release notes")), Textarea(ID("notes"), Name("notes"), Class("form-input form-textarea"), Rows("8"), MaxLength(strconv.Itoa(cfg.MaxTextBodyBytes)), g.Text(v.Notes)), ), Div(Class("form-group"), Label(Class("checkbox-label"), Input(Type("checkbox"), Name("include_source_code"), Value("on"), g.If(v.IncludeSourceCode, Checked())), Span(g.Text("Include source code archives (zip, tar.gz, tar.zst)")), ), ), Div(Class("form-group"), Label(Class("form-label"), For("files"), g.Text("Attach files")), Input(Type("file"), ID("files"), Name("files"), Class("form-input"), Multiple()), P(Class("form-hint"), g.Text("You can attach multiple files to this release.")), ), Div(Class("form-actions"), Button(Type("submit"), Class("btn btn-primary"), g.Text("Create release")), A(Href("/"+repo.Name+"/releases"), Class("btn btn-secondary"), g.Text("Cancel")), ), ), ), ), ) } // ReleaseDetail renders one release with its notes and downloads. func ReleaseDetail(cfg *config.Config, user *db.SessionUser, repo *db.Repo, release *db.Release, notesHTML string, assets []db.ReleaseAsset, sourceArchives []SourceArchive, ) g.Node { id := strconv.FormatInt(release.ID, 10) hasDownloads := len(sourceArchives) > 0 || len(assets) > 0 return Layout(Page{Title: release.Name + " — " + repo.Name, User: user, Cfg: cfg}, Div(Class("container"), RepoHeader(repo), RepoNav(cfg, repo, "releases", user), Div(Class("release-detail"), Div(Class("release-header"), Div(Class("release-item-header"), Div(Class("release-item-main"), H2(Class("page-title"), Style("view-transition-name: release-title-"+id), g.Text(release.Name)), ), Div(Class("release-item-date"), g.If(deref(release.TagName) != "", A(Href("/"+repo.Name+"/tree/"+EscapePath(deref(release.TagName))), Class("badge"), g.Text(deref(release.TagName)))), g.El("time", DateTime(release.CreatedAt), g.Text(util.FormatDate(release.CreatedAt))), ), ), ), g.If(notesHTML != "", Div(Class("markdown-body release-notes"), g.Raw(notesHTML))), g.If(hasDownloads, Div(Class("release-assets"), H3(Class("release-assets-heading"), g.Text("Downloads")), Ul(Class("asset-list"), g.Map(assets, func(a db.ReleaseAsset) g.Node { return Li(Class("asset-item"), A(Href("/"+repo.Name+"/releases/"+id+"/assets/"+EscapePath(a.Filename)), Class("asset-name"), g.Text(a.Filename)), Span(Class("asset-size"), g.Text(util.FormatBytes(a.Size))), ) }), g.Map(sourceArchives, func(a SourceArchive) g.Node { return Li(Class("asset-item"), A(Href("/"+repo.Name+"/releases/"+id+"/source/"+EscapePath(a.Filename)), Class("asset-name"), g.Text(a.Filename)), Span(Class("asset-meta"), g.Text("Source code ("+a.Format+")")), Span(Class("asset-size"), g.Text(util.FormatBytes(a.Size))), ) }), ), )), Div(Class("release-back"), A(Href("/"+repo.Name+"/releases"), Class("btn btn-secondary btn-sm"), g.Text("← All releases")), g.If(user != nil && user.IsAdmin, Details(Class("confirm-details"), Summary(Class("btn btn-sm btn-danger"), g.Text("Delete release")), Div(Class("confirm-popup"), g.Text("Permanently delete this release and all its files?"), Form(Method("POST"), Action("/"+repo.Name+"/releases/"+id+"/delete"), Class("inline-form"), Button(Type("submit"), Class("btn btn-sm btn-danger"), g.Text("Yes, delete")), ), ), )), ), ), ), ) }