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

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 "![" + sanitize.InlineText(alt) + "](data:image/svg+xml;base64," +
 72		base64.StdEncoding.EncodeToString([]byte(svg)) + ")"
 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 &#103; 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", "&#103;1")
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}