Diffstat (limited to 'internal')
| -rw-r--r-- | internal/b2/client.go | 1 | ||||
| -rw-r--r-- | internal/frontmatter/parser.go | 1 | ||||
| -rw-r--r-- | internal/lightgallery/html_renderer.go | 31 | ||||
| -rw-r--r-- | internal/router/api-v1-blog-search.go | 112 | ||||
| -rw-r--r-- | internal/router/api-v1-tz.go | 37 | ||||
| -rw-r--r-- | internal/router/lang-blog-title.go | 79 | ||||
| -rw-r--r-- | internal/router/lang-blog.go | 93 | ||||
| -rw-r--r-- | internal/router/lang-map.go | 85 | ||||
| -rw-r--r-- | internal/router/root.go | 27 | ||||
| -rw-r--r-- | internal/router/tm.go | 18 | ||||
| -rw-r--r-- | internal/templatemanager/templatemanager.go | 24 |
11 files changed, 483 insertions, 25 deletions
diff --git a/internal/b2/client.go b/internal/b2/client.go index 5322b84..7b745a8 100644 --- a/internal/b2/client.go +++ b/internal/b2/client.go @@ -84,6 +84,7 @@ func (c *B2Client) Scan(prefix string) ([]*BlogPage, error) { PublishedTime: publishedTime, Thumbnail: attrs.Info["thumbnail"], Tags: strings.Split(attrs.Info["tags"], ","), + Geolocation: attrs.Info["geolocation"], }, }) } diff --git a/internal/frontmatter/parser.go b/internal/frontmatter/parser.go index 3c00a7f..1a521c0 100644 --- a/internal/frontmatter/parser.go +++ b/internal/frontmatter/parser.go @@ -16,7 +16,6 @@ type Metadata struct { Thumbnail string `yaml:"thumbnail"` Tags []string `yaml:"tags"` Geolocation string `yaml:"geolocation"` - Timezone string `yaml:"timezone"` } func ParseFrontmatter(content []byte) (metadata *Metadata, markdown []byte, err error) { diff --git a/internal/lightgallery/html_renderer.go b/internal/lightgallery/html_renderer.go index 850cd95..e20e9ec 100644 --- a/internal/lightgallery/html_renderer.go +++ b/internal/lightgallery/html_renderer.go @@ -79,22 +79,19 @@ func (r *LightGalleryHTMLRenderer) renderLightGallery(w util.BufWriter, source [ 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-[1vmax]/[0.9] text-left font-spectral text-main-dark">%s</p> - <p class="!text-[1vmax]/[0.9] text-right 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, util.EscapeHTML(captionHTML), imageUrlWithoutExt, imageUrlWithoutExt, captionHTML, 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, @@ -109,7 +106,7 @@ function createLightLibrary%s() { isMobile: () => false, slideDelay: 0, plugins: [lgZoom, lgThumbnail], - thumbWidth: 160, + thumbWidth: calculateVmin(10), thumbHeight: "10vmin", thumbMargin: 4 }); @@ -119,18 +116,8 @@ 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 diff --git a/internal/router/api-v1-blog-search.go b/internal/router/api-v1-blog-search.go new file mode 100644 index 0000000..51ace85 --- /dev/null +++ b/internal/router/api-v1-blog-search.go @@ -0,0 +1,112 @@ +package router + +import ( + "fmt" + "log/slog" + "net/url" + "regexp" + "slices" + "strings" + "time" + + "github.com/SayaAndy/saya-today-web/internal/b2" + "github.com/SayaAndy/saya-today-web/locale" + "github.com/gofiber/fiber/v2" +) + +func init() { + tm.Add("catalogue-blog-cards", "views/partials/catalogue-blog-cards.html", "views/partials/catalogue-blog-card-tags.html") +} + +func Api_V1_BlogSearch(l map[string]*locale.LocaleConfig, langs []string, b2Client *b2.B2Client) func(c *fiber.Ctx) error { + return func(c *fiber.Ctx) error { + sort := c.Query("sort") + lang := c.Query("lang") + tz := c.Query("tz") + + if !slices.Contains(langs, lang) { + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language", lang)) + } + + loc, err := time.LoadLocation(tz) + if err != nil { + loc = time.UTC + slog.Warn("unable to parse a client timezone, defaulting to UTC", slog.String("error", err.Error()), slog.String("tz", tz)) + } + + pages, err := b2Client.Scan(lang + "/") + if err != nil { + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + return c.Status(fiber.ErrInternalServerError.Code).SendString(fmt.Sprintf("failed to scan pages for '%s' lang: %v", lang, err)) + } + + encodedQuery := c.Request().URI().QueryString() + re, err := regexp.Compile(`tags\[\]=([\w]+)`) + if err != nil { + slog.Warn("failed to generate regex for tags gathering", slog.String("error", err.Error())) + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate regex for tags gathering") + } + decodedQuery, 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..b7b5d7e --- /dev/null +++ b/internal/router/lang-blog-title.go @@ -0,0 +1,79 @@ +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 { + 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..650c4b1 --- /dev/null +++ b/internal/router/lang-blog.go @@ -0,0 +1,93 @@ +package router + +import ( + "fmt" + "log/slog" + "net/url" + "regexp" + "slices" + "strings" + + "github.com/SayaAndy/saya-today-web/internal/b2" + "github.com/SayaAndy/saya-today-web/locale" + "github.com/gofiber/fiber/v2" +) + +func init() { + tm.Add("blog-catalogue", "views/layouts/general-page.html", "views/pages/blog-catalogue.html") +} + +func Lang_Blog(l map[string]*locale.LocaleConfig, langs []string, b2Client *b2.B2Client) func(c *fiber.Ctx) error { + return func(c *fiber.Ctx) error { + lang := c.Params("lang") + if !slices.Contains(langs, lang) { + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language... yet??", lang)) + } + + querySort := c.Query("sort") + if querySort == "" { + querySort = "publicationDateDesc" + } + + encodedQuery := c.Request().URI().QueryString() + re, err := regexp.Compile(`tags\[\]=([\w]+)`) + if err != nil { + slog.Warn("failed to generate regex for tags gathering", slog.String("error", err.Error())) + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate regex for tags gathering") + } + decodedQuery, 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..e94d756 --- /dev/null +++ b/internal/router/lang-map.go @@ -0,0 +1,85 @@ +package router + +import ( + "fmt" + "log/slog" + "slices" + "strconv" + "strings" + + "github.com/SayaAndy/saya-today-web/internal/b2" + "github.com/SayaAndy/saya-today-web/locale" + "github.com/gofiber/fiber/v2" +) + +func init() { + tm.Add("global-map", "views/pages/global-map.html") +} + +func Lang_Map(l map[string]*locale.LocaleConfig, langs []string, b2Client *b2.B2Client) func(c *fiber.Ctx) error { + return func(c *fiber.Ctx) error { + lang := c.Params("lang") + if !slices.Contains(langs, lang) { + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server does not support '%s' language... yet??", lang)) + } + + pages, err := b2Client.Scan(lang + "/") + status := fiber.StatusOK + if err != nil { + status = fiber.StatusPartialContent + pages = []*b2.BlogPage{} + } + + type MapMarker struct { + Title string `json:"Title"` + PageLink string `json:"PageLink"` + Lat float64 `json:"Lat"` + Long float64 `json:"Long"` + AccuracyMeters int64 `json:"AccuracyMeters"` + Thumbnail string `json:"Thumbnail"` + } + + mapMarkers := make([]*MapMarker, 0, len(pages)) + for _, page := range pages { + geolocationParts := strings.Split(page.Metadata.Geolocation, " ") + if len(geolocationParts) < 2 { + continue + } + + var x, y float64 + var areaError int64 + if len(geolocationParts) >= 2 { + x, _ = strconv.ParseFloat(geolocationParts[0], 64) + y, _ = strconv.ParseFloat(geolocationParts[1], 64) + } + if len(geolocationParts) >= 3 { + areaError, _ = strconv.ParseInt(geolocationParts[2], 10, 64) + } + + mapMarkers = append(mapMarkers, &MapMarker{ + Title: page.Metadata.Title, + PageLink: fmt.Sprintf("/%s/blog/%s", lang, page.FileName), + Lat: x, + Long: y, + AccuracyMeters: areaError, + Thumbnail: page.Metadata.Thumbnail, + }) + } + + content, err := tm.Render("global-map", fiber.Map{ + "Lang": lang, + "L": l[lang], + "MapLocationLat": 45.4507, + "MapLocationLong": 68.8319, + "MapMarkers": mapMarkers, + }) + if err != nil { + slog.Warn("failed to generate page", slog.String("page", "/"+lang+"/map"), slog.String("error", err.Error())) + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate page") + } + + return c.Type("html").Status(status).Send(content) + } +} diff --git a/internal/router/root.go b/internal/router/root.go new file mode 100644 index 0000000..f4a0692 --- /dev/null +++ b/internal/router/root.go @@ -0,0 +1,27 @@ +package router + +import ( + "log/slog" + + "github.com/SayaAndy/saya-today-web/config" + "github.com/gofiber/fiber/v2" +) + +func init() { + tm.Add("index", "views/index.html") +} + +func Root(localeCfg []config.AvailableLanguageConfig) func(c *fiber.Ctx) error { + return func(c *fiber.Ctx) error { + content, err := tm.Render("index", fiber.Map{ + "AvailableLanguages": localeCfg, + }) + if err != nil { + slog.Warn("failed to generate page", slog.String("page", "/"), slog.String("error", err.Error())) + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate page") + } + + return c.Type("html").Send(content) + } +} diff --git a/internal/router/tm.go b/internal/router/tm.go new file mode 100644 index 0000000..e8095ee --- /dev/null +++ b/internal/router/tm.go @@ -0,0 +1,18 @@ +package router + +import ( + "log/slog" + "os" + + "github.com/SayaAndy/saya-today-web/internal/templatemanager" +) + +var tm = assert(templatemanager.NewTemplateManager()) + +func assert[T any](t T, err error) T { + if err != nil { + slog.Error("fail to initialize template manager", slog.String("error", err.Error())) + os.Exit(1) + } + return t +} diff --git a/internal/templatemanager/templatemanager.go b/internal/templatemanager/templatemanager.go index 5effcb3..f89d0c8 100644 --- a/internal/templatemanager/templatemanager.go +++ b/internal/templatemanager/templatemanager.go @@ -22,7 +22,7 @@ type TemplateManagerTemplates struct { Files []string } -func NewTemplateManager(templates []TemplateManagerTemplates) (*TemplateManager, error) { +func NewTemplateManager(templates ...TemplateManagerTemplates) (*TemplateManager, error) { templateMap := make(map[string]templateManagerRender) for _, tmplStruct := range templates { @@ -37,7 +37,6 @@ func NewTemplateManager(templates []TemplateManagerTemplates) (*TemplateManager, Main: filepath.Base(tmplStruct.Files[0]), Tmpl: tmpl, } - } return &TemplateManager{ @@ -55,3 +54,24 @@ func (tm *TemplateManager) Render(name string, data interface{}) ([]byte, error) err := tmpl.Tmpl.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(template.FuncMap{ + "contains": strings.Contains, + }) + + 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 +} |