diff --git a/community.go b/community.go new file mode 100644 index 0000000..2183e38 --- /dev/null +++ b/community.go @@ -0,0 +1,136 @@ +/* + * 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/. + */ +// Package main contains the high-level Amsterdam logic. +package main + +import ( + "net/http" + "strings" + + "git.erbosoft.com/amy/amsterdam/database" + "git.erbosoft.com/amy/amsterdam/ui" + "github.com/biter777/countries" + "golang.org/x/text/language/display" +) + +func ShowCommunity(ctxt ui.AmContext) (string, any, error) { + me := ctxt.CurrentUser() + prefs, err := me.Prefs() + if err != nil { + return ui.ErrorPage(ctxt, err) + } + globals, err := database.AmGlobals() + if err != nil { + return ui.ErrorPage(ctxt, err) + } + globalFlags, err := globals.Flags() + if err != nil { + return ui.ErrorPage(ctxt, err) + } + comm, err := database.AmGetCommunityFromParam(ctxt.URLParam("cid")) + if err != nil { + ctxt.SetRC(http.StatusNotFound) + return ui.ErrorPage(ctxt, err) + } + member, _, level, err := comm.Membership(me) + if err != nil { + return ui.ErrorPage(ctxt, err) + } + effectiveLevel := me.BaseLevel + if member && level > effectiveLevel { + effectiveLevel = level + } + ci, err := comm.ContactInfo() + if err != nil { + return ui.ErrorPage(ctxt, err) + } + host, err := comm.Host() + if err != nil { + return ui.ErrorPage(ctxt, err) + } + var cats []*database.Category + if !globalFlags.Get(database.GlobalFlagNoCategories) { + cats, err = database.AmGetCategoryHierarchy(comm.CategoryId) + if err != nil { + return ui.ErrorPage(ctxt, err) + } + } + var pvtAddr bool + if database.AmTestPermission("Global.SeeHiddenContactInfo", effectiveLevel) { + pvtAddr = false + } else { + pvtAddr = ci.PrivateAddr + } + + ctxt.VarMap().Set("commName", comm.Name) + // TODO: set photo URL + tz := prefs.Location() + loc := prefs.Localizer() + ctxt.VarMap().Set("dateCreated", loc.Strftime("%x %X", comm.CreateDate.In(tz))) + if comm.LastAccess != nil { + ctxt.VarMap().Set("dateLastAccess", loc.Strftime("%x %X", (*comm.LastAccess).In(tz))) + } + if comm.LastUpdate != nil { + ctxt.VarMap().Set("dateLastUpdate", loc.Strftime("%x %X", (*comm.LastUpdate).In(tz))) + } + if !member && effectiveLevel >= comm.JoinLevel { + ctxt.VarMap().Set("canJoin", true) + } + if member && !me.IsAnon { + ctxt.VarMap().Set("canInvite", true) + } + ctxt.VarMap().Set("public", comm.Public()) + if !globalFlags.Get(database.GlobalFlagNoCategories) { + ctxt.VarMap().Set("categories", cats) + } + if comm.Synopsis != nil && *comm.Synopsis != "" { + ctxt.VarMap().Set("description", *comm.Synopsis) + } + ctxt.VarMap().Set("hostName", host.Username) + if ci.Company != nil && *ci.Company != "" { + ctxt.VarMap().Set("company", *ci.Company) + } + if !pvtAddr && ci.Addr1 != nil && *ci.Addr1 != "" { + ctxt.VarMap().Set("addr1", *ci.Addr1) + } + if !pvtAddr && ci.Addr2 != nil && *ci.Addr2 != "" { + ctxt.VarMap().Set("addr2", *ci.Addr2) + } + var b strings.Builder + if ci.Locality != nil { + b.WriteString(*ci.Locality) + if ci.Region != nil { + b.WriteString(", ") + } + } + if ci.Region != nil { + b.WriteString(*ci.Region) + } + if ci.PostalCode != nil { + b.WriteString(" " + *ci.PostalCode) + } + ctxt.VarMap().Set("addrLast", b.String()) + if ci.Country != nil && *ci.Country != "" { + country := countries.ByName(*ci.Country) + ctxt.VarMap().Set("country", country.String()) + } + tag, err := comm.LanguageTag() + if err == nil && tag != nil { + ctxt.VarMap().Set("language", display.Languages(*prefs.LanguageTag()).Name(tag)) + } + if comm.Rules != nil && *comm.Rules != "" { + ctxt.VarMap().Set("rules", *comm.Rules) + } + if ci.URL != nil && *ci.URL != "" { + ctxt.VarMap().Set("homePage", *ci.URL) + } + + ctxt.VarMap().Set("amsterdam_pageTitle", "Community Profile: "+comm.Name) + return "framed_template", "comprofile.jet", nil +} diff --git a/database/category.go b/database/category.go new file mode 100644 index 0000000..d88768a --- /dev/null +++ b/database/category.go @@ -0,0 +1,115 @@ +/* + * 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/. + */ +// The database package contains database management and storage logic. +package database + +import ( + "errors" + "sync" +) + +// Category is the structure defining a category. +type Category struct { + CatId int32 `db:"catid"` + Parent int32 `db:"parent"` + SymLink int32 `db:"symlink"` + HideDirectory int32 `db:"hide_dir"` + HideSearch int32 `db:"hide_search"` + Name string `db:"name"` +} + +// allCategories is the list of all categories loaded from the database. +var allCategories []Category + +// categoryIdMap maps IDs to categories. +var categoryIdMap map[int32]*Category = make(map[int32]*Category) + +// categoryMutex syncs the loading of the categories. +var categoryMutex sync.Mutex + +// loadCategories loads the categories list from the database. +func loadCategories() error { + categoryMutex.Lock() + defer categoryMutex.Unlock() + if allCategories == nil { + rs, err := amdb.Query("SELECT COUNT(*) FROM refcategory") + if err != nil { + return err + } + if !rs.Next() { + return errors.New("internal error loading categories") + } + var ncats int32 + rs.Scan(&ncats) + allCategories = make([]Category, 0, ncats) + err = amdb.Select(&allCategories, "SELECT * FROM refcategory ORDER BY parent, name") + if err != nil { + return err + } + for i, c := range allCategories { + categoryIdMap[c.CatId] = &(allCategories[i]) + } + } + return nil +} + +/* AmGetCategory returns the category for the given name. + * Parameters: + * catid - The ID of the category to get. + * Returns: + * Pointer to the appropriate Category, or nil. + * Standard Go error status. + */ +func AmGetCategory(catid int32) (*Category, error) { + err := loadCategories() + if err != nil { + return nil, err + } + c := categoryIdMap[catid] + d := 5 + for c.SymLink != -1 { + d-- + if d == 0 { + return nil, errors.New("symlink resolution error") + } + c = categoryIdMap[c.SymLink] + } + return c, nil +} + +/* AmGetCategoryHierarchy returns the category hierarchy for the given ID. + * Parameters: + * catid - The ID of the category to get. + * Returns: + * Array of pointers to the categories in hierarchical order, or nil. + * Standard Go error status. + */ +func AmGetCategoryHierarchy(catid int32) ([]*Category, error) { + err := loadCategories() + if err != nil { + return nil, err + } + // walk all the way to the "root" (parent = -1) + p := catid + ia := make([]*Category, 0, 3) + for p != -1 { + c := categoryIdMap[p] + for c.SymLink != -1 { + c = categoryIdMap[c.SymLink] + } + ia = append(ia, c) + p = c.Parent + } + // reverse the array for return + rc := make([]*Category, 0, len(ia)) + for i := range ia { + rc = append(rc, ia[len(ia)-(i+1)]) + } + return rc, nil +} diff --git a/database/community.go b/database/community.go index 6589bf7..8370322 100644 --- a/database/community.go +++ b/database/community.go @@ -12,10 +12,13 @@ package database import ( "fmt" "slices" + "strconv" "sync" "time" + "git.erbosoft.com/amy/amsterdam/util" lru "github.com/hashicorp/golang-lru" + "golang.org/x/text/language" ) // Community struct contains the high level data for a community. @@ -52,6 +55,27 @@ var communityCache *lru.TwoQueueCache = nil // getCommunityMutex is a mutex on AmGetCommunity. var getCommunityMutex sync.Mutex +// memberCacheData caches membership information for communities. +type memberCacheData struct { + isMember bool + locked bool + level uint16 +} + +// memberCache contains the memberCacheData entries. +var memberCache *lru.Cache = nil + +// memberMutex syncs access to the memberCache. +var memberMutex sync.Mutex + +// stuffMembership stuffs a membership record into the cache. +func stuffMembership(cid int32, uid int32, member bool, locked bool, level uint16) { + key := fmt.Sprintf("%d:%d", cid, uid) + memberMutex.Lock() + memberCache.Add(key, &memberCacheData{isMember: member, locked: locked, level: level}) + memberMutex.Unlock() +} + // init initializes the community cache. func init() { var err error @@ -59,6 +83,98 @@ func init() { if err != nil { panic(err) } + memberCache, err = lru.New(250) + if err != nil { + panic(err) + } +} + +// Public returns true if the community is public. +func (c *Community) Public() bool { + return c.JoinKey == nil || *c.JoinKey == "" +} + +// ContactInfo returns the contact info structure for the community. +func (c *Community) ContactInfo() (*ContactInfo, error) { + if c.ContactId < 0 { + return nil, nil + } + return AmGetContactInfo(c.ContactId) +} + +// Host returns the reference to the host of the community. +func (c *Community) Host() (*User, error) { + if c.HostUid == nil { + return nil, nil + } + return AmGetUser(*c.HostUid) +} + +func (c *Community) LanguageTag() (*language.Tag, error) { + if c.Language == nil { + return nil, nil + } + t, err := language.Parse(*c.Language) + if err != nil { + return nil, err + } + return &t, nil +} + +/* Membership returns the details of the specified user's membership in the community. + * Parameters: + * u - The user to check the membership of. + * Returns: + * true if the user is a member, false if not. + * true if the user's membership is "locked" (cannot unjoin), false if not. + * User's access level in the community, or 0 if the user is not a member. + * Standard Go error status. + */ +func (c *Community) Membership(u *User) (bool, bool, uint16, error) { + key := fmt.Sprintf("%d:%d", c.Id, u.Uid) + memberMutex.Lock() + defer memberMutex.Unlock() + mbr, ok := memberCache.Get(key) + if ok { + m := mbr.(*memberCacheData) + return m.isMember, m.locked, m.level, nil + } + if AmTestPermission("Community.NoJoinRequired", u.BaseLevel) { + // "no join required" - they are effectively a member, but don't cache that + return true, false, u.BaseLevel, nil + } + rs, err := amdb.Query("SELECT locked, granted_lvl FROM commmember WHERE commid = ? AND uid = ?", c.Id, u.Uid) + if err == nil { + if rs.Next() { + var locked bool + var level uint16 + rs.Scan(&locked, &level) + memberCache.Add(key, &memberCacheData{isMember: true, locked: locked, level: level}) + return true, locked, level, nil + } + memberCache.Add(key, &memberCacheData{isMember: false, locked: false, level: uint16(0)}) + } + return false, false, uint16(0), err +} + +/* TestPermission is shorthand that tests if a user has a permission with respect to the community. + * Parameters: + * user - The user to be checked. + * perm - The permission to be tested. + * Returns: + * true if the user has the permission, false if not. + * Standard Go error status. + */ +func (c *Community) TestPermission(user *User, perm string) (bool, error) { + member, _, level, err := c.Membership(user) + if err != nil { + return false, err + } + effectiveLevel := user.BaseLevel + if member && level > effectiveLevel { + effectiveLevel = level + } + return AmTestPermission(perm, effectiveLevel), nil } /* AmGetCommunity returns a reference to the specified community. @@ -79,7 +195,9 @@ func AmGetCommunity(id int32) (*Community, error) { if err != nil { return nil, err } - if len(dbdata) > 1 { + if len(dbdata) == 0 { + return nil, fmt.Errorf("community with ID %d not found", id) + } else if len(dbdata) > 1 { return nil, fmt.Errorf("AmGetCommunity(%d): too many responses(%d)", id, len(dbdata)) } rc = &(dbdata[0]) @@ -88,6 +206,37 @@ func AmGetCommunity(id int32) (*Community, error) { return rc.(*Community), err } +/* AmGetCommunityFromParam returns a reference to the specified community based on the parameter. + * If the parameter is numeric, it's interpreted as a community ID. Otherwise, it's interpreted + * as a community alias. + * Parameters: + * id - The ID of the community. + * Returns: + * Pointer to Community containing community data, or nil + * Standard Go error status + */ +func AmGetCommunityFromParam(param string) (*Community, error) { + if util.IsNumeric(param) { + v, _ := strconv.Atoi(param) + c, err := AmGetCommunity(int32(v)) + if err == nil { + return c, nil + } + // else fall through to trying as alias + } + rs, err := amdb.Query("SELECT commid FROM communities WHERE alias = ?", param) + if err == nil { + if rs.Next() { + var cid int32 + rs.Scan(&cid) + return AmGetCommunity(cid) + } else { + return nil, fmt.Errorf("community with alias \"%s\" not found", param) + } + } + return nil, err +} + /* AmGetCommunitiesForUser returns a list of communities the user is a member of. * Parameters: * uid - The ID of the user. @@ -164,6 +313,7 @@ func AmAutoJoinCommunities(user *User) error { if err != nil { break } + stuffMembership(cid, user.Uid, true, lock, grantLevel) } } } diff --git a/database/globals.go b/database/globals.go new file mode 100644 index 0000000..5687efb --- /dev/null +++ b/database/globals.go @@ -0,0 +1,160 @@ +/* + * 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/. + */ +// The database package contains database management and storage logic. +package database + +import ( + "errors" + "sync" + + "git.erbosoft.com/amy/amsterdam/util" +) + +// Globals contains the global data. +type Globals struct { + Mutex sync.Mutex + PostsPerPage int32 `db:"posts_per_page"` + OldPostsAtTop int32 `db:"old_posts_at_top"` + MaxSearchPage int32 `db:"max_search_page"` + MaxCommunityMemberPage int32 `db:"max_comm_mbr_page"` + MaxConferenceMemberPage int32 `db:"max_conf_mbr_page"` + FrontPagePosts int32 `db:"fp_posts"` + NumAuditPage int32 `db:"num_audit_page"` + CommunityCreateLevel int32 `db:"comm_create_lvl"` + flags *util.OptionSet +} + +// GlobalProperties contains global property entries. +type GlobalProperties struct { + Index int32 `db:"ndx"` + Data string `db:"data"` +} + +// Global property indexes defined. +const ( + GlobalPropFlags = int32(0) +) + +// Global flag indexes defined. +const ( + GlobalFlagPicturesInPosts = uint(0) + GlobalFlagNoCategories = uint(1) +) + +// theGlobals contains the singleton instance of Globals. +var theGlobals *Globals = nil + +// globalsMutex controls access to theGlobals. +var globalsMutex sync.Mutex + +// globalProps is the global properties store. +var globalProps map[int32]string = make(map[int32]string) + +// globalPropMutex controls access to globalProps. +var globalPropMutex sync.Mutex + +// Flags returns the global flags. +func (g *Globals) Flags() (*util.OptionSet, error) { + g.Mutex.Lock() + defer g.Mutex.Unlock() + if g.flags == nil { + s, err := AmGetGlobalProperty(GlobalPropFlags) + if err != nil { + return nil, err + } + g.flags = util.OptionSetFromString(s) + } + return g.flags, nil +} + +// SaveFlags saves off the global flags. +func (g *Globals) SaveFlags(f *util.OptionSet) error { + s := f.AsString() + g.Mutex.Lock() + defer g.Mutex.Unlock() + err := AmSetGlobalProperty(GlobalPropFlags, s) + if err == nil { + g.flags = f + } + return err +} + +// AmGlobals returns trhe pointer to the singleton Globals instance. +func AmGlobals() (*Globals, error) { + globalsMutex.Lock() + defer globalsMutex.Unlock() + if theGlobals == nil { + var dbdata []Globals + err := amdb.Select(&dbdata, "SELECT * FROM globals") + if err != nil { + return nil, err + } + if len(dbdata) > 1 { + return nil, errors.New("should only be one globals record") + } + theGlobals = &(dbdata[0]) + } + return theGlobals, nil +} + +/* AmGetGlobalProperty returns the value of a global property. + * Parameters: + * index - The index of the property to retrieve. + * Returns: + * Value of the property, or empty string. + * Standard Go error status. + */ +func AmGetGlobalProperty(index int32) (string, error) { + globalPropMutex.Lock() + defer globalPropMutex.Unlock() + rc, ok := globalProps[index] + if !ok { + rs, err := amdb.Query("SELECT data FROM propglobal WHERE ndx = ?", index) + if err != nil { + return "", err + } + if rs.Next() { + rs.Scan(&rc) + globalProps[index] = rc + return rc, nil + } + rc = "" + } + return rc, nil +} + +/* AmSetGlobalProperty sets the value of a global property. + * Parameters: + * index - The index of the property to set. + * value - The value of the property to set. + * Returns: + * Standard Go error status. + */ +func AmSetGlobalProperty(index int32, value string) error { + globalPropMutex.Lock() + defer globalPropMutex.Unlock() + _, updateMode := globalProps[index] + if !updateMode { + rs, err := amdb.Query("SELECT data FROM propglobal WHERE ndx = ?", index) + if err != nil { + return err + } + updateMode = rs.Next() + } + var err error = nil + if updateMode { + _, err = amdb.Exec("UPDATE propglobal SET data = ? WHERE ndx = ?", value, index) + } else { + _, err = amdb.Exec("INSERT INTO propglobal (ndx, data) VALUES (?, ?)", index, value) + } + if err == nil { + globalProps[index] = value + } + return err +} diff --git a/database/servicedefs.yaml b/database/servicedefs.yaml new file mode 100644 index 0000000..d145ef0 --- /dev/null +++ b/database/servicedefs.yaml @@ -0,0 +1,47 @@ +# +# 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/. +# +domains: + - domain: "community" + services: + - id: "Profile" + index: 0 + default: true + locked: true + requirePermission: "" + requireRole: "" + linkSequence: 4900 + link: "/comm/[CID]/profile" + title: "Profile" + - id: "Admin" + index: 1 + default: true + locked: true + requirePermission: "Community.Read" + requireRole: "Community.AnyAdmin" + linkSequence: 5000 + link: "/TODO/comm/[CID]/admin" + title: "Administration" + - id: "Conference" + index: 3 + default: true + locked: false + requirePermission: "Community.Read" + requireRole: "" + linkSequence: 500 + link: "/TODO/comm/[CID]/conf" + title: "Conferences" + - id: "Members" + index: 4 + default: true + locked: true + requirePermission: "Community.Read" + requireRole: "Community.Member" + linkSequence: 4800 + link: "/TODO/comm/[CID]/members" + title: "Members" diff --git a/database/services.go b/database/services.go new file mode 100644 index 0000000..b794c27 --- /dev/null +++ b/database/services.go @@ -0,0 +1,116 @@ +/* + * 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/. + */ +// The database package contains database management and storage logic. +package database + +import ( + _ "embed" + "slices" + "sync" + + lru "github.com/hashicorp/golang-lru" + "gopkg.in/yaml.v3" +) + +// ServiceDef holds the definition for an individual service. +type ServiceDef struct { + Id string `yaml:"id"` + Index int16 `yaml:"index"` + Default bool `yaml:"default"` + Locked bool `yaml:"locked"` + RequirePermission string `yaml:"requirePermission"` + RequireRole string `yaml:"requireRole"` + LinkSequence int `yaml:"linkSequence"` + Link int `yaml:"link"` + Title string `yaml:"title"` +} + +// ServiceDomain holds each individual configured service domain. +type ServiceDomain struct { + DomainName string `yaml:"domain"` + Services []ServiceDef `yaml:"services"` + byId map[string]*ServiceDef + byIndex map[int16]*ServiceDef + seqOrder []*ServiceDef +} + +// ServiceConfiguration holds the service configuration. +type ServiceConfiguration struct { + Domains []ServiceDomain `yaml:"domains"` + byName map[string]*ServiceDomain +} + +//go:embed servicedefs.yaml +var initServiceData []byte + +// The service configuration loaded from YAML. +var serviceRoot ServiceConfiguration + +// The services cache for communities. +var servicesCache *lru.TwoQueueCache + +// Mutex on the services cache. +var servicesCacheMutex sync.Mutex + +// init loads the service configuration and builds all the internal indexes. +func init() { + var err error + if err = yaml.Unmarshal(initServiceData, &serviceRoot); err != nil { + panic(err) // can't happen + } + serviceRoot.byName = make(map[string]*ServiceDomain) + for i, dom := range serviceRoot.Domains { + serviceRoot.Domains[i].byId = make(map[string]*ServiceDef) + serviceRoot.Domains[i].byIndex = make(map[int16]*ServiceDef) + sqo := make([]*ServiceDef, 0, len(serviceRoot.Domains[i].Services)) + for j, svc := range serviceRoot.Domains[i].Services { + serviceRoot.Domains[i].byId[svc.Id] = &(serviceRoot.Domains[i].Services[j]) + serviceRoot.Domains[i].byIndex[svc.Index] = &(serviceRoot.Domains[i].Services[j]) + sqo = append(sqo, &(serviceRoot.Domains[i].Services[j])) + } + slices.SortFunc(sqo, func(a, b *ServiceDef) int { + return a.LinkSequence - b.LinkSequence + }) + serviceRoot.Domains[i].seqOrder = sqo + serviceRoot.byName[dom.DomainName] = &(serviceRoot.Domains[i]) + } + servicesCache, err = lru.New2Q(50) + if err != nil { + panic(err) + } +} + +/* AmGetCommunityServices returns all the community service definitions for a community. + * Parameters: + * cid - Community ID to get services for. + * Returns: + * Array of ServiceDef pointers for the community's services. + * Standard Go error status. + */ +func AmGetCommunityServices(cid int32) ([]*ServiceDef, error) { + servicesCacheMutex.Lock() + defer servicesCacheMutex.Unlock() + rc, ok := servicesCache.Get(cid) + if !ok { + rs, err := amdb.Query("SELECT ftr_code FROM commftrs WHERE commid = ?", cid) + if err != nil { + return nil, err + } + dom := serviceRoot.byName["community"] + a := make([]*ServiceDef, 0, len(dom.Services)) + for rs.Next() { + var ndx int16 + rs.Scan(&ndx) + a = append(a, dom.byIndex[ndx]) + } + servicesCache.Add(cid, a) + rc = a + } + return rc.([]*ServiceDef), nil +} diff --git a/main.go b/main.go index 8b6647c..ecbaf76 100644 --- a/main.go +++ b/main.go @@ -59,6 +59,7 @@ func setupEcho() *echo.Echo { e.GET("/user/:uname", ui.AmWrap(ShowProfile)) e.POST("/quick_email", ui.AmWrap(QuickEMail)) e.GET("/sysadmin", ui.AmWrap(SysAdminMenu)) + e.GET("/comm/:cid/profile", ui.AmWrap(ShowCommunity)) return e } diff --git a/ui/static_images/default-community.jpg b/ui/static_images/default-community.jpg new file mode 100644 index 0000000..132630f Binary files /dev/null and b/ui/static_images/default-community.jpg differ diff --git a/ui/views/comprofile.jet b/ui/views/comprofile.jet new file mode 100644 index 0000000..cde403e --- /dev/null +++ b/ui/views/comprofile.jet @@ -0,0 +1,141 @@ +{* + * 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/. + *} +
+ + About This Community: + {{ if public }} + This is a public community open to all users of the {{ GlobalConfig.Site.Title }} site. + You can join discussions in the conferences, view member lists, and participate in community activities. + {{ else }} + This is a private community, open by invitation only. You can only participate if you have been invited + by one of the community hosts. + {{ end }} +
+