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

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}