0
0
mirror of https://github.com/go-gitea/gitea.git synced 2025-07-19 15:00:52 +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 google.golang.org/genproto/googleapis/rpc v0.0.0-20250422160041-2d3770c4ea7f // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v2 v2.4.0 // 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 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= 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 h1:mBi/5l91vocEN8otkC5bDLhi2KdCticRiwbdB0O+rjI=
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk= 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 h1:P3g79IUS/93SYhtoeaHW+kRCIrYaxJ27MFPv+7kaTOw=
modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0= modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0=
modernc.org/ccgo/v3 v3.16.13 h1:Mkgdzl46i5F/CNR/Kj80Ri59hC8TKAhZrYSaqvkwzUw= 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) 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 func (h *HTMLRender) TemplateLookup(name string, ctx context.Context) (TemplateExecutor, error) { //nolint:revive
tmpls := h.templates.Load() tmpls := h.templates.Load()
if tmpls == nil { if tmpls == nil {

View File

@ -7,6 +7,7 @@ import (
"bytes" "bytes"
"net/http" "net/http"
"code.gitea.io/gitea/components"
"code.gitea.io/gitea/models/db" "code.gitea.io/gitea/models/db"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/container" "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) 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 // Users render explore users page
func Users(ctx *context.Context) { func Users(ctx *context.Context) {
if setting.Service.Explore.DisableUsersPage { if setting.Service.Explore.DisableUsersPage {
@ -135,8 +246,6 @@ func Users(ctx *context.Context) {
ctx.Data["OrganizationsPageIsDisabled"] = setting.Service.Explore.DisableOrganizationsPage ctx.Data["OrganizationsPageIsDisabled"] = setting.Service.Explore.DisableOrganizationsPage
ctx.Data["CodePageIsDisabled"] = setting.Service.Explore.DisableCodePage ctx.Data["CodePageIsDisabled"] = setting.Service.Explore.DisableCodePage
ctx.Data["Title"] = ctx.Tr("explore") ctx.Data["Title"] = ctx.Tr("explore")
ctx.Data["PageIsExplore"] = true
ctx.Data["PageIsExploreUsers"] = true
ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled ctx.Data["IsRepoIndexerEnabled"] = setting.Indexer.RepoIndexerEnabled
supportedSortOrders := container.SetOf( supportedSortOrders := container.SetOf(
@ -151,7 +260,7 @@ func Users(ctx *context.Context) {
ctx.SetFormString("sort", sortOrder) ctx.SetFormString("sort", sortOrder)
} }
RenderUserSearch(ctx, user_model.SearchUserOptions{ RenderUserSearch2(ctx, user_model.SearchUserOptions{
Actor: ctx.Doer, Actor: ctx.Doer,
Type: user_model.UserTypeIndividual, Type: user_model.UserTypeIndividual,
ListOptions: db.ListOptions{PageSize: setting.UI.ExplorePagingNum}, 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}, Visible: []structs.VisibleType{structs.VisibleTypePublic, structs.VisibleTypeLimited, structs.VisibleTypePrivate},
SupportedSortOrders: supportedSortOrders, SupportedSortOrders: supportedSortOrders,
}, tplExploreUsers) }, string(ctx.Tr("explore")))
} }

View File

@ -33,6 +33,7 @@ import (
type Render interface { type Render interface {
TemplateLookup(tmpl string, templateCtx context.Context) (templates.TemplateExecutor, error) TemplateLookup(tmpl string, templateCtx context.Context) (templates.TemplateExecutor, error)
HTML(w io.Writer, status int, name templates.TplName, data any, templateCtx context.Context) 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. // Context represents context of a web request.

View File

@ -9,6 +9,7 @@ import (
"html/template" "html/template"
"net" "net"
"net/http" "net/http"
"net/http/httptest"
"net/url" "net/url"
"path" "path"
"strconv" "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 // 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 // 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) { 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 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
}