Organization region field

This commit is contained in:
Jonathon Leight
2026-06-20 15:41:00 -04:00
parent 9d8a24e9e5
commit 342d15566e
7 changed files with 136 additions and 55 deletions
@@ -0,0 +1,7 @@
-- +goose Up
-- Freeform region/location for an organization (e.g. "Buffalo, NY"), shown on
-- the org page and public directory.
ALTER TABLE organizations ADD COLUMN region TEXT NOT NULL DEFAULT '';
-- +goose Down
ALTER TABLE organizations DROP COLUMN region;
+9 -7
View File
@@ -14,6 +14,7 @@ type Org struct {
ID int64
Name string
Description string
Region string
CreatedBy *int64
CreatedAt time.Time
}
@@ -61,8 +62,8 @@ func (s *Store) CreateOrg(ctx context.Context, name string, creatorID int64) (*O
var o Org
if err := tx.QueryRow(ctx,
`INSERT INTO organizations (name, created_by) VALUES ($1, $2)
RETURNING id, name, description, created_by, created_at`,
name, creatorID).Scan(&o.ID, &o.Name, &o.Description, &o.CreatedBy, &o.CreatedAt); err != nil {
RETURNING id, name, description, region, created_by, created_at`,
name, creatorID).Scan(&o.ID, &o.Name, &o.Description, &o.Region, &o.CreatedBy, &o.CreatedAt); err != nil {
return nil, fmt.Errorf("insert org: %w", err)
}
if _, err := tx.Exec(ctx,
@@ -100,8 +101,8 @@ func (s *Store) CreateOrg(ctx context.Context, name string, creatorID int64) (*O
func (s *Store) GetOrg(ctx context.Context, id int64) (*Org, error) {
var o Org
err := s.pool.QueryRow(ctx,
`SELECT id, name, description, created_by, created_at FROM organizations WHERE id = $1`, id).
Scan(&o.ID, &o.Name, &o.Description, &o.CreatedBy, &o.CreatedAt)
`SELECT id, name, description, region, created_by, created_at FROM organizations WHERE id = $1`, id).
Scan(&o.ID, &o.Name, &o.Description, &o.Region, &o.CreatedBy, &o.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrNotFound
}
@@ -111,10 +112,11 @@ func (s *Store) GetOrg(ctx context.Context, id int64) (*Org, error) {
return &o, nil
}
// UpdateOrg updates an org's name and description.
func (s *Store) UpdateOrg(ctx context.Context, orgID int64, name, description string) error {
// UpdateOrg updates an org's name, description, and region.
func (s *Store) UpdateOrg(ctx context.Context, orgID int64, name, description, region string) error {
tag, err := s.pool.Exec(ctx,
`UPDATE organizations SET name = $2, description = $3 WHERE id = $1`, orgID, name, description)
`UPDATE organizations SET name = $2, description = $3, region = $4 WHERE id = $1`,
orgID, name, description, region)
if err != nil {
return fmt.Errorf("update org: %w", err)
}
+5 -2
View File
@@ -130,7 +130,6 @@ func (s *Server) pageOrg(w http.ResponseWriter, r *http.Request) {
"MemberCount": len(members),
"RepeaterCount": len(repeaters),
"AdminCount": adminCount,
"MappedCount": mapped,
"Self": uid,
"Error": r.URL.Query().Get("error"),
}
@@ -198,6 +197,7 @@ func (s *Server) handleEditOrg(w http.ResponseWriter, r *http.Request) {
}
name := strings.TrimSpace(r.FormValue("name"))
desc := strings.TrimSpace(r.FormValue("description"))
region := strings.TrimSpace(r.FormValue("region"))
if name == "" || len(name) > 80 {
orgErr(w, r, id, "Enter an organization name.")
return
@@ -205,7 +205,10 @@ func (s *Server) handleEditOrg(w http.ResponseWriter, r *http.Request) {
if len(desc) > 2000 {
desc = desc[:2000]
}
if err := s.store.UpdateOrg(r.Context(), id, name, desc); err != nil {
if len(region) > 120 {
region = region[:120]
}
if err := s.store.UpdateOrg(r.Context(), id, name, desc, region); err != nil {
orgErr(w, r, id, "Could not save changes.")
return
}
+3 -3
View File
@@ -17,7 +17,7 @@
<span class="navbar-toggler-icon"></span>
</button>
<a href="/" class="navbar-brand navbar-brand-autodark d-flex align-items-center text-reset text-decoration-none">
{{template "icon-tower" "me-2"}}<span class="fw-bold fs-3">MeshTender</span>
{{template "icon-tower" "me-1"}}<span class="fw-bold fs-3">MeshTender</span>
</a>
<div class="navbar-nav flex-row order-md-last align-items-center gap-2">
{{if .UserName}}
@@ -121,7 +121,7 @@
<div class="container container-tight py-4">
<div class="text-center mb-4">
<a href="/" class="navbar-brand navbar-brand-autodark d-inline-flex align-items-center text-reset text-decoration-none">
{{template "icon-tower" "me-2"}}<span class="fw-bold fs-2">MeshTender</span>
{{template "icon-tower" "me-1"}}<span class="fw-bold fs-2">MeshTender</span>
</a>
</div>
{{template "content" .}}
@@ -147,7 +147,7 @@
<header class="navbar navbar-expand-md navbar-transparent py-3">
<div class="container">
<a href="/" class="navbar-brand navbar-brand-autodark d-flex align-items-center text-reset text-decoration-none">
{{template "icon-tower" "me-2"}}<span class="fw-bold fs-3">MeshTender</span>
{{template "icon-tower" "me-1"}}<span class="fw-bold fs-3">MeshTender</span>
</a>
<div class="navbar-nav flex-row order-md-last align-items-center gap-2">
<a href="/login" class="btn btn-ghost-secondary">Sign in</a>
+1
View File
@@ -19,6 +19,7 @@
{{define "icon-arrow-left"}}<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon{{if .}} {{.}}{{end}}"><path d="M5 12l14 0"/><path d="M5 12l6 6"/><path d="M5 12l6 -6"/></svg>{{end}}
{{define "icon-check"}}<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon{{if .}} {{.}}{{end}}"><path d="M5 12l5 5l10 -10"/></svg>{{end}}
{{define "icon-alert"}}<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon{{if .}} {{.}}{{end}}"><path d="M12 9v4"/><path d="M12 17h.01"/><path d="M10.24 3.957l-8.422 14.06a1.989 1.989 0 0 0 1.7 2.983h16.845a1.989 1.989 0 0 0 1.7 -2.983l-8.423 -14.06a1.989 1.989 0 0 0 -3.4 0z"/></svg>{{end}}
{{define "icon-map-pin"}}<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon{{if .}} {{.}}{{end}}"><path d="M9 11a3 3 0 1 0 6 0a3 3 0 0 0 -6 0"/><path d="M17.657 16.657l-4.243 4.243a2 2 0 0 1 -2.827 0l-4.244 -4.243a8 8 0 1 1 11.314 0z"/></svg>{{end}}
{{define "icon-key"}}<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon{{if .}} {{.}}{{end}}"><path d="M16.555 3.843l3.602 3.602a2.877 2.877 0 0 1 0 4.069l-2.643 2.643a2.877 2.877 0 0 1 -4.069 0l-.301 -.301l-6.558 6.558a2 2 0 0 1 -1.239 .578l-.175 .008h-1.476a1 1 0 0 1 -.993 -.883l-.007 -.117v-1.476a2 2 0 0 1 .467 -1.284l.119 -.13l.414 -.414h2v-2h2v-2l2.144 -2.144l-.301 -.301a2.877 2.877 0 0 1 0 -4.069l2.643 -2.643a2.877 2.877 0 0 1 4.069 0z"/><path d="M15 9h.01"/></svg>{{end}}
{{define "icon-eye"}}<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon{{if .}} {{.}}{{end}}"><path d="M10 12a2 2 0 1 0 4 0a2 2 0 0 0 -4 0"/><path d="M21 12c-2.4 4 -5.4 6 -9 6c-3.6 0 -6.6 -2 -9 -6c2.4 -4 5.4 -6 9 -6c3.6 0 6.6 2 9 6"/></svg>{{end}}
{{define "icon-eye-off"}}<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon{{if .}} {{.}}{{end}}"><path d="M10.585 10.587a2 2 0 0 0 2.829 2.828"/><path d="M16.681 16.673a8.717 8.717 0 0 1 -4.681 1.327c-3.6 0 -6.6 -2 -9 -6c1.272 -2.12 2.712 -3.678 4.32 -4.674m2.86 -1.146a9.055 9.055 0 0 1 1.82 -.18c3.6 0 6.6 2 9 6c-.666 1.11 -1.379 2.067 -2.138 2.87"/><path d="M3 3l18 18"/></svg>{{end}}
+19 -12
View File
@@ -31,16 +31,6 @@
</div>
</div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="card card-sm">
<div class="card-body">
<div class="row align-items-center">
<div class="col-auto"><span class="bg-azure text-white avatar">{{template "icon-antenna" ""}}</span></div>
<div class="col"><div class="h1 mb-0">{{.RepeaterCount}}</div><div class="text-secondary">Repeater{{if ne .RepeaterCount 1}}s{{end}}</div></div>
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="card card-sm">
<div class="card-body">
@@ -55,8 +45,21 @@
<div class="card card-sm">
<div class="card-body">
<div class="row align-items-center">
<div class="col-auto"><span class="bg-purple text-white avatar">{{template "icon-world" ""}}</span></div>
<div class="col"><div class="h1 mb-0">{{.MappedCount}}</div><div class="text-secondary">On the map</div></div>
<div class="col-auto"><span class="bg-azure text-white avatar">{{template "icon-antenna" ""}}</span></div>
<div class="col"><div class="h1 mb-0">{{.RepeaterCount}}</div><div class="text-secondary">Repeater{{if ne .RepeaterCount 1}}s{{end}}</div></div>
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="card card-sm">
<div class="card-body">
<div class="row align-items-center">
<div class="col-auto"><span class="bg-purple text-white avatar">{{template "icon-map-pin" ""}}</span></div>
<div class="col text-truncate">
<div class="h3 mb-0 text-truncate">{{if .Org.Region}}{{.Org.Region}}{{else}}<span class="text-secondary fw-normal">Not set</span>{{end}}</div>
<div class="text-secondary">Region</div>
</div>
</div>
</div>
</div>
@@ -147,6 +150,10 @@
<label class="form-label">Name</label>
<input type="text" class="form-control" name="name" value="{{.Org.Name}}" maxlength="80" required>
</div>
<div class="mb-3">
<label class="form-label">Region <span class="text-secondary">(optional)</span></label>
<input type="text" class="form-control" name="region" value="{{.Org.Region}}" maxlength="120" placeholder="e.g. Buffalo, NY">
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<textarea class="form-control" name="description" rows="3" maxlength="2000" placeholder="What this organization is about, where it operates, etc.">{{.Org.Description}}</textarea>
+92 -31
View File
@@ -2,48 +2,109 @@
{{define "header"}}
<div class="row g-2 align-items-center">
<div class="col">
<div class="page-pretitle">Organization</div>
<div class="page-pretitle">Organization{{if .IsMember}} · public preview{{end}}</div>
<h2 class="page-title">{{.Org.Name}}</h2>
</div>
{{if .IsMember}}
<div class="col-auto ms-auto d-print-none">
<a class="btn" href="/orgs/{{.Org.ID}}">{{template "icon-arrow-left" "me-1"}}Back to member view</a>
</div>
{{end}}
</div>
{{end}}
{{define "content"}}
<div class="card">
<div class="card-body">
{{if .IsMember}}<p class="text-secondary"><a href="/orgs/{{.Org.ID}}">{{template "icon-arrow-left" "me-1"}}Back to member view</a> — you're previewing the public page.</p>{{end}}
{{if .Org.Description}}<p>{{.Org.Description}}</p>{{else}}<p class="text-secondary">This organization hasn't added a description yet.</p>{{end}}
<div class="text-secondary small">{{.MemberCount}} member{{if ne .MemberCount 1}}s{{end}} · {{.RepeaterCount}} repeater{{if ne .RepeaterCount 1}}s{{end}}</div>
</div>
</div>
<div class="card mt-3">
<div class="card-body">
<h3 class="card-title">Admins</h3>
{{if .Admins}}
<div class="d-flex flex-wrap gap-2">
{{range .Admins}}<span class="badge bg-success-lt">{{.}}</span>{{end}}
<!-- Summary stats -->
<div class="row row-cards">
<div class="col-sm-6 col-lg-3">
<div class="card card-sm">
<div class="card-body">
<div class="row align-items-center">
<div class="col-auto"><span class="bg-primary text-white avatar">{{template "icon-users" ""}}</span></div>
<div class="col"><div class="h1 mb-0">{{.MemberCount}}</div><div class="text-secondary">Member{{if ne .MemberCount 1}}s{{end}}</div></div>
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="card card-sm">
<div class="card-body">
<div class="row align-items-center">
<div class="col-auto"><span class="bg-green text-white avatar">{{template "icon-user" ""}}</span></div>
<div class="col"><div class="h1 mb-0">{{len .Admins}}</div><div class="text-secondary">Admin{{if ne (len .Admins) 1}}s{{end}}</div></div>
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="card card-sm">
<div class="card-body">
<div class="row align-items-center">
<div class="col-auto"><span class="bg-azure text-white avatar">{{template "icon-antenna" ""}}</span></div>
<div class="col"><div class="h1 mb-0">{{.RepeaterCount}}</div><div class="text-secondary">Repeater{{if ne .RepeaterCount 1}}s{{end}}</div></div>
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-lg-3">
<div class="card card-sm">
<div class="card-body">
<div class="row align-items-center">
<div class="col-auto"><span class="bg-purple text-white avatar">{{template "icon-map-pin" ""}}</span></div>
<div class="col text-truncate">
<div class="h3 mb-0 text-truncate">{{if .Org.Region}}{{.Org.Region}}{{else}}<span class="text-secondary fw-normal">Not set</span>{{end}}</div>
<div class="text-secondary">Region</div>
</div>
</div>
</div>
</div>
{{else}}<p class="text-secondary mb-0">No admins listed.</p>{{end}}
</div>
</div>
{{if .HasMap}}
<div class="card mt-3">
<div class="card-body">
<h3 class="card-title">Public repeater map</h3>
<p class="text-secondary">Repeaters whose owners chose to show them publicly.</p>
<link rel="stylesheet" href="/static/leaflet.css">
<div id="map"></div>
<script src="/static/leaflet.js"></script>
<script src="/static/meshmap.js"></script>
<script>
meshMap('map', [
{{range .Repeaters}}{{if .HasLocation}}{name: {{.Name}}, lat: {{.Lat}}, lon: {{.Lon}}},{{end}}{{end}}
]);
</script>
<div class="row row-cards mt-1">
<!-- Left column -->
<div class="col-lg-8">
<div class="card">
<div class="card-header"><h3 class="card-title">Public repeater map</h3></div>
{{if .HasMap}}
<div class="card-body p-2">
<link rel="stylesheet" href="/static/leaflet.css">
<div id="map" style="height:340px"></div>
<script src="/static/leaflet.js"></script>
<script src="/static/meshmap.js"></script>
<script>
meshMap('map', [
{{range .Repeaters}}{{if .HasLocation}}{name: {{.Name}}, lat: {{.Lat}}, lon: {{.Lon}}},{{end}}{{end}}
]);
</script>
</div>
{{else}}
<div class="card-body"><p class="text-secondary mb-0">No repeaters are shown publicly. Members can opt a repeater into the public map when editing it.</p></div>
{{end}}
</div>
</div>
<!-- Right column -->
<div class="col-lg-4">
<div class="card">
<div class="card-header"><h3 class="card-title">About</h3></div>
<div class="card-body">
{{if .Org.Region}}<div class="d-flex align-items-center text-secondary mb-2">{{template "icon-map-pin" "me-1"}}{{.Org.Region}}</div>{{end}}
{{if .Org.Description}}<p class="mb-0">{{.Org.Description}}</p>{{else}}<p class="text-secondary mb-0">This organization hasn't added a description yet.</p>{{end}}
</div>
</div>
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">Admins</h3></div>
<div class="card-body">
{{if .Admins}}
<div class="d-flex flex-wrap gap-2">
{{range .Admins}}<span class="badge bg-success-lt">{{.}}</span>{{end}}
</div>
{{else}}<p class="text-secondary mb-0">No admins listed.</p>{{end}}
</div>
</div>
</div>
</div>
{{end}}
<a class="btn btn-link px-0 mt-3" href="/orgs">{{template "icon-arrow-left" "me-1"}}All organizations</a>
{{end}}