diff options
Diffstat (limited to 'views.go')
-rw-r--r-- | views.go | 526 |
1 files changed, 526 insertions, 0 deletions
diff --git a/views.go b/views.go new file mode 100644 index 0000000..60c6d7e --- /dev/null +++ b/views.go @@ -0,0 +1,526 @@ +package main + +import ( + "bytes" + "fmt" + "html/template" + "io" + "math" + "path" + "sort" + "strings" + "time" + + "code.laria.me/laria.me/menu" +) + +type ViewMenuItem struct { + Active bool + Url string + Title string +} + +type ViewMenu [][]ViewMenuItem + +func buildViewMenuLevel( + item *menu.MenuItem, + current string, + withNextLevel bool, +) (level []ViewMenuItem, nextLevel []ViewMenuItem) { + level = make([]ViewMenuItem, 0, len(item.Children)) + + for _, child := range item.Children { + isCur := child.Ident == current + + level = append(level, ViewMenuItem{ + Active: isCur, + Url: child.Url, + Title: child.Title, + }) + + if isCur && withNextLevel { + nextLevel, _ = buildViewMenuLevel(child, "", false) + } + } + + return +} + +func buildViewMenuLevels(item *menu.MenuItem, current string, withNextLevel bool) ViewMenu { + if item == nil { + return nil + } + + viewMenu := buildViewMenuLevels(item.Parent, item.Ident, false) + + level, nextLevel := buildViewMenuLevel(item, current, withNextLevel) + + if len(level) > 0 { + viewMenu = append(viewMenu, level) + } + + if len(nextLevel) > 0 { + viewMenu = append(viewMenu, nextLevel) + } + + return viewMenu +} + +func BuildViewMenu(menu *menu.Menu, current string) ViewMenu { + curMenu := menu.Root() + + curMenuItem := menu.ByIdent(current) + if curMenuItem != nil { + curMenu = curMenuItem.Parent + } + + return buildViewMenuLevels(curMenu, current, true) +} + +type RootData struct { + Menu ViewMenu + Title string + Main interface{} +} + +type ViewArticle struct { + Published time.Time + Slug string + Title string + Content template.HTML + ReadMore bool + Tags []string +} + +type Views struct { + archiveDay *template.Template + archive *template.Template + archiveMonth *template.Template + archiveYear *template.Template + article *template.Template + blog *template.Template + content *template.Template + search *template.Template + start *template.Template + tag *template.Template + tags *template.Template +} + +func monthText(m int) string { + switch m { + case 1: + return "January" + case 2: + return "February" + case 3: + return "March" + case 4: + return "April" + case 5: + return "May" + case 6: + return "June" + case 7: + return "July" + case 8: + return "August" + case 9: + return "September" + case 10: + return "October" + case 11: + return "November" + case 12: + return "December" + default: + return fmt.Sprintf("<unknown month %d>", m) + } +} + +func nth(n int) string { + switch n { + case 1: + return "1st" + case 2: + return "2nd" + case 3: + return "3rd" + default: + return fmt.Sprintf("%dth", n) + } +} + +type paginationArg struct { + K, V string +} + +type paginationTemplateData struct { + Action string + Args []paginationArg + Cur int + Pages int +} + +var paginationTemplate = template.Must(template.New("").Funcs(template.FuncMap{"seq": func(max int) <-chan int { + ch := make(chan int) + go func() { + defer close(ch) + for i := 1; i <= max; i++ { + ch <- i + } + }() + return ch +}}).Parse(`<form action="{{.Action}}" method="get" class="pagination"> + {{- range .Args -}} + <input type="hidden" name="{{.K}}" value="{{.V}}"> + {{- end -}} + {{- $cur := .Cur -}} + <label for="pagination-select">Page:</label> + <select name="page" id="pagination-select">{{- range (seq .Pages) -}} + <option {{if eq . $cur}}selected{{end}} value="{{.}}">{{.}}</option> + {{- end -}}</select> + <button type="submit">Go to</button> +</form>`)) + +func normalizeDate(y, m, d int) (int, int, int) { + t := time.Date(y, time.Month(m), d, 0, 0, 0, 0, time.UTC) + y, month, d := t.Date() + + return y, int(month), d +} + +func dayText(y, m, d int) string { + return fmt.Sprintf("%s %s %d", nth(d), monthText(m), y) +} + +func LoadViews(templatesDir string) (Views, error) { + views := Views{} + + root, err := template.New("root.html").ParseFiles(path.Join(templatesDir, "root.html")) + + if err != nil { + return views, err + } + + root.Funcs(template.FuncMap{ + "add": func(nums ...int) int { + sum := 0 + for _, i := range nums { + sum = sum + i + } + return sum + }, + "concat": func(ss ...string) string { + sb := new(strings.Builder) + for _, s := range ss { + sb.WriteString(s) + } + return sb.String() + }, + "nth": nth, + "day_text": dayText, + "month_text": monthText, + "pagination": func(pages, page int, path string, queryArgs ...string) (template.HTML, error) { + if len(queryArgs)%2 != 0 { + return "", fmt.Errorf("pagination: need even number of query args") + } + + args := make([]paginationArg, 0, len(queryArgs)/2) + for i := 0; i < len(queryArgs); i += 2 { + args = append(args, paginationArg{K: queryArgs[i], V: queryArgs[i+1]}) + } + + buf := new(bytes.Buffer) + + err := paginationTemplate.Execute(buf, paginationTemplateData{ + Action: path, + Args: args, + Cur: page, + Pages: pages, + }) + + if err != nil { + return "", err + } + + return template.HTML(buf.String()), nil + }, + "archive_link": func(components ...int) (string, error) { + switch len(components) { + case 0: + return "/blog/archive", nil + case 1: + y := components[0] + return fmt.Sprintf("/blog/%d", y), nil + case 2: + y := components[0] + m := components[1] + + y, m, _ = normalizeDate(y, m, 1) + + return fmt.Sprintf("/blog/%d/%d", y, m), nil + case 3: + y := components[0] + m := components[1] + d := components[2] + + y, m, d = normalizeDate(y, m, d) + + return fmt.Sprintf("/blog/%d/%d/%d", y, m, d), nil + default: + return "", fmt.Errorf("archive_link accepts at most 3 arguments") + } + }, + }) + + for name, t := range map[string]**template.Template{ + "archive-day": &(views.archiveDay), + "archive": &(views.archive), + "archive-month": &(views.archiveMonth), + "archive-year": &(views.archiveYear), + "article": &(views.article), + "blog": &(views.blog), + "content": &(views.content), + "search": &(views.search), + "start": &(views.start), + "tag": &(views.tag), + "tags": &(views.tags), + } { + templateFile := path.Join(templatesDir, name+".html") + + if *t, err = template.Must(root.Clone()).ParseFiles(templateFile); err != nil { + return views, fmt.Errorf("Failed loading template %s: %w", name, err) + } + } + + return views, nil +} + +func (v Views) RenderArchiveDay( + w io.Writer, + menu *menu.Menu, + curMenu string, + y, m, d int, + articles []ViewArticle, +) error { + return v.archiveDay.Execute(w, RootData{BuildViewMenu(menu, curMenu), dayText(y, m, d), struct { + Year, Month, Day int + MonthText string + Articles []ViewArticle + }{ + Year: y, + Month: m, + Day: d, + MonthText: monthText(m), + Articles: articles, + }}) +} + +type archiveEntryWithCount struct { + Num int + Count int +} + +type archiveEntriesWithCount []archiveEntryWithCount + +func (a archiveEntriesWithCount) Len() int { return len(a) } +func (a archiveEntriesWithCount) Less(i, j int) bool { return a[i].Num < a[j].Num } +func (a archiveEntriesWithCount) Swap(i, j int) { a[i], a[j] = a[j], a[i] } + +func buildArchiveEntries(keyedCounts map[int]int) archiveEntriesWithCount { + entries := make(archiveEntriesWithCount, 0, len(keyedCounts)) + for k, v := range keyedCounts { + entries = append(entries, archiveEntryWithCount{ + Num: k, + Count: v, + }) + } + + sort.Sort(entries) + + return entries +} + +func (v Views) RenderArchive( + w io.Writer, + menu *menu.Menu, + curMenu string, + countByYear map[int]int, +) error { + return v.archive.Execute(w, RootData{BuildViewMenu(menu, curMenu), "Archive", struct { + Years archiveEntriesWithCount + }{Years: buildArchiveEntries(countByYear)}}) +} + +func (v Views) RenderArchiveMonth( + w io.Writer, + menu *menu.Menu, + curMenu string, + year, month int, + countByDay map[int]int, +) error { + title := fmt.Sprintf("%s %d", monthText(month), year) + + return v.archiveMonth.Execute(w, RootData{BuildViewMenu(menu, curMenu), title, struct { + Year, Month int + Days archiveEntriesWithCount + }{ + Year: year, + Month: month, + Days: buildArchiveEntries(countByDay), + }}) +} + +func (v Views) RenderArchiveYear( + w io.Writer, + menu *menu.Menu, + curMenu string, + year int, + countByMonth map[int]int, +) error { + return v.archiveYear.Execute(w, RootData{BuildViewMenu(menu, curMenu), string(year), struct { + Year int + Months archiveEntriesWithCount + }{ + Year: year, + Months: buildArchiveEntries(countByMonth), + }}) +} + +func (v Views) RenderArticle( + w io.Writer, + menu *menu.Menu, + curMenu string, + article ViewArticle, +) error { + return v.article.Execute(w, RootData{BuildViewMenu(menu, curMenu), article.Title, article}) +} + +func (v Views) RenderContent( + w io.Writer, + menu *menu.Menu, + curMenu string, + html template.HTML, +) error { + return v.content.Execute(w, RootData{BuildViewMenu(menu, curMenu), "", html}) +} + +func (v Views) RenderSearch( + w io.Writer, + menu *menu.Menu, + curMenu string, + query string, + total int, + results []ViewArticle, + pages int, + page int, +) error { + return v.search.Execute(w, RootData{BuildViewMenu(menu, curMenu), "Search", struct { + Q string + Total int + Results []ViewArticle + Pages, Page int + }{ + Q: query, + Total: total, + Results: results, + Pages: pages, + Page: page, + }}) +} + +func (v Views) RenderStart( + w io.Writer, + menu *menu.Menu, + curMenu string, + content template.HTML, + blogArticles []ViewArticle, +) error { + return v.start.Execute(w, RootData{BuildViewMenu(menu, curMenu), "", struct { + Content template.HTML + Blog []ViewArticle + }{ + Content: content, + Blog: blogArticles, + }}) +} + +func (v Views) RenderTag( + w io.Writer, + menu *menu.Menu, + curMenu string, + tag string, + articles []ViewArticle, + pages, page int, +) error { + return v.tag.Execute(w, RootData{BuildViewMenu(menu, curMenu), "Tag " + tag, struct { + Tag string + Articles []ViewArticle + Pages, Page int + }{ + Tag: tag, + Articles: articles, + Pages: pages, + Page: page, + }}) +} + +func (v Views) RenderBlog( + w io.Writer, + menu *menu.Menu, + curMenu string, + articles []ViewArticle, + pages, page int, +) error { + return v.blog.Execute(w, RootData{BuildViewMenu(menu, curMenu), "Blog", struct { + Articles []ViewArticle + Pages, Page int + }{ + Articles: articles, + Pages: pages, + Page: page, + }}) +} + +type tagcloudTag struct { + Tag string + SizeClass int +} + +type tagcloudTags []tagcloudTag + +func (t tagcloudTags) Len() int { return len(t) } +func (t tagcloudTags) Less(i, j int) bool { + return strings.ToLower(t[i].Tag) < strings.ToLower(t[j].Tag) +} +func (t tagcloudTags) Swap(i, j int) { t[i], t[j] = t[j], t[i] } + +const tagcloudCategories = 5 + +func (v Views) RenderTags(w io.Writer, menu *menu.Menu, curMenu string, tagCounts map[string]int) error { + tags := make(tagcloudTags, 0, len(tagCounts)) + + maxCount := 0 + for tag, count := range tagCounts { + tags = append(tags, tagcloudTag{ + Tag: tag, + SizeClass: count, + }) + + if count > maxCount { + maxCount = count + } + } + + for i, tag := range tags { + tags[i].SizeClass = int(math.Ceil((float64(tag.SizeClass) / float64(maxCount)) * tagcloudCategories)) + } + + sort.Sort(tags) + + return v.tags.Execute(w, RootData{BuildViewMenu(menu, curMenu), "Tags", struct { + Tags tagcloudTags + }{ + Tags: tags, + }}) +} |