Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

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}