| -rw-r--r-- | internal/router/api-v1-blog-search.go | 1 | ||||
| -rw-r--r-- | internal/router/client-cache.go | 99 | ||||
| -rw-r--r-- | internal/router/lang-blog.go | 2 | ||||
| -rw-r--r-- | main.go | 11 | ||||
| -rw-r--r-- | static/favicon.ico | bin | 0 -> 5694 bytes | |||
| -rw-r--r-- | static/input.css | 4 | ||||
| -rw-r--r-- | views/pages/blog-catalogue.html | 75 | ||||
| -rw-r--r-- | views/partials/catalogue-blog-cards.html | 3 |
8 files changed, 121 insertions, 74 deletions
diff --git a/internal/router/api-v1-blog-search.go b/internal/router/api-v1-blog-search.go index a809783..e22fabe 100644 --- a/internal/router/api-v1-blog-search.go +++ b/internal/router/api-v1-blog-search.go @@ -69,6 +69,7 @@ func Api_V1_BlogSearch(l map[string]*locale.LocaleConfig, langs []string, b2Clie "ShortDescription": page.Metadata.ShortDescription, "Thumbnail": page.Metadata.Thumbnail, "Tags": page.Metadata.Tags, + "LikeCount": CCache.GetLikeCount(page.FileName), }) break } diff --git a/internal/router/client-cache.go b/internal/router/client-cache.go index 56cd595..81d7641 100644 --- a/internal/router/client-cache.go +++ b/internal/router/client-cache.go @@ -18,11 +18,14 @@ type PageLike struct { type ClientCache struct { hashMap map[string]string - mutexLikeMap map[string]*sync.Mutex - mutexHashMap map[string]*sync.Mutex - likePageMap map[string]map[string]struct{} - salt []byte - db *sql.DB + hashMapMutex sync.RWMutex + + likePageMap map[string]map[string]struct{} + pageMutexMap map[string]*sync.RWMutex + pageMutexMapMutex sync.Mutex + + salt []byte + db *sql.DB } var CCache *ClientCache @@ -40,20 +43,21 @@ func NewClientCache(db *sql.DB, salt []byte) (*ClientCache, error) { } likePageMap := make(map[string]map[string]struct{}) + pageMutexMap := make(map[string]*sync.RWMutex) for rows.Next() { - pageRef := make([]byte, 32) - userId := make([]byte, 32) + var pageRef string + var userId []byte if err = rows.Scan(&pageRef, &userId); err != nil { tx.Rollback() return nil, fmt.Errorf("fail scanning blog_likes to fill cache: %w", err) } - pageRefString := string(pageRef) userIdString := base64.RawStdEncoding.EncodeToString(userId) - if _, ok := likePageMap[pageRefString]; !ok { - likePageMap[pageRefString] = make(map[string]struct{}) + if _, ok := likePageMap[pageRef]; !ok { + likePageMap[pageRef] = make(map[string]struct{}) + pageMutexMap[pageRef] = &sync.RWMutex{} } - likePageMap[pageRefString][userIdString] = struct{}{} + likePageMap[pageRef][userIdString] = struct{}{} } if err = tx.Commit(); err != nil { @@ -62,9 +66,8 @@ func NewClientCache(db *sql.DB, salt []byte) (*ClientCache, error) { return &ClientCache{ hashMap: make(map[string]string), - mutexLikeMap: make(map[string]*sync.Mutex), - mutexHashMap: make(map[string]*sync.Mutex), likePageMap: likePageMap, + pageMutexMap: pageMutexMap, salt: salt, db: db, }, nil @@ -76,17 +79,20 @@ func (c *ClientCache) Close() error { return fmt.Errorf("fail to init transaction with db to dump cache: %w", err) } + if _, err = tx.Exec("delete from blog_likes;"); err != nil { + tx.Rollback() + return fmt.Errorf("fail to truncate table blog_likes: %w", err) + } + userIdBytes := make(map[string][]byte) sqlStatement := fmt.Sprintf(` INSERT OR IGNORE INTO blog_likes (page_ref, user_id) VALUES %s(?, ?); `, strings.Repeat("(?, ?), ", 99)) - sqlStatementVars := make([]interface{}, 0, 200) + sqlStatementVars := make([]any, 0, 200) for pageRef, userSet := range c.likePageMap { - pageRefBytes := []byte(pageRef) - for userId := range userSet { if _, ok := userIdBytes[userId]; !ok { userIdBytes[userId], err = base64.RawStdEncoding.DecodeString(userId) @@ -96,7 +102,7 @@ func (c *ClientCache) Close() error { } } - sqlStatementVars = append(sqlStatementVars, interface{}(pageRefBytes), interface{}(userIdBytes[userId])) + sqlStatementVars = append(sqlStatementVars, any(pageRef), any(userIdBytes[userId])) if len(sqlStatementVars) < 200 { continue } @@ -105,7 +111,7 @@ func (c *ClientCache) Close() error { slog.Warn("couldn't insert blog like pairs into db", slog.String("error", err.Error())) } - sqlStatementVars = make([]interface{}, 0, 200) + sqlStatementVars = make([]any, 0, 200) } } @@ -124,16 +130,16 @@ func (c *ClientCache) Close() error { } func (c *ClientCache) GetHash(id string) string { + c.hashMapMutex.RLock() if val, ok := c.hashMap[id]; ok { + c.hashMapMutex.RUnlock() slog.Debug("gave an old hash", slog.String("hash", val)) return val } + c.hashMapMutex.RUnlock() - if _, ok := c.mutexHashMap[id]; !ok { - c.mutexHashMap[id] = &sync.Mutex{} - } - c.mutexHashMap[id].Lock() - defer c.mutexHashMap[id].Unlock() + c.hashMapMutex.Lock() + defer c.hashMapMutex.Unlock() if val, ok := c.hashMap[id]; ok { slog.Debug("gave a newly generated hash", slog.String("hash", val)) @@ -145,7 +151,25 @@ func (c *ClientCache) GetHash(id string) string { return c.hashMap[id] } +func (c *ClientCache) getPageMutex(page string) *sync.RWMutex { + c.pageMutexMapMutex.Lock() + defer c.pageMutexMapMutex.Unlock() + + if mutex, ok := c.pageMutexMap[page]; ok { + return mutex + } + + c.pageMutexMap[page] = &sync.RWMutex{} + return c.pageMutexMap[page] +} + func (c *ClientCache) GetLikeStatus(id string, page string) bool { + page = strings.Clone(page) + + mutex := c.getPageMutex(page) + mutex.RLock() + defer mutex.RUnlock() + if _, ok := c.likePageMap[page]; !ok { return false } @@ -154,6 +178,12 @@ func (c *ClientCache) GetLikeStatus(id string, page string) bool { } func (c *ClientCache) GetLikeCount(page string) int { + page = strings.Clone(page) + + mutex := c.getPageMutex(page) + mutex.RLock() + defer mutex.RUnlock() + if userSet, ok := c.likePageMap[page]; ok { return len(userSet) } @@ -161,14 +191,13 @@ func (c *ClientCache) GetLikeCount(page string) int { } 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() - + page = strings.Clone(page) hash := c.GetHash(id) + mutex := c.getPageMutex(page) + mutex.Lock() + defer mutex.Unlock() + if userSet, ok := c.likePageMap[page]; ok { _, alreadyLiked = userSet[hash] c.likePageMap[page][hash] = struct{}{} @@ -181,16 +210,16 @@ func (c *ClientCache) LikeOn(id string, page string) (alreadyLiked bool) { } 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() + page = strings.Clone(page) + hash := c.GetHash(id) + + mutex := c.getPageMutex(page) + mutex.Lock() + defer mutex.Unlock() if _, ok := c.likePageMap[page]; !ok { return true } - hash := c.GetHash(id) if _, ok := c.likePageMap[page][hash]; !ok { return true } diff --git a/internal/router/lang-blog.go b/internal/router/lang-blog.go index e5220c6..dd3f079 100644 --- a/internal/router/lang-blog.go +++ b/internal/router/lang-blog.go @@ -83,7 +83,7 @@ func Lang_Blog(l map[string]*locale.LocaleConfig, langs []string, b2Client *b2.B "Title": l[lang].BlogSearch.Header, }) if err != nil { - slog.Warn("failed to generate page", slog.String("page", "/"+lang+"/blog"), slog.String("error", err.Error())) + slog.Warn("failed to generate page", slog.String("path", c.Path()), slog.String("error", err.Error())) c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8) return c.Status(fiber.ErrInternalServerError.Code).SendString("failed to generate page") } @@ -143,7 +143,14 @@ func main() { <-sigChan slog.Info("gracefully shutting down...") - app.Shutdown() - router.CCache.Close() + if err = app.Shutdown(); err != nil { + slog.Error("fail to shutdown fiber server", slog.String("error", err.Error())) + } + if err = router.CCache.Close(); err != nil { + slog.Error("fail to dump cache", slog.String("error", err.Error())) + } + if err = db.Close(); err != nil { + slog.Error("fail to close db connection", slog.String("error", err.Error())) + } db.Close() } diff --git a/static/favicon.ico b/static/favicon.ico Binary files differnew file mode 100644 index 0000000..33eb226 --- /dev/null +++ b/static/favicon.ico diff --git a/static/input.css b/static/input.css index f254970..47e7d16 100644 --- a/static/input.css +++ b/static/input.css @@ -581,6 +581,10 @@ body[data-theme~="ram"] .leaflet-tile { width: 100vw; } + .ar-lt-0\.8\:max-h-\[25\%\] { + max-height: 25%; + } + .ar-lt-0\.8\:max-h-\[80\%\] { max-height: 80%; } diff --git a/views/pages/blog-catalogue.html b/views/pages/blog-catalogue.html index 25b0685..1d0a2de 100644 --- a/views/pages/blog-catalogue.html +++ b/views/pages/blog-catalogue.html @@ -1,44 +1,47 @@ {{ define "body" }} <div class="flex flex-row ar-lt-0.8:flex-col max-h-[calc(100%-7vmax)] ar-lt-0.8:max-h-[calc(100%-25vh)] flex-1 text-[1vmax]"> <form hx-get="/api/v1/blog-search" hx-vals='{"lang": "{{ .Lang }}"}' hx-target=".blog-cards" hx-swap="innerHTML" - 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]"> - <label class="font-bold"><input type="checkbox" id="tagsAllCheckbox" onclick="selectAll();"> {{ .L.BlogSearch.ChooseAllTags }}</label> + 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%] ar-lt-0.8:max-h-[25%] flex shrink-0 flex-col ar-gt-0.8:mr-[1.2vmin]"> + <div class="flex flex-col overflow-y-auto"> + <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]"> + <label class="font-bold"><input type="checkbox" id="tagsAllCheckbox" onclick="selectAll();"> {{ .L.BlogSearch.ChooseAllTags }}</label> + </div> + {{- range .Tags }} + <div class="m-[0.4vmin]"> + <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 }} </div> - {{- range .Tags }} - <div class="m-[0.4vmin]"> - <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> + </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]"> + <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]"> + <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]"> + <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]"> + <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]"> + <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]"> + <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> - {{- end }} - </div> - </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]"> - <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]"> - <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]"> - <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]"> - <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]"> - <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]"> - <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> - </fieldset> - <div class="flex flex-row bg-background-light my-[2vmin] p-[0.4vmin] rounded-[0.4vmin] justify-items-center"> + </fieldset> + </div> + <hr class="border-t-[0.2vmax] border-dotted border-main-dark my-[0.4vmin]"> + <div class="flex flex-row grow bg-background-light my-[2vmin] p-[0.4vmin] rounded-[0.4vmin] justify-items-center"> <i onclick="defaultSearchParams(); cleanHrefParams();" class="fas fa-rotate-left flex-1 text-center text-[1.5vmax] hover:bg-background-dark cursor-pointer transition-colors duration-300"></i> <div class="w-[0.5vmin] bg-background-dark grow-0"></div> <button class="flex-1" type="submit"> diff --git a/views/partials/catalogue-blog-cards.html b/views/partials/catalogue-blog-cards.html index 62e931a..35c7ad7 100644 --- a/views/partials/catalogue-blog-cards.html +++ b/views/partials/catalogue-blog-cards.html @@ -8,6 +8,9 @@ <span class="font-extrabold">{{ .Title }}</span> <span class="font-extrabold select-none text-secondary">//</span> <span class="italic text-secondary">{{ .ActionDate }}</span> + <span class="font-extrabold select-none text-secondary">//</span> + <i class="fas fa-thumbs-up w-[1vmax] h-[1vmax]"></i> + <span class="italic text-secondary">{{ .LikeCount }}</span> </a> <p class="flex-3/12 grow-0 text-[1vmax] italic text-secondary text-right">{{ .PublishedTime }}</p> </div> |