releases.go
⎇
Raw
1package views
2
3import (
4 "strconv"
5
6 "hearthforge/internal/config"
7 "hearthforge/internal/db"
8 "hearthforge/internal/util"
9
10 g "maragu.dev/gomponents"
11 . "maragu.dev/gomponents/html"
12)
13
14// ReleaseListItem is one row of the release list. The handler renders the
15// notes, so the views package stays free of the markdown renderer.
16type ReleaseListItem struct {
17 Release db.Release
18 AssetCount int
19 NotesPlain string
20 NotesHTML string
21}
22
23// ReleaseFormValues repopulates the new release form after a validation error.
24type ReleaseFormValues struct {
25 CreateTag bool
26 TagName string
27 Revision string
28 Name string
29 Notes string
30 IncludeSourceCode bool
31}
32
33// SourceArchive is one generated source code archive on disk.
34type SourceArchive struct {
35 Format string
36 Filename string
37 Size int64
38}
39
40// ReleaseList renders the releases of one repository.
41func ReleaseList(cfg *config.Config, user *db.SessionUser, repo *db.Repo,
42 releases []ReleaseListItem, page PageInfo,
43) g.Node {
44 return Layout(Page{Title: "Releases — " + repo.Name, User: user, Cfg: cfg},
45 Div(Class("container"),
46 RepoHeader(repo),
47 RepoNav(repo, "releases", user),
48 Div(Class("list-header"),
49 H2(Class("list-heading"), g.Text("Releases")),
50 g.If(user != nil && user.IsAdmin,
51 A(Href("/"+repo.Name+"/releases/new"), Class("btn btn-primary btn-sm"),
52 g.Text("New release"))),
53 ),
54 g.If(len(releases) == 0, Div(Class("empty-state"), P(g.Text("No releases yet.")))),
55 g.If(len(releases) > 0, Ul(Class("issue-list"),
56 g.Map(releases, func(item ReleaseListItem) g.Node {
57 rel := item.Release
58 id := strconv.FormatInt(rel.ID, 10)
59 return Li(Class("issue-item"),
60 Div(Class("release-item-header"),
61 Div(Class("release-item-main"),
62 A(Href("/"+repo.Name+"/releases/"+id), Class("release-item-title"),
63 Style("view-transition-name: release-title-"+id),
64 g.Text(rel.Name)),
65 Div(Class("release-item-meta"),
66 g.If(item.AssetCount > 0,
67 Span(g.Text(strconv.Itoa(item.AssetCount)+" "+assetWord(item.AssetCount)))),
68 g.If(rel.IncludeSourceCode, Span(g.Text("source archives"))),
69 ),
70 ),
71 Div(Class("release-item-date"),
72 g.If(deref(rel.TagName) != "",
73 A(Href("/"+repo.Name+"/tree/"+EscapePath(deref(rel.TagName))), Class("badge"),
74 g.Text(deref(rel.TagName)))),
75 g.El("time", DateTime(rel.CreatedAt), g.Text(util.FormatDate(rel.CreatedAt))),
76 ),
77 ),
78 g.If(deref(rel.Notes) != "", Div(Class("release-notes-section"),
79 P(Class("release-notes-label"), g.Text("Release Notes")),
80 Details(Class("notes-expand"),
81 Summary(Class("notes-toggle"),
82 Div(Class("notes-preview"), g.Text(item.NotesPlain)),
83 Span(Class("notes-toggle-label")),
84 ),
85 Div(Class("notes-full markdown-body"), g.Raw(item.NotesHTML)),
86 ),
87 )),
88 )
89 }),
90 )),
91 Pagination(page),
92 ),
93 )
94}
95
96// assetWord pluralizes the asset count label.
97func assetWord(n int) string {
98 if n == 1 {
99 return "asset"
100 }
101 return "assets"
102}
103
104// NewRelease renders the release creation form. errMsg is empty on first load.
105func NewRelease(cfg *config.Config, user *db.SessionUser, repo *db.Repo,
106 errMsg string, values *ReleaseFormValues,
107) g.Node {
108 v := ReleaseFormValues{Revision: repo.DefaultBranch}
109 if values != nil {
110 v = *values
111 }
112 tagFieldsStyle := "display:none"
113 if v.CreateTag {
114 tagFieldsStyle = ""
115 }
116 return Layout(Page{Title: "New Release — " + repo.Name, User: user, Cfg: cfg},
117 Div(Class("container"),
118 RepoHeader(repo),
119 RepoNav(repo, "releases", user),
120 Div(Class("form-page"),
121 H2(Class("page-title"), g.Text("New Release")),
122 g.If(errMsg != "", Div(Class("form-error"), g.Text(errMsg))),
123 Form(Method("post"), Action("/"+repo.Name+"/releases"),
124 EncType("multipart/form-data"), Class("form"),
125 Div(Class("form-group"),
126 Label(Class("form-label"), For("name"),
127 g.Text("Release title "), Span(Class("form-required"), g.Text("*"))),
128 Input(Type("text"), ID("name"), Name("name"), Class("form-input"), Required(),
129 MaxLength(strconv.Itoa(cfg.MaxTitleBytes)), Value(v.Name),
130 Placeholder("e.g. Version 1.0 — Initial Release")),
131 ),
132 Div(Class("form-group"),
133 Label(Class("checkbox-label"),
134 Input(Type("checkbox"), ID("create_tag"), Name("create_tag"), Value("on"),
135 g.If(v.CreateTag, Checked()),
136 g.Attr("data-toggle-target", "tag-fields-group")),
137 Span(g.Text("Create a git tag for this release")),
138 ),
139 ),
140 Div(ID("tag-fields-group"), Style(tagFieldsStyle),
141 Div(Class("form-group"),
142 Label(Class("form-label"), For("tag_name"),
143 g.Text("Tag name "), Span(Class("form-required"), g.Text("*"))),
144 Input(Type("text"), ID("tag_name"), Name("tag_name"), Class("form-input"),
145 MaxLength(strconv.Itoa(cfg.MaxTitleBytes)),
146 Pattern(`[a-zA-Z0-9._\-+]+`),
147 Title("Letters, digits, dots, hyphens, underscores, and plus signs only"),
148 Value(v.TagName), Placeholder("v1.0.0")),
149 ),
150 Div(Class("form-group"),
151 Label(Class("form-label"), For("revision"),
152 g.Text("Revision "), Span(Class("form-required"), g.Text("*"))),
153 Input(Type("text"), ID("revision"), Name("revision"),
154 Class("form-input monospace"), Value(v.Revision),
155 Placeholder("branch, tag, or commit")),
156 P(Class("form-hint"), g.Text("Branch, tag, or commit to tag.")),
157 ),
158 ),
159 Div(Class("form-group"),
160 Label(Class("form-label"), For("notes"), g.Text("Release notes")),
161 Textarea(ID("notes"), Name("notes"), Class("form-input form-textarea"),
162 Rows("8"), MaxLength(strconv.Itoa(cfg.MaxTextBodyBytes)),
163 g.Text(v.Notes)),
164 ),
165 Div(Class("form-group"),
166 Label(Class("checkbox-label"),
167 Input(Type("checkbox"), Name("include_source_code"), Value("on"),
168 g.If(v.IncludeSourceCode, Checked())),
169 Span(g.Text("Include source code archives (zip, tar.gz, tar.zst)")),
170 ),
171 ),
172 Div(Class("form-group"),
173 Label(Class("form-label"), For("files"), g.Text("Attach files")),
174 Input(Type("file"), ID("files"), Name("files"), Class("form-input"), Multiple()),
175 P(Class("form-hint"), g.Text("You can attach multiple files to this release.")),
176 ),
177 Div(Class("form-actions"),
178 Button(Type("submit"), Class("btn btn-primary"), g.Text("Create release")),
179 A(Href("/"+repo.Name+"/releases"), Class("btn btn-secondary"), g.Text("Cancel")),
180 ),
181 ),
182 ),
183 ),
184 )
185}
186
187// ReleaseDetail renders one release with its notes and downloads.
188func ReleaseDetail(cfg *config.Config, user *db.SessionUser, repo *db.Repo, release *db.Release,
189 notesHTML string, assets []db.ReleaseAsset, sourceArchives []SourceArchive,
190) g.Node {
191 id := strconv.FormatInt(release.ID, 10)
192 hasDownloads := len(sourceArchives) > 0 || len(assets) > 0
193 return Layout(Page{Title: release.Name + " — " + repo.Name, User: user, Cfg: cfg},
194 Div(Class("container"),
195 RepoHeader(repo),
196 RepoNav(repo, "releases", user),
197 Div(Class("release-detail"),
198 Div(Class("release-header"),
199 Div(Class("release-item-header"),
200 Div(Class("release-item-main"),
201 H2(Class("page-title"),
202 Style("view-transition-name: release-title-"+id),
203 g.Text(release.Name)),
204 ),
205 Div(Class("release-item-date"),
206 g.If(deref(release.TagName) != "",
207 A(Href("/"+repo.Name+"/tree/"+EscapePath(deref(release.TagName))), Class("badge"),
208 g.Text(deref(release.TagName)))),
209 g.El("time", DateTime(release.CreatedAt),
210 g.Text(util.FormatDate(release.CreatedAt))),
211 ),
212 ),
213 ),
214 g.If(notesHTML != "", Div(Class("markdown-body release-notes"), g.Raw(notesHTML))),
215 g.If(hasDownloads, Div(Class("release-assets"),
216 H3(Class("release-assets-heading"), g.Text("Downloads")),
217 Ul(Class("asset-list"),
218 g.Map(assets, func(a db.ReleaseAsset) g.Node {
219 return Li(Class("asset-item"),
220 A(Href("/"+repo.Name+"/releases/"+id+"/assets/"+EscapePath(a.Filename)),
221 Class("asset-name"), g.Text(a.Filename)),
222 Span(Class("asset-size"), g.Text(util.FormatBytes(a.Size))),
223 )
224 }),
225 g.Map(sourceArchives, func(a SourceArchive) g.Node {
226 return Li(Class("asset-item"),
227 A(Href("/"+repo.Name+"/releases/"+id+"/source/"+EscapePath(a.Filename)),
228 Class("asset-name"), g.Text(a.Filename)),
229 Span(Class("asset-meta"), g.Text("Source code ("+a.Format+")")),
230 Span(Class("asset-size"), g.Text(util.FormatBytes(a.Size))),
231 )
232 }),
233 ),
234 )),
235 Div(Class("release-back"),
236 A(Href("/"+repo.Name+"/releases"), Class("btn btn-secondary btn-sm"),
237 g.Text("← All releases")),
238 g.If(user != nil && user.IsAdmin, Details(Class("confirm-details"),
239 Summary(Class("btn btn-sm btn-danger"), g.Text("Delete release")),
240 Div(Class("confirm-popup"),
241 g.Text("Permanently delete this release and all its files?"),
242 Form(Method("POST"), Action("/"+repo.Name+"/releases/"+id+"/delete"),
243 Class("inline-form"),
244 Button(Type("submit"), Class("btn btn-sm btn-danger"),
245 g.Text("Yes, delete")),
246 ),
247 ),
248 )),
249 ),
250 ),
251 ),
252 )
253}
254