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