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

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/v1"
 12	"gno.land/p/moul/mdtable/v1"
 13	"gno.land/p/moul/realmpath/v1"
 14	"gno.land/p/moul/txlink/v1"
 15	"gno.land/p/nt/avl/v0"
 16	"gno.land/p/nt/avl/v0/pager"
 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}