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

render.gno

11.28 Kb · 411 lines
  1package boards2
  2
  3import (
  4	"net/url"
  5	"strconv"
  6	"strings"
  7	"time"
  8
  9	"gno.land/p/gnoland/boards"
 10	"gno.land/p/jeronimoalbi/mdform"
 11	"gno.land/p/jeronimoalbi/pager"
 12	"gno.land/p/leon/svgbtn"
 13	"gno.land/p/moul/md"
 14	"gno.land/p/moul/mdtable"
 15	"gno.land/p/nt/markdown/foreign/v0"
 16	"gno.land/p/nt/mux/v0"
 17)
 18
 19const (
 20	pageSizeDefault = 6
 21	pageSizeReplies = 10
 22	// pageSizeFlat is the page size of the flat "all comments" view (?flat=1).
 23	// Each comment renders exactly one <gno-foreign> block (no recursion); with
 24	// the OP (and any repost-source body) the per-page total stays well under
 25	// maxRenderedBodies, so a flat page can't hit the render cap.
 26	pageSizeFlat = 50
 27)
 28
 29// maxRenderedBodies caps user bodies wrapped in <gno-foreign> per page
 30// render, kept a margin under gnoweb's per-render foreign-block cap
 31// (foreign.MaxBlocksPerRender, sourced from chain/markdown so it can't
 32// drift from the renderer). On reaching it the tree stops descending
 33// and shows a truncation notice instead of letting the renderer blank
 34// comments past the cap. The margin absorbs the OP, repost source
 35// bodies, and chrome.
 36//
 37// Computed lazily (a func, not a package-level var) so it tracks the
 38// native cap across chain upgrades: a var initializer runs once at
 39// realm-init and persists, freezing a stale value if the underlying
 40// MaxBlocksPerRender ever changes; recomputing per render re-reads it.
 41func maxRenderedBodies() int {
 42	return foreign.MaxBlocksPerRender() - 10
 43}
 44
 45// sortToggleLink builds the asc/desc sort-toggle link for path. The label
 46// names the DESTINATION order (what you get by clicking), not the order you're
 47// currently viewing, and ?page= is reset since page N of one order is a
 48// different slice in the other. It reports whether the CURRENT order is
 49// descending, so callers can drive their own (signed-count or reverse-iterate)
 50// pagination. Other query params (e.g. flat=1) are preserved.
 51func sortToggleLink(path string) (link string, desc bool) {
 52	r := parseRealmPath(path)
 53	desc = r.Query.Get("order") == "desc"
 54	r.Query.Del("page")
 55	if desc {
 56		r.Query.Set("order", "asc")
 57		return md.Link("oldest first", r.String()), true
 58	}
 59	r.Query.Set("order", "desc")
 60	return md.Link("newest first", r.String()), false
 61}
 62
 63const menuManageBoard = "manageBoard"
 64
 65var (
 66	createBoardURI = gRealmPath + ":create-board"
 67	adminUsersURI  = gRealmPath + ":admin-users"
 68	helpURI        = gRealmPath + ":help"
 69)
 70
 71func Render(path string) string {
 72	var (
 73		b      strings.Builder
 74		router = mux.NewRouter()
 75	)
 76
 77	router.HandleFunc("", renderBoardsList)
 78	router.HandleFunc("help", renderHelp)
 79	router.HandleFunc("admin-users", renderMembers)
 80	router.HandleFunc("create-board", renderCreateBoard)
 81	router.HandleFunc("{board}", renderBoard)
 82	router.HandleFunc("{board}/members", renderMembers)
 83	router.HandleFunc("{board}/invites", renderInvites)
 84	router.HandleFunc("{board}/banned-users", renderBannedUsers)
 85	router.HandleFunc("{board}/create-thread", renderCreateThread)
 86	router.HandleFunc("{board}/invite-member", renderInviteMember)
 87	router.HandleFunc("{board}/{thread}", renderThread)
 88	router.HandleFunc("{board}/{thread}/flag", renderFlagPost)
 89	router.HandleFunc("{board}/{thread}/flagging-reasons", renderFlaggingReasonsPost)
 90	router.HandleFunc("{board}/{thread}/reply", renderReplyPost)
 91	router.HandleFunc("{board}/{thread}/edit", renderEditThread)
 92	router.HandleFunc("{board}/{thread}/repost", renderRepostThread)
 93	router.HandleFunc("{board}/{thread}/{reply}", renderReply)
 94	router.HandleFunc("{board}/{thread}/{reply}/flag", renderFlagPost)
 95	router.HandleFunc("{board}/{thread}/{reply}/flagging-reasons", renderFlaggingReasonsPost)
 96	router.HandleFunc("{board}/{thread}/{reply}/reply", renderReplyPost)
 97	router.HandleFunc("{board}/{thread}/{reply}/edit", renderEditReply)
 98
 99	router.NotFoundHandler = func(res *mux.ResponseWriter, _ *mux.Request) {
100		res.Write(md.Blockquote("Path not found"))
101	}
102
103	// Render common realm header before resolving render path
104	if Notice != "" {
105		b.WriteString(infoAlert("Notice", Notice))
106	}
107
108	// Render view for current path
109	b.WriteString(router.Render(path))
110
111	return b.String()
112}
113
114func renderHelp(res *mux.ResponseWriter, _ *mux.Request) {
115	res.Write(md.H1("Boards Help"))
116	if Help != "" {
117		res.Write(Help)
118		return
119	}
120
121	link := RealmLink.Call("SetHelp", "content", "")
122	res.Write(md.H3("Help content has not been uploaded"))
123	res.Write("Do you want to " + md.Link("upload boards help", link) + "?")
124}
125
126func renderBoardsList(res *mux.ResponseWriter, req *mux.Request) {
127	res.Write(md.H1("Boards"))
128	res.Write(md.Link("chore(boards2): Make public API non-crossing again, move to main folder", "https://github.com/gnolang/gno/pull/5809") + "\n\n")
129	renderBoardListMenu(res, req)
130	res.Write(md.HorizontalRule())
131
132	if gListedBoardsByID.Size() == 0 {
133		res.Write(md.H3("Currently there are no boards"))
134		res.Write("Be the first to " + md.Link("create a new board", createBoardURI) + "!")
135		return
136	}
137
138	p := newClampedPager(req.RawPath, gListedBoardsByID.Size(), pageSizeDefault)
139
140	render := func(_ string, v any) bool {
141		board := v.(*boards.Board)
142		userLink := userLink(board.Creator)
143		date := board.CreatedAt.Format(dateFormat)
144
145		res.Write(md.H6(md.Link(board.Name, makeBoardURI(board))))
146		res.Write("Created by " + userLink + " on " + date + ", #" + board.ID.String() + "  \n")
147
148		status := strconv.Itoa(board.Threads.Size()) + " threads"
149		if board.Readonly {
150			status += ", read-only"
151		}
152
153		res.Write(md.Bold(status) + "\n\n")
154		return false
155	}
156
157	res.Write("Sort by: ")
158	link, desc := sortToggleLink(req.RawPath)
159	res.Write(link + "\n\n")
160	if desc {
161		gListedBoardsByID.ReverseIterateByOffset(p.Offset(), p.PageSize(), render)
162	} else {
163		gListedBoardsByID.IterateByOffset(p.Offset(), p.PageSize(), render)
164	}
165
166	if p.HasPages() {
167		res.Write(md.HorizontalRule())
168		res.Write(pager.Picker(p))
169	}
170}
171
172func renderBoardListMenu(res *mux.ResponseWriter, req *mux.Request) {
173	res.Write(md.Link("Create Board", createBoardURI))
174	res.Write(" • ")
175	res.Write(md.Link("List Admin Users", adminUsersURI))
176	res.Write(" • ")
177	res.Write(md.Link("Help", helpURI))
178	res.Write("\n\n")
179}
180
181func renderCreateBoard(res *mux.ResponseWriter, _ *mux.Request) {
182	form := mdform.New("exec", "CreateBoard")
183	form.Input(
184		"name",
185		"placeholder", "Board name",
186		"required", "true",
187	)
188	form.Radio(
189		"listed",
190		"true",
191		"checked", "true",
192		"description", "Should board be publicly listed?",
193	)
194	form.Radio(
195		"listed",
196		"false",
197	)
198	form.Radio(
199		"open",
200		"true",
201		"description", "Should anyone be allowed to create threads and comments?",
202	)
203	form.Radio(
204		"open",
205		"false",
206		"checked", "true",
207	)
208
209	res.Write(md.H1("Boards: Create Board"))
210	res.Write(md.Link("← Back to boards", gRealmPath) + "\n\n")
211	res.Write(
212		md.Paragraph(
213			"Boards are by default listed by the realm but they can optionally " +
214				"be created so they are only found by their URL.",
215		),
216	)
217	res.Write(
218		md.Paragraph(
219			"They can also be created to be open so anyone is allowed to create " +
220				"new threads and also to comment on any thread within the open board.",
221		),
222	)
223	res.Write(form.String())
224	res.Write("\n\n**Done?** " + svgbtn.ButtonWithRadius(136, 32, 4, "#E2E2E2", "#54595D", "Return to boards", gRealmPath) + "\n")
225}
226
227func renderMembers(res *mux.ResponseWriter, req *mux.Request) {
228	boardID := boards.ID(0)
229	perms := gPerms
230	name := req.GetVar("board")
231	if name != "" {
232		board, found := gBoards.GetByName(name)
233		if !found {
234			res.Write(md.H3("Board not found"))
235			return
236		}
237
238		boardID = board.ID
239		perms = board.Permissions
240
241		res.Write(md.H1(board.Name + " Members"))
242		res.Write(md.H3("These are the board members"))
243	} else {
244		res.Write(md.H1("Admin Users"))
245		res.Write(md.H3("These are the admin users of the realm"))
246	}
247
248	// Create a pager with a small page size to reduce
249	// the number of username lookups per page.
250	p := newClampedPager(req.RawPath, perms.UsersCount(), pageSizeDefault)
251
252	table := mdtable.Table{
253		Headers: []string{"Member", "Role", "Actions"},
254	}
255
256	perms.IterateUsers(p.Offset(), p.PageSize(), func(u boards.User) bool {
257		actions := []string{
258			md.Link("remove", RealmLink.Call(
259				"RemoveMember",
260				"boardID", boardID.String(),
261				"member", u.Address.String(),
262			)),
263			md.Link("change role", RealmLink.Call(
264				"ChangeMemberRole",
265				"boardID", boardID.String(),
266				"member", u.Address.String(),
267				"role", "",
268			)),
269		}
270
271		table.Append([]string{
272			userLink(u.Address),
273			rolesToString(u.Roles),
274			strings.Join(actions, " • "),
275		})
276		return false
277	})
278	res.Write(table.String())
279
280	if p.HasPages() {
281		res.Write("\n" + pager.Picker(p))
282	}
283}
284
285func renderInvites(res *mux.ResponseWriter, req *mux.Request) {
286	name := req.GetVar("board")
287	board, found := gBoards.GetByName(name)
288	if !found {
289		res.Write(md.H3("Board not found"))
290		return
291	}
292
293	res.Write(md.H1(board.Name + " Invite Requests"))
294
295	requests, found := getInviteRequests(board.ID)
296	if !found || requests.Size() == 0 {
297		res.Write(md.H3("Board has no invite requests"))
298		return
299	}
300
301	p := newClampedPager(req.RawPath, requests.Size(), pageSizeDefault)
302
303	table := mdtable.Table{
304		Headers: []string{"User", "Request Date", "Actions"},
305	}
306
307	res.Write(md.H3("These users have requested to be invited to the board"))
308	requests.ReverseIterateByOffset(p.Offset(), p.PageSize(), func(addr string, v any) bool {
309		actions := []string{
310			md.Link("accept", RealmLink.Call(
311				"AcceptInvite",
312				"boardID", board.ID.String(),
313				"user", addr,
314			)),
315			md.Link("revoke", RealmLink.Call(
316				"RevokeInvite",
317				"boardID", board.ID.String(),
318				"user", addr,
319			)),
320		}
321
322		table.Append([]string{
323			userLink(address(addr)),
324			v.(time.Time).Format(dateFormat),
325			strings.Join(actions, " • "),
326		})
327		return false
328	})
329
330	res.Write(table.String())
331
332	if p.HasPages() {
333		res.Write("\n" + pager.Picker(p))
334	}
335}
336
337func renderBannedUsers(res *mux.ResponseWriter, req *mux.Request) {
338	name := req.GetVar("board")
339	board, found := gBoards.GetByName(name)
340	if !found {
341		res.Write(md.H3("Board not found"))
342		return
343	}
344
345	res.Write(md.H1(board.Name + " Banned Users"))
346
347	banned, found := getBannedUsers(board.ID)
348	if !found || banned.Size() == 0 {
349		res.Write(md.H3("Board has no banned users"))
350		return
351	}
352
353	p := newClampedPager(req.RawPath, banned.Size(), pageSizeDefault)
354
355	table := mdtable.Table{
356		Headers: []string{"User", "Banned Until", "Actions"},
357	}
358
359	res.Write(md.H3("These users have been banned from the board"))
360	banned.ReverseIterateByOffset(p.Offset(), p.PageSize(), func(addr string, v any) bool {
361		table.Append([]string{
362			userLink(address(addr)),
363			v.(time.Time).Format(dateFormat),
364			md.Link("unban", RealmLink.Call(
365				"Unban",
366				"boardID", board.ID.String(),
367				"user", addr,
368				"reason", "",
369			)),
370		})
371		return false
372	})
373
374	res.Write(table.String())
375
376	if p.HasPages() {
377		res.Write("\n" + pager.Picker(p))
378	}
379}
380
381func infoAlert(title, msg string) string {
382	header := strings.TrimSpace("[!INFO] " + title)
383	return md.Blockquote(header + "\n" + msg)
384}
385
386func rolesToString(roles []boards.Role) string {
387	if len(roles) == 0 {
388		return ""
389	}
390
391	names := make([]string, len(roles))
392	for i, r := range roles {
393		names[i] = string(r)
394	}
395	return strings.Join(names, ", ")
396}
397
398func menuURL(name string) string {
399	// TODO: Menu URL works because no other GET arguments are being used
400	return "?menu=" + name
401}
402
403func getCurrentMenu(rawURL string) string {
404	_, rawQuery, found := strings.Cut(rawURL, "?")
405	if !found {
406		return ""
407	}
408
409	query, _ := url.ParseQuery(rawQuery)
410	return query.Get("menu")
411}