render.gno

package boards2

import (
	"net/url"
	"strconv"
	"strings"
	"time"

	"gno.land/p/gnoland/boards"
	"gno.land/p/jeronimoalbi/mdform"
	"gno.land/p/jeronimoalbi/pager"
	"gno.land/p/leon/svgbtn"
	"gno.land/p/moul/md"
	"gno.land/p/moul/mdtable"
	"gno.land/p/nt/mux/v0"
)

const (
	pageSizeDefault = 6
	pageSizeReplies = 10
)

const menuManageBoard = "manageBoard"

var (
	createBoardURI = gRealmPath + ":create-board"
	adminUsersURI  = gRealmPath + ":admin-users"
	helpURI        = gRealmPath + ":help"
)

func Render(path string) string {
	var (
		b      strings.Builder
		router = mux.NewRouter()
	)

	router.HandleFunc("", renderBoardsList)
	router.HandleFunc("help", renderHelp)
	router.HandleFunc("admin-users", renderMembers)
	router.HandleFunc("create-board", renderCreateBoard)
	router.HandleFunc("{board}", renderBoard)
	router.HandleFunc("{board}/members", renderMembers)
	router.HandleFunc("{board}/invites", renderInvites)
	router.HandleFunc("{board}/banned-users", renderBannedUsers)
	router.HandleFunc("{board}/create-thread", renderCreateThread)
	router.HandleFunc("{board}/invite-member", renderInviteMember)
	router.HandleFunc("{board}/{thread}", renderThread)
	router.HandleFunc("{board}/{thread}/flag", renderFlagPost)
	router.HandleFunc("{board}/{thread}/flagging-reasons", renderFlaggingReasonsPost)
	router.HandleFunc("{board}/{thread}/reply", renderReplyPost)
	router.HandleFunc("{board}/{thread}/edit", renderEditThread)
	router.HandleFunc("{board}/{thread}/repost", renderRepostThread)
	router.HandleFunc("{board}/{thread}/{reply}", renderReply)
	router.HandleFunc("{board}/{thread}/{reply}/flag", renderFlagPost)
	router.HandleFunc("{board}/{thread}/{reply}/flagging-reasons", renderFlaggingReasonsPost)
	router.HandleFunc("{board}/{thread}/{reply}/reply", renderReplyPost)
	router.HandleFunc("{board}/{thread}/{reply}/edit", renderEditReply)

	router.NotFoundHandler = func(res *mux.ResponseWriter, _ *mux.Request) {
		res.Write(md.Blockquote("Path not found"))
	}

	// Render common realm header before resolving render path
	if gNotice != "" {
		b.WriteString(infoAlert("Notice", gNotice))
	}

	// Render view for current path
	b.WriteString(router.Render(path))

	return b.String()
}

func renderHelp(res *mux.ResponseWriter, _ *mux.Request) {
	res.Write(md.H1("Boards Help"))
	if gHelp != "" {
		res.Write(gHelp)
		return
	}

	link := gRealmLink.Call("SetHelp", "content", "")
	res.Write(md.H3("Help content has not been uploaded"))
	res.Write("Do you want to " + md.Link("upload boards help", link) + "?")
}

func renderBoardsList(res *mux.ResponseWriter, req *mux.Request) {
	res.Write(md.H1("Boards"))
	renderBoardListMenu(res, req)
	res.Write(md.HorizontalRule())

	if gListedBoardsByID.Size() == 0 {
		res.Write(md.H3("Currently there are no boards"))
		res.Write("Be the first to " + md.Link("create a new board", createBoardURI) + "!")
		return
	}

	p, err := pager.New(req.RawPath, gListedBoardsByID.Size(), pager.WithPageSize(pageSizeDefault))
	if err != nil {
		panic(err)
	}

	render := func(_ string, v any) bool {
		board := v.(*boards.Board)
		userLink := userLink(board.Creator)
		date := board.CreatedAt.Format(dateFormat)

		res.Write(md.H6(md.Link(board.Name, makeBoardURI(board))))
		res.Write("Created by " + userLink + " on " + date + ", #" + board.ID.String() + "  \n")

		status := strconv.Itoa(board.Threads.Size()) + " threads"
		if board.Readonly {
			status += ", read-only"
		}

		res.Write(md.Bold(status) + "\n\n")
		return false
	}

	res.Write("Sort by: ")
	r := parseRealmPath(req.RawPath)
	if r.Query.Get("order") == "desc" {
		r.Query.Set("order", "asc")
		res.Write(md.Link("newest first", r.String()) + "\n\n")
		gListedBoardsByID.ReverseIterateByOffset(p.Offset(), p.PageSize(), render)
	} else {
		r.Query.Set("order", "desc")
		res.Write(md.Link("oldest first", r.String()) + "\n\n")
		gListedBoardsByID.IterateByOffset(p.Offset(), p.PageSize(), render)
	}

	if p.HasPages() {
		res.Write(md.HorizontalRule())
		res.Write(pager.Picker(p))
	}
}

func renderBoardListMenu(res *mux.ResponseWriter, req *mux.Request) {
	res.Write(md.Link("Create Board", createBoardURI))
	res.Write(" • ")
	res.Write(md.Link("List Admin Users", adminUsersURI))
	res.Write(" • ")
	res.Write(md.Link("Help", helpURI))
	res.Write("\n\n")
}

func renderCreateBoard(res *mux.ResponseWriter, _ *mux.Request) {
	form := mdform.New("exec", "CreateBoard")
	form.Input(
		"name",
		"placeholder", "Board name",
		"required", "true",
	)
	form.Radio(
		"listed",
		"true",
		"checked", "true",
		"description", "Should board be publicly listed?",
	)
	form.Radio(
		"listed",
		"false",
	)
	form.Radio(
		"open",
		"true",
		"description", "Should anyone be allowed to create threads and comments?",
	)
	form.Radio(
		"open",
		"false",
		"checked", "true",
	)

	res.Write(md.H1("Boards: Create Board"))
	res.Write(md.Link("← Back to boards", gRealmPath) + "\n\n")
	res.Write(
		md.Paragraph(
			"Boards are by default listed by the realm but they can optionally " +
				"be created so they are only found by their URL.",
		),
	)
	res.Write(
		md.Paragraph(
			"They can also be created to be open so anyone is allowed to create " +
				"new threads and also to comment on any thread within the open board.",
		),
	)
	res.Write(form.String())
	res.Write("\n\n**Done?** " + svgbtn.ButtonWithRadius(136, 32, 4, "#E2E2E2", "#54595D", "Return to boards", gRealmPath) + "\n")
}

func renderMembers(res *mux.ResponseWriter, req *mux.Request) {
	boardID := boards.ID(0)
	perms := gPerms
	name := req.GetVar("board")
	if name != "" {
		board, found := gBoards.GetByName(name)
		if !found {
			res.Write(md.H3("Board not found"))
			return
		}

		boardID = board.ID
		perms = board.Permissions

		res.Write(md.H1(board.Name + " Members"))
		res.Write(md.H3("These are the board members"))
	} else {
		res.Write(md.H1("Admin Users"))
		res.Write(md.H3("These are the admin users of the realm"))
	}

	// Create a pager with a small page size to reduce
	// the number of username lookups per page.
	p, err := pager.New(req.RawPath, perms.UsersCount(), pager.WithPageSize(pageSizeDefault))
	if err != nil {
		res.Write(err.Error())
		return
	}

	table := mdtable.Table{
		Headers: []string{"Member", "Role", "Actions"},
	}

	perms.IterateUsers(p.Offset(), p.PageSize(), func(u boards.User) bool {
		actions := []string{
			md.Link("remove", gRealmLink.Call(
				"RemoveMember",
				"boardID", boardID.String(),
				"member", u.Address.String(),
			)),
			md.Link("change role", gRealmLink.Call(
				"ChangeMemberRole",
				"boardID", boardID.String(),
				"member", u.Address.String(),
				"role", "",
			)),
		}

		table.Append([]string{
			userLink(u.Address),
			rolesToString(u.Roles),
			strings.Join(actions, " • "),
		})
		return false
	})
	res.Write(table.String())

	if p.HasPages() {
		res.Write("\n" + pager.Picker(p))
	}
}

func renderInvites(res *mux.ResponseWriter, req *mux.Request) {
	name := req.GetVar("board")
	board, found := gBoards.GetByName(name)
	if !found {
		res.Write(md.H3("Board not found"))
		return
	}

	res.Write(md.H1(board.Name + " Invite Requests"))

	requests, found := getInviteRequests(board.ID)
	if !found || requests.Size() == 0 {
		res.Write(md.H3("Board has no invite requests"))
		return
	}

	p, err := pager.New(req.RawPath, requests.Size(), pager.WithPageSize(pageSizeDefault))
	if err != nil {
		res.Write(err.Error())
		return
	}

	table := mdtable.Table{
		Headers: []string{"User", "Request Date", "Actions"},
	}

	res.Write(md.H3("These users have requested to be invited to the board"))
	requests.ReverseIterateByOffset(p.Offset(), p.PageSize(), func(addr string, v any) bool {
		actions := []string{
			md.Link("accept", gRealmLink.Call(
				"AcceptInvite",
				"boardID", board.ID.String(),
				"user", addr,
			)),
			md.Link("revoke", gRealmLink.Call(
				"RevokeInvite",
				"boardID", board.ID.String(),
				"user", addr,
			)),
		}

		table.Append([]string{
			userLink(address(addr)),
			v.(time.Time).Format(dateFormat),
			strings.Join(actions, " • "),
		})
		return false
	})

	res.Write(table.String())

	if p.HasPages() {
		res.Write("\n" + pager.Picker(p))
	}
}

func renderBannedUsers(res *mux.ResponseWriter, req *mux.Request) {
	name := req.GetVar("board")
	board, found := gBoards.GetByName(name)
	if !found {
		res.Write(md.H3("Board not found"))
		return
	}

	res.Write(md.H1(board.Name + " Banned Users"))

	banned, found := getBannedUsers(board.ID)
	if !found || banned.Size() == 0 {
		res.Write(md.H3("Board has no banned users"))
		return
	}

	p, err := pager.New(req.RawPath, banned.Size(), pager.WithPageSize(pageSizeDefault))
	if err != nil {
		res.Write(err.Error())
		return
	}

	table := mdtable.Table{
		Headers: []string{"User", "Banned Until", "Actions"},
	}

	res.Write(md.H3("These users have been banned from the board"))
	banned.ReverseIterateByOffset(p.Offset(), p.PageSize(), func(addr string, v any) bool {
		table.Append([]string{
			userLink(address(addr)),
			v.(time.Time).Format(dateFormat),
			md.Link("unban", gRealmLink.Call(
				"Unban",
				"boardID", board.ID.String(),
				"user", addr,
				"reason", "",
			)),
		})
		return false
	})

	res.Write(table.String())

	if p.HasPages() {
		res.Write("\n" + pager.Picker(p))
	}
}

func infoAlert(title, msg string) string {
	header := strings.TrimSpace("[!INFO] " + title)
	return md.Blockquote(header + "\n" + msg)
}

func rolesToString(roles []boards.Role) string {
	if len(roles) == 0 {
		return ""
	}

	names := make([]string, len(roles))
	for i, r := range roles {
		names[i] = string(r)
	}
	return strings.Join(names, ", ")
}

func menuURL(name string) string {
	// TODO: Menu URL works because no other GET arguments are being used
	return "?menu=" + name
}

func getCurrentMenu(rawURL string) string {
	_, rawQuery, found := strings.Cut(rawURL, "?")
	if !found {
		return ""
	}

	query, _ := url.ParseQuery(rawQuery)
	return query.Get("menu")
}