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.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}