summaryrefslogtreecommitdiff
path: root/internal
diff refs
from: back
to: back
| flip
diff options
context:
space:
mode:
Diffstat (limited to 'internal')
-rw-r--r--internal/b2/client.go13
-rw-r--r--internal/factgiver/factgiver.go67
-rw-r--r--internal/frontmatter/parser.go3
-rw-r--r--internal/lightgallery/block.go11
-rw-r--r--internal/lightgallery/html_renderer.go118
-rw-r--r--internal/lightgallery/parser.go35
-rw-r--r--internal/router/api-v1-blog-search.go123
-rw-r--r--internal/router/api-v1-general-page-body.go162
-rw-r--r--internal/router/api-v1-general-page-bottom-embeds.go75
-rw-r--r--internal/router/api-v1-general-page-footer.go75
-rw-r--r--internal/router/api-v1-general-page-header.go85
-rw-r--r--internal/router/api-v1-general-page-top-embeds.go75
-rw-r--r--internal/router/api-v1-general-page.go52
-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.go304
-rw-r--r--internal/router/lang-blog-title.go83
-rw-r--r--internal/router/lang-blog.go94
-rw-r--r--internal/router/lang-map.go85
-rw-r--r--internal/router/page-cache.go5
-rw-r--r--internal/router/root.go27
-rw-r--r--internal/router/tm.go18
-rw-r--r--internal/tailwind/transformer.go26
-rw-r--r--internal/templatemanager/templatemanager.go99
24 files changed, 1707 insertions, 87 deletions
diff --git a/internal/b2/client.go b/internal/b2/client.go
index f2b6cad..7b745a8 100644
--- a/internal/b2/client.go
+++ b/internal/b2/client.go
@@ -33,13 +33,14 @@ func NewB2Client(cfg *config.B2Config) (*B2Client, error) {
type BlogPage struct {
Link string
+ FileName string
Metadata *frontmatter.Metadata
}
-func (c *B2Client) Scan() ([]*BlogPage, error) {
+func (c *B2Client) Scan(prefix string) ([]*BlogPage, error) {
filePaths := []*BlogPage{}
- iter := c.bucket.List(context.Background(), b2.ListPrefix(c.prefix))
+ iter := c.bucket.List(context.Background(), b2.ListPrefix(c.prefix+prefix))
for iter.Next() {
obj := iter.Object()
@@ -69,10 +70,13 @@ func (c *B2Client) Scan() ([]*BlogPage, error) {
return nil, fmt.Errorf("failed to parse published time metadata field: %w", err)
}
- linkParts := strings.Split(obj.Name(), ".")
+ linkParts := strings.Split(obj.Name(), "/")
+ nameParts := strings.Split(linkParts[len(linkParts)-1], ".")
+ fileName := strings.Join(nameParts[:len(linkParts)-1], ".")
filePaths = append(filePaths, &BlogPage{
- Link: strings.Join(linkParts[:len(linkParts)-1], "."),
+ Link: obj.Name(),
+ FileName: fileName,
Metadata: &frontmatter.Metadata{
Title: attrs.Info["title"],
ShortDescription: attrs.Info["short-description"],
@@ -80,6 +84,7 @@ func (c *B2Client) Scan() ([]*BlogPage, error) {
PublishedTime: publishedTime,
Thumbnail: attrs.Info["thumbnail"],
Tags: strings.Split(attrs.Info["tags"], ","),
+ Geolocation: attrs.Info["geolocation"],
},
})
}
diff --git a/internal/factgiver/factgiver.go b/internal/factgiver/factgiver.go
new file mode 100644
index 0000000..6f77403
--- /dev/null
+++ b/internal/factgiver/factgiver.go
@@ -0,0 +1,67 @@
+package factgiver
+
+import (
+ "fmt"
+ "math/rand"
+ "regexp"
+ "strings"
+ "time"
+
+ "github.com/SayaAndy/saya-today-web/config"
+ "github.com/SayaAndy/saya-today-web/internal/b2"
+)
+
+type FactGiver struct {
+ b2Client *b2.B2Client
+ cache map[string][]string
+ langs []string
+ factsFileName string
+ nlRe *regexp.Regexp
+ randGen *rand.Rand
+}
+
+func NewFactGiver(cfg *config.FactGiverConfig, langs []string) (*FactGiver, error) {
+ b2Client, err := b2.NewB2Client(&cfg.Storage.Config)
+ if err != nil {
+ return nil, fmt.Errorf("fail to init b2 client for a new fact giver: %s", err.Error())
+ }
+
+ factGiver := &FactGiver{
+ b2Client: b2Client,
+ cache: make(map[string][]string, len(langs)),
+ langs: langs,
+ factsFileName: cfg.FactsFileName,
+ nlRe: regexp.MustCompile(`\r?\n`),
+ randGen: rand.New(rand.NewSource(time.Now().UnixNano())),
+ }
+ if err = factGiver.initCache(); err != nil {
+ return nil, fmt.Errorf("fail to init cache for a new fact giver: %s", err.Error())
+ }
+
+ return factGiver, nil
+}
+
+func (g *FactGiver) Give(lang string) [3]string {
+ factSlice := make([]string, len(g.cache[lang]))
+ copy(factSlice, g.cache[lang])
+ g.randGen.Shuffle(len(factSlice), func(i, j int) {
+ factSlice[i], factSlice[j] = factSlice[j], factSlice[i]
+ })
+ return [3]string{factSlice[0], factSlice[1], factSlice[2]}
+}
+
+func (g *FactGiver) initCache() error {
+ for _, lang := range g.langs {
+ localFacts := strings.Replace(g.factsFileName, "*", lang, 1)
+ factsContentBytes, err := g.b2Client.ReadAll(localFacts)
+ if err != nil {
+ return fmt.Errorf("fail to read '%s' facts file: %s", lang, err.Error())
+ }
+ factsContent := string(factsContentBytes)
+ g.cache[lang] = g.nlRe.Split(factsContent, -1)
+ if g.cache[lang][len(g.cache[lang])-1] == "" {
+ g.cache[lang] = g.cache[lang][:len(g.cache[lang])-1]
+ }
+ }
+ return nil
+}
diff --git a/internal/frontmatter/parser.go b/internal/frontmatter/parser.go
index 6cc3ee8..83312dc 100644
--- a/internal/frontmatter/parser.go
+++ b/internal/frontmatter/parser.go
@@ -15,6 +15,9 @@ type Metadata struct {
PublishedTime time.Time `yaml:"publishedTime"`
Thumbnail string `yaml:"thumbnail"`
Tags []string `yaml:"tags"`
+ Geolocation string `yaml:"geolocation"`
+ Medley string `yaml:"medley"`
+ MedleyPart int `yaml:"medleyPart"`
}
func ParseFrontmatter(content []byte) (metadata *Metadata, markdown []byte, err error) {
diff --git a/internal/lightgallery/block.go b/internal/lightgallery/block.go
index dade2ea..8eb7c9e 100644
--- a/internal/lightgallery/block.go
+++ b/internal/lightgallery/block.go
@@ -1,16 +1,21 @@
package lightgallery
-import "github.com/yuin/goldmark/ast"
+import (
+ "time"
+
+ "github.com/yuin/goldmark/ast"
+)
// LightGalleryBlock represents a light gallery block in the AST
type LightGalleryBlock struct {
ast.BaseBlock
- Images []LightGalleryImage
+ Images []LightGalleryImage
+ Location *time.Location
}
type LightGalleryImage struct {
URL string
- Caption string
+ Caption []byte
}
var KindLightGalleryBlock = ast.NewNodeKind("LightGalleryBlock")
diff --git a/internal/lightgallery/html_renderer.go b/internal/lightgallery/html_renderer.go
index fb65b06..8e184e0 100644
--- a/internal/lightgallery/html_renderer.go
+++ b/internal/lightgallery/html_renderer.go
@@ -1,11 +1,13 @@
package lightgallery
import (
+ "bytes"
"fmt"
"math/rand"
"strings"
"time"
+ "github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/renderer/html"
@@ -14,11 +16,18 @@ import (
type LightGalleryHTMLRenderer struct {
html.Config
+ md goldmark.Markdown
}
func NewLightGalleryHTMLRenderer(opts ...html.Option) renderer.NodeRenderer {
r := &LightGalleryHTMLRenderer{
Config: html.NewConfig(),
+ md: goldmark.New(
+ goldmark.WithRendererOptions(
+ html.WithHardWraps(),
+ html.WithXHTML(),
+ ),
+ ),
}
for _, opt := range opts {
opt.SetHTMLOption(&r.Config)
@@ -38,94 +47,93 @@ func (r *LightGalleryHTMLRenderer) renderLightGallery(w util.BufWriter, source [
return ast.WalkContinue, nil
}
- galleryID := generateDivId(4)
+ galleryID := generateDivId(8)
w.WriteString(fmt.Sprintf(`
<div class="justify-content-center display-block m-2">
- <hr class="border-t-4 border-dotted border-main-dark mt-vmin0-8 mb-vmin0-8 w-p80 ml-auto mr-auto">
+ <hr class="border-t-4 border-dotted border-main-dark mt-[0.8vmin] mb-[0.8vmin] w-[80%%] ml-auto mr-auto">
<div id="lg-%s" class="inline-gallery-container relative ml-auto mr-auto"></div>
- <hr class="border-t-4 border-dotted border-main-dark mt-vmin0-8 mb-vmin0-8 w-p80 ml-auto mr-auto">
+ <hr class="border-t-4 border-dotted border-main-dark mt-[0.8vmin] mb-[0.8vmin] w-[80%%] ml-auto mr-auto">
</div>`, galleryID))
var dynamicElements []string
for _, img := range gallery.Images {
imageUrlSegments := strings.Split(img.URL, ".")
imageUrlWithoutExt := strings.Join(imageUrlSegments[:len(imageUrlSegments)-1], ".")
- imageNameParts := strings.Split(imageUrlWithoutExt, "-")
+ imageUrlParts := strings.Split(imageUrlWithoutExt, "/")
+ imageNameParts := strings.Split(imageUrlParts[len(imageUrlParts)-1], "-")
+
+ var captionBuf bytes.Buffer
+ captionHTML := img.Caption
+ if err := r.md.Convert(img.Caption, &captionBuf); err == nil {
+ captionHTML = captionBuf.Bytes()
+ captionHTML = bytes.TrimPrefix(captionHTML, []byte("<p>"))
+ captionHTML = bytes.TrimSuffix(captionHTML, []byte("</p>\n"))
+ captionHTML = bytes.TrimSuffix(captionHTML, []byte("</p>"))
+ }
dayDate, _ := time.Parse("20060102 150405", imageNameParts[len(imageNameParts)-2]+" "+imageNameParts[len(imageNameParts)-1])
+ dayDate = dayDate.In(gallery.Location)
dynamicElements = append(dynamicElements, fmt.Sprintf(`{
src:
"https://f003.backblazeb2.com/file/sayana-photos/full/%s",
downloadUrl:
"https://f003.backblazeb2.com/file/sayana-photos/full/%s",
alt: "%s",
- sources: [{
- srcset: "https://f003.backblazeb2.com/file/sayana-photos/thumbnails/%s.webp",
- media: "(max-width: 800px)"
- }],
+ responsive: "https://f003.backblazeb2.com/file/sayana-photos/webp-320p/%s.webp 384, https://f003.backblazeb2.com/file/sayana-photos/webp-560p/%s.webp 672, https://f003.backblazeb2.com/file/sayana-photos/webp-800p/%s.webp 960, https://f003.backblazeb2.com/file/sayana-photos/webp-1200p/%s.webp 1440, https://f003.backblazeb2.com/file/sayana-photos/webp-1600p/%s.webp 1920",
thumb:
- "https://f003.backblazeb2.com/file/sayana-photos/thumbnails/%s.webp",
+ "https://f003.backblazeb2.com/file/sayana-photos/webp-320p/%s.webp",
subHtml: `+"`"+`<div class="flex flex-row light-gallery-captions">
- <p class="grow text-vmax1 text-left lh-0-9 font-spectral text-main-dark">%s</p>
- <p class="text-vmax1 text-right lh-0-9 font-spectral text-secondary">%s</p>
+ <p class="grow !text-[1vmax]/[1] text-left font-spectral text-main-dark">%s</p>
+ <p class="!text-[1vmax]/[1] text-right font-spectral text-secondary">%s</p>
</div>`+"`"+`
- }`, img.URL, img.URL, img.Caption, imageUrlWithoutExt, imageUrlWithoutExt, img.Caption, dayDate.Format("2006-01-02 15:04:05 -07:00")))
+ }`, img.URL, img.URL, util.EscapeHTML(captionHTML), imageUrlWithoutExt, imageUrlWithoutExt, imageUrlWithoutExt, imageUrlWithoutExt, imageUrlWithoutExt, imageUrlWithoutExt, captionHTML, dayDate.Format("2006-01-02 15:04:05 -07:00")))
}
w.WriteString(fmt.Sprintf(`
<script>
-function createLightLibrary%s() {
- const $lgContainer = document.getElementById('lg-%s');
- const inlineGallery = lightGallery($lgContainer, {
- container: $lgContainer,
- dynamic: true,
- dynamicEl: [%s],
- width: "100%%",
- height: "50vmin",
- hash: false,
- closable: false,
- showMaximizeIcon: true,
- appendSubHtmlTo: ".lg-sub-html",
- isMobile: () => { return false; },
- slideDelay: 0,
- plugins: [lgZoom, lgThumbnail],
- thumbWidth: 160,
- thumbHeight: "10vmin",
- thumbMargin: 4
- });
+function createLightGallery%s() {
+ const $lgContainer = document.getElementById('lg-%s');
+ const config = {
+ container: $lgContainer,
+ dynamic: true,
+ dynamicEl: [%s],
+ width: "100%%",
+ height: "50vmin",
+ hash: false,
+ closable: false,
+ showMaximizeIcon: true,
+ appendSubHtmlTo: ".lg-sub-html",
+ isMobile: () => false,
+ slideDelay: 0,
+ plugins: [lgZoom, lgThumbnail],
+ thumbWidth: calculateVmin(10),
+ thumbHeight: "10vmin",
+ thumbMargin: 4
+ };
+ const inlineGallery = lightGallery($lgContainer, config);
- setTimeout(() => {
- inlineGallery.openGallery();
- }, 200);
-}
+ setTimeout(() => {
+ inlineGallery.openGallery();
+ }, 200);
-document.addEventListener('DOMContentLoaded', createLightLibrary%s);
+ galleryMap.set(inlineGallery, createLightGallery%s);
+}
-let resizeTimer%s;
-window.addEventListener("resize", () => {
- clearTimeout(resizeTimer%s);
- resizeTimer%s = setTimeout(() => {
- const $lgContainer = document.getElementById('lg-%s');
- $lgContainer.innerHTML = "";
- createLightLibrary%s();
- }, 1000);
-});
-</script>`, galleryID, galleryID, strings.Join(dynamicElements, ","), galleryID, galleryID, galleryID, galleryID, galleryID, galleryID))
+document.addEventListener('htmx:afterRequest', (e) => {
+ if (e.detail.xhr.status == 404 || e.detail.successful != true) {
+ return console.error(e);
+ }
+ if (e.detail.target.id == 'general-page-body') {
+ createLightGallery%s();
+ }
+}, {once: true});
+</script>`, galleryID, galleryID, strings.Join(dynamicElements, ","), galleryID, galleryID))
}
return ast.WalkContinue, nil
}
-func escapeHTML(s string) string {
- s = strings.ReplaceAll(s, "&", "&amp;")
- s = strings.ReplaceAll(s, "<", "&lt;")
- s = strings.ReplaceAll(s, ">", "&gt;")
- s = strings.ReplaceAll(s, "\"", "&quot;")
- s = strings.ReplaceAll(s, "'", "&#39;")
- return s
-}
-
func generateDivId(length int) string {
const charset = "abcdefghijklmnopqrstuvwxyz"
seededRand := rand.New(rand.NewSource(time.Now().UnixNano())) // Seed with current time
diff --git a/internal/lightgallery/parser.go b/internal/lightgallery/parser.go
index 0ce209c..daec797 100644
--- a/internal/lightgallery/parser.go
+++ b/internal/lightgallery/parser.go
@@ -2,7 +2,9 @@ package lightgallery
import (
"bytes"
- "strings"
+ "log/slog"
+ "regexp"
+ "time"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser"
@@ -22,43 +24,52 @@ func (p *LightGalleryParser) Trigger() []byte {
func (p *LightGalleryParser) Open(parent ast.Node, reader text.Reader, pc parser.Context) (ast.Node, parser.State) {
line, _ := reader.PeekLine()
- if !bytes.HasPrefix(line, []byte("{Gallery}")) {
+ if !bytes.HasPrefix(line, []byte("{Gallery:")) {
return nil, parser.NoChildren
}
+ r := regexp.MustCompile(`^\{Gallery:([A-Za-z0-9\+\-/]+)\}$`)
+
trimmed := bytes.TrimSpace(line)
- if !bytes.Equal(trimmed, []byte("{Gallery}")) {
+ parts := r.FindSubmatch(trimmed)
+ if len(parts) < 2 {
+ slog.Warn("invalid gallery header format", slog.String("line", string(trimmed)), slog.Int("submatch_count", len(parts)))
return nil, parser.NoChildren
}
- return &LightGalleryBlock{}, parser.NoChildren
+ loc, err := time.LoadLocation(string(parts[1]))
+ if err != nil {
+ slog.Warn("invalid location specified for a gallery", slog.String("error", err.Error()), slog.String("line", string(trimmed)), slog.String("location", string(parts[1])))
+ return nil, parser.NoChildren
+ }
+
+ return &LightGalleryBlock{Location: loc}, parser.NoChildren
}
func (p *LightGalleryParser) Continue(node ast.Node, reader text.Reader, pc parser.Context) parser.State {
line, segment := reader.PeekLine()
if len(line) == 0 || segment.Len() == 0 {
- return parser.Close
+ return parser.Continue | parser.NoChildren
}
trimmed := bytes.TrimSpace(line)
- if bytes.Equal(trimmed, []byte("{Gallery}")) {
+ if bytes.Equal(trimmed, []byte("{Gallery}")) || bytes.Equal(trimmed, []byte("{/Gallery}")) {
reader.AdvanceLine()
return parser.Close
}
gallery := node.(*LightGalleryBlock)
- lineStr := string(trimmed)
- parts := strings.SplitN(lineStr, "|", 2)
- url := strings.TrimSpace(parts[0])
- caption := ""
+ parts := bytes.SplitN(trimmed, []byte{'|'}, 2)
+ url := bytes.TrimSpace(parts[0])
+ caption := make([]byte, 0)
if len(parts) > 1 {
- caption = strings.TrimSpace(parts[1])
+ caption = bytes.TrimSpace(parts[1])
}
gallery.Images = append(gallery.Images, LightGalleryImage{
- URL: url,
+ URL: string(url),
Caption: caption,
})
diff --git a/internal/router/api-v1-blog-search.go b/internal/router/api-v1-blog-search.go
new file mode 100644
index 0000000..69be651
--- /dev/null
+++ b/internal/router/api-v1-blog-search.go
@@ -0,0 +1,123 @@
+package router
+
+import (
+ "encoding/json"
+ "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))
+ }
+
+ cacheKey := "blog-search." + lang + ".pages-list"
+ var pages []*b2.BlogPage
+ if pagesBytes, ok := PCache.Get(cacheKey); pagesBytes != nil || ok {
+ json.Unmarshal(pagesBytes, &pages)
+ } else {
+ 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))
+ }
+ pagesBytes, _ := json.Marshal(pages)
+ PCache.SetWithTTL(cacheKey, pagesBytes, int64(len(pagesBytes)), 5*time.Minute)
+ }
+
+ 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),
+ "ViewCount": CCache.GetViewCount(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-general-page-body.go b/internal/router/api-v1-general-page-body.go
new file mode 100644
index 0000000..b2fa268
--- /dev/null
+++ b/internal/router/api-v1-general-page-body.go
@@ -0,0 +1,162 @@
+package router
+
+import (
+ "fmt"
+ "html/template"
+ "log/slog"
+ "math/rand"
+ "net/url"
+ "regexp"
+ "slices"
+ "strings"
+ "time"
+
+ "github.com/SayaAndy/saya-today-web/internal/b2"
+ "github.com/SayaAndy/saya-today-web/internal/factgiver"
+ "github.com/SayaAndy/saya-today-web/locale"
+ "github.com/gofiber/fiber/v2"
+ "github.com/yuin/goldmark"
+)
+
+var FactGiver *factgiver.FactGiver
+
+func init() {
+ tm.Add("general-page-body", "views/partials/general-page-body.html")
+}
+
+func Api_V1_GeneralPage_Body(l map[string]*locale.LocaleConfig, langs []string, b2Client *b2.B2Client, md goldmark.Markdown) 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()
+
+ cacheKey := fmt.Sprintf("body.%s", path)
+ if val, ok := PCache.Get(cacheKey); val != nil && ok {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Type("html").Send(val)
+ }
+
+ pathParts := strings.Split(strings.Trim(path, "/"), "/")
+ if len(pathParts) == 0 {
+ return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/...'")
+ }
+
+ lang := pathParts[0]
+ if !slices.Contains(langs, lang) {
+ return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language... yet??", lang))
+ }
+
+ values := fiber.Map{
+ "L": l[lang],
+ "Lang": lang,
+ "QueryString": string(c.Request().URI().QueryString()),
+ }
+ var additionalTemplates []string
+
+ if len(pathParts) == 2 && pathParts[1] == "blog" {
+ 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()))
+ 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 + "/")
+ if err != nil {
+ slog.Warn("failed to scan pages via b2", slog.String("error", err.Error()))
+ return c.Status(fiber.ErrInternalServerError.Code).SendString(fmt.Sprintf("failed to scan pages via b2: %s", slog.String("error", err.Error())))
+ }
+
+ 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)
+ })
+
+ values["Tags"] = tagsArray
+ values["QuerySort"] = querySort
+ values["QueryTags"] = strings.Join(queryTags, ",")
+ values["Title"] = l[lang].BlogSearch.Header
+
+ additionalTemplates = append(additionalTemplates, "views/pages/blog-catalogue.html")
+ } else if len(pathParts) == 3 && pathParts[1] == "blog" {
+ metadata, parsedMarkdown, err := readBlogPost(md, b2Client, lang+"/"+pathParts[2])
+ if err != nil {
+ return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("failed to find '%s' post", pathParts[2]))
+ }
+
+ 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]
+ }
+
+ values["MapLocationX"] = x
+ values["MapLocationY"] = y
+ values["MapLocationAreaMeters"] = areaError
+ values["Title"] = metadata.Title
+ values["ParsedMarkdown"] = template.HTML(parsedMarkdown)
+
+ additionalTemplates = append(additionalTemplates, "views/pages/blog-page.html")
+
+ go CCache.View(c.IP(), pathParts[2])
+ } else if len(pathParts) == 1 {
+ values["Title"] = l[lang].HomePage.Header
+ values["FilledHeartCount"] = uint(40)
+ values["OutlineHeartCount"] = uint(40)
+ values["GifName"] = fmt.Sprintf("otter-%d.gif", rand.Int()%3+1)
+ values["FunFacts"] = FactGiver.Give(lang)
+ additionalTemplates = append(additionalTemplates, "views/pages/home-page.html")
+ }
+
+ content, err := tm.Render("general-page-body", values, additionalTemplates...)
+ 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")
+ }
+
+ go PCache.SetWithTTL(cacheKey, content, int64(len(content)), 5*time.Minute)
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Type("html").Send(content)
+ }
+}
diff --git a/internal/router/api-v1-general-page-bottom-embeds.go b/internal/router/api-v1-general-page-bottom-embeds.go
new file mode 100644
index 0000000..c4d9543
--- /dev/null
+++ b/internal/router/api-v1-general-page-bottom-embeds.go
@@ -0,0 +1,75 @@
+package router
+
+import (
+ "fmt"
+ "log/slog"
+ "net/url"
+ "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("general-page-bottom-embeds", "views/partials/general-page-bottom-embeds.html")
+}
+
+func Api_V1_GeneralPage_BottomEmbeds(l map[string]*locale.LocaleConfig, langs []string, b2Client *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()
+ cacheKey := fmt.Sprintf("bottom-embeds.%s", path)
+ if val, ok := PCache.Get(cacheKey); val != nil && ok {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Type("html").Send(val)
+ }
+
+ pathParts := strings.Split(strings.Trim(path, "/"), "/")
+ if len(pathParts) == 0 {
+ return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/...'")
+ }
+
+ lang := pathParts[0]
+ if !slices.Contains(langs, lang) {
+ return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language... yet??", lang))
+ }
+
+ values := fiber.Map{
+ "L": l[lang],
+ "Lang": lang,
+ "QueryString": string(c.Request().URI().QueryString()),
+ }
+ var additionalTemplates []string
+
+ if len(pathParts) == 2 && pathParts[1] == "blog" {
+ additionalTemplates = append(additionalTemplates, "views/pages/blog-catalogue.html")
+ } else if len(pathParts) == 3 && pathParts[1] == "blog" {
+ additionalTemplates = append(additionalTemplates, "views/pages/blog-page.html")
+ } else if len(pathParts) == 1 {
+ additionalTemplates = append(additionalTemplates, "views/pages/home-page.html")
+ }
+
+ content, err := tm.Render("general-page-bottom-embeds", values, additionalTemplates...)
+ 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")
+ }
+
+ go PCache.SetWithTTL(cacheKey, content, int64(len(content)), 5*time.Minute)
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Type("html").Send(content)
+ }
+}
diff --git a/internal/router/api-v1-general-page-footer.go b/internal/router/api-v1-general-page-footer.go
new file mode 100644
index 0000000..003f54c
--- /dev/null
+++ b/internal/router/api-v1-general-page-footer.go
@@ -0,0 +1,75 @@
+package router
+
+import (
+ "fmt"
+ "log/slog"
+ "net/url"
+ "slices"
+ "strings"
+ "time"
+
+ "github.com/SayaAndy/saya-today-web/locale"
+ "github.com/gofiber/fiber/v2"
+)
+
+func init() {
+ tm.Add("general-page-footer", "views/partials/general-page-footer.html")
+}
+
+func Api_V1_GeneralPage_Footer(l map[string]*locale.LocaleConfig, langs []string) 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()
+
+ cacheKey := fmt.Sprintf("footer.%s", path)
+ if val, ok := PCache.Get(cacheKey); val != nil && ok {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Type("html").Send(val)
+ }
+
+ pathParts := strings.Split(strings.Trim(path, "/"), "/")
+ if len(pathParts) == 0 {
+ return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/...'")
+ }
+
+ lang := pathParts[0]
+ if !slices.Contains(langs, lang) {
+ return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language... yet??", lang))
+ }
+
+ values := fiber.Map{
+ "L": l[lang],
+ "Lang": lang,
+ "QueryString": string(c.Request().URI().QueryString()),
+ }
+ var additionalTemplates []string
+
+ if len(pathParts) == 2 && pathParts[1] == "blog" {
+ additionalTemplates = append(additionalTemplates, "views/pages/blog-catalogue.html")
+ } else if len(pathParts) == 3 && pathParts[1] == "blog" {
+ additionalTemplates = append(additionalTemplates, "views/pages/blog-page.html")
+ } else if len(pathParts) == 1 {
+ additionalTemplates = append(additionalTemplates, "views/pages/home-page.html")
+ }
+
+ content, err := tm.Render("general-page-footer", values, additionalTemplates...)
+ 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")
+ }
+
+ go PCache.SetWithTTL(cacheKey, content, int64(len(content)), 5*time.Minute)
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Type("html").Send(content)
+ }
+}
diff --git a/internal/router/api-v1-general-page-header.go b/internal/router/api-v1-general-page-header.go
new file mode 100644
index 0000000..80228f7
--- /dev/null
+++ b/internal/router/api-v1-general-page-header.go
@@ -0,0 +1,85 @@
+package router
+
+import (
+ "fmt"
+ "log/slog"
+ "net/url"
+ "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("general-page-header", "views/partials/general-page-header.html")
+}
+
+func Api_V1_GeneralPage_Header(l map[string]*locale.LocaleConfig, langs []string, b2Client *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()
+
+ cacheKey := fmt.Sprintf("header.%s", path)
+ if val, ok := PCache.Get(cacheKey); val != nil && ok {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Type("html").Send(val)
+ }
+
+ pathParts := strings.Split(strings.Trim(path, "/"), "/")
+ if len(pathParts) == 0 {
+ return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/...'")
+ }
+
+ lang := pathParts[0]
+ if !slices.Contains(langs, lang) {
+ return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language... yet??", lang))
+ }
+
+ values := fiber.Map{
+ "L": l[lang],
+ "Lang": lang,
+ "QueryString": string(c.Request().URI().QueryString()),
+ }
+ var additionalTemplates []string
+
+ if len(pathParts) == 2 && pathParts[1] == "blog" {
+ values["Title"] = l[lang].BlogSearch.Header
+ additionalTemplates = append(additionalTemplates, "views/pages/blog-catalogue.html")
+ } else if len(pathParts) == 3 && pathParts[1] == "blog" {
+ metadata, _, err := b2Client.ReadFrontmatter(lang + "/" + pathParts[2] + ".md")
+ if err != nil {
+ return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("could not read '%s' for content", path))
+ }
+ values["Title"] = metadata.Title
+ values["PublishedDate"] = metadata.PublishedTime.Format("2006-01-02 15:04:05 -07:00")
+ values["ActionDate"] = metadata.ActionDate
+ additionalTemplates = append(additionalTemplates, "views/pages/blog-page.html")
+ } else if len(pathParts) == 1 {
+ values["Title"] = l[lang].HomePage.Header
+ additionalTemplates = append(additionalTemplates, "views/pages/home-page.html")
+ }
+
+ content, err := tm.Render("general-page-header", values, additionalTemplates...)
+ 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")
+ }
+
+ go PCache.SetWithTTL(cacheKey, content, int64(len(content)), 5*time.Minute)
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Type("html").Send(content)
+ }
+}
diff --git a/internal/router/api-v1-general-page-top-embeds.go b/internal/router/api-v1-general-page-top-embeds.go
new file mode 100644
index 0000000..78021b2
--- /dev/null
+++ b/internal/router/api-v1-general-page-top-embeds.go
@@ -0,0 +1,75 @@
+package router
+
+import (
+ "fmt"
+ "log/slog"
+ "net/url"
+ "slices"
+ "strings"
+ "time"
+
+ "github.com/SayaAndy/saya-today-web/locale"
+ "github.com/gofiber/fiber/v2"
+)
+
+func init() {
+ tm.Add("general-page-top-embeds", "views/partials/general-page-top-embeds.html")
+}
+
+func Api_V1_GeneralPage_TopEmbeds(l map[string]*locale.LocaleConfig, langs []string) 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()
+
+ cacheKey := fmt.Sprintf("top-embeds.%s", path)
+ if val, ok := PCache.Get(cacheKey); val != nil && ok {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Type("html").Send(val)
+ }
+
+ pathParts := strings.Split(strings.Trim(path, "/"), "/")
+ if len(pathParts) == 0 {
+ return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/...'")
+ }
+
+ lang := pathParts[0]
+ if !slices.Contains(langs, lang) {
+ return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language... yet??", lang))
+ }
+
+ values := fiber.Map{
+ "L": l[lang],
+ "Lang": lang,
+ "QueryString": string(c.Request().URI().QueryString()),
+ }
+ var additionalTemplates []string
+
+ if len(pathParts) == 2 && pathParts[1] == "blog" {
+ additionalTemplates = append(additionalTemplates, "views/pages/blog-catalogue.html")
+ } else if len(pathParts) == 3 && pathParts[1] == "blog" {
+ additionalTemplates = append(additionalTemplates, "views/pages/blog-page.html")
+ } else if len(pathParts) == 1 {
+ additionalTemplates = append(additionalTemplates, "views/pages/home-page.html")
+ }
+
+ content, err := tm.Render("general-page-top-embeds", values, additionalTemplates...)
+ 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")
+ }
+
+ go PCache.SetWithTTL(cacheKey, content, int64(len(content)), 5*time.Minute)
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Type("html").Send(content)
+ }
+}
diff --git a/internal/router/api-v1-general-page.go b/internal/router/api-v1-general-page.go
new file mode 100644
index 0000000..d0ea559
--- /dev/null
+++ b/internal/router/api-v1-general-page.go
@@ -0,0 +1,52 @@
+package router
+
+import (
+ "fmt"
+ "log/slog"
+ "slices"
+ "strings"
+
+ "github.com/SayaAndy/saya-today-web/locale"
+ "github.com/gofiber/fiber/v2"
+)
+
+func init() {
+ tm.Add("general-page", "views/layouts/general-page.html")
+}
+
+func Api_V1_GeneralPage(l map[string]*locale.LocaleConfig, langs []string) func(c *fiber.Ctx) error {
+ return func(c *fiber.Ctx) error {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
+ path := c.Path()
+
+ pathParts := strings.Split(strings.Trim(path, "/"), "/")
+ if len(pathParts) == 0 {
+ return c.Status(fiber.ErrBadRequest.Code).SendString("url path is invalid: expect format '/{lang}/...'")
+ }
+
+ lang := pathParts[0]
+ if !slices.Contains(langs, lang) {
+ return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language... yet??", lang))
+ }
+
+ cacheKey := "general-page." + lang
+ if val, ok := PCache.Get(cacheKey); val != nil && ok {
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).Type("html").Send(val)
+ }
+
+ content, err := tm.Render("general-page", fiber.Map{
+ "L": l[lang],
+ "Lang": lang,
+ "QueryString": string(c.Request().URI().QueryString()),
+ })
+ 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")
+ }
+
+ go PCache.Set(cacheKey, content, int64(len(content)))
+ c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8)
+ return c.Status(fiber.StatusOK).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..d607cdd
--- /dev/null
+++ b/internal/router/client-cache.go
@@ -0,0 +1,304 @@
+package router
+
+import (
+ "database/sql"
+ "encoding/base64"
+ "fmt"
+ "log/slog"
+ "strings"
+ "sync"
+
+ "golang.org/x/crypto/argon2"
+)
+
+type ClientCache struct {
+ hashMap map[string]string
+ hashMapMutex sync.RWMutex
+
+ likePageMap map[string]map[string]struct{}
+ viewPageMap 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{})
+ viewPageMap := 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{})
+ viewPageMap[pageRef] = make(map[string]struct{})
+ pageMutexMap[pageRef] = &sync.RWMutex{}
+ }
+ likePageMap[pageRef][userIdString] = struct{}{}
+ viewPageMap[pageRef][userIdString] = struct{}{}
+ }
+
+ rows, err = tx.Query("select * from blog_views;")
+ if err != nil {
+ tx.Rollback()
+ return nil, fmt.Errorf("fail to query db for blog_views to fill cache: %w", err)
+ }
+
+ 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_views to fill cache: %w", err)
+ }
+ userIdString := base64.RawStdEncoding.EncodeToString(userId)
+ if _, ok := viewPageMap[pageRef]; !ok {
+ viewPageMap[pageRef] = make(map[string]struct{})
+ pageMutexMap[pageRef] = &sync.RWMutex{}
+ }
+ viewPageMap[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,
+ viewPageMap: viewPageMap,
+ 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 = batchSave(tx, "blog_likes", c.likePageMap); err != nil {
+ tx.Rollback()
+ return fmt.Errorf("fail to save blog_likes: %s", err)
+ }
+
+ if err = batchSave(tx, "blog_views", c.viewPageMap); err != nil {
+ tx.Rollback()
+ return fmt.Errorf("fail to save blog_views: %s", err)
+ }
+
+ 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
+}
+
+func (c *ClientCache) GetViewStatus(id string, page string) bool {
+ page = strings.Clone(page)
+
+ mutex := c.getPageMutex(page)
+ mutex.RLock()
+ defer mutex.RUnlock()
+
+ if _, ok := c.viewPageMap[page]; !ok {
+ return false
+ }
+ _, ok := c.viewPageMap[page][c.GetHash(id)]
+ return ok
+}
+
+func (c *ClientCache) GetViewCount(page string) int {
+ page = strings.Clone(page)
+
+ mutex := c.getPageMutex(page)
+ mutex.RLock()
+ defer mutex.RUnlock()
+
+ if userSet, ok := c.viewPageMap[page]; ok {
+ return len(userSet)
+ }
+ return 0
+}
+
+func (c *ClientCache) View(id string, page string) {
+ page = strings.Clone(page)
+ hash := c.GetHash(id)
+
+ mutex := c.getPageMutex(page)
+ mutex.Lock()
+ defer mutex.Unlock()
+
+ if _, ok := c.viewPageMap[page]; !ok {
+ c.viewPageMap[page] = make(map[string]struct{})
+ }
+ c.viewPageMap[page][hash] = struct{}{}
+}
+
+func batchSave(tx *sql.Tx, table string, pageMap map[string]map[string]struct{}) (err error) {
+ if _, err = tx.Exec(fmt.Sprintf("delete from %s;", table)); err != nil {
+ return fmt.Errorf("fail to truncate table %s: %w", table, err)
+ }
+
+ userIdBytes := make(map[string][]byte)
+
+ sqlStatement := fmt.Sprintf(`
+ INSERT OR IGNORE INTO %s (page_ref, user_id)
+ VALUES %s(?, ?);
+ `, table, strings.Repeat("(?, ?), ", 99))
+ sqlStatementVars := make([]any, 0, 200)
+
+ for pageRef, userSet := range pageMap {
+ 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 stat pairs into db", slog.String("table", table), slog.String("error", err.Error()))
+ }
+
+ sqlStatementVars = make([]any, 0, 200)
+ }
+ }
+
+ if len(sqlStatementVars) > 0 {
+ sqlStatement = fmt.Sprintf(`
+ INSERT OR IGNORE INTO %s (page_ref, user_id)
+ VALUES %s(?, ?);
+ `, table, strings.Repeat("(?, ?), ", len(sqlStatementVars)/2-1))
+
+ if _, err := tx.Exec(sqlStatement, sqlStatementVars...); err != nil {
+ slog.Warn("couldn't insert blog stat pairs into db", slog.String("table", table), slog.String("error", err.Error()))
+ }
+ }
+
+ return nil
+}
diff --git a/internal/router/lang-blog-title.go b/internal/router/lang-blog-title.go
new file mode 100644
index 0000000..d6ca917
--- /dev/null
+++ b/internal/router/lang-blog-title.go
@@ -0,0 +1,83 @@
+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,
+ "QueryString": string(c.Request().URI().QueryString()),
+ })
+ 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..38a6597
--- /dev/null
+++ b/internal/router/lang-blog.go
@@ -0,0 +1,94 @@
+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, ","),
+ "QueryString": string(c.Request().URI().QueryString()),
+ "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/page-cache.go b/internal/router/page-cache.go
new file mode 100644
index 0000000..01e4792
--- /dev/null
+++ b/internal/router/page-cache.go
@@ -0,0 +1,5 @@
+package router
+
+import "github.com/dgraph-io/ristretto/v2"
+
+var PCache *ristretto.Cache[string, []byte]
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
+}
diff --git a/internal/tailwind/transformer.go b/internal/tailwind/transformer.go
index 51a33bc..2f052b9 100644
--- a/internal/tailwind/transformer.go
+++ b/internal/tailwind/transformer.go
@@ -17,44 +17,44 @@ func (t *TailwindTransformer) Transform(node *ast.Document, reader text.Reader,
switch node := n.(type) {
case *ast.Heading:
classes := map[int]string{
- 1: "font-patua text-vmax2-5 font-bold text-main-dark mb-vmin1-2 ls-vmin0-04",
- 2: "font-spectral text-vmax2 font-bold text-main-dark mb-vmin0-8 ls-vmin0-04",
- 3: "font-spectral text-vmax1-5 font-medium text-main-dark mb-vmin0-6 ls-vmin0-04",
- 4: "font-spectral text-vmax1-2 font-medium text-main-medium mb-vmin0-4 ls-vmin0-04",
- 5: "font-spectral text-vmax1 font-medium text-main-medium mb-vmin0-4 italic ls-vmin0-04",
- 6: "font-spectral text-vmax1 font-medium text-secondary mb-vmin0-4",
+ 1: "font-patua text-[2.5vmax] font-bold text-main-dark mb-[1.2vmin] tracking-[0.04vmin]",
+ 2: "font-spectral text-[2vmax] font-bold text-main-dark mb-[0.8vmin] tracking-[0.04vmin]",
+ 3: "font-spectral text-[1.5vmax] font-medium text-main-dark mb-[0.6vmin] tracking-[0.04vmin]",
+ 4: "font-spectral text-[1.2vmax] font-medium text-main-medium mb-[0.4vmin] tracking-[0.04vmin]",
+ 5: "font-spectral text-[1vmax] font-medium text-main-medium mb-[0.4vmin] italic tracking-[0.04vmin]",
+ 6: "font-spectral text-[1vmax] font-medium text-secondary mb-[0.4vmin]",
}
if class, ok := classes[node.Level]; ok {
node.SetAttribute([]byte("class"), []byte(class))
}
case *ast.Paragraph:
- node.SetAttribute([]byte("class"), []byte("text-vmax1 font-spectral lh-2 ls-vmin0-04 timl-vmax2 mb-vmin1-6"))
+ node.SetAttribute([]byte("class"), []byte("text-[1vmax]/[2] font-spectral tracking-[0.04vmin] -indent-[2vmax] ml-[2vmax] mb-[1.6vmin]"))
case *ast.List:
if node.IsOrdered() {
- node.SetAttribute([]byte("class"), []byte("list-decimal list-inside space-y-vmin0-8 mb-vmin1-6 pl-vmax2"))
+ node.SetAttribute([]byte("class"), []byte("list-decimal list-inside space-y-[0.8vmin] mb-[1.6vmin] pl-[2vmax]"))
} else {
- node.SetAttribute([]byte("class"), []byte("list-disc list-inside space-y-vmin0-8 mb-vmin1-6 pl-vmax2"))
+ node.SetAttribute([]byte("class"), []byte("list-disc list-inside space-y-[0.8vmin] mb-[1.6vmin] pl-[2vmax]"))
}
case *ast.ListItem:
- node.SetAttribute([]byte("class"), []byte("text-vmax1 font-spectral lh-2 ls-vmin0-04"))
+ node.SetAttribute([]byte("class"), []byte("text-[1vmax]/[2] font-spectral tracking-[0.04vmin]"))
case *ast.Blockquote:
- node.SetAttribute([]byte("class"), []byte("border-l-vmin0-4 border-main-medium bg-background-dark p-vmin0-8 mb-vmin0-8 italic"))
+ node.SetAttribute([]byte("class"), []byte("border-l-[0.4vmin] border-main-medium bg-background-dark p-[0.8vmin] mb-[0.8vmin] italic"))
case *ast.CodeSpan:
node.SetAttribute([]byte("class"), []byte("bg-background-dark"))
case *ast.CodeBlock, *ast.FencedCodeBlock:
- node.SetAttribute([]byte("class"), []byte("bg-background-dark p-vmin0-8 rounded-lg overflow-x-auto mb-vmin1-6"))
+ node.SetAttribute([]byte("class"), []byte("bg-background-dark p-[0.8vmin] rounded-lg overflow-x-auto mb-[1.6vmin]"))
case *ast.Link:
node.SetAttribute([]byte("class"), []byte("text-secondary hover:text-main-dark underline"))
case *ast.Image:
- node.SetAttribute([]byte("class"), []byte("max-w-full h-auto rounded-lg shadow-lg mb-vmin1-6"))
+ node.SetAttribute([]byte("class"), []byte("max-w-full h-auto rounded-lg shadow-lg mb-[1.6vmin]"))
case *ast.Emphasis:
switch node.Level {
diff --git a/internal/templatemanager/templatemanager.go b/internal/templatemanager/templatemanager.go
new file mode 100644
index 0000000..4bf9a49
--- /dev/null
+++ b/internal/templatemanager/templatemanager.go
@@ -0,0 +1,99 @@
+package templatemanager
+
+import (
+ "bytes"
+ "fmt"
+ "html/template"
+ "path/filepath"
+ "strings"
+)
+
+type TemplateManager struct {
+ templates map[string]templateManagerRender
+}
+
+type templateManagerRender struct {
+ Main string
+ Tmpl *template.Template
+}
+
+type TemplateManagerTemplates struct {
+ Name string
+ Files []string
+}
+
+var templateFuncMap = template.FuncMap{
+ "contains": strings.Contains,
+ "iterate": func(count uint) []uint {
+ items := make([]uint, count)
+ for i := range count {
+ items[i] = i
+ }
+ return items
+ },
+}
+
+func NewTemplateManager(templates ...TemplateManagerTemplates) (*TemplateManager, error) {
+ templateMap := make(map[string]templateManagerRender)
+
+ for _, tmplStruct := range templates {
+ tmpl := template.New("").Funcs(templateFuncMap)
+ tmpl, err := tmpl.ParseFiles(tmplStruct.Files...)
+ if err != nil {
+ return nil, err
+ }
+ templateMap[tmplStruct.Name] = templateManagerRender{
+ Main: filepath.Base(tmplStruct.Files[0]),
+ Tmpl: tmpl,
+ }
+ }
+
+ return &TemplateManager{
+ templates: templateMap,
+ }, nil
+}
+
+func (tm *TemplateManager) Render(name string, data any, files ...string) ([]byte, error) {
+ tmpl, exists := tm.templates[name]
+ if !exists {
+ return nil, fmt.Errorf("template %s is not found", name)
+ }
+
+ var err error
+ var tempTmpl *template.Template
+ if len(files) == 0 {
+ tempTmpl = tmpl.Tmpl
+ } else {
+ tempTmpl, err = tmpl.Tmpl.Clone()
+ if err != nil {
+ return nil, fmt.Errorf("couldn't clone existing template for rendering: %w", err)
+ }
+ tempTmpl, err = tempTmpl.ParseFiles(files...)
+ if err != nil {
+ return nil, fmt.Errorf("couldn't include additional files in template rendering: %w", err)
+ }
+ }
+
+ var buf bytes.Buffer
+ err = tempTmpl.ExecuteTemplate(&buf, tmpl.Main, data)
+ return buf.Bytes(), err
+}
+
+func (tm *TemplateManager) Add(name string, files ...string) error {
+ if len(files) == 0 {
+ return fmt.Errorf("you can't add template without any files")
+ }
+
+ tmpl := template.New("").Funcs(templateFuncMap)
+
+ tmpl, err := tmpl.ParseFiles(files...)
+ if err != nil {
+ return fmt.Errorf("failed to add template into manager: %w", err)
+ }
+
+ tm.templates[name] = templateManagerRender{
+ Main: filepath.Base(files[0]),
+ Tmpl: tmpl,
+ }
+ return nil
+}