Org social media links

This commit is contained in:
Jonathon Leight
2026-06-28 17:33:41 -04:00
parent 6f11d275cb
commit 0e105cd694
9 changed files with 396 additions and 0 deletions
+81
View File
@@ -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())
+73
View File
@@ -80,6 +80,12 @@
{{end}}
</div>
</div>
{{if .Links}}
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">Links</h3></div>
<div class="card-body">{{template "link-list" .Links}}</div>
</div>
{{end}}
{{end}}
{{if .IsAdmin}}
@@ -113,6 +119,73 @@
</div>
</div>
<div class="card mt-3">
<div class="card-header"><h3 class="card-title">Links <span class="text-secondary">(public)</span></h3></div>
<div class="card-body">
<p class="text-secondary">Social media and other sites for your organization. These appear on your public page.</p>
<form method="post" action="/orgs/{{.Org.Slug}}/links">
<div id="links-rows">
{{range .Links}}
<div class="row g-2 mb-2 align-items-center link-row">
<div class="col-12 col-sm-3">
<select class="form-select" name="link_platform" aria-label="Link type">
{{$sel := .Platform}}
{{range $.Platforms}}<option value="{{.Key}}"{{if eq .Key $sel}} selected{{end}}>{{.Name}}</option>{{end}}
</select>
</div>
<div class="col-12 col-sm-3">
<input class="form-control" name="link_label" value="{{.Label}}" maxlength="60" placeholder="Label (optional)" aria-label="Link label">
</div>
<div class="col">
<input class="form-control" type="url" name="link_url" value="{{.URL}}" maxlength="300" placeholder="https://…" aria-label="Link URL">
</div>
<div class="col-auto">
<button type="button" class="btn btn-ghost-danger btn-icon remove-link" aria-label="Remove link">{{template "icon-trash" ""}}</button>
</div>
</div>
{{end}}
</div>
<div class="btn-list mt-2">
<button type="button" class="btn" id="add-link">{{template "icon-plus" "me-1"}}Add link</button>
<button type="submit" class="btn btn-primary">Save links</button>
</div>
</form>
<template id="link-row-tpl">
<div class="row g-2 mb-2 align-items-center link-row">
<div class="col-12 col-sm-3">
<select class="form-select" name="link_platform" aria-label="Link type">
{{range .Platforms}}<option value="{{.Key}}">{{.Name}}</option>{{end}}
</select>
</div>
<div class="col-12 col-sm-3">
<input class="form-control" name="link_label" maxlength="60" placeholder="Label (optional)" aria-label="Link label">
</div>
<div class="col">
<input class="form-control" type="url" name="link_url" maxlength="300" placeholder="https://…" aria-label="Link URL">
</div>
<div class="col-auto">
<button type="button" class="btn btn-ghost-danger btn-icon remove-link" aria-label="Remove link">{{template "icon-trash" ""}}</button>
</div>
</div>
</template>
<script>
(function(){
var wrap = document.getElementById('links-rows');
var tpl = document.getElementById('link-row-tpl');
var add = document.getElementById('add-link');
if(!wrap || !tpl || !add) return;
add.addEventListener('click', function(){
wrap.appendChild(tpl.content.cloneNode(true));
});
wrap.addEventListener('click', function(e){
var btn = e.target.closest('.remove-link');
if(btn){ btn.closest('.link-row').remove(); }
});
})();
</script>
</div>
</div>
{{/* 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.
+1
View File
@@ -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)
+6
View File
@@ -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,
@@ -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;
+117
View File
@@ -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-<key>" (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
})
}
+81
View File
@@ -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")
}
}
+17
View File
@@ -29,3 +29,20 @@
{{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" aria-hidden="true" focusable="false" 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}}
{{define "icon-copy"}}<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" aria-hidden="true" focusable="false" class="icon{{if .}} {{.}}{{end}}"><path d="M8 8m0 2a2 2 0 0 1 2 -2h8a2 2 0 0 1 2 2v8a2 2 0 0 1 -2 2h-8a2 2 0 0 1 -2 -2z"/><path d="M16 8v-2a2 2 0 0 0 -2 -2h-8a2 2 0 0 0 -2 2v8a2 2 0 0 0 2 2h2"/></svg>{{end}}
{{define "icon-qrcode"}}<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" aria-hidden="true" focusable="false" class="icon{{if .}} {{.}}{{end}}"><path d="M4 4m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z"/><path d="M7 17l0 .01"/><path d="M14 4m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z"/><path d="M7 7l0 .01"/><path d="M4 14m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v4a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z"/><path d="M17 7l0 .01"/><path d="M14 14l3 0"/><path d="M20 14l0 .01"/><path d="M14 14l0 3"/><path d="M14 20l3 0"/><path d="M17 17l3 0"/><path d="M20 17l0 3"/></svg>{{end}}
{{define "icon-link"}}<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" aria-hidden="true" focusable="false" class="icon{{if .}} {{.}}{{end}}"><path d="M9 15l6 -6"/><path d="M11 6l.463 -.536a5 5 0 0 1 7.071 7.072l-.534 .464"/><path d="M13 18l-.397 .534a5.068 5.068 0 0 1 -7.127 0a4.972 4.972 0 0 1 0 -7.071l.524 -.463"/></svg>{{end}}
{{/* Tabler brand icons for org social/site links. Keys match store.LinkPlatform. */}}
{{define "icon-brand-discord"}}<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" aria-hidden="true" focusable="false" class="icon{{if .}} {{.}}{{end}}"><path d="M8 12a1 1 0 1 0 2 0a1 1 0 0 0 -2 0"/><path d="M14 12a1 1 0 1 0 2 0a1 1 0 0 0 -2 0"/><path d="M15.5 17c0 1 1.5 3 2 3c1.5 0 2.833 -1.667 3.5 -3c.667 -1.667 .5 -5.833 -1.5 -11.5c-1.457 -1.015 -3 -1.34 -4.5 -1.5l-.972 1.923a11.913 11.913 0 0 0 -4.053 0l-.975 -1.923c-1.5 .16 -3.043 .485 -4.5 1.5c-2 5.667 -2.167 9.833 -1.5 11.5c.667 1.333 2 3 3.5 3c.5 0 2 -2 2 -3"/><path d="M7 16.5c3.5 1 6.5 1 10 0"/></svg>{{end}}
{{define "icon-brand-facebook"}}<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" aria-hidden="true" focusable="false" class="icon{{if .}} {{.}}{{end}}"><path d="M7 10v4h3v7h4v-7h3l1 -4h-4v-2a1 1 0 0 1 1 -1h3v-4h-3a5 5 0 0 0 -5 5v2h-3"/></svg>{{end}}
{{define "icon-brand-instagram"}}<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" aria-hidden="true" focusable="false" class="icon{{if .}} {{.}}{{end}}"><path d="M4 4m0 4a4 4 0 0 1 4 -4h8a4 4 0 0 1 4 4v8a4 4 0 0 1 -4 4h-8a4 4 0 0 1 -4 -4z"/><path d="M9 12a3 3 0 1 0 6 0a3 3 0 0 0 -6 0"/><path d="M16.5 7.5l0 .01"/></svg>{{end}}
{{define "icon-brand-x"}}<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" aria-hidden="true" focusable="false" class="icon{{if .}} {{.}}{{end}}"><path d="M4 4l11.733 16h4.267l-11.733 -16z"/><path d="M4 20l6.768 -6.768m2.46 -2.46l6.772 -6.772"/></svg>{{end}}
{{define "icon-brand-youtube"}}<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" aria-hidden="true" focusable="false" class="icon{{if .}} {{.}}{{end}}"><path d="M2 8a4 4 0 0 1 4 -4h12a4 4 0 0 1 4 4v8a4 4 0 0 1 -4 4h-12a4 4 0 0 1 -4 -4v-8z"/><path d="M10 9l5 3l-5 3z"/></svg>{{end}}
{{define "icon-brand-github"}}<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" aria-hidden="true" focusable="false" class="icon{{if .}} {{.}}{{end}}"><path d="M9 19c-4.3 1.4 -4.3 -2.5 -6 -3m12 5v-3.5c0 -1 .1 -1.4 -.5 -2c2.8 -.3 5.5 -1.4 5.5 -6a4.6 4.6 0 0 0 -1.3 -3.2a4.2 4.2 0 0 0 -.1 -3.2s-1.1 -.3 -3.5 1.3a12.3 12.3 0 0 0 -6.2 0c-2.4 -1.6 -3.5 -1.3 -3.5 -1.3a4.2 4.2 0 0 0 -.1 3.2a4.6 4.6 0 0 0 -1.3 3.2c0 4.6 2.7 5.7 5.5 6c-.6 .6 -.6 1.2 -.5 2v3.5"/></svg>{{end}}
{{define "icon-brand-telegram"}}<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" aria-hidden="true" focusable="false" class="icon{{if .}} {{.}}{{end}}"><path d="M15 10l-4 4l6 6l4 -16l-18 7l4 2l2 6l3 -4"/></svg>{{end}}
{{define "icon-brand-reddit"}}<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" aria-hidden="true" focusable="false" class="icon{{if .}} {{.}}{{end}}"><path d="M12 8c2.648 0 5.028 .826 6.675 2.14a2.5 2.5 0 0 1 2.326 4.36c0 3.59 -4.03 6.5 -9 6.5c-4.97 0 -9 -2.91 -9 -6.5a2.5 2.5 0 0 1 2.326 -4.36c1.646 -1.313 4.026 -2.14 6.674 -2.14z"/><path d="M12 8l1 -5l6 1"/><path d="M19 4m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/><path d="M9 13l0 .01"/><path d="M15 13l0 .01"/><path d="M10 17a3.5 3.5 0 0 0 4 0"/></svg>{{end}}
{{define "icon-brand-linkedin"}}<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" aria-hidden="true" focusable="false" class="icon{{if .}} {{.}}{{end}}"><path d="M8 11l0 5"/><path d="M8 8l0 .01"/><path d="M12 16l0 -5"/><path d="M16 16v-3a2 2 0 0 0 -4 0"/><path d="M3 7a4 4 0 0 1 4 -4h10a4 4 0 0 1 4 4v10a4 4 0 0 1 -4 4h-10a4 4 0 0 1 -4 -4z"/></svg>{{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"}}<div class="btn-list">{{range .}}<a class="btn" href="{{.URL}}" target="_blank" rel="noopener noreferrer nofollow">{{template "link-icon" .Platform}}<span class="ms-1">{{.Display}}</span></a>{{end}}</div>{{end}}
+4
View File
@@ -79,6 +79,10 @@
{{range .Admins}}<span class="badge bg-success-lt">{{.}}</span>{{end}}
</div>
{{else}}<p class="text-secondary mb-0">No admins listed.</p>{{end}}
{{if .Links}}
<div class="hr-text">Links</div>
{{template "link-list" .Links}}
{{end}}
</div>
</div>
</div>