fix: restrict write access to owners and collaborators; theme merged states; release editor
b1978f4
Файлы на момент коммита
- CanWrite no longer treats instance admins as writers on repos they do not own (also collaborator management and the settings page); read access unchanged - Merged PR icons/labels were styled with the palette accent (invisible in both themes); they now use the themed add colour plus a tinted badge - Releases gain an edit page (title, notes, pre-release flag, asset add/remove) with a pencil button next to delete
R
rin
·
2026-10-16 19:36 UTC
11 файлов изменено
+245
-12
internal/i18n/i18n.go
+20
-0
| ... | ... | @@ -205,6 +205,12 @@ var translations = map[Lang]map[string]string{ |
| 205 | 205 | "releases.prerelease": "Пре-релиз (нестабильная сборка)", |
| 206 | 206 | "releases.publish": "Опубликовать релиз", |
| 207 | 207 | "releases.delete": "Удалить релиз", |
| 208 | + "releases.edit": "Редактировать релиз", | |
| 209 | + "releases.edit_title": "Редактирование релиза", | |
| 210 | + "releases.save_btn": "Сохранить изменения", | |
| 211 | + "releases.current_assets": "Текущие файлы", | |
| 212 | + "releases.add_assets": "Добавить файлы", | |
| 213 | + "releases.tag_locked": "Тег нельзя изменить после публикации.", | |
| 208 | 214 | "releases.assets": "Файлы", |
| 209 | 215 | "releases.source_zip": "Исходный код (zip)", |
| 210 | 216 | "releases.source_tar": "Исходный код (tar.gz)", |
| ... | ... | @@ -354,6 +360,10 @@ var translations = map[Lang]map[string]string{ |
| 354 | 360 | "flash.release_published": "Релиз %s опубликован.", |
| 355 | 361 | "flash.release_delete_failed": "Не удалось удалить релиз: %s", |
| 356 | 362 | "flash.release_deleted": "Релиз удалён.", |
| 363 | + "flash.release_updated": "Релиз %s обновлён.", | |
| 364 | + "flash.release_update_failed": "Не удалось обновить релиз: %s", | |
| 365 | + "flash.release_updated_with_assets": "Релиз %s обновлён, файлов добавлено: %d.", | |
| 366 | + "flash.release_updated_assets_failed": "Релиз %s обновлён, но часть файлов не удалось сохранить.", | |
| 357 | 367 | "flash.release_published_with_assets": "Релиз %s опубликован, файлов прикреплено: %d.", |
| 358 | 368 | "flash.release_published_assets_failed": "Релиз %s опубликован, но часть файлов не удалось сохранить.", |
| 359 | 369 | "flash.release_asset_too_many": "Слишком много файлов: максимум %d.", |
| ... | ... | @@ -713,6 +723,12 @@ var translations = map[Lang]map[string]string{ |
| 713 | 723 | "releases.prerelease": "This is a pre-release", |
| 714 | 724 | "releases.publish": "Publish release", |
| 715 | 725 | "releases.delete": "Delete release", |
| 726 | + "releases.edit": "Edit release", | |
| 727 | + "releases.edit_title": "Edit release", | |
| 728 | + "releases.save_btn": "Save changes", | |
| 729 | + "releases.current_assets": "Current files", | |
| 730 | + "releases.add_assets": "Add files", | |
| 731 | + "releases.tag_locked": "The tag cannot be changed after publishing.", | |
| 716 | 732 | "releases.assets": "Assets", |
| 717 | 733 | "releases.source_zip": "Source code (zip)", |
| 718 | 734 | "releases.source_tar": "Source code (tar.gz)", |
| ... | ... | @@ -862,6 +878,10 @@ var translations = map[Lang]map[string]string{ |
| 862 | 878 | "flash.release_published": "Release %s published.", |
| 863 | 879 | "flash.release_delete_failed": "Could not delete the release: %s", |
| 864 | 880 | "flash.release_deleted": "Release deleted.", |
| 881 | + "flash.release_updated": "Release %s updated.", | |
| 882 | + "flash.release_update_failed": "Could not update the release: %s", | |
| 883 | + "flash.release_updated_with_assets": "Release %s updated with %d new file(s).", | |
| 884 | + "flash.release_updated_assets_failed": "Release %s updated, but some files could not be saved.", | |
| 865 | 885 | "flash.release_published_with_assets": "Release %s published with %d attached file(s).", |
| 866 | 886 | "flash.release_published_assets_failed": "Release %s published, but some files could not be saved.", |
| 867 | 887 | "flash.release_asset_too_many": "Too many files: %d max.", |
internal/repo/collab.go
+4
-3
| ... | ... | @@ -139,13 +139,14 @@ func (s *Service) CanRead(ctx context.Context, r *Repository, u *user.User) bool |
| 139 | 139 | return role == RoleRead || role == RoleWrite |
| 140 | 140 | } |
| 141 | 141 | |
| 142 | -// CanWrite reports whether the user may push to the repository (owner, admin | |
| 143 | -// or a collaborator with the write role). | |
| 142 | +// CanWrite reports whether the user may push to the repository (owner or a | |
| 143 | +// collaborator with the write role). Instance admins get no write access to | |
| 144 | +// repositories they do not own. | |
| 144 | 145 | func (s *Service) CanWrite(ctx context.Context, r *Repository, u *user.User) bool { |
| 145 | 146 | if r == nil || u == nil { |
| 146 | 147 | return false |
| 147 | 148 | } |
| 148 | - if u.ID == r.OwnerID || u.IsAdmin { | |
| 149 | + if u.ID == r.OwnerID { | |
| 149 | 150 | return true |
| 150 | 151 | } |
| 151 | 152 | return s.GetCollaboratorRole(ctx, r.ID, u.ID) == RoleWrite |
internal/web/handlers/collaborators.go
+2
-2
| ... | ... | @@ -17,7 +17,7 @@ func (h *Handler) RepoCollaboratorAddSubmit(c *fiber.Ctx) error { |
| 17 | 17 | return nil |
| 18 | 18 | } |
| 19 | 19 | viewer := middleware.CurrentUser(c) |
| 20 | - if viewer == nil || (viewer.ID != r.OwnerID && !viewer.IsAdmin) { | |
| 20 | + if viewer == nil || viewer.ID != r.OwnerID { | |
| 21 | 21 | return h.renderErrorT(c, fiber.StatusForbidden, "err.forbidden", "err.collab_forbidden") |
| 22 | 22 | } |
| 23 | 23 | |
| ... | ... | @@ -39,7 +39,7 @@ func (h *Handler) RepoCollaboratorRemoveSubmit(c *fiber.Ctx) error { |
| 39 | 39 | return nil |
| 40 | 40 | } |
| 41 | 41 | viewer := middleware.CurrentUser(c) |
| 42 | - if viewer == nil || (viewer.ID != r.OwnerID && !viewer.IsAdmin) { | |
| 42 | + if viewer == nil || viewer.ID != r.OwnerID { | |
| 43 | 43 | return h.renderErrorT(c, fiber.StatusForbidden, "err.forbidden", "err.collab_forbidden") |
| 44 | 44 | } |
| 45 | 45 |
internal/web/handlers/handlers.go
+2
-0
| ... | ... | @@ -151,6 +151,8 @@ func (h *Handler) RegisterRoutes(app *fiber.App) { |
| 151 | 151 | app.Get("/:owner/:repo/releases", h.RepoReleasesPage) |
| 152 | 152 | app.Get("/:owner/:repo/releases/new", middleware.RequireAuth(), h.RepoNewReleasePage) |
| 153 | 153 | app.Post("/:owner/:repo/releases/new", middleware.RequireAuth(), h.RepoNewReleaseSubmit) |
| 154 | + app.Get("/:owner/:repo/releases/:id/edit", middleware.RequireAuth(), h.RepoEditReleasePage) | |
| 155 | + app.Post("/:owner/:repo/releases/:id/edit", middleware.RequireAuth(), h.RepoEditReleaseSubmit) | |
| 154 | 156 | app.Post("/:owner/:repo/releases/:id/delete", middleware.RequireAuth(), h.RepoDeleteReleaseSubmit) |
| 155 | 157 | app.Get("/:owner/:repo/releases/:id/assets/:assetID/download", h.RepoReleaseAssetDownload) |
| 156 | 158 | app.Post("/:owner/:repo/releases/:id/assets/:assetID/delete", middleware.RequireAuth(), h.RepoDeleteReleaseAssetSubmit) |
internal/web/handlers/releases.go
+97
-0
| ... | ... | @@ -220,6 +220,100 @@ func (h *Handler) RepoNewReleaseSubmit(c *fiber.Ctx) error { |
| 220 | 220 | return c.Redirect(fmt.Sprintf("/%s/%s/releases", r.OwnerUsername, r.Name), fiber.StatusSeeOther) |
| 221 | 221 | } |
| 222 | 222 | |
| 223 | +// RepoEditReleasePage renders the editor for an existing release. | |
| 224 | +// GET /:owner/:repo/releases/:id/edit | |
| 225 | +func (h *Handler) RepoEditReleasePage(c *fiber.Ctx) error { | |
| 226 | + r, _, err := h.loadAuthorizedRepo(c, true) | |
| 227 | + if err != nil { | |
| 228 | + return nil | |
| 229 | + } | |
| 230 | + if !r.EnableReleases { | |
| 231 | + return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.releases_disabled") | |
| 232 | + } | |
| 233 | + | |
| 234 | + relID, _ := strconv.ParseInt(c.Params("id"), 10, 64) | |
| 235 | + rel, err := h.repoSvc.GetReleaseByID(c.UserContext(), r.ID, relID) | |
| 236 | + if err != nil { | |
| 237 | + return h.renderErrorT(c, fiber.StatusNotFound, "err.release_not_found", "err.release_not_found_desc") | |
| 238 | + } | |
| 239 | + | |
| 240 | + assetsByRelease, _ := h.repoSvc.ListReleaseAssetsByRepo(c.UserContext(), r.ID) | |
| 241 | + | |
| 242 | + viewer := middleware.CurrentUser(c) | |
| 243 | + data := h.baseData(c, h.tr(c, "releases.edit")+" · "+r.OwnerUsername+"/"+r.Name) | |
| 244 | + data["Repo"] = r | |
| 245 | + data["Ref"] = r.DefaultBranch | |
| 246 | + data["ActiveTab"] = "releases" | |
| 247 | + data["CanWrite"] = h.repoSvc.CanWrite(c.UserContext(), r, viewer) | |
| 248 | + data["CloneURL"] = h.cloneURL(c, r.OwnerUsername, r.Name) | |
| 249 | + data["Release"] = rel | |
| 250 | + data["Assets"] = assetsByRelease[rel.ID] | |
| 251 | + | |
| 252 | + return h.render(c, fiber.StatusOK, "release_edit", data) | |
| 253 | +} | |
| 254 | + | |
| 255 | +// RepoEditReleaseSubmit saves edited metadata and any newly attached files. | |
| 256 | +// POST /:owner/:repo/releases/:id/edit | |
| 257 | +func (h *Handler) RepoEditReleaseSubmit(c *fiber.Ctx) error { | |
| 258 | + r, _, err := h.loadAuthorizedRepo(c, true) | |
| 259 | + if err != nil { | |
| 260 | + return nil | |
| 261 | + } | |
| 262 | + if !r.EnableReleases { | |
| 263 | + return h.renderErrorT(c, fiber.StatusNotFound, "err.not_found", "err.releases_disabled") | |
| 264 | + } | |
| 265 | + | |
| 266 | + relID, _ := strconv.ParseInt(c.Params("id"), 10, 64) | |
| 267 | + backTo := fmt.Sprintf("/%s/%s/releases/%d/edit", r.OwnerUsername, r.Name, relID) | |
| 268 | + | |
| 269 | + rel, err := h.repoSvc.GetReleaseByID(c.UserContext(), r.ID, relID) | |
| 270 | + if err != nil { | |
| 271 | + return h.renderErrorT(c, fiber.StatusNotFound, "err.release_not_found", "err.release_not_found_desc") | |
| 272 | + } | |
| 273 | + | |
| 274 | + uploads, uerr := h.readReleaseAssetUploads(c) | |
| 275 | + if uerr != nil { | |
| 276 | + switch uerr.Error() { | |
| 277 | + case "too many": | |
| 278 | + h.setFlashT(c, "flash.release_asset_too_many", maxReleaseAssets) | |
| 279 | + default: | |
| 280 | + h.setFlashT(c, "flash.release_asset_too_big") | |
| 281 | + } | |
| 282 | + return c.Redirect(backTo, fiber.StatusSeeOther) | |
| 283 | + } | |
| 284 | + | |
| 285 | + u := middleware.CurrentUser(c) | |
| 286 | + title := strings.TrimSpace(c.FormValue("title")) | |
| 287 | + body := strings.TrimSpace(c.FormValue("body")) | |
| 288 | + isPrerelease := c.FormValue("is_prerelease") == "true" || c.FormValue("is_prerelease") == "on" || c.FormValue("is_prerelease") == "1" | |
| 289 | + | |
| 290 | + if err := h.repoSvc.UpdateRelease(c.UserContext(), rel.ID, title, body, isPrerelease); err != nil { | |
| 291 | + h.setFlashT(c, "flash.release_update_failed", err.Error()) | |
| 292 | + return c.Redirect(backTo, fiber.StatusSeeOther) | |
| 293 | + } | |
| 294 | + | |
| 295 | + saved, failed := 0, 0 | |
| 296 | + for _, up := range uploads { | |
| 297 | + if _, err := h.repoSvc.SaveReleaseAsset(c.UserContext(), r, rel.ID, u.ID, up.name, up.data, up.contentType); err != nil { | |
| 298 | + failed++ | |
| 299 | + continue | |
| 300 | + } | |
| 301 | + saved++ | |
| 302 | + } | |
| 303 | + | |
| 304 | + h.repoSvc.TouchUpdatedAt(c.UserContext(), r.ID) | |
| 305 | + switch { | |
| 306 | + case failed > 0: | |
| 307 | + h.setFlashT(c, "flash.release_updated_assets_failed", rel.TagName) | |
| 308 | + case saved > 0: | |
| 309 | + h.setFlashT(c, "flash.release_updated_with_assets", rel.TagName, saved) | |
| 310 | + default: | |
| 311 | + h.setFlashT(c, "flash.release_updated", rel.TagName) | |
| 312 | + } | |
| 313 | + | |
| 314 | + return c.Redirect(fmt.Sprintf("/%s/%s/releases#%s", r.OwnerUsername, r.Name, rel.TagName), fiber.StatusSeeOther) | |
| 315 | +} | |
| 316 | + | |
| 223 | 317 | // RepoReleaseAssetDownload streams an uploaded release asset. |
| 224 | 318 | // GET /:owner/:repo/releases/:id/assets/:assetID/download |
| 225 | 319 | func (h *Handler) RepoReleaseAssetDownload(c *fiber.Ctx) error { |
| ... | ... | @@ -393,6 +487,9 @@ func (h *Handler) RepoDeleteReleaseAssetSubmit(c *fiber.Ctx) error { |
| 393 | 487 | return h.renderErrorT(c, fiber.StatusNotFound, "err.release_not_found", "err.release_not_found_desc") |
| 394 | 488 | } |
| 395 | 489 | backTo += "#" + rel.TagName |
| 490 | + if next := strings.TrimSpace(c.FormValue("next")); strings.HasPrefix(next, "/") && !strings.HasPrefix(next, "//") { | |
| 491 | + backTo = next | |
| 492 | + } | |
| 396 | 493 | |
| 397 | 494 | asset, err := h.repoSvc.GetReleaseAsset(c.UserContext(), assetID) |
| 398 | 495 | if err != nil || asset.ReleaseID != relID { |
internal/web/handlers/repo.go
+1
-1
| ... | ... | @@ -322,7 +322,7 @@ func (h *Handler) RepoSettingsPage(c *fiber.Ctx) error { |
| 322 | 322 | viewer := middleware.CurrentUser(c) |
| 323 | 323 | collaborators, _ := h.repoSvc.ListCollaborators(c.UserContext(), r.ID) |
| 324 | 324 | data["Collaborators"] = collaborators |
| 325 | - data["CanManageCollab"] = viewer != nil && (viewer.ID == r.OwnerID || viewer.IsAdmin) | |
| 325 | + data["CanManageCollab"] = viewer != nil && viewer.ID == r.OwnerID | |
| 326 | 326 | |
| 327 | 327 | return h.render(c, fiber.StatusOK, "repo_settings", data) |
| 328 | 328 | } |
internal/web/views/pages/activity.html
+1
-1
| ... | ... | @@ -67,7 +67,7 @@ |
| 67 | 67 | {{if eq .Type "commit"}} |
| 68 | 68 | <span style="color:hsl(var(--primary));">{{icon "git-commit" "size-4"}}</span> |
| 69 | 69 | {{else if eq .Type "pull"}} |
| 70 | - <span style="color:hsl(var(--accent));">{{icon "git-pull-request" "size-4"}}</span> | |
| 70 | + <span style="color:hsl(var(--primary));">{{icon "git-pull-request" "size-4"}}</span> | |
| 71 | 71 | {{else if eq .Type "issue"}} |
| 72 | 72 | <span style="color:hsl(var(--primary));">{{icon "issue" "size-4"}}</span> |
| 73 | 73 | {{else if eq .Type "release"}} |
internal/web/views/pages/pull_detail.html
+2
-2
| ... | ... | @@ -34,7 +34,7 @@ |
| 34 | 34 | |
| 35 | 35 | <div class="flex items-center gap-2 mt-2 text-xs text-muted flex-wrap"> |
| 36 | 36 | {{if eq .PR.Status "merged"}} |
| 37 | - <span class="badge badge-primary font-medium flex items-center gap-1.5" style="background:hsl(var(--accent));color:#fff;"> | |
| 37 | + <span class="badge font-medium flex items-center gap-1.5" style="background:hsl(var(--diff-add-bg));color:hsl(var(--diff-add-fg));border-color:hsl(var(--diff-add-fg)/0.35);"> | |
| 38 | 38 | {{icon "git-merge" "size-3.5"}} {{t .Lang "pulls.status.merged"}} |
| 39 | 39 | </span> |
| 40 | 40 | {{else if eq .PR.Status "closed"}} |
| ... | ... | @@ -212,7 +212,7 @@ |
| 212 | 212 | <div class="card mb-4"> |
| 213 | 213 | {{if eq .PR.Status "merged"}} |
| 214 | 214 | <div class="card-body flex items-center gap-3" style="background:hsl(var(--muted)/0.2);"> |
| 215 | - <span style="color:hsl(var(--accent));">{{icon "git-merge" "size-6"}}</span> | |
| 215 | + <span style="color:hsl(var(--diff-add-fg));">{{icon "git-merge" "size-6"}}</span> | |
| 216 | 216 | <div class="flex flex-col gap-0.5"> |
| 217 | 217 | <div class="font-semibold text-sm">{{t .Lang "pulls.merged_message"}}</div> |
| 218 | 218 | <div class="text-xs text-muted"> |
internal/web/views/pages/pulls.html
+3
-3
| ... | ... | @@ -9,7 +9,7 @@ |
| 9 | 9 | <span>{{t .Lang "pulls.open"}} ({{.OpenCount}})</span> |
| 10 | 10 | </a> |
| 11 | 11 | <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/pulls?state=merged" class="btn {{if eq .CurrentState "merged"}}btn-secondary{{else}}btn-ghost{{end}} btn-sm"> |
| 12 | - <span style="color:hsl(var(--accent));">{{icon "git-merge" "size-3.5"}}</span> | |
| 12 | + <span style="color:hsl(var(--diff-add-fg));">{{icon "git-merge" "size-3.5"}}</span> | |
| 13 | 13 | <span>{{t .Lang "pulls.merged"}} ({{.MergedCount}})</span> |
| 14 | 14 | </a> |
| 15 | 15 | <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/pulls?state=closed" class="btn {{if eq .CurrentState "closed"}}btn-secondary{{else}}btn-ghost{{end}} btn-sm"> |
| ... | ... | @@ -33,7 +33,7 @@ |
| 33 | 33 | <div class="dense-row" style="min-height:3rem;padding:0.6rem 0.85rem;"> |
| 34 | 34 | <div class="flex items-center gap-2.5 min-w-0"> |
| 35 | 35 | {{if eq .Status "merged"}} |
| 36 | - <span style="color:hsl(var(--accent));">{{icon "git-merge" "size-4"}}</span> | |
| 36 | + <span style="color:hsl(var(--diff-add-fg));">{{icon "git-merge" "size-4"}}</span> | |
| 37 | 37 | {{else if eq .Status "closed"}} |
| 38 | 38 | <span class="text-muted">{{icon "close" "size-4"}}</span> |
| 39 | 39 | {{else}} |
| ... | ... | @@ -51,7 +51,7 @@ |
| 51 | 51 | <span>{{timeAgo .CreatedAt $.Lang}}</span> |
| 52 | 52 | {{if eq .Status "merged"}} |
| 53 | 53 | <span>·</span> |
| 54 | - <span style="color:hsl(var(--accent));font-weight:500;">{{t $.Lang "pulls.merged"}}</span> | |
| 54 | + <span style="color:hsl(var(--diff-add-fg));font-weight:500;">{{t $.Lang "pulls.merged"}}</span> | |
| 55 | 55 | {{end}} |
| 56 | 56 | </div> |
| 57 | 57 | </div> |
internal/web/views/pages/release_edit.html
+110
-0
| ... | ... | @@ -0,0 +1,110 @@ |
| 1 | +{{define "content"}} | |
| 2 | +<div class="container"> | |
| 3 | + {{template "repo_header" .}} | |
| 4 | + | |
| 5 | + <section class="card mb-4"> | |
| 6 | + <div class="card-header flex items-center justify-between flex-wrap gap-2"> | |
| 7 | + <span class="font-semibold text-sm flex items-center gap-2"> | |
| 8 | + {{icon "edit" "size-4"}} | |
| 9 | + <span>{{t .Lang "releases.edit_title"}}</span> | |
| 10 | + </span> | |
| 11 | + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/tree/{{.Release.TagName}}" class="badge font-mono text-xs flex items-center gap-1"> | |
| 12 | + {{icon "tag" "size-3"}} | |
| 13 | + <span>{{.Release.TagName}}</span> | |
| 14 | + </a> | |
| 15 | + </div> | |
| 16 | + <form method="post" action="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/releases/{{.Release.ID}}/edit" enctype="multipart/form-data" class="card-body flex flex-col gap-3"> | |
| 17 | + <input type="hidden" name="_csrf" value="{{.CSRFToken}}"> | |
| 18 | + | |
| 19 | + <div> | |
| 20 | + <label class="label" for="title">{{t .Lang "releases.title"}}</label> | |
| 21 | + <input id="title" name="title" type="text" class="input" placeholder="{{t .Lang "releases.title_ph"}}" maxlength="200" value="{{.Release.Title}}" autofocus> | |
| 22 | + </div> | |
| 23 | + | |
| 24 | + <div> | |
| 25 | + <label class="label" for="body">{{t .Lang "releases.body"}}</label> | |
| 26 | + <textarea id="body" name="body" class="textarea" rows="8" placeholder="{{t .Lang "releases.body_ph"}}" maxlength="50000">{{.Release.Body}}</textarea> | |
| 27 | + </div> | |
| 28 | + | |
| 29 | + {{if .Assets}} | |
| 30 | + <div class="flex flex-col gap-1.5"> | |
| 31 | + <div class="label" style="margin-bottom:0;">{{t .Lang "releases.current_assets"}}</div> | |
| 32 | + {{range .Assets}} | |
| 33 | + <div class="flex items-center justify-between gap-3"> | |
| 34 | + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/releases/{{$.Release.ID}}/assets/{{.ID}}/download" class="flex items-center gap-2 text-xs font-mono font-medium hover:underline min-w-0" style="color:hsl(var(--primary));"> | |
| 35 | + {{icon "file" "size-3.5"}} | |
| 36 | + <span class="truncate">{{.Filename}}</span> | |
| 37 | + </a> | |
| 38 | + <div class="flex items-center gap-2 flex-shrink-0"> | |
| 39 | + <span class="text-xs text-muted">{{formatSize .Size}}</span> | |
| 40 | + <form method="post" action="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/releases/{{$.Release.ID}}/assets/{{.ID}}/delete" onsubmit="return confirm('{{t $.Lang "releases.asset_delete_confirm"}}');" style="margin:0;"> | |
| 41 | + <input type="hidden" name="_csrf" value="{{$.CSRFToken}}"> | |
| 42 | + <input type="hidden" name="next" value="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/releases/{{$.Release.ID}}/edit"> | |
| 43 | + <button type="submit" class="btn btn-ghost btn-icon text-muted" style="width:1.6rem;height:1.6rem;" title="{{t $.Lang "releases.asset_delete"}}"> | |
| 44 | + {{icon "trash" "size-3"}} | |
| 45 | + </button> | |
| 46 | + </form> | |
| 47 | + </div> | |
| 48 | + </div> | |
| 49 | + {{end}} | |
| 50 | + </div> | |
| 51 | + {{end}} | |
| 52 | + | |
| 53 | + <div class="flex flex-col gap-1"> | |
| 54 | + <label class="label" for="assets" style="margin-bottom:0;">{{t .Lang "releases.add_assets"}}</label> | |
| 55 | + <label class="upload-drop" id="assets-drop" for="assets"> | |
| 56 | + {{icon "upload" "size-5"}} | |
| 57 | + <span class="text-sm font-medium">{{t .Lang "file.upload_pick"}}</span> | |
| 58 | + <span class="text-xs">{{t .Lang "releases.assets_upload_hint"}}</span> | |
| 59 | + <input id="assets" name="assets" type="file" multiple style="display:none;"> | |
| 60 | + </label> | |
| 61 | + <div id="assets-list" class="text-xs font-mono text-muted"></div> | |
| 62 | + </div> | |
| 63 | + | |
| 64 | + <div> | |
| 65 | + <label class="flex items-center gap-2 cursor-pointer text-sm"> | |
| 66 | + <input type="checkbox" name="is_prerelease" value="true" class="switch" {{if .Release.IsPrerelease}}checked{{end}}> | |
| 67 | + <span>{{t .Lang "releases.is_prerelease"}}</span> | |
| 68 | + </label> | |
| 69 | + </div> | |
| 70 | + | |
| 71 | + <div class="flex items-center justify-between gap-2 flex-wrap"> | |
| 72 | + <span class="text-xs text-muted">{{t .Lang "releases.tag_locked"}}</span> | |
| 73 | + <div class="flex items-center gap-2"> | |
| 74 | + <a href="/{{.Repo.OwnerUsername}}/{{.Repo.Name}}/releases#{{.Release.TagName}}" class="btn btn-ghost btn-sm">{{t .Lang "common.cancel"}}</a> | |
| 75 | + <button type="submit" class="btn btn-primary btn-sm"> | |
| 76 | + {{icon "check" "size-3.5"}} | |
| 77 | + <span>{{t .Lang "releases.save_btn"}}</span> | |
| 78 | + </button> | |
| 79 | + </div> | |
| 80 | + </div> | |
| 81 | + </form> | |
| 82 | + </section> | |
| 83 | +</div> | |
| 84 | +<script> | |
| 85 | +(function () { | |
| 86 | + var input = document.getElementById('assets'); | |
| 87 | + var drop = document.getElementById('assets-drop'); | |
| 88 | + var list = document.getElementById('assets-list'); | |
| 89 | + if (!input || !drop || !list) return; | |
| 90 | + function render() { | |
| 91 | + var names = []; | |
| 92 | + for (var i = 0; i < input.files.length; i++) names.push(input.files[i].name); | |
| 93 | + list.textContent = names.length ? names.join(' · ') : ''; | |
| 94 | + } | |
| 95 | + input.addEventListener('change', render); | |
| 96 | + ['dragover', 'dragenter'].forEach(function (ev) { | |
| 97 | + drop.addEventListener(ev, function (e) { e.preventDefault(); drop.classList.add('drag'); }); | |
| 98 | + }); | |
| 99 | + ['dragleave', 'drop'].forEach(function (ev) { | |
| 100 | + drop.addEventListener(ev, function (e) { e.preventDefault(); drop.classList.remove('drag'); }); | |
| 101 | + }); | |
| 102 | + drop.addEventListener('drop', function (e) { | |
| 103 | + if (e.dataTransfer && e.dataTransfer.files.length) { | |
| 104 | + input.files = e.dataTransfer.files; | |
| 105 | + render(); | |
| 106 | + } | |
| 107 | + }); | |
| 108 | +})(); | |
| 109 | +</script> | |
| 110 | +{{end}} |
internal/web/views/pages/releases.html
+3
-0
| ... | ... | @@ -46,6 +46,9 @@ |
| 46 | 46 | <span>{{formatDate .PublishedAt}}</span> |
| 47 | 47 | </div> |
| 48 | 48 | {{if $.CanWrite}} |
| 49 | + <a href="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/releases/{{.ID}}/edit" class="btn btn-ghost btn-icon text-muted" style="width:1.8rem;height:1.8rem;" title="{{t $.Lang "releases.edit"}}"> | |
| 50 | + {{icon "edit" "size-3.5"}} | |
| 51 | + </a> | |
| 49 | 52 | <form method="post" action="/{{$.Repo.OwnerUsername}}/{{$.Repo.Name}}/releases/{{.ID}}/delete" onsubmit="return confirm('{{t $.Lang "releases.delete_confirm"}}');" style="margin:0;"> |
| 50 | 53 | <input type="hidden" name="_csrf" value="{{$.CSRFToken}}"> |
| 51 | 54 | <button type="submit" class="btn btn-ghost btn-icon text-muted" style="width:1.8rem;height:1.8rem;" title="{{t $.Lang "releases.delete"}}"> |