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("[ + ")](" + 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("&", "&", "<", "<", ">", ">", "\"", """, "'", "'").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}