mirror of
https://github.com/MeshTender/MeshTender.git
synced 2026-09-01 17:38:15 +00:00
Org social media links
This commit is contained in:
@@ -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())
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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}}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user