package views import ( "strings" "hearthforge/internal/config" "hearthforge/internal/db" "hearthforge/internal/util" g "maragu.dev/gomponents" . "maragu.dev/gomponents/html" ) // ImageList renders the Images tab: one block per image path with its tags. // host is what a client puts in front of the image name. func ImageList(cfg *config.Config, user *db.SessionUser, repo *db.Repo, tags []db.ImageTag, sizes map[string]int64, ) g.Node { host := cfg.PublicHost isAdmin := user != nil && user.IsAdmin // Group tags by image path, keeping the sorted order from the query. var order []string byImage := map[string][]db.ImageTag{} for _, t := range tags { if _, seen := byImage[t.Image]; !seen { order = append(order, t.Image) } byImage[t.Image] = append(byImage[t.Image], t) } // Clients lowercase image names, so the tab shows the name they use. lowerRepo := strings.ToLower(repo.Name) fullName := func(image string) string { return strings.TrimSuffix(lowerRepo+"/"+image, "/") } return Layout(Page{Title: "Images — " + repo.Name, User: user, Cfg: cfg}, Div(Class("container"), RepoHeader(repo), RepoNav(cfg, repo, "images", user), Div(Class("list-header"), H2(Class("list-heading"), g.Text("Images")), g.If(isAdmin && len(order) > 0, Details(Class("confirm-details"), Summary(Class("btn btn-sm btn-danger"), g.Text("Delete all")), Div(Class("confirm-popup"), g.Text("Delete every image and tag of this repository?"), Form(Method("POST"), Action("/"+repo.Name+"/images/delete-all"), Class("inline-form"), Button(Type("submit"), Class("btn btn-sm btn-danger"), g.Text("Yes, delete all")), ), ), )), ), g.If(len(order) == 0, Div(Class("empty-state"), P(g.Text("No images yet.")), g.If(isAdmin, P(Class("text-muted"), g.Text("Push one with "), Code(g.Text("docker push "+host+"/"+lowerRepo+":TAG")), g.Text("."))), )), g.If(len(order) > 0, Ul(Class("issue-list"), g.Map(order, func(image string) g.Node { return Li(Class("issue-item"), Div(Class("release-item-header"), Div(Class("release-item-main"), Code(Class("release-item-title"), g.Text(host+"/"+fullName(image))), Div(Class("release-item-meta"), Span(g.Text(countLabel(len(byImage[image]), "tag", "tags"))), Span(g.Text(util.FormatBytes(sizes[image]))), ), ), g.If(isAdmin, Details(Class("confirm-details"), Summary(Class("btn btn-sm btn-danger"), g.Text("Delete image")), Div(Class("confirm-popup"), g.Text("Delete every tag and layer of "+fullName(image)+"?"), Form(Method("POST"), Action("/"+repo.Name+"/images/delete"), Class("inline-form"), Input(Type("hidden"), Name("image"), Value(image)), Button(Type("submit"), Class("btn btn-sm btn-danger"), g.Text("Yes, delete")), ), ), )), ), Ul(Class("image-tags"), g.Map(byImage[image], func(t db.ImageTag) g.Node { return Li(Class("image-tag"), Span(Class("badge"), g.Text(t.Tag)), // Digests passed the registry's regex, so 19 chars is "sha256:" plus 12 hex. Code(Class("image-digest"), Title(t.Digest), g.Text(t.Digest[:19])), g.El("time", Class("text-muted"), DateTime(t.UpdatedAt), g.Text(util.FormatDate(t.UpdatedAt))), g.If(isAdmin, Form(Method("POST"), Action("/"+repo.Name+"/images/delete"), Class("inline-form image-tag-delete"), Input(Type("hidden"), Name("image"), Value(image)), Input(Type("hidden"), Name("tag"), Value(t.Tag)), Button(Type("submit"), Class("btn btn-xs"), g.Text("Delete tag")), )), ) }), ), ) }), )), ), ) }