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}