| -rw-r--r-- | deploy/inventory.yml | 6 | ||||
| -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 | ||||
| -rw-r--r-- | locale/localization.en.yaml | 2 | ||||
| -rw-r--r-- | locale/localization.go | 5 | ||||
| -rw-r--r-- | locale/localization.ru.yaml | 2 | ||||
| -rw-r--r-- | main.go | 288 | ||||
| -rw-r--r-- | static/input.css | 87 | ||||
| -rw-r--r-- | views/layouts/general-page.html | 132 | ||||
| -rw-r--r-- | views/pages/blog-catalogue.html | 20 | ||||
| -rw-r--r-- | views/pages/blog-page.html | 10 | ||||
| -rw-r--r-- | views/pages/global-map.html | 248 | ||||
| -rw-r--r-- | views/partials/catalogue-blog-cards.html | 2 |
22 files changed, 343 insertions, 967 deletions
diff --git a/deploy/inventory.yml b/deploy/inventory.yml index dde952e..3c1220d 100644 --- a/deploy/inventory.yml +++ b/deploy/inventory.yml @@ -2,16 +2,14 @@ all: children: prod: hosts: - node_prod: - ansible_host: pl.saya.today + pl.saya.today: saya_today_web_name: sayana-web saya_today_web_hostname: sayana-web saya_today_web_ipv4_address: 172.16.0.18 stage: hosts: - node_stage: - ansible_host: pl.saya.today + pl.saya.today: saya_today_web_name: sayana-demo saya_today_web_hostname: sayana-demo saya_today_web_ipv4_address: 172.16.0.17 diff --git a/internal/b2/client.go b/internal/b2/client.go index 7b745a8..5322b84 100644 --- a/internal/b2/client.go +++ b/internal/b2/client.go @@ -84,7 +84,6 @@ 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 1a521c0..3c00a7f 100644 --- a/internal/frontmatter/parser.go +++ b/internal/frontmatter/parser.go @@ -16,6 +16,7 @@ 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 e20e9ec..850cd95 100644 --- a/internal/lightgallery/html_renderer.go +++ b/internal/lightgallery/html_renderer.go @@ -79,19 +79,22 @@ func (r *LightGalleryHTMLRenderer) renderLightGallery(w util.BufWriter, source [ downloadUrl: "https://f003.backblazeb2.com/file/sayana-photos/full/%s", alt: "%s", - 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", + sources: [{ + srcset: "https://f003.backblazeb2.com/file/sayana-photos/thumbnails/%s.webp", + media: "(max-width: 800px)" + }], thumb: - "https://f003.backblazeb2.com/file/sayana-photos/webp-320p/%s.webp", + "https://f003.backblazeb2.com/file/sayana-photos/thumbnails/%s.webp", subHtml: `+"`"+`<div class="flex flex-row light-gallery-captions"> - <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> + <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> </div>`+"`"+` - }`, img.URL, img.URL, util.EscapeHTML(captionHTML), imageUrlWithoutExt, imageUrlWithoutExt, imageUrlWithoutExt, imageUrlWithoutExt, imageUrlWithoutExt, imageUrlWithoutExt, captionHTML, dayDate.Format("2006-01-02 15:04:05 -07:00"))) + }`, img.URL, img.URL, util.EscapeHTML(captionHTML), imageUrlWithoutExt, imageUrlWithoutExt, captionHTML, dayDate.Format("2006-01-02 15:04:05 -07:00"))) } w.WriteString(fmt.Sprintf(` <script> -function createLightGallery%s() { +function createLightLibrary%s() { const $lgContainer = document.getElementById('lg-%s'); const inlineGallery = lightGallery($lgContainer, { container: $lgContainer, @@ -106,7 +109,7 @@ function createLightGallery%s() { isMobile: () => false, slideDelay: 0, plugins: [lgZoom, lgThumbnail], - thumbWidth: calculateVmin(10), + thumbWidth: 160, thumbHeight: "10vmin", thumbMargin: 4 }); @@ -116,8 +119,18 @@ function createLightGallery%s() { }, 200); } -document.addEventListener('DOMContentLoaded', createLightGallery%s); -</script>`, galleryID, galleryID, strings.Join(dynamicElements, ","), galleryID)) +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)) } return ast.WalkContinue, nil diff --git a/internal/router/api-v1-blog-search.go b/internal/router/api-v1-blog-search.go deleted file mode 100644 index 51ace85..0000000 --- a/internal/router/api-v1-blog-search.go +++ /dev/null @@ -1,112 +0,0 @@ -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 deleted file mode 100644 index 4e4e462..0000000 --- a/internal/router/api-v1-tz.go +++ /dev/null @@ -1,37 +0,0 @@ -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 deleted file mode 100644 index b7b5d7e..0000000 --- a/internal/router/lang-blog-title.go +++ /dev/null @@ -1,79 +0,0 @@ -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 deleted file mode 100644 index 650c4b1..0000000 --- a/internal/router/lang-blog.go +++ /dev/null @@ -1,93 +0,0 @@ -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 deleted file mode 100644 index e94d756..0000000 --- a/internal/router/lang-map.go +++ /dev/null @@ -1,85 +0,0 @@ -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 deleted file mode 100644 index f4a0692..0000000 --- a/internal/router/root.go +++ /dev/null @@ -1,27 +0,0 @@ -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 deleted file mode 100644 index e8095ee..0000000 --- a/internal/router/tm.go +++ /dev/null @@ -1,18 +0,0 @@ -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 f89d0c8..5effcb3 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,6 +37,7 @@ func NewTemplateManager(templates ...TemplateManagerTemplates) (*TemplateManager Main: filepath.Base(tmplStruct.Files[0]), Tmpl: tmpl, } + } return &TemplateManager{ @@ -54,24 +55,3 @@ 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 -} diff --git a/locale/localization.en.yaml b/locale/localization.en.yaml index f5ee6c9..879fce8 100644 --- a/locale/localization.en.yaml +++ b/locale/localization.en.yaml @@ -7,5 +7,3 @@ BlogSearch: ActionDateOrdered: 'Action Date' PublicationDateOrdered: 'Publication Date' ChooseAllTags: 'Choose All' -GlobalMap: - Header: 'Global Map' diff --git a/locale/localization.go b/locale/localization.go index 56b6462..d5c6b48 100644 --- a/locale/localization.go +++ b/locale/localization.go @@ -10,7 +10,6 @@ import ( type LocaleConfig struct { TagsLabel string `yaml:"TagsLabel" json:"TagsLabel"` BlogSearch BlogSearchConfig `yaml:"BlogSearch" json:"BlogSearch"` - GlobalMap GlobalMapConfig `yaml:"GlobalMap" json:"GlobalMap"` } type BlogSearchConfig struct { @@ -23,10 +22,6 @@ type BlogSearchConfig struct { ChooseAllTags string `yaml:"ChooseAllTags" json:"ChooseAllTags"` } -type GlobalMapConfig struct { - Header string `yaml:"Header" json:"Header"` -} - func LoadConfig(path string, config *LocaleConfig) error { fileBytes, err := os.ReadFile(path) if err != nil { diff --git a/locale/localization.ru.yaml b/locale/localization.ru.yaml index c236d1e..786665d 100644 --- a/locale/localization.ru.yaml +++ b/locale/localization.ru.yaml @@ -7,5 +7,3 @@ BlogSearch: ActionDateOrdered: 'дате действия' PublicationDateOrdered: 'времени публикации' ChooseAllTags: 'Выбрать все' -GlobalMap: - Header: 'Глобальная карта' @@ -1,16 +1,26 @@ package main import ( + "bytes" "flag" + "fmt" + "html/template" "log" "log/slog" + "net/url" "os" + "regexp" + "slices" + "strconv" + "strings" + "time" "github.com/SayaAndy/saya-today-web/config" "github.com/SayaAndy/saya-today-web/internal/b2" + "github.com/SayaAndy/saya-today-web/internal/frontmatter" "github.com/SayaAndy/saya-today-web/internal/lightgallery" - "github.com/SayaAndy/saya-today-web/internal/router" "github.com/SayaAndy/saya-today-web/internal/tailwind" + "github.com/SayaAndy/saya-today-web/internal/templatemanager" "github.com/SayaAndy/saya-today-web/locale" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/middleware/redirect" @@ -68,6 +78,17 @@ func main() { localization[lang.Name] = localeCfg } + tm, err := templatemanager.NewTemplateManager([]templatemanager.TemplateManagerTemplates{ + {Name: "blog-page", Files: []string{"views/layouts/general-page.html", "views/pages/blog-page.html"}}, + {Name: "blog-catalogue", Files: []string{"views/layouts/general-page.html", "views/pages/blog-catalogue.html"}}, + {Name: "index", Files: []string{"views/index.html"}}, + {Name: "catalogue-blog-cards", Files: []string{"views/partials/catalogue-blog-cards.html", "views/partials/catalogue-blog-card-tags.html"}}, + }) + if err != nil { + slog.Error("fail to initialize template manager", slog.String("error", err.Error())) + os.Exit(1) + } + app := fiber.New(fiber.Config{}) app.Use(redirect.New(redirect.Config{ @@ -78,14 +99,267 @@ func main() { StatusCode: 301, })) - app.Get("/", router.Root(cfg.AvailableLanguages)) - app.Get("/:lang/map", router.Lang_Map(localization, availableLanguages, b2Client)) - app.Get("/:lang/blog", router.Lang_Blog(localization, availableLanguages, b2Client)) - app.Get("/:lang/blog/:title", router.Lang_Blog_Title(localization, availableLanguages, b2Client, md)) - app.Get("/api/v1/tz", router.Api_V1_TZ()) - app.Get("/api/v1/blog-search", router.Api_V1_BlogSearch(localization, availableLanguages, b2Client)) + app.Get("/", func(c *fiber.Ctx) error { + content, err := tm.Render("index", fiber.Map{ + "AvailableLanguages": cfg.AvailableLanguages, + }) + 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) + }) + + app.Get("/:lang/blog", func(c *fiber.Ctx) error { + lang := c.Params("lang") + if !slices.Contains(availableLanguages, 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": localization[lang], + "PublishedYear": "2025", + "Title": localization[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) + }) + + app.Get("/:lang/blog/:title", func(c *fiber.Ctx) error { + lang := c.Params("lang") + if !slices.Contains(availableLanguages, 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(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) + }) + + app.Get("/api/v1/tz", 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)) + }) + + app.Get("/api/v1/blog-search", func(c *fiber.Ctx) error { + sort := c.Query("sort") + lang := c.Query("lang") + tz := c.Query("tz") + + if !slices.Contains(availableLanguages, 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": localization[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) + }) app.Static("/", "./static") log.Fatal(app.Listen(":3000")) } + +func readBlogPost(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/static/input.css b/static/input.css index 8c0069b..131faa9 100644 --- a/static/input.css +++ b/static/input.css @@ -1,6 +1,17 @@ @import "tailwindcss"; @theme { + --color-olive-dark: #4b513a; + --color-olive-medium: #8a9d8a; + --color-olive-light: #a5b8a5; + --color-cream-dark: #ffffdd; + --color-cream-light: #fffff0; + --color-lettuce-dark: #384a0c; + --color-lettuce-medium: #9bb665; + --color-lettuce-light: #bbd095; + --color-sunny-dark: #f5eee2; + --color-sunny-light: #fff93f; + --font-spectral: Spectral, serif; --font-patua: "Patua One", cursive; } @@ -98,16 +109,16 @@ a:hover { color: var(--main-light-color) !important; } -#map-container { +#location-map { color: var(--leaflet-text-color) !important; } -#map-container a { +#location-map a { color: var(--leaflet-link-color) !important; text-decoration: underline; } -#map-container a:hover { +#location-map a:hover { color: var(--leaflet-link-hover-color) !important; text-decoration: underline; } @@ -237,52 +248,6 @@ body:has(.lg-container:not([class~="lg-inline"])) #sidebar { -webkit-text-stroke-width: 0.1vw; } -.theme-wheel { - position: fixed; - width: 15vmax; - height: 15vmax; - border-radius: 50%; - background: var(--background-light-color); - opacity: 0.8; - border: 1vmax outset var(--background-dark-color); - box-shadow: 0 0 5vmax var(--main-light-color); - opacity: 0; - visibility: hidden; - transform: scale(0.5); - transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); - z-index: 1001; -} - -.theme-wheel.active { - opacity: 1; - visibility: visible; - transform: scale(1); -} - -.theme-icon { - position: absolute; - width: 4vmax; - height: 4vmax; - border-radius: 50%; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: all 0.3s ease; - border: 0.1vmax inset var(--background-light-color); - background: var(--interlocked-hexagons-background); - background-size: contain; -} - -.theme-icon:hover { - transform: scale(1.2); -} - -.theme-icon.active { - transform: scale(1.2); - border: 0.3vmax outset var(--main-light-color); -} - .lg-backdrop { background-color: var(--background-light-color); opacity: 0.8 !important; @@ -323,21 +288,14 @@ body:has(.lg-container:not([class~="lg-inline"])) #sidebar { } .lg-content { - top: 2.5vmax !important; - bottom: calc(10vmin + 5vmax) !important; -} - -.lg-thumb-item { - width: 10vmin !important; - height: 10vmin !important; + top: 3vmax !important; } .lg-container:not([class~="lg-inline"]) .lg-content { top: 4vmax !important; } -.lg-toolbar .lg-icon, -.lg-content .lg-icon { +.lg-toolbar .lg-icon { padding: 0 0.7vmax; font-size: 1.5vmax !important; height: 1.5vmax; @@ -345,20 +303,11 @@ body:has(.lg-container:not([class~="lg-inline"])) #sidebar { color: var(--main-dark-color); } -.lg-container:not([class~="lg-inline"]) .lg-toolbar .lg-icon, -.lg-container:not([class~="lg-inline"]) .lg-content .lg-icon { +.lg-container:not([class~="lg-inline"]) .lg-toolbar .lg-icon { font-size: 3vmax !important; height: 3vmax; } -.lg-prev { - left: 0 !important; -} - -.lg-next { - right: 0 !important; -} - .lg-toolbar .lg-icon:hover { color: var(--main-medium-color); } @@ -378,7 +327,7 @@ body:has(.lg-container:not([class~="lg-inline"])) #sidebar { .inline-gallery-container { width: 80%; - height: 70vmin; + height: 60vmin; } .light-gallery-captions { diff --git a/views/layouts/general-page.html b/views/layouts/general-page.html index cd19bd0..46ef2e6 100644 --- a/views/layouts/general-page.html +++ b/views/layouts/general-page.html @@ -44,14 +44,8 @@ <div class="text-[3vw] ar-lt-0.8:text-[5vh] text-stroke-(--sidebar-stroke-color) text-stroke-[0.1vw] flex flex-col ar-lt-0.8:flex-row gap-0 relative z-20"> <i onclick="location.href='/';" class="fas fa-home w-[5vw] ar-lt-0.8:w-[8vh] h-[5vw] ar-lt-0.8:h-[8vh] content-center text-center text-sidebar hover:bg-background-dark cursor-pointer transition-colors duration-300 flex rounded-lg"></i> <i onclick="location.href='/{{ .Lang }}/blog';" class="fas fa-search w-[5vw] ar-lt-0.8:w-[8vh] h-[5vw] ar-lt-0.8:h-[8vh] content-center text-center text-sidebar hover:bg-background-dark cursor-pointer transition-colors duration-300 flex rounded-lg"></i> - <i onclick="location.href='/{{ .Lang }}/map';" class="fas fa-map w-[5vw] ar-lt-0.8:w-[8vh] h-[5vw] ar-lt-0.8:h-[8vh] content-center text-center text-sidebar hover:bg-background-dark cursor-pointer transition-colors duration-300 flex rounded-lg"></i> - <i onclick="toggleThemeWheel();" id="theme-button" class="fas fa-swatchbook w-[5vw] ar-lt-0.8:w-[8vh] h-[5vw] ar-lt-0.8:h-[8vh] content-center text-center text-sidebar hover:bg-background-dark cursor-pointer transition-colors duration-300 flex rounded-lg"></i> - </div> - <div class="theme-wheel" id="theme-wheel"> - <div class="theme-icon" data-theme="olive"></div> - <div class="theme-icon" data-theme="lettuce"></div> - <div class="theme-icon" data-theme="night"></div> - <div class="theme-icon" data-theme="ram"></div> + <!-- <i class="fas fa-images w-[5vw] ar-lt-0.8:w-[8vh] h-[5vw] ar-lt-0.8:h-[8vh] content-center text-center text-sidebar hover:bg-background-dark cursor-pointer transition-colors duration-300 flex rounded-lg"></i> --> + <i onclick="switchTheme('')" class="fas fa-swatchbook w-[5vw] ar-lt-0.8:w-[8vh] h-[5vw] ar-lt-0.8:h-[8vh] content-center text-center text-sidebar hover:bg-background-dark cursor-pointer transition-colors duration-300 flex rounded-lg"></i> </div> </div> @@ -87,128 +81,6 @@ <script src="https://f003.backblazeb2.com/file/sayana-static/libs/htmx/2.0.6/htmx.min.js"></script> - <script> - const themeWheel = document.getElementById('theme-wheel'); - const themeButton = document.getElementById('theme-button'); - const themeIcons = document.querySelectorAll('.theme-icon'); - - themeIcons.forEach(icon => { - if (icon.getAttribute('data-theme') == savedTheme) { - icon.classList.add('active'); - } - }); - - function calculateVmin(percent) { - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; - - const smallerDimension = Math.min(viewportWidth, viewportHeight); - - const vminValue = (smallerDimension / 100) * percent; - - return vminValue; - } - - function calculateVmax(percent) { - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; - - const largerDimension = Math.max(viewportWidth, viewportHeight); - - const vmaxValue = (largerDimension / 100) * percent; - - return vmaxValue; - } - - function positionThemeIcons() { - const icons = themeWheel.querySelectorAll('.theme-icon'); - const iconCount = icons.length; - const vmax = calculateVmax(1); - const wheelRadius = 7.5*vmax; // Radius of the wheel - const iconRadius = 4*vmax; // Distance from center to place icons - const iconSize = 3*vmax; // Half of icon size for centering - - icons.forEach((icon, index) => { - const angle = (index * 360 / iconCount) - 90; - const angleRad = (angle * Math.PI) / 180; - - const x = Math.cos(angleRad) * iconRadius + wheelRadius - iconSize; - const y = Math.sin(angleRad) * iconRadius + wheelRadius - iconSize; - - icon.style.left = x + 'px'; - icon.style.top = y + 'px'; - }); - } - - function positionThemeWheel() { - const buttonRect = themeButton.getBoundingClientRect(); - const buttonCenterX = buttonRect.left + buttonRect.width / 2; - const buttonCenterY = buttonRect.top + buttonRect.height / 2; - - const wheelRadius = calculateVmax(7.5); - const margin = calculateVmax(1); - - let wheelX = buttonCenterX - wheelRadius; - let wheelY = buttonCenterY - wheelRadius; - - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; - - if (wheelX < margin) { - wheelX = margin; - } else if (wheelX + (wheelRadius * 2) > viewportWidth - margin) { - wheelX = viewportWidth - (wheelRadius * 2) - margin; - } - - if (wheelY < margin) { - wheelY = margin; - } else if (wheelY + (wheelRadius * 2) > viewportHeight - margin) { - wheelY = viewportHeight - (wheelRadius * 2) - margin; - } - - themeWheel.style.left = wheelX + 'px'; - themeWheel.style.top = wheelY + 'px'; - } - - function hideThemeWheel() { - themeWheel.classList.remove('active'); - } - - function showThemeWheel() { - positionThemeWheel(); - positionThemeIcons(); - themeWheel.classList.add('active'); - } - - function toggleThemeWheel() { - if (themeWheel.classList.contains('active')) { - hideThemeWheel(); - } else { - showThemeWheel(); - } - } - - themeIcons.forEach(icon => { - icon.addEventListener('click', (e) => { - e.stopPropagation(); - const theme = icon.getAttribute('data-theme'); - switchTheme(theme); - themeIcons.forEach(icon => { - icon.classList.remove('active'); - }) - icon.classList.add('active'); - }); - }); - - themeWheel.addEventListener('mouseleave', hideThemeWheel); - - document.addEventListener('click', (e) => { - if (!themeWheel.contains(e.target) && e.target !== themeButton) { - hideThemeWheel(); - } - }); - </script> - {{ block "footer" . }}{{ end }} </body> diff --git a/views/pages/blog-catalogue.html b/views/pages/blog-catalogue.html index 9fa8ea7..2b248c3 100644 --- a/views/pages/blog-catalogue.html +++ b/views/pages/blog-catalogue.html @@ -4,12 +4,12 @@ class="tags-list ar-gt-0.8:min-w-[10vh] ar-gt-0.8:max-w-[20vh] ar-gt-0.8:w-fit ar-lt-0.8:w-[100%] flex shrink-0 flex-col overflow-y-auto ar-gt-0.8:mr-[1.2vmin]"> <fieldset> <legend class="font-bold w-[100%] text-center">{{ .L.BlogSearch.TagsHeader }}</legend> - <div class="flex flex-col ar-lt-0.8:grid grid-cols-5 grid-flow-row-dense"> - <div class="m-[0.4vmin]"> + <div class="flex flex-col ar-lt-0.8:grid grid-cols-5 grid-flow-row-dense gap-[5%]"> + <div> <label class="font-bold"><input type="checkbox" id="tagsAllCheckbox" onclick="selectAll();"> {{ .L.BlogSearch.ChooseAllTags }}</label> </div> {{- range .Tags }} - <div class="m-[0.4vmin]"> + <div> <label><input type="checkbox" id="tag{{ .Name }}Checkbox" name="tags[]" value="{{ .Name }}" onclick="contextAll();" {{ if contains $.QueryTags .Name }}checked{{ end }}> {{ .Name }} <span class="text-secondary italic">{{ .Count }}</span></label> </div> {{- end }} @@ -17,23 +17,23 @@ </fieldset> <fieldset class="mt-[0.8vmin]"> <legend class="font-bold w-[100%] text-center">{{ .L.BlogSearch.OrderByHeader }}</legend> - <div class="flex flex-col ar-lt-0.8:grid grid-cols-3 grid-rows-2 grid-flow-col"> - <div class="m-[0.4vmin]"> + <div class="flex flex-col ar-lt-0.8:grid grid-cols-3 grid-rows-2 grid-flow-col gap-[5%]"> + <div> <label><input type="radio" id="sortTitleAsc" name="sort" value="titleAsc" {{ if eq .QuerySort "titleAsc" }}checked{{ end }}> {{ .L.BlogSearch.TitleOrdered }} <i class="fas fa-arrow-down-a-z"></i></label> </div> - <div class="m-[0.4vmin]"> + <div> <label><input type="radio" id="sortTitleDesc" name="sort" value="titleDesc" {{ if eq .QuerySort "titleDesc" }}checked{{ end }}> {{ .L.BlogSearch.TitleOrdered }} <i class="fas fa-arrow-down-z-a"></i></label> </div> - <div class="m-[0.4vmin]"> + <div> <label><input type="radio" id="sortActionDateAsc" name="sort" value="actionDateAsc" {{ if eq .QuerySort "actionDateAsc" }}checked{{ end }}> {{ .L.BlogSearch.ActionDateOrdered }} <i class="fas fa-arrow-down-1-9"></i></label> </div> - <div class="m-[0.4vmin]"> + <div> <label><input type="radio" id="sortActionDateDesc" name="sort" value="actionDateDesc" {{ if eq .QuerySort "actionDateDesc" }}checked{{ end }}> {{ .L.BlogSearch.ActionDateOrdered }} <i class="fas fa-arrow-down-9-1"></i></label> </div> - <div class="m-[0.4vmin]"> + <div> <label><input type="radio" id="sortPublicationDateAsc" name="sort" value="publicationDateAsc" {{ if eq .QuerySort "publicationDateAsc" }}checked{{ end }}> {{ .L.BlogSearch.PublicationDateOrdered }} <i class="fas fa-arrow-down-1-9"></i></label> </div> - <div class="m-[0.4vmin]"> + <div> <label><input type="radio" id="sortPublicationDateDesc" name="sort" value="publicationDateDesc" {{ if eq .QuerySort "publicationDateDesc" }}checked{{ end }}> {{ .L.BlogSearch.PublicationDateOrdered }} <i class="fas fa-arrow-down-9-1"></i></label> </div> </div> diff --git a/views/pages/blog-page.html b/views/pages/blog-page.html index 755744d..330a1ac 100644 --- a/views/pages/blog-page.html +++ b/views/pages/blog-page.html @@ -19,7 +19,7 @@ {{ .ParsedMarkdown }} {{- if .MapLocationX }} <hr class="border-t-4 border-dotted border-main-dark mt-[0.8vmin] mb-[0.8vmin] w-[80%] ml-auto mr-auto"> - <div id="map-container" class="relative p-[0.8vmin] flex-none ml-auto mr-auto w-[60%] ar-lt-0.8:w-[80%] h-[40vh] ar-lt-0.8:h-[30vh]"></div> + <div id="location-map" class="relative p-[0.8vmin] flex-none ml-auto mr-auto w-[60%] ar-lt-0.8:w-[80%] h-[40vh] ar-lt-0.8:h-[30vh]"></div> <hr class="border-t-4 border-dotted border-main-dark mt-[0.8vmin] mb-[0.8vmin] w-[80%] ml-auto mr-auto"> {{- end }} </div> @@ -33,10 +33,8 @@ <script src="https://f003.backblazeb2.com/file/sayana-static/libs/leaflet/1.9.4/leaflet.js"></script> <script> - var map; - function initLocationMap(id, x, y, relativeErrorMeters = 0) { - map = L.map(id).setView([x, y], 8); + var map = L.map(id).setView([x, y], 8); L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 18, @@ -60,7 +58,7 @@ } document.addEventListener('DOMContentLoaded', () => {initLocationMap( - 'map-container', + 'location-map', {{ .MapLocationX }}, {{ .MapLocationY }}, {{ .MapLocationAreaMeters }} @@ -68,7 +66,7 @@ window.addEventListener('resize', function() { setTimeout(() => { - map.invalidateSize(); + if (window.location-map) window.location-map.invalidateSize(); }, 100); }); </script> diff --git a/views/pages/global-map.html b/views/pages/global-map.html deleted file mode 100644 index 05c39a1..0000000 --- a/views/pages/global-map.html +++ /dev/null @@ -1,248 +0,0 @@ -<!DOCTYPE html> -<html lang="en"> -<head> - <meta charset="UTF-8"> - <meta name="viewport" content="width=device-width, initial-scale=1.0"> - <title>SAYA TODAY // {{ .L.GlobalMap.Header }}</title> - <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css"> - <link rel="preconnect" href="https://fonts.googleapis.com"> - <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> - <link href="https://fonts.googleapis.com/css2?family=Spectral:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;0,800;1,200;1,300;1,400;1,500;1,600;1,700;1,800&family=Patua+One&display=swap" rel="stylesheet"> - {{ block "header" . }}{{ end }} - <link href="/output.css" rel="stylesheet"> - <link rel="stylesheet" href="https://f003.backblazeb2.com/file/sayana-static/libs/leaflet/1.9.4/leaflet.css"> - <link rel="stylesheet" href="https://f003.backblazeb2.com/file/sayana-static/libs/leaflet.markercluster/1.4.1/MarkerCluster.css"> - <link rel="stylesheet" href="https://f003.backblazeb2.com/file/sayana-static/libs/leaflet.markercluster/1.4.1/MarkerCluster.Default.css"> -</head> - -<body data-theme="ram" class="font-spectral text-main-dark overflow-hidden bg-interlocked-hexagons h-screen flex flex-row ar-lt-0.8:flex-col"> - - <script> - function switchTheme(theme) { - if (theme == "" || typeof theme == "undefined") { - const currentTheme = document.body.getAttribute('data-theme'); - switch (currentTheme) { - case "olive": theme = "lettuce"; break; - case "lettuce": theme = "night"; break; - case "night": theme = "ram"; break; - case "ram": theme = "olive"; break; - } - } - localStorage.setItem('theme', theme); - document.body.setAttribute('data-theme', theme); - } - - const savedTheme = localStorage.getItem('theme') || 'ram'; - switchTheme(savedTheme); - </script> - - <script> - const clientTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; - </script> - - <div id="sidebar" class="bg-sidebar flex sticky flex-col ar-gt-0.8:items-center shadow-2xl z-20 w-[5vw] ar-lt-0.8:w-screen h-screen ar-lt-0.8:h-[8vh]"> - <div class="logo-custom ar-lt-0.8:hidden text-sidebar font-patua font-extrabold text-center relative z-20"> - saya.today - </div> - <div class="text-[3vw] ar-lt-0.8:text-[5vh] text-stroke-(--sidebar-stroke-color) text-stroke-[0.1vw] flex flex-col ar-lt-0.8:flex-row gap-0 relative z-20"> - <i onclick="location.href='/';" class="fas fa-home w-[5vw] ar-lt-0.8:w-[8vh] h-[5vw] ar-lt-0.8:h-[8vh] content-center text-center text-sidebar hover:bg-background-dark cursor-pointer transition-colors duration-300 flex rounded-lg"></i> - <i onclick="location.href='/{{ .Lang }}/blog';" class="fas fa-search w-[5vw] ar-lt-0.8:w-[8vh] h-[5vw] ar-lt-0.8:h-[8vh] content-center text-center text-sidebar hover:bg-background-dark cursor-pointer transition-colors duration-300 flex rounded-lg"></i> - <i onclick="location.href='/{{ .Lang }}/map';" class="fas fa-map w-[5vw] ar-lt-0.8:w-[8vh] h-[5vw] ar-lt-0.8:h-[8vh] content-center text-center text-sidebar hover:bg-background-dark cursor-pointer transition-colors duration-300 flex rounded-lg"></i> - <i onclick="toggleThemeWheel();" id="theme-button" class="fas fa-swatchbook w-[5vw] ar-lt-0.8:w-[8vh] h-[5vw] ar-lt-0.8:h-[8vh] content-center text-center text-sidebar hover:bg-background-dark cursor-pointer transition-colors duration-300 flex rounded-lg"></i> - </div> - <div class="theme-wheel" id="theme-wheel"> - <div class="theme-icon" data-theme="olive"></div> - <div class="theme-icon" data-theme="lettuce"></div> - <div class="theme-icon" data-theme="night"></div> - <div class="theme-icon" data-theme="ram"></div> - </div> - </div> - - <div id="map-container" class="bg-background-dark flex z-10 w-[95vw] ar-lt-0.8:w-screen h-screen ar-lt-0.8:h-[92vh]"></div> - - <script src="https://f003.backblazeb2.com/file/sayana-static/libs/htmx/2.0.6/htmx.min.js"></script> - <script src="https://f003.backblazeb2.com/file/sayana-static/libs/leaflet/1.9.4/leaflet.js"></script> - <script src="https://f003.backblazeb2.com/file/sayana-static/libs/leaflet.markercluster/1.4.1/leaflet.markercluster.js"></script> - - <script> - const themeWheel = document.getElementById('theme-wheel'); - const themeButton = document.getElementById('theme-button'); - const themeIcons = document.querySelectorAll('.theme-icon'); - - themeIcons.forEach(icon => { - if (icon.getAttribute('data-theme') == savedTheme) { - icon.classList.add('active'); - } - }); - - function calculateVmax(percent) { - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; - - const largerDimension = Math.max(viewportWidth, viewportHeight); - - const vmaxValue = (largerDimension / 100) * percent; - - return vmaxValue; - } - - function positionThemeIcons() { - const icons = themeWheel.querySelectorAll('.theme-icon'); - const iconCount = icons.length; - const vmax = calculateVmax(1); - const wheelRadius = 7.5*vmax; // Radius of the wheel - const iconRadius = 4*vmax; // Distance from center to place icons - const iconSize = 3*vmax; // Half of icon size for centering - - icons.forEach((icon, index) => { - const angle = (index * 360 / iconCount) - 90; - const angleRad = (angle * Math.PI) / 180; - - const x = Math.cos(angleRad) * iconRadius + wheelRadius - iconSize; - const y = Math.sin(angleRad) * iconRadius + wheelRadius - iconSize; - - icon.style.left = x + 'px'; - icon.style.top = y + 'px'; - }); - } - - function positionThemeWheel() { - const buttonRect = themeButton.getBoundingClientRect(); - const buttonCenterX = buttonRect.left + buttonRect.width / 2; - const buttonCenterY = buttonRect.top + buttonRect.height / 2; - - const wheelRadius = calculateVmax(7.5); - const margin = calculateVmax(1); - - let wheelX = buttonCenterX - wheelRadius; - let wheelY = buttonCenterY - wheelRadius; - - const viewportWidth = window.innerWidth; - const viewportHeight = window.innerHeight; - - if (wheelX < margin) { - wheelX = margin; - } else if (wheelX + (wheelRadius * 2) > viewportWidth - margin) { - wheelX = viewportWidth - (wheelRadius * 2) - margin; - } - - if (wheelY < margin) { - wheelY = margin; - } else if (wheelY + (wheelRadius * 2) > viewportHeight - margin) { - wheelY = viewportHeight - (wheelRadius * 2) - margin; - } - - themeWheel.style.left = wheelX + 'px'; - themeWheel.style.top = wheelY + 'px'; - } - - function hideThemeWheel() { - themeWheel.classList.remove('active'); - } - - function showThemeWheel() { - positionThemeWheel(); - positionThemeIcons(); - themeWheel.classList.add('active'); - } - - function toggleThemeWheel() { - if (themeWheel.classList.contains('active')) { - hideThemeWheel(); - } else { - showThemeWheel(); - } - } - - themeIcons.forEach(icon => { - icon.addEventListener('click', (e) => { - e.stopPropagation(); - const theme = icon.getAttribute('data-theme'); - switchTheme(theme); - themeIcons.forEach(icon => { - icon.classList.remove('active'); - }) - icon.classList.add('active'); - }); - }); - - themeWheel.addEventListener('mouseleave', hideThemeWheel); - - document.addEventListener('click', (e) => { - if (!themeWheel.contains(e.target) && e.target !== themeButton) { - hideThemeWheel(); - } - }); - </script> - - <script> - var map; - var markers = L.markerClusterGroup(); - - function initLocationMap() { - map = L.map('map-container').setView([{{ .MapLocationLat }}, {{ .MapLocationLong }}], 4); - - L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { - maxZoom: 18, - attribution: '© <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>' - }).addTo(map); - - map.addLayer(markers); - } - - function stringToColor(str) { - let hash = 0; - for (let i = 0; i < str.length; i++) { - hash = str.charCodeAt(i) + ((hash << 5) - hash); - } - - let color = '#'; - for (let i = 0; i < 3; i++) { - const value = (hash >> (i * 8)) & 0xFF; - color += ('00' + value.toString(16)).substr(-2); - } - - return color; - } - - function mapAddMarker(x, y, relativeErrorMeters = 0, title = "", link = "", thumbnail = "") { - const titleColor = stringToColor(title); - - if (relativeErrorMeters != 0) { - var circle = L.circle([x, y], { - color: titleColor, - fillColor: titleColor, - fillOpacity: 0.15, - radius: relativeErrorMeters - }).addTo(map); - } - - var marker = L.marker([x, y], { - title: title, - }).bindPopup(` - <div class="flex flex-row justify-center justify-items-center"> - <img onclick="location.href='${link}';" class="cursor-pointer object-cover rounded-[10%] select-none w-[3vmax] h-[3vmax] mr-[0.5vmax]" src="https://f003.backblazeb2.com/file/sayana-photos/webp-320p/${thumbnail}.webp"> - <span><b><a href="${link}">${title}</a></b><br><a href="https://www.openstreetmap.org/#map=13/${x}/${y}">${x}, ${y}</a></span> - </div>`); - - markers.addLayer(marker); - } - - document.addEventListener('DOMContentLoaded', () => { - initLocationMap(); - {{- range .MapMarkers }} - mapAddMarker({{ .Lat }}, {{ .Long }}, {{ .AccuracyMeters }}, {{ .Title }}, {{ .PageLink }}, {{ .Thumbnail }}); - {{- end }} - }); - - window.addEventListener('resize', function() { - setTimeout(() => { - map.invalidateSize(); - }, 100); - }); - </script> - - {{ block "footer" . }}{{ end }} - -</body> -</html> diff --git a/views/partials/catalogue-blog-cards.html b/views/partials/catalogue-blog-cards.html index 62e931a..406ef9b 100644 --- a/views/partials/catalogue-blog-cards.html +++ b/views/partials/catalogue-blog-cards.html @@ -1,7 +1,7 @@ {{- range .BlogPages }} <hr class="first-of-type:hidden border-t-[0.3vh] my-[0.8vmin] border-dotted border-main-dark w-[80%] mx-auto"> <div class="m-[0.4vmin] flex flex-row justify-center justify-items-center"> - <img onclick="location.href='{{ .ArticleLink }}';" class="cursor-pointer object-cover rounded-[10%] select-none w-[10vmin] h-[10vmin] aspect-square my-[0.4vmin] mr-[0.8vmin]" src="https://f003.backblazeb2.com/file/sayana-photos/webp-320p/{{ .Thumbnail }}.webp"> + <img onclick="location.href='{{ .ArticleLink }}';" class="cursor-pointer object-cover rounded-[10%] select-none w-[10vmin] h-[10vmin] aspect-square my-[0.4vmin] mr-[0.8vmin]" src="https://f003.backblazeb2.com/file/sayana-photos/thumbnails/{{ .Thumbnail }}.webp"> <div class="grow my-[0.4vmin] mx-[0.8vmin] flex flex-col justify-center"> <div class="flex flex-row gap-[5%] max-w-[100%]"> <a href="{{ .ArticleLink }}" class="flex-9/12 grow-0 block text-[1vmax] cursor-pointer"> |