0
0
mirror of https://github.com/go-gitea/gitea.git synced 2025-07-19 21:28:33 +02:00

POC: Use gomponents for Explore Users page

This commit is contained in:
Philip Peterson 2025-05-26 16:06:25 -07:00 committed by Philip Peterson
parent b0936f4f41
commit da75b09c90
17 changed files with 569 additions and 4 deletions

25
components/components.go Normal file
View File

@ -0,0 +1,25 @@
package components
import (
"code.gitea.io/gitea/modules/svg"
g "maragu.dev/gomponents"
)
func If(condition bool, node g.Node) g.Node {
if condition {
return node
}
return nil
}
func SVG(icon string, others ...any) g.Node {
return g.Raw(string(svg.RenderHTML(icon)))
}
// Utility to add "active" class if condition is true
func classIf(condition bool, class string) string {
if condition {
return class
}
return ""
}

View File

@ -0,0 +1,66 @@
package components
import (
"code.gitea.io/gitea/models/unit"
"code.gitea.io/gitea/modules/translation"
g "maragu.dev/gomponents"
gh "maragu.dev/gomponents/html"
)
type ExploreNavbarProps struct {
PageIsExploreRepositories bool
UsersPageIsDisabled bool
AppSubUrl string
PageIsExploreUsers bool
PageIsExploreCode bool
IsRepoIndexerEnabled bool
CodePageIsDisabled bool
PageIsExploreOrganizations bool
OrganizationsPageIsDisabled bool
Locale translation.Locale
}
func ExploreNavbar(data ExploreNavbarProps) g.Node {
tr := func(key string) string {
return string(data.Locale.Tr(key))
}
isCodeGlobalDisabled := unit.TypeCode.UnitGlobalDisabled()
return g.El("overflow-menu",
gh.Class("ui secondary pointing tabular top attached borderless menu secondary-nav"),
gh.Div(
gh.Class("overflow-menu-items tw-justify-center"),
gh.A(
gh.Class(classIf(data.PageIsExploreRepositories, "active ")+"item"),
gh.Href(data.AppSubUrl+"/explore/repos"),
SVG("octicon-repo"),
g.Text(" "+tr("explore.repos")),
),
If(!data.UsersPageIsDisabled,
gh.A(
gh.Class(classIf(data.PageIsExploreUsers, "active ")+"item"),
gh.Href(data.AppSubUrl+"/explore/users"),
SVG("octicon-person"),
g.Text(" "+tr("explore.users")),
),
),
If(!data.OrganizationsPageIsDisabled,
gh.A(
gh.Class(classIf(data.PageIsExploreOrganizations, "active ")+"item"),
gh.Href(data.AppSubUrl+"/explore/organizations"),
SVG("octicon-organization"),
g.Text(" "+tr("explore.organizations")),
),
),
If(!isCodeGlobalDisabled && data.IsRepoIndexerEnabled && !data.CodePageIsDisabled,
gh.A(
gh.Class(classIf(data.PageIsExploreCode, "active ")+"item"),
gh.Href(data.AppSubUrl+"/explore/code"),
SVG("octicon-code"),
g.Text(" "+tr("explore.code")),
),
),
),
)
}

View File

@ -0,0 +1,45 @@
package components
import (
g "maragu.dev/gomponents"
gh "maragu.dev/gomponents/html"
)
func ExploreSearchMenu(data ExploreUsersPageProps, pageIsExploreUsers bool) g.Node {
// Corresponds to templates/explore/search.tmpl
tr := func(key string) string {
return string(data.Locale.Tr(key))
}
return g.Group([]g.Node{
gh.Div(
gh.Class("ui small secondary filter menu tw-items-center tw-mx-0"),
gh.Form(
gh.Class("ui form ignore-dirty tw-flex-1"),
If(pageIsExploreUsers,
SearchCombo(data.Locale, data.Keyword, tr("search.user_kind")),
),
If(!pageIsExploreUsers,
SearchCombo(data.Locale, data.Keyword, tr("search.org_kind")),
),
),
gh.Div(
gh.Class("ui small dropdown type jump item tw-mr-0"),
gh.Span(
gh.Class("text"),
g.Text(tr("repo.issues.filter_sort")),
),
SVG("octicon-triangle-down", 14, "dropdown icon"),
gh.Div(
gh.Class("menu"),
SortOption(data, "newest", tr("repo.issues.filter_sort.latest")),
SortOption(data, "oldest", tr("repo.issues.filter_sort.oldest")),
SortOption(data, "alphabetically", tr("repo.issues.label.filter_sort.alphabetically")),
SortOption(data, "reversealphabetically", tr("repo.issues.label.filter_sort.reverse_alphabetically")),
),
),
),
gh.Div(gh.Class("divider")),
})
}

View File

@ -0,0 +1,62 @@
package components
import (
"code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/translation"
"code.gitea.io/gitea/services/context"
g "maragu.dev/gomponents"
gh "maragu.dev/gomponents/html"
)
type ExploreUsersPageProps struct {
Title string
Locale translation.Locale
Keyword string
SortType string
Users []*user.User
// ContextUser *user.User
Context *context.Context
IsSigned bool
}
func ExploreUsersPage(data ExploreUsersPageProps) g.Node {
// pageIsExplore := true
pageIsExploreUsers := true
head, err := data.Context.HTMLPartial(200, "base/head")
if err != nil {
panic("could not render head")
}
footer, err := data.Context.HTMLPartial(200, "base/footer")
if err != nil {
panic("could not render footer")
}
return g.Group([]g.Node{
g.Raw(head),
gh.Div(
gh.Role("main"),
gh.Aria("label", data.Title),
gh.Class("page-content explore users"),
ExploreNavbar(ExploreNavbarProps{
Locale: data.Locale,
PageIsExploreUsers: pageIsExploreUsers,
}),
gh.Div(
gh.Class("ui container"),
ExploreSearchMenu(data, true),
UserList(UserListProps{
// ContextUser: data.ContextUser,
Context: data.Context,
Users: data.Users,
IsSigned: data.IsSigned,
Locale: data.Locale,
PageIsAdminUsers: false,
}),
// Pagination(data),
),
),
g.Raw(footer),
})
}

View File

@ -0,0 +1,32 @@
package components
import (
g "maragu.dev/gomponents"
gh "maragu.dev/gomponents/html"
)
func SearchButton(disabled bool, tooltip string) g.Node {
// Corresponds to templates/shared/search/button.tmpl
class := "ui icon button"
if disabled {
class += " disabled"
}
btn := gh.Button(
gh.Type("submit"),
gh.Class(class),
SVG("octicon-search", 16),
)
if tooltip != "" {
btn = gh.Button(
gh.Type("submit"),
gh.Class(class),
g.Attr("data-tooltip-content", tooltip),
SVG("octicon-search", 16),
)
}
return btn
}

View File

@ -0,0 +1,19 @@
package components
import (
"code.gitea.io/gitea/modules/translation"
g "maragu.dev/gomponents"
gh "maragu.dev/gomponents/html"
)
func SearchCombo(locale translation.Locale, value, placeholder string) g.Node {
// Corresponds to templates/shared/search/combo.tmpl
disabled := false
return gh.Div(
gh.Class("ui small fluid action input"),
SearchInput(value, placeholder, disabled),
// TODO SearchModeDropdown
SearchButton(disabled, ""),
)
}

View File

@ -0,0 +1,20 @@
package components
import (
g "maragu.dev/gomponents"
gh "maragu.dev/gomponents/html"
)
func SearchInput(value, placeholder string, disabled bool) g.Node {
// Corresponds to templates/shared/search/input.tmpl
return gh.Input(
gh.Type("search"),
gh.Name("q"),
gh.MaxLength("255"),
g.Attr("spellcheck", "false"),
gh.Value(value),
gh.Placeholder(placeholder),
If(disabled, gh.Disabled()),
)
}

21
components/sort_option.go Normal file
View File

@ -0,0 +1,21 @@
package components
import (
"fmt"
"net/url"
g "maragu.dev/gomponents"
gh "maragu.dev/gomponents/html"
)
func SortOption(data ExploreUsersPageProps, sortType, label string) g.Node {
active := ""
if data.SortType == sortType {
active = "active "
}
return gh.A(
gh.Class(active+"item"),
gh.Href(fmt.Sprintf("?sort=%s&q=%s", sortType, url.QueryEscape(data.Keyword))),
g.Text(label),
)
}

92
components/user_list.go Normal file
View File

@ -0,0 +1,92 @@
package components
import (
"code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/setting"
templates "code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/modules/translation"
"code.gitea.io/gitea/services/context"
g "maragu.dev/gomponents"
gh "maragu.dev/gomponents/html"
)
type UserListProps struct {
Users []*user.User
// ContextUser *user.User
IsSigned bool
PageIsAdminUsers bool
Locale translation.Locale
Context *context.Context
}
func UserList(data UserListProps) g.Node {
tr := func(key string, args ...any) string {
return string(data.Locale.Tr(key, args...))
}
if len(data.Users) == 0 {
return gh.Div(
gh.Class("flex-list"),
gh.Div(
gh.Class("flex-item"),
g.Text(tr("search.no_results")),
),
)
}
return gh.Div(
gh.Class("flex-list"),
g.Group(g.Map(data.Users, func(u *user.User) g.Node {
utils := templates.NewAvatarUtils(data.Context)
return gh.Div(
gh.Class("flex-item tw-items-center"),
gh.Div(
gh.Class("flex-item-leading"),
g.Raw(string(utils.Avatar(u, 48))),
),
gh.Div(
gh.Class("flex-item-main"),
gh.Div(
gh.Class("flex-item-title"),
UserName(UserNameProps{
Locale: data.Locale,
User: u,
}),
If(u.Visibility.IsPrivate(),
gh.Span(
gh.Class("ui basic tiny label"),
g.Text(tr("repo.desc.private")),
),
),
),
gh.Div(
gh.Class("flex-item-body"),
If(u.Location != "",
gh.Span(
gh.Class("flex-text-inline"),
SVG("octicon-location", 16),
g.Text(u.Location),
),
),
If(u.Email != "" && (data.PageIsAdminUsers || (setting.UI.ShowUserEmail && data.IsSigned && !u.KeepEmailPrivate)),
gh.Span(
gh.Class("flex-text-inline"),
SVG("octicon-mail", 16),
gh.A(
gh.Href("mailto:"+u.Email),
g.Text(u.Email),
),
),
),
gh.Span(
gh.Class("flex-text-inline"),
SVG("octicon-calendar", 16),
g.Raw(tr("user.joined_on", templates.NewDateUtils().AbsoluteShort(u.CreatedUnix))),
),
),
),
)
})),
)
}

26
components/user_name.go Normal file
View File

@ -0,0 +1,26 @@
package components
import (
"code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/translation"
g "maragu.dev/gomponents"
gh "maragu.dev/gomponents/html"
)
type UserNameProps struct {
User *user.User
Locale translation.Locale
}
func UserName(data UserNameProps) g.Node {
return gh.A(
gh.Class("text muted"),
gh.Href(data.User.HomeLink()),
g.Group([]g.Node{
g.Text(data.User.Name),
If(data.User.FullName != "" && data.User.FullName != data.User.Name,
g.Text(" ("+data.User.FullName+")"),
),
}),
)
}

1
go.mod
View File

@ -311,6 +311,7 @@ require (
google.golang.org/genproto/googleapis/rpc v0.0.0-20250422160041-2d3770c4ea7f // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
maragu.dev/gomponents v1.1.0 // indirect
)
replace github.com/hashicorp/go-version => github.com/6543/go-version v1.3.1

2
go.sum
View File

@ -1004,6 +1004,8 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
lukechampine.com/uint128 v1.2.0 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
maragu.dev/gomponents v1.1.0 h1:iCybZZChHr1eSlvkWp/JP3CrZGzctLudQ/JI3sBcO4U=
maragu.dev/gomponents v1.1.0/go.mod h1:oEDahza2gZoXDoDHhw8jBNgH+3UR5ni7Ur648HORydM=
modernc.org/cc/v3 v3.40.0 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw=
modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0=
modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw=

View File

@ -57,6 +57,17 @@ func (h *HTMLRender) HTML(w io.Writer, status int, tplName TplName, data any, ct
return t.Execute(w, data)
}
func (h *HTMLRender) Gomponents(w io.Writer, status int, data []byte) error { //nolint:revive
if respWriter, ok := w.(http.ResponseWriter); ok {
if respWriter.Header().Get("Content-Type") == "" {
respWriter.Header().Set("Content-Type", "text/html; charset=utf-8")
}
respWriter.WriteHeader(status)
}
_, err := w.Write(data)
return err
}
func (h *HTMLRender) TemplateLookup(name string, ctx context.Context) (TemplateExecutor, error) { //nolint:revive
tmpls := h.templates.Load()
if tmpls == nil {

View File

@ -7,6 +7,7 @@ import (
"bytes"
"net/http"
"code.gitea.io/gitea/components"
"code.gitea.io/gitea/models/db"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/container"
@ -126,6 +127,116 @@ func RenderUserSearch(ctx *context.Context, opts user_model.SearchUserOptions, t
ctx.HTML(http.StatusOK, tplName)
}
func RenderUserSearch2(ctx *context.Context, opts user_model.SearchUserOptions, title string) {
// Sitemap index for sitemap paths
isSitemap := ctx.PathParam("idx") != ""
if isSitemap {
opts.Page = int(ctx.PathParamInt64("idx"))
opts.PageSize = setting.UI.SitemapPagingNum
} else {
opts.Page = ctx.FormInt("page")
}
if opts.Page <= 1 {
opts.Page = 1
}
var (
users []*user_model.User
count int64
err error
orderBy db.SearchOrderBy
)
// we can not set orderBy to `models.SearchOrderByXxx`, because there may be a JOIN in the statement, different tables may have the same name columns
sortOrder := ctx.FormString("sort")
if sortOrder == "" {
sortOrder = setting.UI.ExploreDefaultSort
}
switch sortOrder {
case "newest":
orderBy = "`user`.id DESC"
case "oldest":
orderBy = "`user`.id ASC"
case "leastupdate":
orderBy = "`user`.updated_unix ASC"
case "reversealphabetically":
orderBy = "`user`.name DESC"
case "lastlogin":
orderBy = "`user`.last_login_unix ASC"
case "reverselastlogin":
orderBy = "`user`.last_login_unix DESC"
case "alphabetically":
orderBy = "`user`.name ASC"
case "recentupdate":
fallthrough
default:
// in case the sortType is not valid, we set it to recentupdate
sortOrder = "recentupdate"
orderBy = "`user`.updated_unix DESC"
}
ctx.Data["SortType"] = sortOrder
if opts.SupportedSortOrders != nil && !opts.SupportedSortOrders.Contains(sortOrder) {
ctx.NotFound(nil)
return
}
opts.Keyword = ctx.FormTrim("q")
opts.OrderBy = orderBy
if len(opts.Keyword) == 0 || isKeywordValid(opts.Keyword) {
users, count, err = user_model.SearchUsers(ctx, opts)
if err != nil {
ctx.ServerError("SearchUsers", err)
return
}
}
if isSitemap {
m := sitemap.NewSitemap()
for _, item := range users {
m.Add(sitemap.URL{URL: item.HTMLURL(), LastMod: item.UpdatedUnix.AsTimePtr()})
}
ctx.Resp.Header().Set("Content-Type", "text/xml")
if _, err := m.WriteTo(ctx.Resp); err != nil {
log.Error("Failed writing sitemap: %v", err)
}
return
}
ctx.Data["Keyword"] = opts.Keyword
ctx.Data["Total"] = count
ctx.Data["Users"] = users
ctx.Data["UsersTwoFaStatus"] = user_model.UserList(users).GetTwoFaStatus(ctx)
ctx.Data["ShowUserEmail"] = setting.UI.ShowUserEmail
ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled
pager := context.NewPagination(int(count), opts.PageSize, opts.Page, 5)
pager.AddParamFromRequest(ctx.Req)
ctx.Data["Page"] = pager
var IsSigned bool
if val, ok := ctx.Data["IsSigned"]; ok && val != nil {
IsSigned = *val.(*bool)
}
data := components.ExploreUsersPage(components.ExploreUsersPageProps{
Title: title,
Locale: ctx.Base.Locale,
Keyword: opts.Keyword,
SortType: sortOrder,
Users: users,
IsSigned: IsSigned,
Context: ctx,
})
var bodyBuffer bytes.Buffer
data.Render(&bodyBuffer)
ctx.Gomponents(http.StatusOK, bodyBuffer.String(), "ExploreUsersPage")
}
// Users render explore users page
func Users(ctx *context.Context) {
if setting.Service.Explore.DisableUsersPage {
@ -135,8 +246,6 @@ func Users(ctx *context.Context) {
ctx.Data["OrganizationsPageIsDisabled"] = setting.Service.Explore.DisableOrganizationsPage
ctx.Data["CodePageIsDisabled"] = setting.Service.Explore.DisableCodePage
ctx.Data["Title"] = ctx.Tr("explore")
ctx.Data["PageIsExplore"] = true
ctx.Data["PageIsExploreUsers"] = true
ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled
supportedSortOrders := container.SetOf(
@ -151,7 +260,7 @@ func Users(ctx *context.Context) {
ctx.SetFormString("sort", sortOrder)
}
RenderUserSearch(ctx, user_model.SearchUserOptions{
RenderUserSearch2(ctx, user_model.SearchUserOptions{
Actor: ctx.Doer,
Type: user_model.UserTypeIndividual,
ListOptions: db.ListOptions{PageSize: setting.UI.ExplorePagingNum},
@ -159,5 +268,5 @@ func Users(ctx *context.Context) {
Visible: []structs.VisibleType{structs.VisibleTypePublic, structs.VisibleTypeLimited, structs.VisibleTypePrivate},
SupportedSortOrders: supportedSortOrders,
}, tplExploreUsers)
}, string(ctx.Tr("explore")))
}

View File

@ -33,6 +33,7 @@ import (
type Render interface {
TemplateLookup(tmpl string, templateCtx context.Context) (templates.TemplateExecutor, error)
HTML(w io.Writer, status int, name templates.TplName, data any, templateCtx context.Context) error
Gomponents(w io.Writer, status int, data []byte) error
}
// Context represents context of a web request.

View File

@ -9,6 +9,7 @@ import (
"html/template"
"net"
"net/http"
"net/http/httptest"
"net/url"
"path"
"strconv"
@ -91,6 +92,31 @@ func (ctx *Context) HTML(status int, name templates.TplName) {
}
}
func (ctx *Context) HTMLPartial(status int, name templates.TplName) (string, error) {
log.Debug("Partial Template: %s", name)
rec := httptest.NewRecorder()
err := ctx.Render.HTML(rec, status, name, ctx.Data, ctx.TemplateContext)
if err != nil {
return "", err
}
return rec.Body.String(), nil
}
func (ctx *Context) Gomponents(status int, html string, tplName string) {
log.Debug("Component: %s", tplName)
err := ctx.Render.Gomponents(ctx.Resp, status, []byte(html))
if err == nil || errors.Is(err, syscall.EPIPE) {
return
}
err = fmt.Errorf("failed to render component: %s, error: %s", tplName, err)
ctx.ServerError("Render failed", err) // show the 500 error page
}
// JSONTemplate renders the template as JSON response
// keep in mind that the template is processed in HTML context, so JSON-things should be handled carefully, eg: by JSEscape
func (ctx *Context) JSONTemplate(tmpl templates.TplName) {

View File

@ -198,3 +198,10 @@ func (tr *MockRender) HTML(w io.Writer, status int, _ templates.TplName, _ any,
}
return nil
}
func (tr *MockRender) Gomponents(w io.Writer, status int, _ string) error {
if resp, ok := w.(http.ResponseWriter); ok {
resp.WriteHeader(status)
}
return nil
}