github.com/gnolang/gno@v0.0.0-20240520182011-228e9d0192ce/examples/gno.land/p/demo/blog/blog.gno (about) 1 package blog 2 3 import ( 4 "std" 5 "strconv" 6 "strings" 7 "time" 8 9 "gno.land/p/demo/avl" 10 "gno.land/p/demo/mux" 11 "gno.land/p/demo/ufmt" 12 ) 13 14 type Blog struct { 15 Title string 16 Prefix string // i.e. r/gnoland/blog: 17 Posts avl.Tree // slug -> *Post 18 PostsPublished avl.Tree // published-date -> *Post 19 PostsAlphabetical avl.Tree // title -> *Post 20 NoBreadcrumb bool 21 } 22 23 func (b Blog) RenderLastPostsWidget(limit int) string { 24 if b.PostsPublished.Size() == 0 { 25 return "No posts." 26 } 27 28 output := "" 29 i := 0 30 b.PostsPublished.ReverseIterate("", "", func(key string, value interface{}) bool { 31 p := value.(*Post) 32 output += ufmt.Sprintf("- [%s](%s)\n", p.Title, p.URL()) 33 i++ 34 return i >= limit 35 }) 36 return output 37 } 38 39 func (b Blog) RenderHome(res *mux.ResponseWriter, req *mux.Request) { 40 if !b.NoBreadcrumb { 41 res.Write(breadcrumb([]string{b.Title})) 42 } 43 44 if b.Posts.Size() == 0 { 45 res.Write("No posts.") 46 return 47 } 48 49 res.Write("<div class='columns-3'>") 50 b.PostsPublished.ReverseIterate("", "", func(key string, value interface{}) bool { 51 post := value.(*Post) 52 res.Write(post.RenderListItem()) 53 return false 54 }) 55 res.Write("</div>") 56 57 // FIXME: tag list/cloud. 58 } 59 60 func (b Blog) RenderPost(res *mux.ResponseWriter, req *mux.Request) { 61 slug := req.GetVar("slug") 62 63 post, found := b.Posts.Get(slug) 64 if !found { 65 res.Write("404") 66 return 67 } 68 p := post.(*Post) 69 70 res.Write("# " + p.Title + "\n\n") 71 res.Write(p.Body + "\n\n") 72 res.Write("---\n\n") 73 74 res.Write(p.RenderTagList() + "\n\n") 75 res.Write(p.RenderAuthorList() + "\n\n") 76 res.Write(p.RenderPublishData() + "\n\n") 77 78 res.Write("---\n") 79 res.Write("<details><summary>Comment section</summary>\n\n") 80 81 // comments 82 p.Comments.ReverseIterate("", "", func(key string, value interface{}) bool { 83 comment := value.(*Comment) 84 res.Write(comment.RenderListItem()) 85 return false 86 }) 87 88 res.Write("</details>\n") 89 } 90 91 func (b Blog) RenderTag(res *mux.ResponseWriter, req *mux.Request) { 92 slug := req.GetVar("slug") 93 94 if slug == "" { 95 res.Write("404") 96 return 97 } 98 99 if !b.NoBreadcrumb { 100 breadStr := breadcrumb([]string{ 101 ufmt.Sprintf("[%s](%s)", b.Title, b.Prefix), 102 "t", 103 slug, 104 }) 105 res.Write(breadStr) 106 } 107 108 nb := 0 109 b.Posts.Iterate("", "", func(key string, value interface{}) bool { 110 post := value.(*Post) 111 if !post.HasTag(slug) { 112 return false 113 } 114 res.Write(post.RenderListItem()) 115 nb++ 116 return false 117 }) 118 if nb == 0 { 119 res.Write("No posts.") 120 } 121 } 122 123 func (b Blog) Render(path string) string { 124 router := mux.NewRouter() 125 router.HandleFunc("", b.RenderHome) 126 router.HandleFunc("p/{slug}", b.RenderPost) 127 router.HandleFunc("t/{slug}", b.RenderTag) 128 return router.Render(path) 129 } 130 131 func (b *Blog) NewPost(publisher std.Address, slug, title, body, pubDate string, authors, tags []string) error { 132 if _, found := b.Posts.Get(slug); found { 133 return ErrPostSlugExists 134 } 135 136 var parsedTime time.Time 137 var err error 138 if pubDate != "" { 139 parsedTime, err = time.Parse(time.RFC3339, pubDate) 140 if err != nil { 141 return err 142 } 143 } else { 144 // If no publication date was passed in by caller, take current block time 145 parsedTime = time.Now() 146 } 147 148 post := &Post{ 149 Publisher: publisher, 150 Authors: authors, 151 Slug: slug, 152 Title: title, 153 Body: body, 154 Tags: tags, 155 CreatedAt: parsedTime, 156 } 157 158 return b.prepareAndSetPost(post) 159 } 160 161 func (b *Blog) prepareAndSetPost(post *Post) error { 162 post.Title = strings.TrimSpace(post.Title) 163 post.Body = strings.TrimSpace(post.Body) 164 165 if post.Title == "" { 166 return ErrPostTitleMissing 167 } 168 if post.Body == "" { 169 return ErrPostBodyMissing 170 } 171 if post.Slug == "" { 172 return ErrPostSlugMissing 173 } 174 175 post.Blog = b 176 post.UpdatedAt = time.Now() 177 178 trimmedTitleKey := strings.Replace(post.Title, " ", "", -1) 179 pubDateKey := post.CreatedAt.Format(time.RFC3339) 180 181 // Cannot have two posts with same title key 182 if _, found := b.PostsAlphabetical.Get(trimmedTitleKey); found { 183 return ErrPostTitleExists 184 } 185 // Cannot have two posts with *exact* same timestamp 186 if _, found := b.PostsPublished.Get(pubDateKey); found { 187 return ErrPostPubDateExists 188 } 189 190 // Store post under keys 191 b.PostsAlphabetical.Set(trimmedTitleKey, post) 192 b.PostsPublished.Set(pubDateKey, post) 193 b.Posts.Set(post.Slug, post) 194 195 return nil 196 } 197 198 func (b *Blog) RemovePost(slug string) { 199 _, exists := b.Posts.Get(slug) 200 if !exists { 201 panic("post with specified slug does not exist") 202 } 203 204 _, _ = b.Posts.Remove(slug) 205 _, _ = b.PostsAlphabetical.Remove(slug) 206 _, _ = b.PostsPublished.Remove(slug) 207 } 208 209 func (b *Blog) GetPost(slug string) *Post { 210 post, found := b.Posts.Get(slug) 211 if !found { 212 return nil 213 } 214 return post.(*Post) 215 } 216 217 type Post struct { 218 Blog *Blog 219 Slug string // FIXME: save space? 220 Title string 221 Body string 222 CreatedAt time.Time 223 UpdatedAt time.Time 224 Comments avl.Tree 225 Authors []string 226 Publisher std.Address 227 Tags []string 228 CommentIndex int 229 } 230 231 func (p *Post) Update(title, body, publicationDate string, authors, tags []string) error { 232 p.Title = title 233 p.Body = body 234 p.Tags = tags 235 p.Authors = authors 236 237 parsedTime, err := time.Parse(time.RFC3339, publicationDate) 238 if err != nil { 239 return err 240 } 241 242 p.CreatedAt = parsedTime 243 return p.Blog.prepareAndSetPost(p) 244 } 245 246 func (p *Post) AddComment(author std.Address, comment string) error { 247 if p == nil { 248 return ErrNoSuchPost 249 } 250 p.CommentIndex++ 251 commentKey := strconv.Itoa(p.CommentIndex) 252 comment = strings.TrimSpace(comment) 253 p.Comments.Set(commentKey, &Comment{ 254 Post: p, 255 CreatedAt: time.Now(), 256 Author: author, 257 Comment: comment, 258 }) 259 260 return nil 261 } 262 263 func (p *Post) DeleteComment(index int) error { 264 if p == nil { 265 return ErrNoSuchPost 266 } 267 commentKey := strconv.Itoa(index) 268 p.Comments.Remove(commentKey) 269 return nil 270 } 271 272 func (p *Post) HasTag(tag string) bool { 273 if p == nil { 274 return false 275 } 276 for _, t := range p.Tags { 277 if t == tag { 278 return true 279 } 280 } 281 return false 282 } 283 284 func (p *Post) RenderListItem() string { 285 if p == nil { 286 return "error: no such post\n" 287 } 288 output := "<div>\n\n" 289 output += ufmt.Sprintf("### [%s](%s)\n", p.Title, p.URL()) 290 // output += ufmt.Sprintf("**[Learn More](%s)**\n\n", p.URL()) 291 292 output += " " + p.CreatedAt.Format("02 Jan 2006") 293 // output += p.Summary() + "\n\n" 294 // output += p.RenderTagList() + "\n\n" 295 output += "\n" 296 output += "</div>" 297 return output 298 } 299 300 // Render post tags 301 func (p *Post) RenderTagList() string { 302 if p == nil { 303 return "error: no such post\n" 304 } 305 if len(p.Tags) == 0 { 306 return "" 307 } 308 309 output := "Tags: " 310 for idx, tag := range p.Tags { 311 if idx > 0 { 312 output += " " 313 } 314 tagURL := p.Blog.Prefix + "t/" + tag 315 output += ufmt.Sprintf("[#%s](%s)", tag, tagURL) 316 317 } 318 return output 319 } 320 321 // Render authors if there are any 322 func (p *Post) RenderAuthorList() string { 323 out := "Written" 324 if len(p.Authors) != 0 { 325 out += " by " 326 327 for idx, author := range p.Authors { 328 out += author 329 if idx < len(p.Authors)-1 { 330 out += ", " 331 } 332 } 333 } 334 out += " on " + p.CreatedAt.Format("02 Jan 2006") 335 336 return out 337 } 338 339 func (p *Post) RenderPublishData() string { 340 out := "Published " 341 if p.Publisher != "" { 342 out += "by " + p.Publisher.String() + " " 343 } 344 out += "to " + p.Blog.Title 345 346 return out 347 } 348 349 func (p *Post) URL() string { 350 if p == nil { 351 return p.Blog.Prefix + "404" 352 } 353 return p.Blog.Prefix + "p/" + p.Slug 354 } 355 356 func (p *Post) Summary() string { 357 if p == nil { 358 return "error: no such post\n" 359 } 360 361 // FIXME: better summary. 362 lines := strings.Split(p.Body, "\n") 363 if len(lines) <= 3 { 364 return p.Body 365 } 366 return strings.Join(lines[0:3], "\n") + "..." 367 } 368 369 type Comment struct { 370 Post *Post 371 CreatedAt time.Time 372 Author std.Address 373 Comment string 374 } 375 376 func (c Comment) RenderListItem() string { 377 output := "<h5>" 378 output += c.Comment + "\n\n" 379 output += "</h5>" 380 381 output += "<h6>" 382 output += ufmt.Sprintf("by %s on %s", c.Author, c.CreatedAt.Format(time.RFC822)) 383 output += "</h6>\n\n" 384 385 output += "---\n\n" 386 387 return output 388 }