summaryrefslogtreecommitdiff
path: root/internal/router
diff refs
from: back
to: back
| flip
diff options
context:
space:
mode:
Diffstat (limited to 'internal/router')
-rw-r--r--internal/router/api-v1-blog-search.go113
-rw-r--r--internal/router/api-v1-like.go122
-rw-r--r--internal/router/api-v1-tz.go37
-rw-r--r--internal/router/client-cache.go229
-rw-r--r--internal/router/lang-blog-title.go82
-rw-r--r--internal/router/lang-blog.go93
-rw-r--r--internal/router/lang-map.go85
-rw-r--r--internal/router/root.go27
-rw-r--r--internal/router/tm.go18
9 files changed, 806 insertions, 0 deletions
diff --git a/internal/router/api-v1-blog-search.go b/internal/router/api-v1-blog-search.go
new file mode 100644
index 0000000..e22fabe
--- /dev/null
+++ b/internal/router/api-v1-blog-search.go
@@ -0,0 +1,113 @@
+package router
+
+import (
+ "fmt"
+ "log/slog"
+ "net/url"
+ "regexp"
+ "slices"
+ "strings"
+ "time"
+
+ "github.com/SayaAndy/saya-today-web/internal/b2"
+ "github.com/SayaAndy/saya-today-web/locale"
+ "github.com/gofiber/fiber/v2"
+)
+
+func init() {
+ tm.Add("catalogue-blog-cards", "views/partials/catalogue-blog-cards.html", "views/partials/catalogue-blog-card-tags.html")
+}
+
+func Api_V1_BlogSearch(l map[string]*locale.LocaleConfig, langs []string, b2Client *b2.B2Client) func(c *fiber.Ctx) error {
+ return func(c *fiber.Ctx) error {
+ sort := c.Query("sort")
+ lang := c.Query("lang")
+ tz := c.Query("tz")
+
+ if !slices.Contains(langs, lang) {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+ return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language", lang))
+ }
+
+ loc, err := time.LoadLocation(tz)
+ if err != nil {
+ loc = time.UTC
+ slog.Warn("unable to parse a client timezone, defaulting to UTC", slog.String("error", err.Error()), slog.String("tz", tz))
+ }
+
+ pages, err := b2Client.Scan(lang + "/")
+ if err != nil {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+ return c.Status(fiber.ErrInternalServerError.Code).SendString(fmt.Sprintf("failed to scan pages for '%s' lang: %v", lang, err))
+ }
+
+ encodedQuery := c.Request().URI().QueryString()
+ re, err := regexp.Compile(`tags\[\]=([\w]+)`)
+ if err != nil {
+ slog.Warn("failed to generate regex for tags gathering", slog.String("error", err.Error()))
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+ return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate regex for tags gathering")
+ }
+ decodedQuery, _ := url.QueryUnescape(string(encodedQuery))
+ matches := re.FindAllStringSubmatch(decodedQuery, -1)
+
+ tags := make([]string, 0, len(matches))
+ for _, match := range matches {
+ tags = append(tags, string(match[1]))
+ }
+
+ pageMeta := make([]fiber.Map, 0, len(pages))
+ for _, page := range pages {
+ for _, tag := range page.Metadata.Tags {
+ if len(tags) == 0 || slices.Contains(tags, tag) {
+ pageMeta = append(pageMeta, fiber.Map{
+ "Link": page.Link,
+ "ArticleLink": "/" + lang + "/blog/" + page.FileName,
+ "Title": page.Metadata.Title,
+ "PublishedTime": page.Metadata.PublishedTime.In(loc).Format("2006-01-02 15:04:05 -07:00"),
+ "ActionDate": page.Metadata.ActionDate,
+ "ShortDescription": page.Metadata.ShortDescription,
+ "Thumbnail": page.Metadata.Thumbnail,
+ "Tags": page.Metadata.Tags,
+ "LikeCount": CCache.GetLikeCount(page.FileName),
+ })
+ break
+ }
+ }
+ }
+
+ slices.SortFunc(pageMeta, func(a, b fiber.Map) int {
+ switch sort {
+ case "titleAsc":
+ return strings.Compare(a["Title"].(string), b["Title"].(string))
+ case "titleDesc":
+ return strings.Compare(b["Title"].(string), a["Title"].(string))
+ case "actionDateAsc":
+ return strings.Compare(a["ActionDate"].(string), b["ActionDate"].(string))
+ case "actionDateDesc":
+ return strings.Compare(b["ActionDate"].(string), a["ActionDate"].(string))
+ case "publicationDateAsc":
+ publishedTimeA, _ := time.Parse("2006-01-02 15:04:05 -07:00", a["PublishedTime"].(string))
+ publishedTimeB, _ := time.Parse("2006-01-02 15:04:05 -07:00", b["PublishedTime"].(string))
+ return publishedTimeA.Compare(publishedTimeB)
+ case "publicationDateDesc":
+ publishedTimeA, _ := time.Parse("2006-01-02 15:04:05 -07:00", a["PublishedTime"].(string))
+ publishedTimeB, _ := time.Parse("2006-01-02 15:04:05 -07:00", b["PublishedTime"].(string))
+ return publishedTimeB.Compare(publishedTimeA)
+ }
+ return 0
+ })
+
+ content, err := tm.Render("catalogue-blog-cards", fiber.Map{
+ "BlogPages": pageMeta,
+ "L": l[lang],
+ })
+ if err != nil {
+ slog.Warn("failed to generate div", slog.String("page", "/"+lang+"/blog"), slog.String("error", err.Error()))
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+ return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div")
+ }
+
+ return c.Type("html").Send(content)
+ }
+}
diff --git a/internal/router/api-v1-like.go b/internal/router/api-v1-like.go
new file mode 100644
index 0000000..327dafa
--- /dev/null
+++ b/internal/router/api-v1-like.go
@@ -0,0 +1,122 @@
+package router
+
+import (
+ "fmt"
+ "log/slog"
+ "net/url"
+ "strconv"
+ "strings"
+
+ "github.com/SayaAndy/saya-today-web/internal/b2"
+ "github.com/SayaAndy/saya-today-web/locale"
+ "github.com/gofiber/fiber/v2"
+)
+
+func init() {
+ tm.Add("blog-page-like-button", "views/partials/blog-page-like-button.html")
+}
+
+func Api_V1_Like_Put(l map[string]*locale.LocaleConfig, b2 *b2.B2Client) func(c *fiber.Ctx) error {
+ return func(c *fiber.Ctx) error {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+
+ referer := c.Get("Referer", "")
+ if referer == "" {
+ return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is empty")
+ }
+ urlStruct, err := url.ParseRequestURI(referer)
+ if err != nil {
+ return c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("'Referer' header is invalid: %s", err.Error()))
+ }
+
+ path := urlStruct.EscapedPath()
+ pathParts := strings.Split(strings.Trim(path, "/"), "/")
+ if len(pathParts) != 3 {
+ return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/blog/{page}'")
+ }
+
+ lang, page := pathParts[0], pathParts[2]
+
+ pageLink := lang + "/" + page + ".md"
+ if pages, _ := b2.Scan(pageLink); len(pages) == 0 {
+ return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server did not find '%s' article", pageLink))
+ }
+
+ newLikeStatus, err := strconv.ParseBool(c.FormValue("like", "true"))
+ if err != nil {
+ return c.Status(fiber.ErrBadRequest.Code).SendString("invalid 'like' value")
+ }
+
+ ip := c.IP()
+ if newLikeStatus {
+ CCache.LikeOn(ip, page)
+ } else {
+ CCache.LikeOff(ip, page)
+ }
+
+ slog.Debug("someone pressed the like button!", slog.String("ip", ip), slog.String("page", page), slog.String("new_like_status", fmt.Sprint(newLikeStatus)))
+ if c.Get("HX-Request", "false") == "true" {
+ content, err := tm.Render("blog-page-like-button", fiber.Map{
+ "L": l[lang],
+ "Liked": newLikeStatus,
+ "LikedCount": CCache.GetLikeCount(page),
+ })
+ if err != nil {
+ slog.Warn("failed to generate div", slog.String("path", path), slog.String("error", err.Error()))
+ return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div")
+ }
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Send(content)
+ }
+
+ return c.Status(fiber.StatusOK).SendString(fmt.Sprint(newLikeStatus))
+ }
+}
+
+func Api_V1_Like_Get(l map[string]*locale.LocaleConfig, b2 *b2.B2Client) func(c *fiber.Ctx) error {
+ return func(c *fiber.Ctx) error {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+
+ referer := c.Get("Referer", "")
+ if referer == "" {
+ return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is empty")
+ }
+ urlStruct, err := url.ParseRequestURI(referer)
+ if err != nil {
+ return c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("'Referer' header is invalid: %s", err.Error()))
+ }
+
+ path := urlStruct.EscapedPath()
+ pathParts := strings.Split(strings.Trim(path, "/"), "/")
+ if len(pathParts) != 3 {
+ return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/blog/{page}'")
+ }
+
+ lang, page := pathParts[0], pathParts[2]
+
+ pageLink := lang + "/" + page + ".md"
+ if pages, _ := b2.Scan(pageLink); len(pages) == 0 {
+ return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server did not find '%s' article", pageLink))
+ }
+
+ ip := c.IP()
+ likeStatus := CCache.GetLikeStatus(ip, page)
+
+ slog.Debug("someone requested the like status!", slog.String("ip", ip), slog.String("page", page), slog.Bool("like_status", likeStatus))
+ if c.Get("HX-Request", "false") == "true" {
+ content, err := tm.Render("blog-page-like-button", fiber.Map{
+ "L": l[lang],
+ "Liked": likeStatus,
+ "LikedCount": CCache.GetLikeCount(page),
+ })
+ if err != nil {
+ slog.Warn("failed to generate div", slog.String("path", path), slog.String("error", err.Error()))
+ return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div")
+ }
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Send(content)
+ }
+
+ return c.Status(fiber.StatusOK).SendString(fmt.Sprint(likeStatus))
+ }
+}
diff --git a/internal/router/api-v1-tz.go b/internal/router/api-v1-tz.go
new file mode 100644
index 0000000..4e4e462
--- /dev/null
+++ b/internal/router/api-v1-tz.go
@@ -0,0 +1,37 @@
+package router
+
+import (
+ "log/slog"
+ "time"
+
+ "github.com/gofiber/fiber/v2"
+)
+
+func Api_V1_TZ() func(c *fiber.Ctx) error {
+ return func(c *fiber.Ctx) error {
+ timestampString := c.Query("timestamp")
+ tz := c.Query("tz")
+
+ loc, err := time.LoadLocation(tz)
+ if err != nil {
+ loc = time.UTC
+ slog.Warn("unable to parse a client timezone, defaulting to UTC", slog.String("error", err.Error()), slog.String("tz", tz))
+ }
+
+ var format string
+ var timestamp time.Time
+ for _, format = range []string{"2006-01-02 15:04:05 -07:00", time.RFC3339} {
+ if timestamp, err = time.Parse(format, timestampString); err == nil {
+ break
+ }
+ }
+
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+
+ if err != nil {
+ return c.Status(fiber.StatusBadRequest).SendString("invalid timestamp format")
+ }
+
+ return c.Status(fiber.StatusOK).SendString(timestamp.In(loc).Format(format))
+ }
+}
diff --git a/internal/router/client-cache.go b/internal/router/client-cache.go
new file mode 100644
index 0000000..81d7641
--- /dev/null
+++ b/internal/router/client-cache.go
@@ -0,0 +1,229 @@
+package router
+
+import (
+ "database/sql"
+ "encoding/base64"
+ "fmt"
+ "log/slog"
+ "strings"
+ "sync"
+
+ "golang.org/x/crypto/argon2"
+)
+
+type PageLike struct {
+ PageRef string
+ UserId string
+}
+
+type ClientCache struct {
+ hashMap map[string]string
+ hashMapMutex sync.RWMutex
+
+ likePageMap map[string]map[string]struct{}
+ pageMutexMap map[string]*sync.RWMutex
+ pageMutexMapMutex sync.Mutex
+
+ salt []byte
+ db *sql.DB
+}
+
+var CCache *ClientCache
+
+func NewClientCache(db *sql.DB, salt []byte) (*ClientCache, error) {
+ tx, err := db.Begin()
+ if err != nil {
+ return nil, fmt.Errorf("fail to init transaction with db to fill cache: %w", err)
+ }
+
+ rows, err := tx.Query("select * from blog_likes;")
+ if err != nil {
+ tx.Rollback()
+ return nil, fmt.Errorf("fail to query db for blog_likes to fill cache: %w", err)
+ }
+
+ likePageMap := make(map[string]map[string]struct{})
+ pageMutexMap := make(map[string]*sync.RWMutex)
+
+ for rows.Next() {
+ var pageRef string
+ var userId []byte
+ if err = rows.Scan(&pageRef, &userId); err != nil {
+ tx.Rollback()
+ return nil, fmt.Errorf("fail scanning blog_likes to fill cache: %w", err)
+ }
+ userIdString := base64.RawStdEncoding.EncodeToString(userId)
+ if _, ok := likePageMap[pageRef]; !ok {
+ likePageMap[pageRef] = make(map[string]struct{})
+ pageMutexMap[pageRef] = &sync.RWMutex{}
+ }
+ likePageMap[pageRef][userIdString] = struct{}{}
+ }
+
+ if err = tx.Commit(); err != nil {
+ return nil, fmt.Errorf("fail to commit transaction in db: %w", err)
+ }
+
+ return &ClientCache{
+ hashMap: make(map[string]string),
+ likePageMap: likePageMap,
+ pageMutexMap: pageMutexMap,
+ salt: salt,
+ db: db,
+ }, nil
+}
+
+func (c *ClientCache) Close() error {
+ tx, err := c.db.Begin()
+ if err != nil {
+ return fmt.Errorf("fail to init transaction with db to dump cache: %w", err)
+ }
+
+ if _, err = tx.Exec("delete from blog_likes;"); err != nil {
+ tx.Rollback()
+ return fmt.Errorf("fail to truncate table blog_likes: %w", err)
+ }
+
+ userIdBytes := make(map[string][]byte)
+
+ sqlStatement := fmt.Sprintf(`
+ INSERT OR IGNORE INTO blog_likes (page_ref, user_id)
+ VALUES %s(?, ?);
+ `, strings.Repeat("(?, ?), ", 99))
+ sqlStatementVars := make([]any, 0, 200)
+
+ for pageRef, userSet := range c.likePageMap {
+ for userId := range userSet {
+ if _, ok := userIdBytes[userId]; !ok {
+ userIdBytes[userId], err = base64.RawStdEncoding.DecodeString(userId)
+ if err != nil {
+ slog.Warn("couldn't parse one of user hashes into bytes back", slog.String("hash", userId), slog.String("error", err.Error()))
+ continue
+ }
+ }
+
+ sqlStatementVars = append(sqlStatementVars, any(pageRef), any(userIdBytes[userId]))
+ if len(sqlStatementVars) < 200 {
+ continue
+ }
+
+ if _, err := tx.Exec(sqlStatement, sqlStatementVars...); err != nil {
+ slog.Warn("couldn't insert blog like pairs into db", slog.String("error", err.Error()))
+ }
+
+ sqlStatementVars = make([]any, 0, 200)
+ }
+ }
+
+ if len(sqlStatementVars) > 0 {
+ sqlStatement = fmt.Sprintf(`
+ INSERT OR IGNORE INTO blog_likes (page_ref, user_id)
+ VALUES %s(?, ?);
+ `, strings.Repeat("(?, ?), ", len(sqlStatementVars)/2-1))
+
+ if _, err := tx.Exec(sqlStatement, sqlStatementVars...); err != nil {
+ slog.Warn("couldn't insert blog like pairs into db", slog.String("error", err.Error()))
+ }
+ }
+
+ return tx.Commit()
+}
+
+func (c *ClientCache) GetHash(id string) string {
+ c.hashMapMutex.RLock()
+ if val, ok := c.hashMap[id]; ok {
+ c.hashMapMutex.RUnlock()
+ slog.Debug("gave an old hash", slog.String("hash", val))
+ return val
+ }
+ c.hashMapMutex.RUnlock()
+
+ c.hashMapMutex.Lock()
+ defer c.hashMapMutex.Unlock()
+
+ if val, ok := c.hashMap[id]; ok {
+ slog.Debug("gave a newly generated hash", slog.String("hash", val))
+ return val
+ }
+
+ c.hashMap[id] = base64.RawStdEncoding.EncodeToString(argon2.IDKey([]byte(id), c.salt, 1, 64*1024, 4, 32))
+ slog.Debug("generated hash", slog.String("hash", c.hashMap[id]))
+ return c.hashMap[id]
+}
+
+func (c *ClientCache) getPageMutex(page string) *sync.RWMutex {
+ c.pageMutexMapMutex.Lock()
+ defer c.pageMutexMapMutex.Unlock()
+
+ if mutex, ok := c.pageMutexMap[page]; ok {
+ return mutex
+ }
+
+ c.pageMutexMap[page] = &sync.RWMutex{}
+ return c.pageMutexMap[page]
+}
+
+func (c *ClientCache) GetLikeStatus(id string, page string) bool {
+ page = strings.Clone(page)
+
+ mutex := c.getPageMutex(page)
+ mutex.RLock()
+ defer mutex.RUnlock()
+
+ if _, ok := c.likePageMap[page]; !ok {
+ return false
+ }
+ _, ok := c.likePageMap[page][c.GetHash(id)]
+ return ok
+}
+
+func (c *ClientCache) GetLikeCount(page string) int {
+ page = strings.Clone(page)
+
+ mutex := c.getPageMutex(page)
+ mutex.RLock()
+ defer mutex.RUnlock()
+
+ if userSet, ok := c.likePageMap[page]; ok {
+ return len(userSet)
+ }
+ return 0
+}
+
+func (c *ClientCache) LikeOn(id string, page string) (alreadyLiked bool) {
+ page = strings.Clone(page)
+ hash := c.GetHash(id)
+
+ mutex := c.getPageMutex(page)
+ mutex.Lock()
+ defer mutex.Unlock()
+
+ if userSet, ok := c.likePageMap[page]; ok {
+ _, alreadyLiked = userSet[hash]
+ c.likePageMap[page][hash] = struct{}{}
+ return
+ }
+
+ c.likePageMap[page] = make(map[string]struct{})
+ c.likePageMap[page][hash] = struct{}{}
+ return
+}
+
+func (c *ClientCache) LikeOff(id string, page string) (alreadyUnliked bool) {
+ page = strings.Clone(page)
+ hash := c.GetHash(id)
+
+ mutex := c.getPageMutex(page)
+ mutex.Lock()
+ defer mutex.Unlock()
+
+ if _, ok := c.likePageMap[page]; !ok {
+ return true
+ }
+ if _, ok := c.likePageMap[page][hash]; !ok {
+ return true
+ }
+
+ delete(c.likePageMap[page], hash)
+ return false
+}
diff --git a/internal/router/lang-blog-title.go b/internal/router/lang-blog-title.go
new file mode 100644
index 0000000..bafb0d2
--- /dev/null
+++ b/internal/router/lang-blog-title.go
@@ -0,0 +1,82 @@
+package router
+
+import (
+ "bytes"
+ "fmt"
+ "html/template"
+ "log/slog"
+ "slices"
+ "strconv"
+ "strings"
+
+ "github.com/SayaAndy/saya-today-web/internal/b2"
+ "github.com/SayaAndy/saya-today-web/internal/frontmatter"
+ "github.com/SayaAndy/saya-today-web/locale"
+ "github.com/gofiber/fiber/v2"
+ "github.com/yuin/goldmark"
+)
+
+func init() {
+ tm.Add("blog-page", "views/layouts/general-page.html", "views/pages/blog-page.html")
+}
+
+func Lang_Blog_Title(l map[string]*locale.LocaleConfig, langs []string, b2Client *b2.B2Client, md goldmark.Markdown) func(c *fiber.Ctx) error {
+ return func(c *fiber.Ctx) error {
+ ip := c.IP()
+ slog.Debug("client entering blog page", slog.String("ip", ip), slog.String("page", c.Path()))
+
+ lang := c.Params("lang")
+ if !slices.Contains(langs, lang) {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+ return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language... yet??", lang))
+ }
+
+ metadata, parsedMarkdown, err := readBlogPost(md, b2Client, lang+"/"+c.Params("title"))
+ if err != nil {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+ return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("failed to find '%s' post", c.Params("title")))
+ }
+
+ geolocationParts := strings.Split(metadata.Geolocation, " ")
+ var x, y, areaError string
+ if len(geolocationParts) >= 2 {
+ x = geolocationParts[0]
+ y = geolocationParts[1]
+ }
+ if len(geolocationParts) >= 3 {
+ areaError = geolocationParts[2]
+ }
+
+ content, err := tm.Render("blog-page", fiber.Map{
+ "Title": metadata.Title,
+ "PublishedDate": metadata.PublishedTime.Format("2006-01-02 15:04:05 -07:00"),
+ "PublishedYear": strconv.Itoa(metadata.PublishedTime.Year()),
+ "ParsedMarkdown": template.HTML(parsedMarkdown),
+ "MapLocationX": x,
+ "MapLocationY": y,
+ "MapLocationAreaMeters": areaError,
+ "Lang": lang,
+ })
+ if err != nil {
+ slog.Warn("failed to generate page", slog.String("page", "/"+lang+"/blog/"+c.Params("title")), slog.String("error", err.Error()))
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+ return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate page")
+ }
+
+ return c.Type("html").Send(content)
+ }
+}
+
+func readBlogPost(md goldmark.Markdown, b2Client *b2.B2Client, sourceName string) (metadata *frontmatter.Metadata, html string, err error) {
+ metadata, markdown, err := b2Client.ReadFrontmatter(sourceName + ".md")
+ if err != nil {
+ return nil, "", fmt.Errorf("failed to read a frontmatter file: %w", err)
+ }
+
+ var buf bytes.Buffer
+ if err := md.Convert(markdown, &buf); err != nil {
+ return nil, "", fmt.Errorf("convert source context from md to html: %w", err)
+ }
+
+ return metadata, buf.String(), nil
+}
diff --git a/internal/router/lang-blog.go b/internal/router/lang-blog.go
new file mode 100644
index 0000000..dd3f079
--- /dev/null
+++ b/internal/router/lang-blog.go
@@ -0,0 +1,93 @@
+package router
+
+import (
+ "fmt"
+ "log/slog"
+ "net/url"
+ "regexp"
+ "slices"
+ "strings"
+
+ "github.com/SayaAndy/saya-today-web/internal/b2"
+ "github.com/SayaAndy/saya-today-web/locale"
+ "github.com/gofiber/fiber/v2"
+)
+
+func init() {
+ tm.Add("blog-catalogue", "views/layouts/general-page.html", "views/pages/blog-catalogue.html")
+}
+
+func Lang_Blog(l map[string]*locale.LocaleConfig, langs []string, b2Client *b2.B2Client) func(c *fiber.Ctx) error {
+ return func(c *fiber.Ctx) error {
+ lang := c.Params("lang")
+ if !slices.Contains(langs, lang) {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+ return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language... yet??", lang))
+ }
+
+ querySort := c.Query("sort")
+ if querySort == "" {
+ querySort = "publicationDateDesc"
+ }
+
+ encodedQuery := c.Request().URI().QueryString()
+ re, err := regexp.Compile(`tags\[\]=([\w]+)`)
+ if err != nil {
+ slog.Warn("failed to generate regex for tags gathering", slog.String("error", err.Error()))
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+ return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate regex for tags gathering")
+ }
+ decodedQuery, _ := url.QueryUnescape(string(encodedQuery))
+ matches := re.FindAllStringSubmatch(decodedQuery, -1)
+
+ queryTags := make([]string, 0, len(matches))
+ for _, match := range matches {
+ queryTags = append(queryTags, string(match[1]))
+ }
+
+ pages, err := b2Client.Scan(lang + "/")
+ status := fiber.StatusOK
+ if err != nil {
+ status = fiber.StatusPartialContent
+ pages = []*b2.BlogPage{}
+ }
+
+ tagsMap := make(map[string]int)
+ for _, page := range pages {
+ for _, tag := range page.Metadata.Tags {
+ tagsMap[tag]++
+ }
+ }
+ slog.Debug("enlist pages for catalogue", slog.Int("tag_count", len(tagsMap)), slog.Int("page_count", len(pages)), slog.String("path", c.Path()))
+
+ type Tag struct {
+ Name string `json:"Name" yaml:"name"`
+ Count int `json:"Count" yaml:"count"`
+ }
+
+ tagsArray := make([]Tag, 0, len(tagsMap))
+ for tag, count := range tagsMap {
+ tagsArray = append(tagsArray, Tag{tag, count})
+ }
+ slices.SortFunc(tagsArray, func(a Tag, b Tag) int {
+ return strings.Compare(a.Name, b.Name)
+ })
+
+ content, err := tm.Render("blog-catalogue", fiber.Map{
+ "QuerySort": querySort,
+ "QueryTags": strings.Join(queryTags, ","),
+ "Tags": tagsArray,
+ "Lang": lang,
+ "L": l[lang],
+ "PublishedYear": "2025",
+ "Title": l[lang].BlogSearch.Header,
+ })
+ if err != nil {
+ slog.Warn("failed to generate page", slog.String("path", c.Path()), slog.String("error", err.Error()))
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+ return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate page")
+ }
+
+ return c.Type("html").Status(status).Send(content)
+ }
+}
diff --git a/internal/router/lang-map.go b/internal/router/lang-map.go
new file mode 100644
index 0000000..e94d756
--- /dev/null
+++ b/internal/router/lang-map.go
@@ -0,0 +1,85 @@
+package router
+
+import (
+ "fmt"
+ "log/slog"
+ "slices"
+ "strconv"
+ "strings"
+
+ "github.com/SayaAndy/saya-today-web/internal/b2"
+ "github.com/SayaAndy/saya-today-web/locale"
+ "github.com/gofiber/fiber/v2"
+)
+
+func init() {
+ tm.Add("global-map", "views/pages/global-map.html")
+}
+
+func Lang_Map(l map[string]*locale.LocaleConfig, langs []string, b2Client *b2.B2Client) func(c *fiber.Ctx) error {
+ return func(c *fiber.Ctx) error {
+ lang := c.Params("lang")
+ if !slices.Contains(langs, lang) {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+ return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language... yet??", lang))
+ }
+
+ pages, err := b2Client.Scan(lang + "/")
+ status := fiber.StatusOK
+ if err != nil {
+ status = fiber.StatusPartialContent
+ pages = []*b2.BlogPage{}
+ }
+
+ type MapMarker struct {
+ Title string `json:"Title"`
+ PageLink string `json:"PageLink"`
+ Lat float64 `json:"Lat"`
+ Long float64 `json:"Long"`
+ AccuracyMeters int64 `json:"AccuracyMeters"`
+ Thumbnail string `json:"Thumbnail"`
+ }
+
+ mapMarkers := make([]*MapMarker, 0, len(pages))
+ for _, page := range pages {
+ geolocationParts := strings.Split(page.Metadata.Geolocation, " ")
+ if len(geolocationParts) < 2 {
+ continue
+ }
+
+ var x, y float64
+ var areaError int64
+ if len(geolocationParts) >= 2 {
+ x, _ = strconv.ParseFloat(geolocationParts[0], 64)
+ y, _ = strconv.ParseFloat(geolocationParts[1], 64)
+ }
+ if len(geolocationParts) >= 3 {
+ areaError, _ = strconv.ParseInt(geolocationParts[2], 10, 64)
+ }
+
+ mapMarkers = append(mapMarkers, &MapMarker{
+ Title: page.Metadata.Title,
+ PageLink: fmt.Sprintf("/%s/blog/%s", lang, page.FileName),
+ Lat: x,
+ Long: y,
+ AccuracyMeters: areaError,
+ Thumbnail: page.Metadata.Thumbnail,
+ })
+ }
+
+ content, err := tm.Render("global-map", fiber.Map{
+ "Lang": lang,
+ "L": l[lang],
+ "MapLocationLat": 45.4507,
+ "MapLocationLong": 68.8319,
+ "MapMarkers": mapMarkers,
+ })
+ if err != nil {
+ slog.Warn("failed to generate page", slog.String("page", "/"+lang+"/map"), slog.String("error", err.Error()))
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+ return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate page")
+ }
+
+ return c.Type("html").Status(status).Send(content)
+ }
+}
diff --git a/internal/router/root.go b/internal/router/root.go
new file mode 100644
index 0000000..f4a0692
--- /dev/null
+++ b/internal/router/root.go
@@ -0,0 +1,27 @@
+package router
+
+import (
+ "log/slog"
+
+ "github.com/SayaAndy/saya-today-web/config"
+ "github.com/gofiber/fiber/v2"
+)
+
+func init() {
+ tm.Add("index", "views/index.html")
+}
+
+func Root(localeCfg []config.AvailableLanguageConfig) func(c *fiber.Ctx) error {
+ return func(c *fiber.Ctx) error {
+ content, err := tm.Render("index", fiber.Map{
+ "AvailableLanguages": localeCfg,
+ })
+ if err != nil {
+ slog.Warn("failed to generate page", slog.String("page", "/"), slog.String("error", err.Error()))
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+ return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate page")
+ }
+
+ return c.Type("html").Send(content)
+ }
+}
diff --git a/internal/router/tm.go b/internal/router/tm.go
new file mode 100644
index 0000000..e8095ee
--- /dev/null
+++ b/internal/router/tm.go
@@ -0,0 +1,18 @@
+package router
+
+import (
+ "log/slog"
+ "os"
+
+ "github.com/SayaAndy/saya-today-web/internal/templatemanager"
+)
+
+var tm = assert(templatemanager.NewTemplateManager())
+
+func assert[T any](t T, err error) T {
+ if err != nil {
+ slog.Error("fail to initialize template manager", slog.String("error", err.Error()))
+ os.Exit(1)
+ }
+ return t
+}