widgets.gno
13.67 Kb · 392 lines
1package home
2
3import (
4 "chain/runtime"
5 "encoding/base64"
6 "html"
7 "strconv"
8 "strings"
9
10 "gno.land/p/nt/markdown/sanitize/v0"
11)
12
13// Suggested amount on the Tip button (1 GNOT); visitors can change it.
14const tipSuggestion = "1000000ugnot"
15
16// expand replaces every widget token in one pass. strings.Replacer does not
17// rescan its output, so visitor text inside a widget can never trigger a
18// token. Only the tokens present in md are built, found with a single scan
19// of md.
20func expand(md, tab string, page int) string {
21 words := map[string]bool{}
22 for _, w := range strings.Split(md, ":") {
23 words[w] = true
24 }
25 var pairs []string
26 add := func(tok string, build func() string) {
27 if words[tok[1:len(tok)-1]] {
28 pairs = append(pairs, tok, build())
29 }
30 }
31 add(":banner:", bannerMD)
32 add(":badges:", badgesMD)
33 add(":now:", nowMD)
34 add(":waves:", wavesMD)
35 add(":tip:", tipMD)
36 add(":sign:", signMD)
37 add(":guestbook:", func() string {
38 if tab != guestbookTab {
39 return "" // a form would target /u/<name> on the profile view
40 }
41 return guestbookMD(page)
42 })
43 add(":blog:", func() string {
44 if tab != blogTab {
45 return "" // its page links lead to the Blog tab
46 }
47 return blogMD(page)
48 })
49 add(":posts:", postsMD)
50 add(":height:", func() string { return strconv.FormatInt(runtime.ChainHeight(), 10) })
51 add(":owner:", func() string { return owner.String() })
52 if len(pairs) == 0 {
53 return md
54 }
55 return strings.NewReplacer(pairs...).Replace(md)
56}
57
58func itoa(n int) string { return strconv.Itoa(n) }
59
60// plural renders "1 wave", "2 waves".
61func plural(n int, word string) string {
62 if n == 1 {
63 return "1 " + word
64 }
65 return itoa(n) + " " + word + "s"
66}
67
68// svgImage embeds an SVG as a base64 data URI, the one inline image form
69// gnoweb keeps.
70func svgImage(alt, svg string) string {
71 return ") + ")"
73}
74
75// ----- colors -----
76
77// gno.land's own greens, from gnoweb's palette. An image can't follow
78// gnoweb's theme toggle, so each one reads on both its white and its
79// near-black background.
80const (
81 colBrand = "#226C57" // gnoweb's links and buttons
82 colDeep = "#144134" // banner background, text on colSoft
83 colAccent = "#60AB96" // banner accents, pill outlines
84 colSoft = "#E7EFED" // pale surface
85 colGhost = "#F0F0F0"
86 fontUI = "system-ui, -apple-system, Segoe UI, Helvetica, Arial, sans-serif"
87)
88
89// ----- banner -----
90
91func bannerMD() string {
92 return svgImage(profile.name+" banner", bannerSVG(runtime.ChainHeight()))
93}
94
95// bannerSVG draws the 720x160 banner: the ghost, a coffee cup, the name and
96// the tagline. The face depends on height % 4: calm, happy, wink, surprised.
97func bannerSVG(height int64) string {
98 var sb strings.Builder
99 sb.WriteString(`<svg xmlns="http://www.w3.org/2000/svg" width="720" height="160" viewBox="0 0 720 160">`)
100 sb.WriteString(`<rect width="720" height="160" rx="16" fill="` + colDeep + `"/>`)
101 sb.WriteString(`<path d="M60 140 V72 A50 50 0 0 1 160 72 V140 Q147.5 124 135 140 Q122.5 124 110 140 Q97.5 124 85 140 Q72.5 124 60 140 Z" fill="` + colGhost + `"/>`)
102 switch height % 4 {
103 case 0: // calm
104 sb.WriteString(`<ellipse cx="92" cy="74" rx="6" ry="8" fill="` + colDeep + `"/>`)
105 sb.WriteString(`<ellipse cx="128" cy="74" rx="6" ry="8" fill="` + colDeep + `"/>`)
106 sb.WriteString(`<path d="M100 100 Q110 106 120 100" stroke="` + colDeep + `" stroke-width="4" fill="none" stroke-linecap="round"/>`)
107 case 1: // happy
108 sb.WriteString(`<path d="M84 78 Q92 66 100 78 M120 78 Q128 66 136 78" stroke="` + colDeep + `" stroke-width="4" fill="none" stroke-linecap="round"/>`)
109 sb.WriteString(`<path d="M96 96 Q110 116 124 96 Z" fill="` + colDeep + `"/>`)
110 case 2: // wink
111 sb.WriteString(`<ellipse cx="92" cy="74" rx="6" ry="8" fill="` + colDeep + `"/>`)
112 sb.WriteString(`<path d="M120 76 H136" stroke="` + colDeep + `" stroke-width="4" stroke-linecap="round"/>`)
113 sb.WriteString(`<path d="M98 98 Q110 110 122 98" stroke="` + colDeep + `" stroke-width="4" fill="none" stroke-linecap="round"/>`)
114 default: // surprised
115 sb.WriteString(`<circle cx="92" cy="72" r="8" fill="` + colDeep + `"/>`)
116 sb.WriteString(`<circle cx="128" cy="72" r="8" fill="` + colDeep + `"/>`)
117 sb.WriteString(`<ellipse cx="110" cy="104" rx="7" ry="9" fill="` + colDeep + `"/>`)
118 }
119 // Coffee cup with steam: "I turn coffee into code".
120 sb.WriteString(`<rect x="180" y="108" width="30" height="30" rx="5" fill="` + colAccent + `"/>`)
121 sb.WriteString(`<path d="M210 114 h6 a7 7 0 0 1 0 14 h-6" stroke="` + colAccent + `" stroke-width="4" fill="none"/>`)
122 sb.WriteString(`<path d="M188 100 q-5 -7 0 -14 M200 100 q-5 -7 0 -14" stroke="` + colAccent + `" stroke-width="3" fill="none" stroke-linecap="round"/>`)
123 sb.WriteString(`<text x="260" y="78" font-family="ui-monospace, SFMono-Regular, Menlo, monospace" font-size="` + fontSize(profile.name, 44, 26) + `" font-weight="700" fill="` + colGhost + `">` + html.EscapeString(profile.name) + `</text>`)
124 sb.WriteString(`<text x="260" y="116" font-family="` + fontUI + `" font-size="` + fontSize(profile.tagline, 22, 12) + `" fill="` + colAccent + `">` + html.EscapeString(profile.tagline) + `</text>`)
125 sb.WriteString(`</svg>`)
126 return sb.String()
127}
128
129// fontSize shrinks size so text fits the 440 px between x=260 and the
130// banner's right edge. perChar is the rough glyph width at that size; runes
131// from U+1100 on (CJK, emoji and the like) count double. Scaling the font,
132// rather than setting textLength, never stretches text that already fits.
133func fontSize(s string, size, perChar int) string {
134 w := 0
135 for _, r := range s {
136 w += perChar
137 if r >= 0x1100 {
138 w += perChar
139 }
140 }
141 if w > 440 {
142 size = size * 440 / w
143 }
144 return itoa(size)
145}
146
147// ----- pills: tabs, badges, buttons -----
148
149// Pills come in two heights: tabs, and chips (badges and buttons). gnoweb
150// draws a 16 px icon on a linked image, 8 px from its left edge: images in
151// a row get an 8 px margin, and the icon's center sits that far above the
152// image's bottom edge. It lands on the center line of a chip about 16 px
153// high, so chips are 17 px, with iconGap pixels left clear for it.
154// External and $help links get the icon; tabs and the guestbook button
155// link inside the realm's own namespace and don't.
156const (
157 tabH = 26
158 chipH = 17
159 iconGap = 30
160)
161
162// seg is one colored part of a pill.
163type seg struct{ text, fill, ink string }
164
165func segOn(s string) seg { return seg{s, colBrand, "#fff"} }
166func segSoft(s string) seg { return seg{s, colSoft, colDeep} }
167
168// pillSVG draws a pill h pixels high from segments, left to right, with
169// gap empty pixels before the first text. SVG can't measure text before
170// drawing, so widths come from the rune count, wide runes (U+1100 on)
171// counting double. The outline gives the pale parts an edge on a white
172// page.
173func pillSVG(h, gap int, segs ...seg) string {
174 font, char := 11, 7
175 if h == tabH {
176 font, char = 13, 8
177 }
178 widths := make([]int, len(segs))
179 w := gap
180 for i, s := range segs {
181 widths[i] = h // half a pill's height of padding on each side
182 for _, r := range s.text {
183 widths[i] += char
184 if r >= 0x1100 {
185 widths[i] += char
186 }
187 }
188 w += widths[i]
189 }
190 mid := half(h)
191 var sb strings.Builder
192 sb.WriteString(`<svg xmlns="http://www.w3.org/2000/svg" width="` + itoa(w) + `" height="` + itoa(h) + `" viewBox="0 0 ` + itoa(w) + ` ` + itoa(h) + `">`)
193 sb.WriteString(`<clipPath id="c"><rect width="` + itoa(w) + `" height="` + itoa(h) + `" rx="` + mid + `"/></clipPath>`)
194 sb.WriteString(`<g clip-path="url(#c)" font-family="` + fontUI + `" font-size="` + itoa(font) + `" font-weight="600" text-anchor="middle" dominant-baseline="central">`)
195 x := 0
196 for i, s := range segs {
197 pad := 0
198 if i == 0 {
199 pad = gap
200 }
201 sb.WriteString(`<rect x="` + itoa(x) + `" width="` + itoa(pad+widths[i]) + `" height="` + itoa(h) + `" fill="` + s.fill + `"/>`)
202 sb.WriteString(`<text x="` + itoa(x+pad+widths[i]/2) + `" y="` + mid + `" fill="` + s.ink + `">` + html.EscapeString(s.text) + `</text>`)
203 x += pad + widths[i]
204 }
205 sb.WriteString(`</g><rect x=".5" y=".5" width="` + itoa(w-1) + `" height="` + itoa(h-1) + `" rx="` + half(h-1) + `" fill="none" stroke="` + colAccent + `"/></svg>`)
206 return sb.String()
207}
208
209// half writes n/2 with its .5, for SVG coordinates.
210func half(n int) string {
211 if n%2 == 1 {
212 return itoa(n/2) + ".5"
213 }
214 return itoa(n / 2)
215}
216
217// pillLink is a pill image linked to url, with alt text for screen readers.
218func pillLink(alt, url string, h, gap int, segs ...seg) string {
219 return "[" + svgImage(alt, pillSVG(h, gap, segs...)) + "](" + url + ")"
220}
221
222// badgesMD draws the badges as one paragraph. A new row starts after a
223// hard line break (a backslash ending the line), so rows sit as close as
224// wrapped ones.
225func badgesMD() string {
226 var sb strings.Builder
227 for i, b := range badges {
228 switch {
229 case i > 0 && b.row:
230 sb.WriteString("\\\n")
231 case i > 0:
232 sb.WriteString(" ")
233 }
234 sb.WriteString(pillLink(b.label+": "+b.value, b.url, chipH, iconGap, segSoft(b.label), segOn(b.value)))
235 }
236 return sb.String()
237}
238
239// ----- now, buttons -----
240
241func nowMD() string {
242 if now.text == "" {
243 return ""
244 }
245 return "> [!SUCCESS] Now\n> " + now.text + " (block " + strconv.FormatInt(now.height, 10) + ")"
246}
247
248// button is a linked chip: the action, then its count. $help links get
249// gnoweb's transaction icon, hence the gap.
250func button(action, count, url string, gap int) string {
251 return pillLink(action+", "+count, url, chipH, gap, segOn(action), segSoft(count))
252}
253
254func wavesMD() string {
255 return button("Wave", plural(waves, "wave"), base+"$help&func=Wave", iconGap)
256}
257
258func tipMD() string {
259 return button("Tip 1 GNOT", plural(tips.count, "tip"), base+"$help&func=Tip&.send="+tipSuggestion, iconGap)
260}
261
262// signMD counts hidden entries too: their authors did sign.
263func signMD() string {
264 return button("Sign the guestbook", plural(order.Size(), "signature"), tabURL(guestbookTab), 0)
265}
266
267// shortAddr keeps the ends of an address: g1jg8mtu…sqf5.
268func shortAddr(a address) string {
269 s := a.String()
270 if len(s) < 16 {
271 return s
272 }
273 return s[:8] + "…" + s[len(s)-4:]
274}
275
276// userLink links an address to its profile page under its short form. The
277// address comes from the chain, never from visitor text.
278func userLink(a address) string {
279 return "[" + shortAddr(a) + "](/u/" + a.String() + ")"
280}
281
282// ----- paging -----
283
284// clampPage returns the page to show, at most the last one, and the last
285// page for total items.
286func clampPage(page, total int) (int, int) {
287 last := (total + pageSize - 1) / pageSize
288 if page > last {
289 page = last
290 }
291 return page, last
292}
293
294// pageNav links the pages around page on tab: Newer, then Older.
295func pageNav(tab string, page, last int) string {
296 var nav []string
297 if page > 1 {
298 nav = append(nav, "[Newer]("+tabURL(tab)+"?page="+itoa(page-1)+")")
299 }
300 if page < last {
301 nav = append(nav, "[Older]("+tabURL(tab)+"?page="+itoa(page+1)+")")
302 }
303 if len(nav) == 0 {
304 return ""
305 }
306 return "\n" + strings.Join(nav, " · ") + "\n"
307}
308
309// ----- guestbook -----
310
311const signForm = `<gno-form exec="Sign">
312 <gno-input name="msg" type="text" placeholder="Say hi in 140 characters" required="true" />
313</gno-form>`
314
315// visitorText makes a stored message safe as one markdown list item.
316// sanitize.InlineText escapes links, images, HTML and emphasis; the escaped
317// @ and the g entity for the g of g1 keep gnoweb from turning "@name"
318// or a typed g1 address into a profile link. Neither replacement produces
319// the other's pattern, so the order doesn't matter.
320func visitorText(msg string) string {
321 s := strings.ReplaceAll(sanitize.InlineText(msg), "@", `\@`)
322 return strings.ReplaceAll(s, "g1", "g1")
323}
324
325// guestbookMD shows the form and one page of entries, newest first. Hidden
326// entries still count toward the offset, so a page can show fewer than 20.
327func guestbookMD(page int) string {
328 var sb strings.Builder
329 sb.WriteString(signForm)
330 sb.WriteString("\n\n")
331 total := order.Size()
332 if total == 0 {
333 sb.WriteString("No entries yet.\n")
334 return sb.String()
335 }
336 page, last := clampPage(page, total)
337 order.ReverseIterateByOffset((page-1)*pageSize, pageSize, func(_ string, v any) bool {
338 addr, _ := v.(address)
339 e, ok := entries.Get(addr.String()).(*entry)
340 if ok && !e.hidden { // Render never panics, even on a broken invariant
341 sb.WriteString("- " + userLink(addr) + ": " + visitorText(e.msg) + "\n")
342 }
343 return false
344 })
345 sb.WriteString(pageNav(guestbookTab, page, last))
346 return sb.String()
347}
348
349// ----- blog -----
350
351// postList lists count posts from offset, newest first: one item each with
352// the publish date as a code chip and the linked title.
353func postList(offset, count int) string {
354 var sb strings.Builder
355 postOrder.ReverseIterateByOffset(offset, count, func(_ string, v any) bool {
356 slug, _ := v.(string)
357 if p, ok := posts.Get(slug).(*post); ok { // Render never panics, even on a broken invariant
358 sb.WriteString("- `" + p.published + "` [" + sanitize.InlineText(p.title) + "](" + tabURL(blogTab) + "/" + slug + ")\n")
359 }
360 return false
361 })
362 return sb.String()
363}
364
365// blogMD lists one page of posts.
366func blogMD(page int) string {
367 total := postOrder.Size()
368 if total == 0 {
369 return "No posts yet.\n"
370 }
371 page, last := clampPage(page, total)
372 return postList((page-1)*pageSize, pageSize) + pageNav(blogTab, page, last)
373}
374
375// postsMD is the Latest posts section, or nothing while there are no posts,
376// so no empty heading shows.
377func postsMD() string {
378 if postOrder.Size() == 0 {
379 return ""
380 }
381 return "## Latest posts\n\n" + postList(0, latestPosts) + "\n[All posts](" + tabURL(blogTab) + ")\n"
382}
383
384// postMD is a post's page. The body is shown as written: no tokens.
385func postMD(p *post) string {
386 date := p.published
387 if p.edited != "" && p.edited != p.published {
388 date += " · edited " + p.edited
389 }
390 return "# " + sanitize.InlineText(p.title) + "\n\n*" + date + "*\n\n" + p.body +
391 "\n\n[All posts](" + tabURL(blogTab) + ")"
392}