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

present.gno

9.04 Kb · 362 lines
  1package present
  2
  3import (
  4	"net/url"
  5	"strconv"
  6	"strings"
  7	"time"
  8
  9	"chain/runtime"
 10
 11	"gno.land/p/moul/md/v0"
 12	"gno.land/p/moul/mdtable/v0"
 13	"gno.land/p/moul/realmpath/v0"
 14	"gno.land/p/moul/txlink/v0"
 15	"gno.land/p/nt/avl/v0"
 16	"gno.land/p/nt/avl/pager/v0"
 17	"gno.land/p/nt/ownable/v0"
 18	"gno.land/p/nt/seqid/v0"
 19	"gno.land/p/nt/ufmt/v0"
 20)
 21
 22var chainDomain = runtime.ChainDomain()
 23
 24type Presentation struct {
 25	id        string
 26	Slug      string
 27	Title     string
 28	Event     string
 29	Author    string
 30	Uploader  address
 31	Date      time.Time
 32	Content   string
 33	EditDate  time.Time
 34	NumSlides int
 35}
 36
 37var (
 38	byID    avl.Tree // id -> *Presentation
 39	bySlug  avl.Tree // slug -> *Presentation
 40	idgen   seqid.ID
 41	Ownable *ownable.Ownable
 42)
 43
 44// owner is the realm admin allowed to add/update/delete presentations.
 45const owner address = "g1manfred47kzduec920z88wfr64ylksmdcedlf5" // @moul
 46
 47func init() {
 48	Ownable = ownable.NewWithAddress(owner)
 49}
 50
 51// Render handles the realm's rendering logic
 52func Render(path string) string {
 53	req := realmpath.Parse(path)
 54
 55	// Get slug from path
 56	slug := req.PathPart(0)
 57
 58	// List view (home)
 59	if slug == "" {
 60		return renderList(req)
 61	}
 62
 63	// Slides view
 64	if req.PathPart(1) == "slides" {
 65		page := 1
 66		if pageStr := req.Query.Get("page"); pageStr != "" {
 67			var err error
 68			page, err = strconv.Atoi(pageStr)
 69			if err != nil {
 70				return "400: invalid page number"
 71			}
 72		}
 73		return renderSlides(slug, page)
 74	}
 75
 76	// Regular view
 77	return renderView(slug)
 78}
 79
 80// Set adds or updates a presentation
 81func Set(cur realm, slug, title, event, author, date, content string) string {
 82	caller := cur.Previous().Address()
 83	Ownable.AssertOwnedBy(caller)
 84	return setPresentation(caller, slug, title, event, author, date, content)
 85}
 86
 87// setPresentation is the shared record-mutation preamble used by both the
 88// owner-gated Set entrypoint and the init seeding.
 89func setPresentation(uploader address, slug, title, event, author, date, content string) string {
 90	parsedDate, err := time.Parse("2006-01-02", date)
 91	if err != nil {
 92		return "400: invalid date format (expected: YYYY-MM-DD)"
 93	}
 94
 95	numSlides := 1 // Count intro slide
 96	for _, line := range strings.Split(content, "\n") {
 97		if strings.HasPrefix(line, "## ") {
 98			numSlides++
 99		}
100	}
101	numSlides++ // Count thank you slide
102
103	// Reuse the existing id when the slug is already known (update in place).
104	var id string
105	if v := bySlug.Get(slug); v != nil {
106		id = v.(*Presentation).id
107	} else {
108		id = idgen.Next().String()
109	}
110
111	p := &Presentation{
112		id:        id,
113		Slug:      slug,
114		Title:     title,
115		Event:     event,
116		Author:    author,
117		Uploader:  uploader,
118		Date:      parsedDate,
119		Content:   content,
120		EditDate:  time.Now(),
121		NumSlides: numSlides,
122	}
123
124	byID.Set(id, p)
125	bySlug.Set(slug, p)
126	return "presentation saved successfully"
127}
128
129// Delete removes a presentation
130func Delete(cur realm, slug string) string {
131	Ownable.AssertOwnedBy(cur.Previous().Address())
132
133	v := bySlug.Get(slug)
134	if v == nil {
135		return "404: presentation not found"
136	}
137	p := v.(*Presentation)
138
139	// XXX: consider this:
140	// if p.Uploader != cur.Previous().Address() {
141	// 	return "401: unauthorized - only the uploader can delete their presentations"
142	// }
143
144	byID.Remove(p.id)
145	bySlug.Remove(slug)
146	return "presentation deleted successfully"
147}
148
149func renderList(req *realmpath.Request) string {
150	var out strings.Builder
151	out.WriteString(md.H1("Presentations"))
152
153	// Setup pager over a tree sorted by the requested field.
154	tree := buildSortTree(getSortField(req))
155	pgr := pager.NewPager(tree, 10, isSortReversed(req))
156
157	// Get current page. The pager only reads the query string; pass just the
158	// query so url.Parse is not tripped by the "/realm:slug" colon in the
159	// full realm path.
160	page := pgr.MustGetPageByPath("?" + req.Query.Encode())
161
162	// Create table
163	dateColumn := renderSortLink(req, "date", "Date")
164	titleColumn := renderSortLink(req, "title", "Title")
165	authorColumn := renderSortLink(req, "author", "Author")
166	table := mdtable.Table{
167		Headers: []string{dateColumn, titleColumn, "Event", authorColumn, "Slides"},
168	}
169
170	// Add rows from current page
171	for _, item := range page.Items {
172		p := item.Value.(*Presentation)
173		table.Append([]string{
174			p.Date.Format("2006-01-02"),
175			md.Link(p.Title, localPath(p.Slug, nil)),
176			p.Event,
177			p.Author,
178			ufmt.Sprintf("%d", p.NumSlides),
179		})
180	}
181
182	out.WriteString(table.String())
183	out.WriteString(page.Picker("?" + req.Query.Encode()))
184	return out.String()
185}
186
187// buildSortTree builds an avl.Tree whose keys order the presentations by the
188// requested field. A trailing id keeps keys unique and makes ties
189// deterministic (insertion order).
190func buildSortTree(field string) *avl.Tree {
191	tree := avl.NewTree()
192	byID.Iterate("", "", func(id string, v any) bool {
193		p := v.(*Presentation)
194		var key string
195		switch field {
196		case "title":
197			key = p.Title + "\x00" + id
198		case "author":
199			key = p.Author + "\x00" + id
200		default: // date (and any unknown field)
201			key = p.Date.Format("2006-01-02") + "\x00" + id
202		}
203		tree.Set(key, p)
204		return false
205	})
206	return tree
207}
208
209func (p *Presentation) FirstSlide() string {
210	var out strings.Builder
211	out.WriteString(md.H1(p.Title))
212	out.WriteString(md.Paragraph(md.Bold(p.Event) + ", " + p.Date.Format("2 Jan 2006")))
213	out.WriteString(md.Paragraph("by " + md.Bold(p.Author))) // XXX: link to u/?
214	return out.String()
215}
216
217func (p *Presentation) LastSlide() string {
218	var out strings.Builder
219	out.WriteString(md.H1(p.Title))
220	out.WriteString(md.H2("Thank You!"))
221	out.WriteString(md.Paragraph(p.Author))
222	fullPath := "https://" + chainDomain + localPath(p.Slug, nil)
223	out.WriteString(md.Paragraph("🔗 " + md.Link(fullPath, fullPath)))
224	// XXX: QRCode
225	return out.String()
226}
227
228func renderView(slug string) string {
229	if slug == "" {
230		return "400: missing presentation slug"
231	}
232
233	v := bySlug.Get(slug)
234	if v == nil {
235		return "404: presentation not found"
236	}
237
238	p := v.(*Presentation)
239	var out strings.Builder
240
241	// Header using FirstSlide helper
242	out.WriteString(p.FirstSlide())
243
244	// Slide mode link
245	out.WriteString(md.Link("View as slides", localPath(p.Slug+"/slides", nil)) + "\n\n")
246	out.WriteString(md.HorizontalRule())
247	out.WriteString(md.Paragraph(p.Content))
248
249	// Metadata footer
250	out.WriteString(md.HorizontalRule())
251	out.WriteString(ufmt.Sprintf("Last edited: %s\n\n", p.EditDate.Format("2006-01-02 15:04:05")))
252	out.WriteString(ufmt.Sprintf("Uploader: `%s`\n\n", p.Uploader))
253	out.WriteString(ufmt.Sprintf("Number of slides: %d\n\n", p.NumSlides))
254
255	// Admin actions
256	// XXX: consider a dynamic toggle for admin actions
257	editLink := txlink.Call("Set",
258		"slug", p.Slug,
259		"title", p.Title,
260		"author", p.Author,
261		"event", p.Event,
262		"date", p.Date.Format("2006-01-02"),
263	)
264	deleteLink := txlink.Call("Delete", "slug", p.Slug)
265	out.WriteString(md.Paragraph(md.Link("Edit", editLink) + " | " + md.Link("Delete", deleteLink)))
266
267	return out.String()
268}
269
270// renderSlidesNavigation returns the navigation bar for slides
271func renderSlidesNavigation(slug string, currentPage, totalSlides int) string {
272	var out strings.Builder
273	if currentPage > 1 {
274		prevLink := localPath(slug+"/slides", url.Values{"page": {ufmt.Sprintf("%d", currentPage-1)}})
275		out.WriteString(md.Link("← Prev", prevLink) + " ")
276	}
277	out.WriteString(ufmt.Sprintf("| %d/%d |", currentPage, totalSlides))
278	if currentPage < totalSlides {
279		nextLink := localPath(slug+"/slides", url.Values{"page": {ufmt.Sprintf("%d", currentPage+1)}})
280		out.WriteString(" " + md.Link("Next →", nextLink))
281	}
282	return md.Paragraph(out.String())
283}
284
285func renderSlides(slug string, currentPage int) string {
286	if slug == "" {
287		return "400: missing presentation ID"
288	}
289
290	v := bySlug.Get(slug)
291	if v == nil {
292		return "404: presentation not found"
293	}
294
295	p := v.(*Presentation)
296	slides := strings.Split("\n"+p.Content, "\n## ")
297	if currentPage < 1 || currentPage > p.NumSlides {
298		return "404: invalid slide number"
299	}
300
301	var out strings.Builder
302
303	// Display current slide
304	if currentPage == 1 {
305		out.WriteString(p.FirstSlide())
306	} else if currentPage == p.NumSlides {
307		out.WriteString(p.LastSlide())
308	} else {
309		out.WriteString(md.H1(p.Title))
310		out.WriteString("## " + slides[currentPage-1] + "\n\n")
311	}
312
313	out.WriteString(renderSlidesNavigation(slug, currentPage, p.NumSlides))
314	return out.String()
315}
316
317// Helper functions for sorting and pagination
318func getSortField(req *realmpath.Request) string {
319	field := req.Query.Get("sort")
320	switch field {
321	case "date", "author", "title":
322		return field
323	}
324	return "date"
325}
326
327func isSortReversed(req *realmpath.Request) bool {
328	return req.Query.Get("order") != "asc"
329}
330
331func renderSortLink(req *realmpath.Request, field, label string) string {
332	currentField := getSortField(req)
333	currentOrder := req.Query.Get("order")
334
335	newOrder := "desc"
336	if field == currentField && currentOrder != "asc" {
337		newOrder = "asc"
338	}
339
340	query := req.Query
341	query.Set("sort", field)
342	query.Set("order", newOrder)
343
344	if field == currentField {
345		if newOrder == "asc" {
346			label += " ↑"
347		} else {
348			label += " ↓"
349		}
350	}
351
352	return md.Link(label, "?"+query.Encode())
353}
354
355// helper to create local realm links
356func localPath(path string, query url.Values) string {
357	req := &realmpath.Request{
358		Path:  path,
359		Query: query,
360	}
361	return req.String()
362}