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}} +
+

Links

+
{{template "link-list" .Links}}
+
+ {{end}} {{end}} {{if .IsAdmin}} @@ -113,6 +119,73 @@ +
+

Links (public)

+
+

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"}}
{{range .}}{{template "link-icon" .Platform}}{{.Display}}{{end}}
{{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}}