Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

gallery.gno

8.45 Kb · 264 lines
  1package home
  2
  3import (
  4	"encoding/base64"
  5	"strings"
  6
  7	"gno.land/p/nt/avl/v0"
  8)
  9
 10// This file is shared, unchanged, by every Samouraï home realm.
 11//
 12// A gallery is a named list of cards. gnoweb's CSP only paints images from a
 13// few hosts (YouTube thumbnails are not among them) but it allows
 14// data:image/svg+xml, so every card is a poster drawn here, on chain, and
 15// linked to its video or site.
 16//
 17// A gallery is written in one Set-like call as lines of
 18//
 19//	kind | title | meta | url [| description]
 20//
 21// The optional description becomes the card's hover text.
 22// and rendered wherever the layout says :gallery.<name>:.
 23
 24const (
 25	galleryPrefix = "gallery."
 26	cardsPerRow   = 3
 27	maxCards      = 48
 28	maxTitleLen   = 80
 29	maxMetaLen    = 80
 30	maxDescLen    = 200
 31)
 32
 33type card struct {
 34	kind, title, meta, url string
 35	desc                   string // hover text, optional
 36	label                  string // overrides the kind's label when set
 37}
 38
 39var galleries = avl.NewTree() // name -> []card
 40
 41type style struct {
 42	label, glyph, accent, shade string
 43	video                       bool
 44}
 45
 46var styles = map[string]style{
 47	"report":        {"CONTRIBUTION REPORT", "報", "#e63946", "#3a1014", true},
 48	"gno":           {"GNO.LAND · FILMED BY SAMOURAÏ", "道", "#2ec4b6", "#0d2e2b", true},
 49	"webtrip":       {"WEB TRIP · SERIES", "旅", "#ff7aa2", "#3a1422", true},
 50	"peerdev":       {"PEER DEV · GNO TUTORIAL", "学", "#4ea8de", "#0f2536", true},
 51	"event":         {"EVENT", "祭", "#f4a261", "#3a2412", true},
 52	"festival":      {"FESTIVAL · BY SAMOURAÏ", "祭", "#f4a261", "#3a2412", false},
 53	"film":          {"FILM · DIRECTED BY THE CREW", "映", "#b388eb", "#261a36", true},
 54	"product":       {"PRODUCT", "作", "#e9c46a", "#352b10", false},
 55	"memba":         {"MEMBA · PRODUCT", "組", "#e9c46a", "#352b10", false},
 56	"game":          {"ARCADE · PLAY IN MEMBA", "遊", "#8ac926", "#1c2a0b", true},
 57	"oss":           {"OPEN SOURCE · GITHUB", "源", "#a8b3bd", "#1b2026", false},
 58	"member":        {"SAMOURAÏ · CREW", "侍", "#e63946", "#2a1012", false},
 59	"member-media":  {"SAMOURAÏ · MEDIA", "映", "#b388eb", "#261a36", false},
 60	"member-alumni": {"SAMOURAÏ · ALUMNI", "侍", "#9aa0a6", "#1d1f22", false},
 61}
 62
 63func styleOf(kind string) style {
 64	if s, ok := styles[kind]; ok {
 65		return s
 66	}
 67	return style{strings.ToUpper(kind), "侍", "#e63946", "#2a1012", false}
 68}
 69
 70// SetGallery replaces a whole gallery from its line format.
 71func SetGallery(cur realm, name, spec string) {
 72	assertAdmin(cur)
 73	if !validSlug(name) {
 74		panic("invalid gallery name: " + name)
 75	}
 76	cards := parseGallery(spec)
 77	rev++
 78	galleries.Set(name, cards)
 79}
 80
 81// DeleteGallery removes a gallery.
 82func DeleteGallery(cur realm, name string) {
 83	assertAdmin(cur)
 84	if _, removed := galleries.Remove(name); !removed {
 85		panic("no such gallery: " + name)
 86	}
 87	rev++
 88}
 89
 90func parseGallery(spec string) []card {
 91	var cards []card
 92	for _, line := range strings.Split(spec, "\n") {
 93		line = strings.TrimSpace(line)
 94		if line == "" || strings.HasPrefix(line, "#") {
 95			continue
 96		}
 97		f := strings.Split(line, "|")
 98		if len(f) != 4 && len(f) != 5 {
 99			panic("gallery line needs 4 or 5 fields (kind | title | meta | url [| description]): " + line)
100		}
101		c := card{
102			kind:  strings.TrimSpace(f[0]),
103			title: strings.TrimSpace(f[1]),
104			meta:  strings.TrimSpace(f[2]),
105			url:   strings.TrimSpace(f[3]),
106		}
107		if len(f) == 5 {
108			c.desc = strings.TrimSpace(f[4])
109			if len(c.desc) > maxDescLen || strings.ContainsAny(c.desc, "\"\\") {
110				panic("gallery description must be at most 200 bytes, without quotes or backslashes: " + line)
111			}
112		}
113		if !validSlug(c.kind) || c.title == "" || len(c.title) > maxTitleLen || len(c.meta) > maxMetaLen {
114			panic("bad gallery line: " + line)
115		}
116		if !strings.HasPrefix(c.url, "https://") && !strings.HasPrefix(c.url, "/") {
117			panic("gallery url must be https:// or a /path: " + c.url)
118		}
119		if strings.ContainsAny(c.url, " ()<>\"") {
120			panic("gallery url has characters markdown would break on: " + c.url)
121		}
122		cards = append(cards, c)
123	}
124	if len(cards) == 0 || len(cards) > maxCards {
125		panic("a gallery holds 1 to 48 cards")
126	}
127	return cards
128}
129
130// renderGallery lays cards out cardsPerRow to a row, each one a poster that
131// links to its target.
132func renderGallery(name string) (string, bool) {
133	v := galleries.Get(name)
134	if v == nil {
135		return "", false
136	}
137	cards := v.([]card)
138	var b strings.Builder
139	for i := 0; i < len(cards); i += cardsPerRow {
140		b.WriteString("<gno-columns>\n")
141		for j := i; j < i+cardsPerRow; j++ {
142			if j > i {
143				b.WriteString("<gno-columns-sep />\n")
144			}
145			if j < len(cards) {
146				c := cards[j]
147				b.WriteString("[![" + mdText(c.title) + "](" + posterURI(c) + ")](" + c.url + linkTitle(c.desc) + ")\n")
148			}
149		}
150		b.WriteString("</gno-columns>\n\n")
151	}
152	return strings.TrimSuffix(b.String(), "\n\n"), true
153}
154
155// linkTitle renders an optional markdown link title, which gnoweb emits as
156// the link's hover text.
157func linkTitle(desc string) string {
158	if desc == "" {
159		return ""
160	}
161	return ` "` + desc + `"`
162}
163
164// mdText keeps a title from closing the markdown image alt text early.
165func mdText(s string) string {
166	return strings.NewReplacer("[", "(", "]", ")").Replace(s)
167}
168
169func xmlText(s string) string {
170	return strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", "\"", "&quot;", "'", "&#39;").Replace(s)
171}
172
173// wrap splits s into at most two lines of about width runes; the second line
174// is cut with an ellipsis when the title is longer.
175func wrap(s string, width int) []string {
176	words := strings.Fields(s)
177	var lines []string
178	cur := ""
179	for _, w := range words {
180		if cur == "" {
181			cur = w
182		} else if runeLen(cur)+1+runeLen(w) <= width {
183			cur += " " + w
184		} else {
185			lines = append(lines, cur)
186			cur = w
187		}
188	}
189	if cur != "" {
190		lines = append(lines, cur)
191	}
192	if len(lines) > 2 {
193		second := []rune(strings.Join(lines[1:], " "))
194		if len(second) > width {
195			second = append(second[:width-1], '…')
196		}
197		lines = []string{lines[0], string(second)}
198	}
199	return lines
200}
201
202func runeLen(s string) int { return len([]rune(s)) }
203
204// clip cuts s to at most n runes, ending with an ellipsis when it had to cut.
205func clip(s string, n int) string {
206	r := []rune(s)
207	if len(r) <= n {
208		return s
209	}
210	return string(append(r[:n-1], '…'))
211}
212
213func posterURI(c card) string {
214	return "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(poster(c)))
215}
216
217// poster draws a 320x180 card: a dark gradient, a rising sun in the kind's
218// accent (a play button for videos), a large kanji watermark, the kind label,
219// the title on up to two lines and a meta line.
220func poster(c card) string {
221	st := styleOf(c.kind)
222	if c.label != "" {
223		st.label = c.label
224	}
225	var b strings.Builder
226	b.WriteString(`<svg xmlns="http://www.w3.org/2000/svg" width="320" height="180" viewBox="0 0 320 180">`)
227	b.WriteString(`<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#121212"/><stop offset="1" stop-color="` + st.shade + `"/></linearGradient></defs>`)
228	b.WriteString(`<rect width="320" height="180" rx="14" fill="url(#g)"/>`)
229	b.WriteString(`<text x="306" y="170" text-anchor="end" font-family="serif" font-size="120" fill="#ffffff" fill-opacity=".06">` + st.glyph + `</text>`)
230	b.WriteString(`<circle cx="268" cy="52" r="30" fill="` + st.accent + `"/>`)
231	if st.video {
232		b.WriteString(`<path d="M260 38 L282 52 L260 66 Z" fill="#121212"/>`)
233	} else {
234		b.WriteString(`<path d="M256 52 H280 M272 44 L280 52 L272 60" stroke="#121212" stroke-width="4" fill="none" stroke-linecap="round" stroke-linejoin="round"/>`)
235	}
236	b.WriteString(`<rect x="18" y="20" width="3" height="12" fill="` + st.accent + `"/>`)
237	b.WriteString(`<text x="27" y="31" font-family="ui-monospace,Menlo,monospace" font-size="10" letter-spacing="1.5" fill="` + st.accent + `">` + xmlText(st.label) + `</text>`)
238	lines := wrap(c.title, 24)
239	y := 108
240	if len(lines) == 1 {
241		y = 126
242	}
243	for _, l := range lines {
244		b.WriteString(`<text x="18" y="` + itoa(y) + `" font-family="system-ui,-apple-system,Segoe UI,sans-serif" font-weight="700" font-size="20" fill="#ffffff">` + xmlText(l) + `</text>`)
245		y += 25
246	}
247	// The meta line hugs the bottom-right corner: gnoweb draws its
248	// external-link badge over the bottom-left one.
249	b.WriteString(`<text x="304" y="164" text-anchor="end" font-family="ui-monospace,Menlo,monospace" font-size="11" fill="#bdbdbd">` + xmlText(clip(c.meta, 40)) + `</text>`)
250	b.WriteString(`</svg>`)
251	return b.String()
252}
253
254func itoa(n int) string {
255	if n == 0 {
256		return "0"
257	}
258	var d []byte
259	for n > 0 {
260		d = append([]byte{byte('0' + n%10)}, d...)
261		n /= 10
262	}
263	return string(d)
264}