Diffstat (limited to 'internal')
| -rw-r--r-- | internal/b2/client.go | 13 | ||||
| -rw-r--r-- | internal/lightgallery/block.go | 11 | ||||
| -rw-r--r-- | internal/lightgallery/html_renderer.go | 65 | ||||
| -rw-r--r-- | internal/lightgallery/parser.go | 35 | ||||
| -rw-r--r-- | internal/router/api-v1-blog-search.go | 109 | ||||
| -rw-r--r-- | internal/router/api-v1-tz.go | 37 | ||||
| -rw-r--r-- | internal/router/lang-blog-title.go | 76 | ||||
| -rw-r--r-- | internal/router/lang-blog.go | 90 | ||||
| -rw-r--r-- | internal/router/lang-map.go | 82 | ||||
| -rw-r--r-- | internal/router/root.go | 24 | ||||
| -rw-r--r-- | internal/tailwind/transformer.go | 26 | ||||
| -rw-r--r-- | internal/templatemanager/templatemanager.go | 57 |
12 files changed, 559 insertions, 66 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/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 bd73eac..e20e9ec 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,13 +47,13 @@ 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 @@ -53,29 +62,36 @@ func (r *LightGalleryHTMLRenderer) renderLightGallery(w util.BufWriter, source [ imageUrlWithoutExt := strings.Join(imageUrlSegments[:len(imageUrlSegments)-1], ".") imageNameParts := strings.Split(imageUrlWithoutExt, "-") + 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() { +function createLightGallery%s() { const $lgContainer = document.getElementById('lg-%s'); const inlineGallery = lightGallery($lgContainer, { container: $lgContainer, @@ -90,7 +106,7 @@ function createLightLibrary%s() { isMobile: () => false, slideDelay: 0, plugins: [lgZoom, lgThumbnail], - thumbWidth: 160, + thumbWidth: calculateVmin(10), thumbHeight: "10vmin", thumbMargin: 4 }); @@ -100,32 +116,13 @@ function createLightLibrary%s() { }, 200); } -document.addEventListener('DOMContentLoaded', createLightLibrary%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('DOMContentLoaded', createLightGallery%s); +</script>`, galleryID, galleryID, strings.Join(dynamicElements, ","), galleryID)) } return ast.WalkContinue, nil } -func escapeHTML(s string) string { - s = strings.ReplaceAll(s, "&", "&") - s = strings.ReplaceAll(s, "<", "<") - s = strings.ReplaceAll(s, ">", ">") - s = strings.ReplaceAll(s, "\"", """) - s = strings.ReplaceAll(s, "'", "'") - 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..2997407 --- /dev/null +++ b/internal/router/api-v1-blog-search.go @@ -0,0 +1,109 @@ +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/internal/templatemanager" + "github.com/SayaAndy/saya-today-web/locale" + "github.com/gofiber/fiber/v2" +) + +func Api_V1_BlogSearch(tm *templatemanager.TemplateManager, 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, err := 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, + }) + 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-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/lang-blog-title.go b/internal/router/lang-blog-title.go new file mode 100644 index 0000000..39d4252 --- /dev/null +++ b/internal/router/lang-blog-title.go @@ -0,0 +1,76 @@ +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/internal/templatemanager" + "github.com/SayaAndy/saya-today-web/locale" + "github.com/gofiber/fiber/v2" + "github.com/yuin/goldmark" +) + +func Lang_Blog_Title(tm *templatemanager.TemplateManager, l map[string]*locale.LocaleConfig, langs []string, b2Client *b2.B2Client, md goldmark.Markdown) 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)) + } + + 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..1a7cbca --- /dev/null +++ b/internal/router/lang-blog.go @@ -0,0 +1,90 @@ +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/internal/templatemanager" + "github.com/SayaAndy/saya-today-web/locale" + "github.com/gofiber/fiber/v2" +) + +func Lang_Blog(tm *templatemanager.TemplateManager, 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, err := 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 { + slog.Debug("enlist page for catalogue", slog.Any("page", page), slog.String("endpoint", "/"+lang+"/blog")) + for _, tag := range page.Metadata.Tags { + tagsMap[tag]++ + } + } + + 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("page", "/"+lang+"/blog"), 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..8cc070a --- /dev/null +++ b/internal/router/lang-map.go @@ -0,0 +1,82 @@ +package router + +import ( + "fmt" + "log/slog" + "slices" + "strconv" + "strings" + + "github.com/SayaAndy/saya-today-web/internal/b2" + "github.com/SayaAndy/saya-today-web/internal/templatemanager" + "github.com/SayaAndy/saya-today-web/locale" + "github.com/gofiber/fiber/v2" +) + +func Lang_Map(tm *templatemanager.TemplateManager, 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..f5be6cb --- /dev/null +++ b/internal/router/root.go @@ -0,0 +1,24 @@ +package router + +import ( + "log/slog" + + "github.com/SayaAndy/saya-today-web/config" + "github.com/SayaAndy/saya-today-web/internal/templatemanager" + "github.com/gofiber/fiber/v2" +) + +func Root(tm *templatemanager.TemplateManager, 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/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..5effcb3 --- /dev/null +++ b/internal/templatemanager/templatemanager.go @@ -0,0 +1,57 @@ +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 +} + +func NewTemplateManager(templates []TemplateManagerTemplates) (*TemplateManager, error) { + templateMap := make(map[string]templateManagerRender) + + for _, tmplStruct := range templates { + tmpl := template.New("").Funcs(template.FuncMap{ + "contains": strings.Contains, + }) + 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 interface{}) ([]byte, error) { + tmpl, exists := tm.templates[name] + if !exists { + return nil, fmt.Errorf("template %s is not found", name) + } + + var buf bytes.Buffer + err := tmpl.Tmpl.ExecuteTemplate(&buf, tmpl.Main, data) + return buf.Bytes(), err +} |