diff --git a/internal/core/orgs.go b/internal/core/orgs.go
index eba9950..6181a39 100644
--- a/internal/core/orgs.go
+++ b/internal/core/orgs.go
@@ -3,6 +3,7 @@ package core
import (
"errors"
"net/http"
+ "net/url"
"strconv"
"strings"
@@ -103,6 +104,11 @@ func (s *Handlers) pageOrg(w http.ResponseWriter, r *http.Request) {
http.Error(w, "could not load repeaters", http.StatusInternalServerError)
return
}
+ links, err := s.Store.ListOrgLinks(r.Context(), id)
+ if err != nil {
+ http.Error(w, "could not load links", http.StatusInternalServerError)
+ return
+ }
mapped := 0
for _, rp := range repeaters {
if rp.HasLocation {
@@ -123,6 +129,8 @@ func (s *Handlers) pageOrg(w http.ResponseWriter, r *http.Request) {
"IsAdmin": isAdmin,
"Members": members,
"Repeaters": repeaters,
+ "Links": links,
+ "Platforms": store.LinkPlatforms(),
"HasMap": mapped > 0,
"MemberCount": len(members),
"RepeaterCount": len(repeaters),
@@ -155,6 +163,11 @@ func (s *Handlers) renderOrgPublic(w http.ResponseWriter, r *http.Request, org *
http.Error(w, "could not load org", http.StatusInternalServerError)
return
}
+ links, err := s.Store.ListOrgLinks(r.Context(), org.ID)
+ if err != nil {
+ http.Error(w, "could not load org", http.StatusInternalServerError)
+ return
+ }
uid := s.Auth.CurrentUserID(r.Context())
s.Render(w, r, "org_public.html", map[string]any{
"Org": org,
@@ -163,6 +176,7 @@ func (s *Handlers) renderOrgPublic(w http.ResponseWriter, r *http.Request, org *
"MemberCount": memberCount,
"RepeaterCount": repeaterCount,
"Repeaters": pubReps,
+ "Links": links,
"HasMap": len(pubReps) > 0,
"IsMember": isMember,
"LoggedIn": uid != 0,
@@ -276,6 +290,73 @@ func (s *Handlers) handleEditOrg(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/orgs/"+slug, http.StatusSeeOther)
}
+// handleSetOrgLinks replaces an org's whole set of social/site links from the
+// repeatable rows posted by the profile editor (admin only). Rows with a blank
+// URL are dropped, so removing a link is just clearing its row and saving.
+func (s *Handlers) handleSetOrgLinks(w http.ResponseWriter, r *http.Request) {
+ id, ok := s.requireOrgAdmin(w, r)
+ if !ok {
+ return
+ }
+ if err := r.ParseForm(); err != nil {
+ orgErr(w, r, "Could not save links.")
+ return
+ }
+ // The three fields are submitted as index-aligned parallel arrays: one entry
+ // each per row, in row order.
+ platforms := r.Form["link_platform"]
+ labels := r.Form["link_label"]
+ urls := r.Form["link_url"]
+ var links []store.OrgLink
+ for i, raw := range urls {
+ u := strings.TrimSpace(raw)
+ if u == "" {
+ continue // empty row — skip it
+ }
+ platform := ""
+ if i < len(platforms) {
+ platform = platforms[i]
+ }
+ if !store.ValidLinkPlatform(platform) {
+ orgErr(w, r, "Choose a type for each link.")
+ return
+ }
+ if !validLinkURL(u) {
+ orgErr(w, r, "Each link must be a valid http:// or https:// URL.")
+ return
+ }
+ label := ""
+ if i < len(labels) {
+ label = strings.TrimSpace(labels[i])
+ }
+ if len(u) > 300 {
+ u = u[:300]
+ }
+ if len(label) > 60 {
+ label = label[:60]
+ }
+ links = append(links, store.OrgLink{Platform: platform, Label: label, URL: u})
+ if len(links) >= store.MaxOrgLinks {
+ break
+ }
+ }
+ if err := s.Store.ReplaceOrgLinks(r.Context(), id, links); err != nil {
+ orgErr(w, r, "Could not save links.")
+ return
+ }
+ http.Redirect(w, r, "/orgs/"+orgParam(r), http.StatusSeeOther)
+}
+
+// validLinkURL reports whether s is an absolute http(s) URL with a host. Limiting
+// the scheme keeps javascript:/data: URLs out of rendered hrefs.
+func validLinkURL(s string) bool {
+ u, err := url.Parse(s)
+ if err != nil {
+ return false
+ }
+ return (u.Scheme == "http" || u.Scheme == "https") && u.Host != ""
+}
+
// requireOrgAdmin resolves {id} and verifies the current user is an org admin.
func (s *Handlers) requireOrgAdmin(w http.ResponseWriter, r *http.Request) (int64, bool) {
uid := s.Auth.CurrentUserID(r.Context())
diff --git a/internal/core/templates/org.html b/internal/core/templates/org.html
index 2e54937..e9ef38c 100644
--- a/internal/core/templates/org.html
+++ b/internal/core/templates/org.html
@@ -80,6 +80,12 @@
{{end}}
+ {{if .Links}}
+
+
+
{{template "link-list" .Links}}
+
+ {{end}}
{{end}}
{{if .IsAdmin}}
@@ -113,6 +119,73 @@
+
+
+
+
Social media and other sites for your organization. These appear on your public page.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{/* Custom domains — hidden for now; the hosting/TLS infrastructure isn't in
place yet. Re-enable this card (plus the domains data in pageOrg and the
/domains routes in web.go) once it is.
diff --git a/internal/core/web.go b/internal/core/web.go
index 2290bb4..ddbb4d0 100644
--- a/internal/core/web.go
+++ b/internal/core/web.go
@@ -157,6 +157,7 @@ func (s *Handlers) appRouter() chi.Router {
r.Get("/orgs/new", s.pageNewOrg)
r.Post("/orgs", s.handleCreateOrg)
r.Post("/orgs/{id}/edit", s.handleEditOrg)
+ r.Post("/orgs/{id}/links", s.handleSetOrgLinks)
r.Get("/orgs/{id}/join", s.pageJoinOrg)
r.Post("/orgs/{id}/join", s.handleJoinOrg)
r.Post("/orgs/{id}/leave", s.handleLeaveOrg)
diff --git a/internal/marketing/orgs.go b/internal/marketing/orgs.go
index 2866838..afebb32 100644
--- a/internal/marketing/orgs.go
+++ b/internal/marketing/orgs.go
@@ -80,6 +80,11 @@ func (s *Handlers) renderOrgPublic(w http.ResponseWriter, r *http.Request, org *
http.Error(w, "could not load org", http.StatusInternalServerError)
return
}
+ links, err := s.Store.ListOrgLinks(r.Context(), org.ID)
+ if err != nil {
+ http.Error(w, "could not load org", http.StatusInternalServerError)
+ return
+ }
uid := s.Auth.CurrentUserID(r.Context())
s.Render(w, r, "org_public.html", map[string]any{
"Org": org,
@@ -88,6 +93,7 @@ func (s *Handlers) renderOrgPublic(w http.ResponseWriter, r *http.Request, org *
"MemberCount": memberCount,
"RepeaterCount": repeaterCount,
"Repeaters": pubReps,
+ "Links": links,
"HasMap": len(pubReps) > 0,
"IsMember": isMember,
"LoggedIn": uid != 0,
diff --git a/internal/store/migrations/0030_org_links.sql b/internal/store/migrations/0030_org_links.sql
new file mode 100644
index 0000000..3dc6918
--- /dev/null
+++ b/internal/store/migrations/0030_org_links.sql
@@ -0,0 +1,16 @@
+-- +goose Up
+-- Social media and third-party site links shown on an org's public page (e.g. a
+-- Discord server, a community wiki). Each link names a known platform (which
+-- drives its icon), an optional display label, a URL, and an admin-chosen order.
+CREATE TABLE org_links (
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ org_id BIGINT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+ platform TEXT NOT NULL,
+ label TEXT NOT NULL DEFAULT '',
+ url TEXT NOT NULL,
+ position INT NOT NULL DEFAULT 0
+);
+CREATE INDEX org_links_org_idx ON org_links(org_id, position);
+
+-- +goose Down
+DROP TABLE org_links;
diff --git a/internal/store/org_links.go b/internal/store/org_links.go
new file mode 100644
index 0000000..9b84cb0
--- /dev/null
+++ b/internal/store/org_links.go
@@ -0,0 +1,117 @@
+package store
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/jackc/pgx/v5"
+)
+
+// MaxOrgLinks caps how many social/site links an org may list. Enough for the
+// usual suspects (Discord, a few sites) without turning the page into a directory.
+const MaxOrgLinks = 20
+
+// LinkPlatform is a known destination an org link can point at. Key is the stable
+// identifier persisted in org_links.platform and used to pick a brand icon; Name
+// is the human label shown when a link has no custom label of its own.
+type LinkPlatform struct {
+ Key string
+ Name string
+}
+
+// linkPlatforms is the curated, ordered set of platforms an admin can choose from.
+// "website" is the generic fallback for anything without a dedicated brand icon.
+// To add a platform: append it here and define a matching "icon-brand-" (or
+// reuse an existing icon) in the link-icon partial in web/templates/icons.html.
+var linkPlatforms = []LinkPlatform{
+ {"website", "Website"},
+ {"discord", "Discord"},
+ {"facebook", "Facebook"},
+ {"instagram", "Instagram"},
+ {"x", "X (Twitter)"},
+ {"youtube", "YouTube"},
+ {"github", "GitHub"},
+ {"telegram", "Telegram"},
+ {"reddit", "Reddit"},
+ {"linkedin", "LinkedIn"},
+}
+
+var linkPlatformByKey = func() map[string]LinkPlatform {
+ m := make(map[string]LinkPlatform, len(linkPlatforms))
+ for _, p := range linkPlatforms {
+ m[p.Key] = p
+ }
+ return m
+}()
+
+// LinkPlatforms returns the selectable platforms, in display order.
+func LinkPlatforms() []LinkPlatform { return linkPlatforms }
+
+// ValidLinkPlatform reports whether key is one of the known platforms.
+func ValidLinkPlatform(key string) bool {
+ _, ok := linkPlatformByKey[key]
+ return ok
+}
+
+// linkPlatformName returns the display name for a platform key, or the key itself
+// if it is unknown (defensive — stored rows should always be valid).
+func linkPlatformName(key string) string {
+ if p, ok := linkPlatformByKey[key]; ok {
+ return p.Name
+ }
+ return key
+}
+
+// OrgLink is a single social/third-party link on an org's public page.
+type OrgLink struct {
+ ID int64
+ OrgID int64
+ Platform string
+ Label string
+ URL string
+ Position int
+}
+
+// Display is the text to show for the link: the admin's custom label if set,
+// otherwise the platform's name (e.g. "Discord").
+func (l OrgLink) Display() string {
+ if l.Label != "" {
+ return l.Label
+ }
+ return linkPlatformName(l.Platform)
+}
+
+// ListOrgLinks returns an org's links in display order.
+func (s *Store) ListOrgLinks(ctx context.Context, orgID int64) ([]OrgLink, error) {
+ rows, err := s.pool.Query(ctx,
+ `SELECT id, org_id, platform, label, url, position
+ FROM org_links WHERE org_id = $1 ORDER BY position, id`, orgID)
+ if err != nil {
+ return nil, fmt.Errorf("list org links: %w", err)
+ }
+ return collectRows(rows, func(r pgx.Row) (OrgLink, error) {
+ var l OrgLink
+ err := r.Scan(&l.ID, &l.OrgID, &l.Platform, &l.Label, &l.URL, &l.Position)
+ return l, err
+ })
+}
+
+// ReplaceOrgLinks atomically replaces an org's entire link set with links, in the
+// given order. An empty slice clears all links. Platform/URL validation is the
+// caller's responsibility.
+func (s *Store) ReplaceOrgLinks(ctx context.Context, orgID int64, links []OrgLink) error {
+ return s.inTx(ctx, func(tx pgx.Tx) error {
+ if _, err := tx.Exec(ctx, `DELETE FROM org_links WHERE org_id = $1`, orgID); err != nil {
+ return fmt.Errorf("clear org links: %w", err)
+ }
+ for i, l := range links {
+ if _, err := tx.Exec(ctx,
+ `INSERT INTO org_links (org_id, platform, label, url, position)
+ VALUES ($1, $2, $3, $4, $5)`,
+ orgID, l.Platform, l.Label, l.URL, i); err != nil {
+ return fmt.Errorf("insert org link: %w", err)
+ }
+ }
+ return nil
+ })
+}
diff --git a/internal/store/org_links_test.go b/internal/store/org_links_test.go
new file mode 100644
index 0000000..16d7f17
--- /dev/null
+++ b/internal/store/org_links_test.go
@@ -0,0 +1,81 @@
+package store
+
+import "testing"
+
+func TestOrgLinksReplaceAndList(t *testing.T) {
+ t.Parallel()
+ st, ctx := orgTestStore(t)
+
+ owner, err := st.CreateUser(ctx, "linkowner", "")
+ if err != nil {
+ t.Fatalf("create user: %v", err)
+ }
+ org, err := st.CreateOrg(ctx, "Linky Org", owner.ID)
+ if err != nil {
+ t.Fatalf("create org: %v", err)
+ }
+
+ // A fresh org has no links.
+ got, err := st.ListOrgLinks(ctx, org.ID)
+ if err != nil {
+ t.Fatalf("list (empty): %v", err)
+ }
+ if len(got) != 0 {
+ t.Fatalf("new org links = %d, want 0", len(got))
+ }
+
+ // Replace with two links; order is preserved by insertion order.
+ links := []OrgLink{
+ {Platform: "discord", URL: "https://discord.gg/abc"},
+ {Platform: "website", Label: "Wiki", URL: "https://wiki.example.org"},
+ }
+ if err := st.ReplaceOrgLinks(ctx, org.ID, links); err != nil {
+ t.Fatalf("replace: %v", err)
+ }
+ got, err = st.ListOrgLinks(ctx, org.ID)
+ if err != nil {
+ t.Fatalf("list: %v", err)
+ }
+ if len(got) != 2 {
+ t.Fatalf("links = %d, want 2", len(got))
+ }
+ if got[0].Platform != "discord" || got[0].URL != "https://discord.gg/abc" {
+ t.Errorf("link[0] = %+v", got[0])
+ }
+ if got[0].Position != 0 || got[1].Position != 1 {
+ t.Errorf("positions = %d,%d, want 0,1", got[0].Position, got[1].Position)
+ }
+ // Display falls back to the platform name when no label is set, and uses the
+ // label when present.
+ if d := got[0].Display(); d != "Discord" {
+ t.Errorf("link[0].Display() = %q, want %q", d, "Discord")
+ }
+ if d := got[1].Display(); d != "Wiki" {
+ t.Errorf("link[1].Display() = %q, want %q", d, "Wiki")
+ }
+
+ // Replacing with an empty set clears every link.
+ if err := st.ReplaceOrgLinks(ctx, org.ID, nil); err != nil {
+ t.Fatalf("replace (clear): %v", err)
+ }
+ got, err = st.ListOrgLinks(ctx, org.ID)
+ if err != nil {
+ t.Fatalf("list (after clear): %v", err)
+ }
+ if len(got) != 0 {
+ t.Errorf("links after clear = %d, want 0", len(got))
+ }
+}
+
+func TestValidLinkPlatform(t *testing.T) {
+ t.Parallel()
+ if !ValidLinkPlatform("discord") {
+ t.Error("discord should be valid")
+ }
+ if ValidLinkPlatform("myspace") {
+ t.Error("myspace should be invalid")
+ }
+ if ValidLinkPlatform("") {
+ t.Error("empty should be invalid")
+ }
+}
diff --git a/internal/web/templates/icons.html b/internal/web/templates/icons.html
index 2163b62..97165af 100644
--- a/internal/web/templates/icons.html
+++ b/internal/web/templates/icons.html
@@ -29,3 +29,20 @@
{{define "icon-eye-off"}}{{end}}
{{define "icon-copy"}}{{end}}
{{define "icon-qrcode"}}{{end}}
+{{define "icon-link"}}{{end}}
+{{/* Tabler brand icons for org social/site links. Keys match store.LinkPlatform. */}}
+{{define "icon-brand-discord"}}{{end}}
+{{define "icon-brand-facebook"}}{{end}}
+{{define "icon-brand-instagram"}}{{end}}
+{{define "icon-brand-x"}}{{end}}
+{{define "icon-brand-youtube"}}{{end}}
+{{define "icon-brand-github"}}{{end}}
+{{define "icon-brand-telegram"}}{{end}}
+{{define "icon-brand-reddit"}}{{end}}
+{{define "icon-brand-linkedin"}}{{end}}
+{{/* link-icon renders the brand/site icon for an org link's platform key (.).
+ Falls back to a generic link glyph for "website" and anything unknown. */}}
+{{define "link-icon"}}{{if eq . "discord"}}{{template "icon-brand-discord" ""}}{{else if eq . "facebook"}}{{template "icon-brand-facebook" ""}}{{else if eq . "instagram"}}{{template "icon-brand-instagram" ""}}{{else if eq . "x"}}{{template "icon-brand-x" ""}}{{else if eq . "youtube"}}{{template "icon-brand-youtube" ""}}{{else if eq . "github"}}{{template "icon-brand-github" ""}}{{else if eq . "telegram"}}{{template "icon-brand-telegram" ""}}{{else if eq . "reddit"}}{{template "icon-brand-reddit" ""}}{{else if eq . "linkedin"}}{{template "icon-brand-linkedin" ""}}{{else}}{{template "icon-link" ""}}{{end}}{{end}}
+{{/* link-list renders a wrapping row of org link buttons. . is a slice of links,
+ each exposing .Platform, .URL, and .Display. */}}
+{{define "link-list"}}{{end}}
diff --git a/internal/web/templates/org_public.html b/internal/web/templates/org_public.html
index a8658e8..6c2a10d 100644
--- a/internal/web/templates/org_public.html
+++ b/internal/web/templates/org_public.html
@@ -79,6 +79,10 @@
{{range .Admins}}{{.}}{{end}}
{{else}}No admins listed.
{{end}}
+ {{if .Links}}
+ Links
+ {{template "link-list" .Links}}
+ {{end}}