landed the Member List functionality
This commit is contained in:
+148
@@ -13,6 +13,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.erbosoft.com/amy/amsterdam/database"
|
||||
@@ -238,6 +239,14 @@ func JoinCommunityWithKey(ctxt ui.AmContext) (string, any, error) {
|
||||
return "redirect", fmt.Sprintf("/comm/%s/profile", comm.Alias), nil
|
||||
}
|
||||
|
||||
/* UnjoinCommunity starts the process of unjoining a community.
|
||||
* Parameters:
|
||||
* ctxt - The AmContext for the request.
|
||||
* Returns:
|
||||
* Command string dictating what to be rendered.
|
||||
* Data as a parameter for the command string.
|
||||
* Standard Go error status.
|
||||
*/
|
||||
func UnjoinCommunity(ctxt ui.AmContext) (string, any, error) {
|
||||
me := ctxt.CurrentUser()
|
||||
err := ctxt.SetCommunityContext(ctxt.URLParam("cid"))
|
||||
@@ -264,6 +273,14 @@ func UnjoinCommunity(ctxt ui.AmContext) (string, any, error) {
|
||||
return "framed_template", "unjoin.jet", nil
|
||||
}
|
||||
|
||||
/* UnjoinCommunityConfirm finishes the process of unjoining a community.
|
||||
* Parameters:
|
||||
* ctxt - The AmContext for the request.
|
||||
* Returns:
|
||||
* Command string dictating what to be rendered.
|
||||
* Data as a parameter for the command string.
|
||||
* Standard Go error status.
|
||||
*/
|
||||
func UnjoinCommunityConfirm(ctxt ui.AmContext) (string, any, error) {
|
||||
me := ctxt.CurrentUser()
|
||||
err := ctxt.SetCommunityContext(ctxt.URLParam("cid"))
|
||||
@@ -298,3 +315,134 @@ func UnjoinCommunityConfirm(ctxt ui.AmContext) (string, any, error) {
|
||||
}
|
||||
return ui.ErrorPage(ctxt, errors.New("unknown button pressed to confirm unjoin"))
|
||||
}
|
||||
|
||||
/* MemberList lists the members of the community.
|
||||
* Parameters:
|
||||
* ctxt - The AmContext for the request.
|
||||
* Returns:
|
||||
* Command string dictating what to be rendered.
|
||||
* Data as a parameter for the command string.
|
||||
* Standard Go error status.
|
||||
*/
|
||||
func MemberList(ctxt ui.AmContext) (string, any, error) {
|
||||
err := ctxt.SetCommunityContext(ctxt.URLParam("cid"))
|
||||
if err != nil {
|
||||
ctxt.SetRC(http.StatusNotFound)
|
||||
return ui.ErrorPage(ctxt, err)
|
||||
}
|
||||
comm := ctxt.CurrentCommunity()
|
||||
ofs := 0
|
||||
p := ctxt.Parameter("ofs")
|
||||
if p != "" {
|
||||
v, err := strconv.Atoi(p)
|
||||
if err == nil {
|
||||
ofs = v
|
||||
}
|
||||
}
|
||||
ctxt.SetLeftMenu("community")
|
||||
ctxt.VarMap().Set("comm", comm)
|
||||
showHidden := ctxt.TestPermission("Community.ShowHiddenMembers")
|
||||
ctxt.VarMap().Set("canExport", showHidden)
|
||||
ctxt.VarMap().Set("field", "name")
|
||||
ctxt.VarMap().Set("oper", "st")
|
||||
ctxt.VarMap().Set("term", "")
|
||||
ctxt.VarMap().Set("ofs", ofs)
|
||||
ctxt.VarMap().Set("amsterdam_pageTitle", "List Members")
|
||||
listMax := int(ctxt.Globals().MaxCommunityMemberPage)
|
||||
results, total, err := comm.ListMembers(database.ListMembersFieldNone, database.ListMembersOperNone, "", ofs*listMax, listMax, showHidden)
|
||||
if err != nil {
|
||||
return ui.ErrorPage(ctxt, err)
|
||||
}
|
||||
if total == 0 {
|
||||
ctxt.VarMap().Set("headerLine", "Community Members: (None)")
|
||||
} else {
|
||||
ctxt.VarMap().Set("headerLine", fmt.Sprintf("Community Members: (Displaying %d-%d of %d)",
|
||||
ofs*listMax+1, ofs*listMax+len(results), total))
|
||||
}
|
||||
if len(results) > 0 {
|
||||
ctxt.VarMap().Set("resultList", results)
|
||||
if ofs > 0 {
|
||||
ctxt.VarMap().Set("resultShowPrev", true)
|
||||
}
|
||||
if ofs*listMax+len(results) < total {
|
||||
ctxt.VarMap().Set("resultShowNext", true)
|
||||
}
|
||||
}
|
||||
return "framed_template", "memberlist.jet", nil
|
||||
}
|
||||
|
||||
/* MemberSearch searches for members of the community.
|
||||
* Parameters:
|
||||
* ctxt - The AmContext for the request.
|
||||
* Returns:
|
||||
* Command string dictating what to be rendered.
|
||||
* Data as a parameter for the command string.
|
||||
* Standard Go error status.
|
||||
*/
|
||||
func MemberSearch(ctxt ui.AmContext) (string, any, error) {
|
||||
err := ctxt.SetCommunityContext(ctxt.URLParam("cid"))
|
||||
if err != nil {
|
||||
ctxt.SetRC(http.StatusNotFound)
|
||||
return ui.ErrorPage(ctxt, err)
|
||||
}
|
||||
comm := ctxt.CurrentCommunity()
|
||||
ofs, _ := ctxt.FormFieldInt("ofs")
|
||||
field := ctxt.FormField("field")
|
||||
oper := ctxt.FormField("oper")
|
||||
term := ctxt.FormField("term")
|
||||
ctxt.SetLeftMenu("community")
|
||||
ctxt.VarMap().Set("comm", comm)
|
||||
showHidden := ctxt.TestPermission("Community.ShowHiddenMembers")
|
||||
ctxt.VarMap().Set("canExport", showHidden)
|
||||
ctxt.VarMap().Set("field", field)
|
||||
ctxt.VarMap().Set("oper", oper)
|
||||
ctxt.VarMap().Set("term", term)
|
||||
ctxt.VarMap().Set("ofs", ofs)
|
||||
ctxt.VarMap().Set("amsterdam_pageTitle", "Search for Members")
|
||||
var iField, iOper int
|
||||
switch field {
|
||||
case "name":
|
||||
iField = database.ListMembersFieldName
|
||||
case "descr":
|
||||
iField = database.ListMembersFieldDescription
|
||||
case "first":
|
||||
iField = database.ListMembersFieldFirstName
|
||||
case "last":
|
||||
iField = database.ListMembersFieldLastName
|
||||
default:
|
||||
ctxt.VarMap().Set("errorMessage", "invalid parameter to find")
|
||||
return "framed_template", "memberlist.jet", nil
|
||||
}
|
||||
switch oper {
|
||||
case "st":
|
||||
iOper = database.ListMembersOperPrefix
|
||||
case "in":
|
||||
iOper = database.ListMembersOperSubstring
|
||||
case "re":
|
||||
iOper = database.ListMembersOperRegex
|
||||
default:
|
||||
ctxt.VarMap().Set("errorMessage", "invalid parameter to find")
|
||||
return "framed_template", "memberlist.jet", nil
|
||||
}
|
||||
listMax := int(ctxt.Globals().MaxCommunityMemberPage)
|
||||
results, total, err := comm.ListMembers(iField, iOper, term, ofs*listMax, listMax, showHidden)
|
||||
if err != nil {
|
||||
return ui.ErrorPage(ctxt, err)
|
||||
}
|
||||
if total == 0 {
|
||||
ctxt.VarMap().Set("headerLine", "Search Results: (None)")
|
||||
} else {
|
||||
ctxt.VarMap().Set("headerLine", fmt.Sprintf("Search Results: (Displaying %d-%d of %d)",
|
||||
ofs*listMax+1, ofs*listMax+len(results), total))
|
||||
}
|
||||
if len(results) > 0 {
|
||||
ctxt.VarMap().Set("resultList", results)
|
||||
if ofs > 0 {
|
||||
ctxt.VarMap().Set("resultShowPrev", true)
|
||||
}
|
||||
if ofs*listMax+len(results) < total {
|
||||
ctxt.VarMap().Set("resultShowNext", true)
|
||||
}
|
||||
}
|
||||
return "framed_template", "memberlist.jet", nil
|
||||
}
|
||||
|
||||
@@ -80,6 +80,20 @@ const (
|
||||
SearchCommOperRegex = 2
|
||||
)
|
||||
|
||||
// Field and operator selectors for ListMembers.
|
||||
const (
|
||||
ListMembersFieldNone = -1
|
||||
ListMembersFieldName = 0
|
||||
ListMembersFieldDescription = 1
|
||||
ListMembersFieldFirstName = 2
|
||||
ListMembersFieldLastName = 3
|
||||
|
||||
ListMembersOperNone = -1
|
||||
ListMembersOperPrefix = 0
|
||||
ListMembersOperSubstring = 1
|
||||
ListMembersOperRegex = 2
|
||||
)
|
||||
|
||||
// communityCache is the cache for Community objects.
|
||||
var communityCache *lru.TwoQueueCache = nil
|
||||
|
||||
@@ -231,6 +245,96 @@ func (c *Community) MemberCount(hidden bool) (int, error) {
|
||||
return -1, errors.New("internal error reading member count")
|
||||
}
|
||||
|
||||
/* ListMembers lists or searches for community members matching certain criteria.
|
||||
* Parameters:
|
||||
* field - A value indicating which field to search:
|
||||
* ListMembersFieldNone - Do not search, just return all community members.
|
||||
* ListMembersFieldName - The user name.
|
||||
* ListMembersFieldDescription - The user description.
|
||||
* ListMembersFieldFirstName - The user's first name.
|
||||
* ListMembersFieldLastName - The user's last name.
|
||||
* oper - The operation to perform on the search field:
|
||||
* ListMembersOperNone - Do not search, just return all community members.
|
||||
* ListMembersOperPrefix - The specified field has the string "term" as a prefix.
|
||||
* ListMembersOperSubstring - The specified field contains the string "term".
|
||||
* ListMembersOperRegex - The specified field matches the regular expression in "term".
|
||||
* term - The search term, as specified above.
|
||||
* offset - Number of members to skip at beginning of list.
|
||||
* max - Maximum number of members to return.
|
||||
* Returns:
|
||||
* Array of User pointers representing the return elements.
|
||||
* The total number of members matching this query (could be greater than max)
|
||||
* Standard Go error status.
|
||||
*/
|
||||
func (c *Community) ListMembers(field int, oper int, term string, offset int, max int, showHidden bool) ([]*User, int, error) {
|
||||
var query strings.Builder
|
||||
if field != ListMembersFieldNone && oper != ListMembersOperNone {
|
||||
query.WriteString(" AND ")
|
||||
switch field {
|
||||
case ListMembersFieldName:
|
||||
query.WriteString("u.username ")
|
||||
case ListMembersFieldDescription:
|
||||
query.WriteString("u.description ")
|
||||
case ListMembersFieldFirstName:
|
||||
query.WriteString("c.given_name ")
|
||||
case ListMembersFieldLastName:
|
||||
query.WriteString("c.family_name ")
|
||||
default:
|
||||
return nil, -1, errors.New("invalid field selector")
|
||||
}
|
||||
switch oper {
|
||||
case ListMembersOperPrefix:
|
||||
query.WriteString("LIKE '")
|
||||
query.WriteString(util.SqlEscape(term, true))
|
||||
query.WriteString("%'")
|
||||
case ListMembersOperSubstring:
|
||||
query.WriteString("LIKE '%")
|
||||
query.WriteString(util.SqlEscape(term, true))
|
||||
query.WriteString("%'")
|
||||
case ListMembersOperRegex:
|
||||
query.WriteString("REGEXP '")
|
||||
query.WriteString(util.SqlEscape(term, false))
|
||||
query.WriteString("'")
|
||||
default:
|
||||
return nil, -1, errors.New("invalid operator selector")
|
||||
}
|
||||
}
|
||||
if !showHidden {
|
||||
query.WriteString(" AND m.hidden = 0")
|
||||
}
|
||||
q := query.String()
|
||||
rs, err := amdb.Query(`SELECT COUNT(*) FROM commmember m, users u, contacts c WHERE m.commid = ? AND m.uid = u.uid
|
||||
AND u.contactid = c.contactid`+q, c.Id)
|
||||
if err != nil {
|
||||
return nil, -1, err
|
||||
}
|
||||
if !rs.Next() {
|
||||
return nil, -1, errors.New("internal error getting member count")
|
||||
}
|
||||
var total int
|
||||
rs.Scan(&total)
|
||||
if offset > 0 {
|
||||
rs, err = amdb.Query(`SELECT m.uid FROM commmember m, users u, contacts c WHERE m.commid = ? AND m.uid = u.uid
|
||||
AND u.contactid = c.contactid`+q+" ORDER BY u.username LIMIT ? OFFSET ?", c.Id, max, offset)
|
||||
} else {
|
||||
rs, err = amdb.Query(`SELECT m.uid FROM commmember m, users u, contacts c WHERE m.commid = ? AND m.uid = u.uid
|
||||
AND u.contactid = c.contactid`+q+" ORDER BY u.username LIMIT ?", c.Id, max)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, total, err
|
||||
}
|
||||
rc := make([]*User, 0, min(max, 10000))
|
||||
for rs.Next() {
|
||||
var uid int32
|
||||
rs.Scan(&uid)
|
||||
u, err := AmGetUser(uid)
|
||||
if err == nil {
|
||||
rc = append(rc, u)
|
||||
}
|
||||
}
|
||||
return rc, total, nil
|
||||
}
|
||||
|
||||
/* SetMembership sets a user's membership status within the community.
|
||||
* Parameters:
|
||||
* u - The user to change the membership status of.
|
||||
|
||||
@@ -52,5 +52,5 @@ domains:
|
||||
requirePermission: "Community.Read"
|
||||
requireRole: "Community.Member"
|
||||
linkSequence: 4800
|
||||
link: "/TODO/comm/[CID]/members"
|
||||
link: "/comm/[CID]/members"
|
||||
title: "Members"
|
||||
|
||||
@@ -76,6 +76,8 @@ func setupEcho() *echo.Echo {
|
||||
e.POST("/comm/:cid/join", ui.AmWrap(JoinCommunityWithKey))
|
||||
e.GET("/comm/:cid/unjoin", ui.AmWrap(UnjoinCommunity))
|
||||
e.POST("/comm/:cid/unjoin", ui.AmWrap(UnjoinCommunityConfirm))
|
||||
e.GET("/comm/:cid/members", ui.AmWrap(MemberList))
|
||||
e.POST("/comm/:cid/members", ui.AmWrap(MemberSearch))
|
||||
e.GET("/comm/:cid/admin", ui.AmWrap(CommunityAdminMenu))
|
||||
e.GET("/comm/:cid/admin/profile", ui.AmWrap(CommunityProfileForm))
|
||||
e.POST("/comm/:cid/admin/profile", ui.AmWrap(EditCommunityProfile))
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
{*
|
||||
* Amsterdam Web Communities System
|
||||
* Copyright (c) 2025 Erbosoft Metaverse Design Solutions, All Rights Reserved
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*}
|
||||
<div class="p-4">
|
||||
<!-- Page Title with Tabs -->
|
||||
<div class="mb-6">
|
||||
<div class="flex items-baseline gap-2">
|
||||
<h1 class="text-blue-800 text-4xl font-bold">Find:</h1>
|
||||
<span class="text-blue-800 text-2xl">{{ comm.Name }}</span>
|
||||
</div>
|
||||
<hr class="border-2 border-gray-400 w-4/5 mt-2 mb-6">
|
||||
</div>
|
||||
{{ if canExport }}
|
||||
<div class="flex">
|
||||
[
|
||||
<a href="/TODO/comm/{{ comm.Alias }}/member_export" class="text-blue-700 hover:text-blue-900">Export Member List</a>
|
||||
]
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
{{ if isset(errorMessage) }}
|
||||
<!-- Error Message Banner -->
|
||||
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-6" id="error-banner">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-shrink-0">
|
||||
<span class="text-red-500 text-xl">⚠️</span>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<p class="text-sm font-medium" id="error-message">{{ CapitalizeString(errorMessage) }}.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
<!-- Search Form -->
|
||||
<div class="max-w-3xl mb-8">
|
||||
<form method="POST" action="/comm/{{ comm.Alias }}/members">
|
||||
<input type="hidden" name="ofs" value="0">
|
||||
<div class="bg-gray-50 p-6 rounded-lg">
|
||||
<h2 class="text-xl font-bold text-black mb-4">Find Members of Community "{{ comm.Name }}":</h2>
|
||||
<div class="space-y-4">
|
||||
<!-- Field Selection -->
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="text-black">Display all community members whose</span>
|
||||
<select name="field"
|
||||
class="px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="name" {{ if field == "name" }}selected{{ end }}>user name</option>
|
||||
<option value="descr"{{ if field == "descr" }}selected{{ end }}>description</option>
|
||||
<option value="first"{{ if field == "first" }}selected{{ end }}>first name</option>
|
||||
<option value="last"{{ if field == "last" }}selected{{ end }}>last name</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Search Criteria -->
|
||||
<div class="flex items-center gap-2 text-sm flex-wrap">
|
||||
<select name="oper"
|
||||
class="px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="st" {{ if oper == "st" }}selected{{ end }}>starts with the string</option>
|
||||
<option value="in" {{ if oper == "in" }}selected{{ end }}>contains the string</option>
|
||||
<option value="re" {{ if oper == "in" }}selected{{ end }}>matches the regular expression</option>
|
||||
</select>
|
||||
<input type="text" name="term" size="32" maxlength="255" value="{{ term }}"
|
||||
placeholder="Enter search term..."
|
||||
class="flex-1 min-w-64 px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
|
||||
<!-- Search Button -->
|
||||
<div>
|
||||
<button type="submit" name="search"
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white px-8 py-2 rounded font-medium transition-colors">
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Member List -->
|
||||
<hr class="border-gray-400 mb-4">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<div class="text-sm text-black font-bold">{{ headerLine }}</div>
|
||||
</div>
|
||||
{{ if isset(resultList) }}
|
||||
<!-- Results List -->
|
||||
<div class="bg-gray-50 p-6 rounded-lg">
|
||||
<div class="space-y-4">
|
||||
{{ range _, rx := resultList }}
|
||||
<!-- User Result -->
|
||||
<div class="flex items-start gap-3">
|
||||
<span class="text-sm pt-0.5 flex-shrink-0">🟣</span>
|
||||
<div class="flex-1">
|
||||
<div class="mb-2">
|
||||
<a href="/user/{{ rx.Username }}"
|
||||
class="text-blue-700 hover:text-blue-900 font-bold text-base">{{ rx.Username }}</a>
|
||||
</div>
|
||||
<div class="text-sm text-gray-700 space-y-1">
|
||||
<div>
|
||||
{{ ci := rx.ContactInfoQ() }}
|
||||
{{ DisplayFullName(ci) }}, from {{ ci.Locality }}, {{ ci.Region }} {{ ci.Country }}
|
||||
{{ if rx.Uid == comm.HostUid }}<span class="ml-1">👑</span>{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ if isset(resultShowPrev) || isset(resultShowNext) }}
|
||||
<div class="flex justify-center gap-4 mt-6">
|
||||
{{ if isset(fromSearch) }}
|
||||
<form method="POST" action="/comm/{{ comm.Alias }}/members">
|
||||
<input type="hidden" name="ofs" value="{{ ofs }}"/>
|
||||
<input type="hidden" name="field" value="{{ field }}"/>
|
||||
<input type="hidden" name="oper" value="{{ oper }}"/>
|
||||
<input type="hidden" name="term" value="{{ term }}"/>
|
||||
{{ if isset(resultShowPrev) }}
|
||||
<button type="submit" name="prev"
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white px-8 py-2 rounded font-medium transition-colors">
|
||||
⏪ Prev
|
||||
</button>
|
||||
{{ end }}
|
||||
{{ if isset(resultShowNext) }}
|
||||
<button type="submit" name="next"
|
||||
class="bg-blue-600 hover:bg-blue-700 text-white px-8 py-2 rounded font-medium transition-colors">
|
||||
Next ⏩
|
||||
</button>
|
||||
{{ end }}
|
||||
</form>
|
||||
{{ else }}
|
||||
{{ if isset(resultShowPrev) }}
|
||||
<a href="/comm/{{ comm.Alias }}/members?ofs={{ ofs - 1 }}"
|
||||
class="inline-block bg-blue-600 hover:bg-blue-700 text-white px-4 py-1 rounded text-xs font-medium transition-colors">
|
||||
⏪ Prev
|
||||
</a>
|
||||
{{ end }}
|
||||
{{ if isset(resultShowNext) }}
|
||||
<a href="/comm/{{ comm.Alias }}/members?ofs={{ ofs + 1 }}"
|
||||
class="inline-block bg-blue-600 hover:bg-blue-700 text-white px-4 py-1 rounded text-xs font-medium transition-colors">
|
||||
Next ⏩
|
||||
</a>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
Reference in New Issue
Block a user