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