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  }