diff options
| author | 2025-08-26 23:41:00 +0700 | |
|---|---|---|
| committer | 2025-08-26 23:41:00 +0700 | |
| commit | b0ab2ed92870d8c651267db10d68eb695da2ec62 (patch) | |
| tree | 880b194c4d0953e486be60eca45fd7aac17ee5d8 | |
| parent | bbe41e5b06ab540a38c8c6b4e0cf719ab31b8e55 (diff) | |
| download | web-b0ab2ed92870d8c651267db10d68eb695da2ec62.tar.gz web-b0ab2ed92870d8c651267db10d68eb695da2ec62.zip | |
feat: like button (only inmem-cache for now)
| -rw-r--r-- | internal/router/api-v1-like.go | 117 | ||||
| -rw-r--r-- | internal/router/client-cache.go | 82 | ||||
| -rw-r--r-- | internal/router/lang-blog-title.go | 1 | ||||
| -rw-r--r-- | main.go | 2 | ||||
| -rw-r--r-- | static/input.css | 36 | ||||
| -rw-r--r-- | views/layouts/general-page.html | 14 | ||||
| -rw-r--r-- | views/pages/blog-catalogue.html | 2 | ||||
| -rw-r--r-- | views/pages/blog-page.html | 6 | ||||
| -rw-r--r-- | views/pages/global-map.html | 2 | ||||
| -rw-r--r-- | views/partials/blog-page-like-button.html | 6 |
10 files changed, 241 insertions, 27 deletions
diff --git a/internal/router/api-v1-like.go b/internal/router/api-v1-like.go new file mode 100644 index 0000000..1732c77 --- /dev/null +++ b/internal/router/api-v1-like.go @@ -0,0 +1,117 @@ +package router + +import ( + "fmt" + "log/slog" + "net/url" + "strconv" + "strings" + + "github.com/SayaAndy/saya-today-web/internal/b2" + "github.com/gofiber/fiber/v2" +) + +func init() { + tm.Add("blog-page-like-button", "views/partials/blog-page-like-button.html") +} + +func Api_V1_Like_Put(b2 *b2.B2Client) func(c *fiber.Ctx) error { + return func(c *fiber.Ctx) error { + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + + referer := c.Get("Referer", "") + if referer == "" { + return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is empty") + } + urlStruct, err := url.ParseRequestURI(referer) + if err != nil { + return c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("'Referer' header is invalid: %s", err.Error())) + } + + path := urlStruct.EscapedPath() + pathParts := strings.Split(strings.Trim(path, "/"), "/") + if len(pathParts) != 3 { + return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/blog/{page}'") + } + + lang, page := pathParts[0], pathParts[2] + + pageLink := lang + "/" + page + ".md" + if pages, _ := b2.Scan(pageLink); len(pages) == 0 { + return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server did not find '%s' article", pageLink)) + } + + ip := c.IP() + newLikeStatus, err := strconv.ParseBool(c.FormValue("like", "true")) + if err != nil { + return c.Status(fiber.ErrBadRequest.Code).SendString("invalid 'like' value") + } + + if newLikeStatus { + CCache.LikeOn(ip, page) + } else { + CCache.LikeOff(ip, page) + } + + slog.Debug("someone pressed the like button!", slog.String("ip", ip), slog.String("page", page), slog.String("new_like_status", fmt.Sprint(newLikeStatus))) + if c.Get("HX-Request", "false") == "true" { + content, err := tm.Render("blog-page-like-button", fiber.Map{ + "Liked": newLikeStatus, + }) + if err != nil { + slog.Warn("failed to generate div", slog.String("path", path), slog.String("error", err.Error())) + return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div") + } + c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8) + return c.Status(fiber.StatusOK).Send(content) + } + + return c.Status(fiber.StatusOK).SendString(fmt.Sprint(newLikeStatus)) + } +} + +func Api_V1_Like_Get(b2 *b2.B2Client) func(c *fiber.Ctx) error { + return func(c *fiber.Ctx) error { + c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) + + referer := c.Get("Referer", "") + if referer == "" { + return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is empty") + } + urlStruct, err := url.ParseRequestURI(referer) + if err != nil { + return c.Status(fiber.ErrBadRequest.Code).SendString(fmt.Sprintf("'Referer' header is invalid: %s", err.Error())) + } + + path := urlStruct.EscapedPath() + pathParts := strings.Split(strings.Trim(path, "/"), "/") + if len(pathParts) != 3 { + return c.Status(fiber.ErrBadRequest.Code).SendString("'Referer' header is invalid: expect format '/{lang}/blog/{page}'") + } + + lang, page := pathParts[0], pathParts[2] + + pageLink := lang + "/" + page + ".md" + if pages, _ := b2.Scan(pageLink); len(pages) == 0 { + return c.Status(fiber.ErrNotFound.Code).SendString(fmt.Sprintf("server did not find '%s' article", pageLink)) + } + + ip := c.IP() + likeStatus := CCache.GetLikeStatus(ip, page) + + slog.Debug("someone requested the like status!", slog.String("ip", ip), slog.String("page", page), slog.Bool("like_status", likeStatus)) + if c.Get("HX-Request", "false") == "true" { + content, err := tm.Render("blog-page-like-button", fiber.Map{ + "Liked": likeStatus, + }) + if err != nil { + slog.Warn("failed to generate div", slog.String("path", path), slog.String("error", err.Error())) + return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate div") + } + c.Set(fiber.HeaderContentType, fiber.MIMETextHTMLCharsetUTF8) + return c.Status(fiber.StatusOK).Send(content) + } + + return c.Status(fiber.StatusOK).SendString(fmt.Sprint(likeStatus)) + } +} diff --git a/internal/router/client-cache.go b/internal/router/client-cache.go index a4a0cc2..3104a66 100644 --- a/internal/router/client-cache.go +++ b/internal/router/client-cache.go @@ -9,40 +9,90 @@ import ( ) type ClientCache struct { - hashMap map[string][]byte - mutexMap map[string]*sync.Mutex - salt []byte + hashMap map[string]string + mutexLikeMap map[string]*sync.Mutex + mutexHashMap map[string]*sync.Mutex + likePageMap map[string]map[string]struct{} + salt []byte } var CCache *ClientCache func NewClientCache(salt []byte) *ClientCache { return &ClientCache{ - hashMap: make(map[string][]byte), - mutexMap: make(map[string]*sync.Mutex), - salt: salt, + hashMap: make(map[string]string), + mutexLikeMap: make(map[string]*sync.Mutex), + mutexHashMap: make(map[string]*sync.Mutex), + likePageMap: make(map[string]map[string]struct{}), + salt: salt, } } -func (c *ClientCache) GetHash(id string) []byte { - +func (c *ClientCache) GetHash(id string) string { if val, ok := c.hashMap[id]; ok { - slog.Debug("gave an old hash", slog.String("hash", base64.RawStdEncoding.EncodeToString(val))) + slog.Debug("gave an old hash", slog.String("hash", val)) return val } - if _, ok := c.mutexMap[id]; !ok { - c.mutexMap[id] = &sync.Mutex{} + if _, ok := c.mutexHashMap[id]; !ok { + c.mutexHashMap[id] = &sync.Mutex{} } - c.mutexMap[id].Lock() - defer c.mutexMap[id].Unlock() + c.mutexHashMap[id].Lock() + defer c.mutexHashMap[id].Unlock() if val, ok := c.hashMap[id]; ok { - slog.Debug("gave a newly generated hash", slog.String("hash", base64.RawStdEncoding.EncodeToString(val))) + slog.Debug("gave a newly generated hash", slog.String("hash", val)) return val } - c.hashMap[id] = argon2.IDKey([]byte(id), c.salt, 1, 64*1024, 4, 32) - slog.Debug("generated hash", slog.String("hash", base64.RawStdEncoding.EncodeToString(c.hashMap[id]))) + c.hashMap[id] = base64.RawStdEncoding.EncodeToString(argon2.IDKey([]byte(id), c.salt, 1, 64*1024, 4, 32)) + slog.Debug("generated hash", slog.String("hash", c.hashMap[id])) return c.hashMap[id] } + +func (c *ClientCache) GetLikeStatus(id string, page string) bool { + if _, ok := c.likePageMap[page]; !ok { + return false + } + _, ok := c.likePageMap[page][c.GetHash(id)] + return ok +} + +func (c *ClientCache) LikeOn(id string, page string) (alreadyLiked bool) { + if _, ok := c.mutexLikeMap[id]; !ok { + c.mutexLikeMap[id] = &sync.Mutex{} + } + c.mutexLikeMap[id].Lock() + defer c.mutexLikeMap[id].Unlock() + + hash := c.GetHash(id) + + if userSet, ok := c.likePageMap[page]; ok { + _, alreadyLiked = userSet[hash] + c.likePageMap[page][hash] = struct{}{} + return + } + + c.likePageMap[page] = make(map[string]struct{}) + c.likePageMap[page][hash] = struct{}{} + return +} + +func (c *ClientCache) LikeOff(id string, page string) (alreadyUnliked bool) { + if _, ok := c.mutexLikeMap[id]; !ok { + c.mutexLikeMap[id] = &sync.Mutex{} + } + c.mutexLikeMap[id].Lock() + defer c.mutexLikeMap[id].Unlock() + + if _, ok := c.likePageMap[page]; !ok { + return true + } + hash := c.GetHash(id) + if _, ok := c.likePageMap[page][hash]; !ok { + return true + } + + delete(c.likePageMap[page], hash) + return false +} diff --git a/internal/router/lang-blog-title.go b/internal/router/lang-blog-title.go index 5b80ec8..bafb0d2 100644 --- a/internal/router/lang-blog-title.go +++ b/internal/router/lang-blog-title.go @@ -24,7 +24,6 @@ func Lang_Blog_Title(l map[string]*locale.LocaleConfig, langs []string, b2Client return func(c *fiber.Ctx) error { ip := c.IP() slog.Debug("client entering blog page", slog.String("ip", ip), slog.String("page", c.Path())) - go CCache.GetHash(ip) lang := c.Params("lang") if !slices.Contains(langs, lang) { @@ -88,6 +88,8 @@ func main() { 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("/api/v1/like", router.Api_V1_Like_Get(b2Client)) + app.Put("/api/v1/like", router.Api_V1_Like_Put(b2Client)) app.Static("/", "./static") diff --git a/static/input.css b/static/input.css index 9f834a6..18910eb 100644 --- a/static/input.css +++ b/static/input.css @@ -139,10 +139,18 @@ a:hover { color: var(--main-medium-color); } +.text-main-light { + color: var(--main-light-color); +} + .text-background-dark { color: var(--background-dark-color); } +.text-background-light { + color: var(--background-dark-color); +} + .text-secondary { color: var(--secondary-color); } @@ -151,6 +159,14 @@ a:hover { background-color: var(--main-dark-color); } +.bg-main-medium { + background-color: var(--main-medium-color); +} + +.bg-main-light { + background-color: var(--main-light-color); +} + .bg-background-dark { background-color: var(--background-dark-color); } @@ -167,6 +183,26 @@ a:hover { border-color: var(--main-medium-color); } +.border-main-light { + border-color: var(--main-light-color); +} + +.border-background-dark { + border-color: var(--background-dark-color); +} + +.border-background-light { + border-color: var(--background-light-color); +} + +.border-inset { + border-style: inset; +} + +.border-outset { + border-style: outset; +} + .desktop-sidebar-custom { width: 5vw; background-size: calc(var(--squares-and-triangles-background-size) * 1vw); diff --git a/views/layouts/general-page.html b/views/layouts/general-page.html index cd19bd0..9885d66 100644 --- a/views/layouts/general-page.html +++ b/views/layouts/general-page.html @@ -8,7 +8,7 @@ <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 }} + {{ block "top-embeds" . }}{{ end }} <link href="/output.css" rel="stylesheet"> </head> @@ -66,17 +66,19 @@ <p class="text-[2vmax] font-spectral italic text-left mt-auto">{{ .Title }}</p> <p id="published-date" class="grow text-[1.5vmax] font-spectral text-secondary text-right mt-auto">{{ .PublishedDate }}</p> </div> + {{ block "header" . }}{{ end }} <hr class="border-t-4 border-dotted border-main-dark mb-[0.8vmin]"> {{ block "body" . }}{{ end }} <div class="flex flex-row mt-[0.8vmin] mb-[0.4vmin]"> - <p class="grow text-[0.8vmax]/[0.9] font-spectral italic text-right text-secondary mr-2"> + {{ block "footer" . }}{{ end }} + <p class="grow content-center text-[1vmax]/[1] font-spectral italic text-right text-secondary mr-2"> All the photos on <a href="https://saya.today/">saya.today</a> are licensed under <a href="https://creativecommons.org/licenses/by-sa/4.0/">CC BY-SA 4.0</a> by <a href="https://t.me/EarlInisMona">Saya Andy</a> © {{ .PublishedYear }} </p> - <img src="https://mirrors.creativecommons.org/presskit/icons/cc.svg" alt="" style="max-width:1.2vh; max-height:1.2vh;"> - <img src="https://mirrors.creativecommons.org/presskit/icons/by.svg" alt="" style="max-width:1.2vh; max-height:1.2vh;"> - <img src="https://mirrors.creativecommons.org/presskit/icons/sa.svg" alt="" style="max-width:1.2vh; max-height:1.2vh;"> + <img class="max-w-[1vmax] max-h-[1vmax] self-center" src="https://mirrors.creativecommons.org/presskit/icons/cc.svg" alt="CC"> + <img class="max-w-[1vmax] max-h-[1vmax] self-center" src="https://mirrors.creativecommons.org/presskit/icons/by.svg" alt="BY"> + <img class="max-w-[1vmax] max-h-[1vmax] self-center" src="https://mirrors.creativecommons.org/presskit/icons/sa.svg" alt="SA"> </div> <hr class="border-t-4 border-dotted border-main-dark mb-[0.8vmin]"> @@ -209,7 +211,7 @@ }); </script> - {{ block "footer" . }}{{ end }} + {{ block "bottom-embeds" . }}{{ end }} </body> </html> diff --git a/views/pages/blog-catalogue.html b/views/pages/blog-catalogue.html index 9fa8ea7..25b0685 100644 --- a/views/pages/blog-catalogue.html +++ b/views/pages/blog-catalogue.html @@ -56,7 +56,7 @@ </div> {{ end }} -{{ define "footer" }} +{{ define "bottom-embeds" }} <script> function selectAll() { const tagsContainers = document.querySelectorAll('form.tags-list'); diff --git a/views/pages/blog-page.html b/views/pages/blog-page.html index 755744d..83860d7 100644 --- a/views/pages/blog-page.html +++ b/views/pages/blog-page.html @@ -1,4 +1,4 @@ -{{ define "header" }} +{{ define "top-embeds" }} <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/lightgallery/2.8.3/css/lightgallery.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/lightgallery/2.8.3/css/lg-zoom.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/lightgallery/2.8.3/css/lg-thumbnail.min.css"> @@ -26,6 +26,10 @@ {{ end }} {{ define "footer" }} +<div hx-get="/api/v1/like" hx-target="this" hx-swap="outerHTML" hx-trigger="load"></div> +{{ end }} + +{{ define "bottom-embeds" }} <script src="https://f003.backblazeb2.com/file/sayana-static/libs/lightgallery/2.8.3/lightgallery.min.js"></script> <script src="https://f003.backblazeb2.com/file/sayana-static/libs/lightgallery/2.8.3/plugins/lg-zoom.min.js"></script> <script src="https://f003.backblazeb2.com/file/sayana-static/libs/lightgallery/2.8.3/plugins/lg-thumbnail.min.js"></script> diff --git a/views/pages/global-map.html b/views/pages/global-map.html index 6185076..569166f 100644 --- a/views/pages/global-map.html +++ b/views/pages/global-map.html @@ -263,7 +263,5 @@ }); </script> - {{ block "footer" . }}{{ end }} - </body> </html> diff --git a/views/partials/blog-page-like-button.html b/views/partials/blog-page-like-button.html new file mode 100644 index 0000000..50a9d7a --- /dev/null +++ b/views/partials/blog-page-like-button.html @@ -0,0 +1,6 @@ +<button + hx-put="/api/v1/like" hx-vals='{"like": {{ not .Liked }}}' hx-target="this" hx-swap="outerHTML" hx-trigger="click" + class="text-[0.8vmax]/[0.9] font-spectral text-left px-[0.4vmax] py-[0.2vmax] {{ if .Liked }}bg-main-light hover:bg-main-medium{{ else }}bg-main-dark hover:bg-main-medium{{ end }} text-background-dark cursor-pointer {{ if .Liked }}border-inset{{ else }}border-outset{{ end }} border-[0.3vmax] border-background-dark"> + <i class="fas fa-thumbs-up w-[0.8vmax] h-[0.8vmax] mr-[0.4vmax]"></i> + <span>Нраица!</span> +</button>
\ No newline at end of file |