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

gallery.gno

8.58 Kb · 265 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	"blog":          {"BLOG · SAMOURAI.WORLD", "記", "#e8e2d8", "#2a2620", false},
 59	"member":        {"SAMOURAÏ · CREW", "侍", "#e63946", "#2a1012", false},
 60	"member-media":  {"SAMOURAÏ · MEDIA", "映", "#b388eb", "#261a36", false},
 61	"member-alumni": {"SAMOURAÏ · ALUMNI", "侍", "#9aa0a6", "#1d1f22", false},
 62}
 63
 64func styleOf(kind string) style {
 65	if s, ok := styles[kind]; ok {
 66		return s
 67	}
 68	return style{strings.ToUpper(kind), "侍", "#e63946", "#2a1012", false}
 69}
 70
 71// SetGallery replaces a whole gallery from its line format.
 72func SetGallery(cur realm, name, spec string) {
 73	assertAdmin(cur)
 74	if !validSlug(name) {
 75		panic("invalid gallery name: " + name)
 76	}
 77	cards := parseGallery(spec)
 78	rev++
 79	galleries.Set(name, cards)
 80}
 81
 82// DeleteGallery removes a gallery.
 83func DeleteGallery(cur realm, name string) {
 84	assertAdmin(cur)
 85	if _, removed := galleries.Remove(name); !removed {
 86		panic("no such gallery: " + name)
 87	}
 88	rev++
 89}
 90
 91func parseGallery(spec string) []card {
 92	var cards []card
 93	for _, line := range strings.Split(spec, "\n") {
 94		line = strings.TrimSpace(line)
 95		if line == "" || strings.HasPrefix(line, "#") {
 96			continue
 97		}
 98		f := strings.Split(line, "|")
 99		if len(f) != 4 && len(f) != 5 {
100			panic("gallery line needs 4 or 5 fields (kind | title | meta | url [| description]): " + line)
101		}
102		c := card{
103			kind:  strings.TrimSpace(f[0]),
104			title: strings.TrimSpace(f[1]),
105			meta:  strings.TrimSpace(f[2]),
106			url:   strings.TrimSpace(f[3]),
107		}
108		if len(f) == 5 {
109			c.desc = strings.TrimSpace(f[4])
110			if len(c.desc) > maxDescLen || strings.ContainsAny(c.desc, "\"\\") {
111				panic("gallery description must be at most 200 bytes, without quotes or backslashes: " + line)
112			}
113		}
114		if !validSlug(c.kind) || c.title == "" || len(c.title) > maxTitleLen || len(c.meta) > maxMetaLen {
115			panic("bad gallery line: " + line)
116		}
117		if !strings.HasPrefix(c.url, "https://") && (!strings.HasPrefix(c.url, "/") || strings.HasPrefix(c.url, "//")) {
118			panic("gallery url must be https:// or a /path: " + c.url)
119		}
120		if strings.ContainsAny(c.url, " ()<>\"\\") {
121			panic("gallery url has characters markdown would break on: " + c.url)
122		}
123		cards = append(cards, c)
124	}
125	if len(cards) == 0 || len(cards) > maxCards {
126		panic("a gallery holds 1 to 48 cards")
127	}
128	return cards
129}
130
131// renderGallery lays cards out cardsPerRow to a row, each one a poster that
132// links to its target.
133func renderGallery(name string) (string, bool) {
134	v := galleries.Get(name)
135	if v == nil {
136		return "", false
137	}
138	cards := v.([]card)
139	var b strings.Builder
140	for i := 0; i < len(cards); i += cardsPerRow {
141		b.WriteString("<gno-columns>\n")
142		for j := i; j < i+cardsPerRow; j++ {
143			if j > i {
144				b.WriteString("<gno-columns-sep />\n")
145			}
146			if j < len(cards) {
147				c := cards[j]
148				b.WriteString("[![" + mdText(c.title) + "](" + posterURI(c) + ")](" + c.url + linkTitle(c.desc) + ")\n")
149			}
150		}
151		b.WriteString("</gno-columns>\n\n")
152	}
153	return strings.TrimSuffix(b.String(), "\n\n"), true
154}
155
156// linkTitle renders an optional markdown link title, which gnoweb emits as
157// the link's hover text.
158func linkTitle(desc string) string {
159	if desc == "" {
160		return ""
161	}
162	return ` "` + desc + `"`
163}
164
165// mdText keeps a title from closing the markdown image alt text early.
166func mdText(s string) string {
167	return strings.NewReplacer("[", "(", "]", ")", "\\", "/").Replace(s)
168}
169
170func xmlText(s string) string {
171	return strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;", "\"", "&quot;", "'", "&#39;").Replace(s)
172}
173
174// wrap splits s into at most two lines of about width runes; the second line
175// is cut with an ellipsis when the title is longer.
176func wrap(s string, width int) []string {
177	words := strings.Fields(s)
178	var lines []string
179	cur := ""
180	for _, w := range words {
181		if cur == "" {
182			cur = w
183		} else if runeLen(cur)+1+runeLen(w) <= width {
184			cur += " " + w
185		} else {
186			lines = append(lines, cur)
187			cur = w
188		}
189	}
190	if cur != "" {
191		lines = append(lines, cur)
192	}
193	if len(lines) > 2 {
194		second := []rune(strings.Join(lines[1:], " "))
195		if len(second) > width {
196			second = append(second[:width-1], '…')
197		}
198		lines = []string{lines[0], string(second)}
199	}
200	return lines
201}
202
203func runeLen(s string) int { return len([]rune(s)) }
204
205// clip cuts s to at most n runes, ending with an ellipsis when it had to cut.
206func clip(s string, n int) string {
207	r := []rune(s)
208	if len(r) <= n {
209		return s
210	}
211	return string(append(r[:n-1], '…'))
212}
213
214func posterURI(c card) string {
215	return "data:image/svg+xml;base64," + base64.StdEncoding.EncodeToString([]byte(poster(c)))
216}
217
218// poster draws a 320x180 card: a dark gradient, a rising sun in the kind's
219// accent (a play button for videos), a large kanji watermark, the kind label,
220// the title on up to two lines and a meta line.
221func poster(c card) string {
222	st := styleOf(c.kind)
223	if c.label != "" {
224		st.label = c.label
225	}
226	var b strings.Builder
227	b.WriteString(`<svg xmlns="http://www.w3.org/2000/svg" width="320" height="180" viewBox="0 0 320 180">`)
228	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>`)
229	b.WriteString(`<rect width="320" height="180" rx="14" fill="url(#g)"/>`)
230	b.WriteString(`<text x="306" y="170" text-anchor="end" font-family="serif" font-size="120" fill="#ffffff" fill-opacity=".06">` + st.glyph + `</text>`)
231	b.WriteString(`<circle cx="268" cy="52" r="30" fill="` + st.accent + `"/>`)
232	if st.video {
233		b.WriteString(`<path d="M260 38 L282 52 L260 66 Z" fill="#121212"/>`)
234	} else {
235		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"/>`)
236	}
237	b.WriteString(`<rect x="18" y="20" width="3" height="12" fill="` + st.accent + `"/>`)
238	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>`)
239	lines := wrap(c.title, 24)
240	y := 108
241	if len(lines) == 1 {
242		y = 126
243	}
244	for _, l := range lines {
245		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>`)
246		y += 25
247	}
248	// The meta line hugs the bottom-right corner: gnoweb draws its
249	// external-link badge over the bottom-left one.
250	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>`)
251	b.WriteString(`</svg>`)
252	return b.String()
253}
254
255func itoa(n int) string {
256	if n == 0 {
257		return "0"
258	}
259	var d []byte
260	for n > 0 {
261		d = append([]byte{byte('0' + n%10)}, d...)
262		n /= 10
263	}
264	return string(d)
265}