summaryrefslogtreecommitdiff
diff options
from:
to:
context:
space:
mode:
authorGravatar SayaAndy <saya.andy@posteo.com> 2025-09-10 13:55:30 +0700
committerGravatar SayaAndy <saya.andy@posteo.com> 2025-09-10 13:55:30 +0700
commitf9a621d56c54110d5938ad0071bbace325773070 (patch)
tree13709c592639b13838eb072b31f743382d0a2712
parentf219d4ac0e8d8f6f9dd7cd9be9e8038939863233 (diff)
downloadweb-f9a621d56c54110d5938ad0071bbace325773070.tar.gz
web-f9a621d56c54110d5938ad0071bbace325773070.zip
feat: add page views
feat: more responsive header size to keep it in one row refactor: move like and view count into separate row in blog card
-rw-r--r--internal/router/api-v1-blog-search.go1
-rw-r--r--internal/router/api-v1-general-page-body.go2
-rw-r--r--internal/router/client-cache.go164
-rw-r--r--views/partials/catalogue-blog-cards.html11
-rw-r--r--views/partials/general-page-header.html6
5 files changed, 135 insertions, 49 deletions
diff --git a/internal/router/api-v1-blog-search.go b/internal/router/api-v1-blog-search.go
index 946b830..69be651 100644
--- a/internal/router/api-v1-blog-search.go
+++ b/internal/router/api-v1-blog-search.go
@@ -79,6 +79,7 @@ func Api_V1_BlogSearch(l map[string]*locale.LocaleConfig, langs []string, b2Clie
"Thumbnail": page.Metadata.Thumbnail,
"Tags": page.Metadata.Tags,
"LikeCount": CCache.GetLikeCount(page.FileName),
+ "ViewCount": CCache.GetViewCount(page.FileName),
})
break
}
diff --git a/internal/router/api-v1-general-page-body.go b/internal/router/api-v1-general-page-body.go
index f27e0c0..b2fa268 100644
--- a/internal/router/api-v1-general-page-body.go
+++ b/internal/router/api-v1-general-page-body.go
@@ -138,6 +138,8 @@ func Api_V1_GeneralPage_Body(l map[string]*locale.LocaleConfig, langs []string,
values["ParsedMarkdown"] = template.HTML(parsedMarkdown)
additionalTemplates = append(additionalTemplates, "views/pages/blog-page.html")
+
+ go CCache.View(c.IP(), pathParts[2])
} else if len(pathParts) == 1 {
values["Title"] = l[lang].HomePage.Header
values["FilledHeartCount"] = uint(40)
diff --git a/internal/router/client-cache.go b/internal/router/client-cache.go
index e448fb3..d607cdd 100644
--- a/internal/router/client-cache.go
+++ b/internal/router/client-cache.go
@@ -16,6 +16,7 @@ type ClientCache struct {
hashMapMutex sync.RWMutex
likePageMap map[string]map[string]struct{}
+ viewPageMap map[string]map[string]struct{}
pageMutexMap map[string]*sync.RWMutex
pageMutexMapMutex sync.Mutex
@@ -38,6 +39,7 @@ func NewClientCache(db *sql.DB, salt []byte) (*ClientCache, error) {
}
likePageMap := make(map[string]map[string]struct{})
+ viewPageMap := make(map[string]map[string]struct{})
pageMutexMap := make(map[string]*sync.RWMutex)
for rows.Next() {
@@ -50,9 +52,32 @@ func NewClientCache(db *sql.DB, salt []byte) (*ClientCache, error) {
userIdString := base64.RawStdEncoding.EncodeToString(userId)
if _, ok := likePageMap[pageRef]; !ok {
likePageMap[pageRef] = make(map[string]struct{})
+ viewPageMap[pageRef] = make(map[string]struct{})
pageMutexMap[pageRef] = &sync.RWMutex{}
}
likePageMap[pageRef][userIdString] = struct{}{}
+ viewPageMap[pageRef][userIdString] = struct{}{}
+ }
+
+ rows, err = tx.Query("select * from blog_views;")
+ if err != nil {
+ tx.Rollback()
+ return nil, fmt.Errorf("fail to query db for blog_views to fill cache: %w", err)
+ }
+
+ for rows.Next() {
+ var pageRef string
+ var userId []byte
+ if err = rows.Scan(&pageRef, &userId); err != nil {
+ tx.Rollback()
+ return nil, fmt.Errorf("fail scanning blog_views to fill cache: %w", err)
+ }
+ userIdString := base64.RawStdEncoding.EncodeToString(userId)
+ if _, ok := viewPageMap[pageRef]; !ok {
+ viewPageMap[pageRef] = make(map[string]struct{})
+ pageMutexMap[pageRef] = &sync.RWMutex{}
+ }
+ viewPageMap[pageRef][userIdString] = struct{}{}
}
if err = tx.Commit(); err != nil {
@@ -62,6 +87,7 @@ func NewClientCache(db *sql.DB, salt []byte) (*ClientCache, error) {
return &ClientCache{
hashMap: make(map[string]string),
likePageMap: likePageMap,
+ viewPageMap: viewPageMap,
pageMutexMap: pageMutexMap,
salt: salt,
db: db,
@@ -74,51 +100,14 @@ 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 {
+ if err = batchSave(tx, "blog_likes", c.likePageMap); 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([]any, 0, 200)
-
- for pageRef, userSet := range c.likePageMap {
- for userId := range userSet {
- if _, ok := userIdBytes[userId]; !ok {
- userIdBytes[userId], err = base64.RawStdEncoding.DecodeString(userId)
- if err != nil {
- slog.Warn("couldn't parse one of user hashes into bytes back", slog.String("hash", userId), slog.String("error", err.Error()))
- continue
- }
- }
-
- sqlStatementVars = append(sqlStatementVars, any(pageRef), any(userIdBytes[userId]))
- if len(sqlStatementVars) < 200 {
- continue
- }
-
- if _, err := tx.Exec(sqlStatement, sqlStatementVars...); err != nil {
- slog.Warn("couldn't insert blog like pairs into db", slog.String("error", err.Error()))
- }
-
- sqlStatementVars = make([]any, 0, 200)
- }
+ return fmt.Errorf("fail to save blog_likes: %s", err)
}
- if len(sqlStatementVars) > 0 {
- sqlStatement = fmt.Sprintf(`
- INSERT OR IGNORE INTO blog_likes (page_ref, user_id)
- VALUES %s(?, ?);
- `, strings.Repeat("(?, ?), ", len(sqlStatementVars)/2-1))
-
- if _, err := tx.Exec(sqlStatement, sqlStatementVars...); err != nil {
- slog.Warn("couldn't insert blog like pairs into db", slog.String("error", err.Error()))
- }
+ if err = batchSave(tx, "blog_views", c.viewPageMap); err != nil {
+ tx.Rollback()
+ return fmt.Errorf("fail to save blog_views: %s", err)
}
return tx.Commit()
@@ -222,3 +211,94 @@ func (c *ClientCache) LikeOff(id string, page string) (alreadyUnliked bool) {
delete(c.likePageMap[page], hash)
return false
}
+
+func (c *ClientCache) GetViewStatus(id string, page string) bool {
+ page = strings.Clone(page)
+
+ mutex := c.getPageMutex(page)
+ mutex.RLock()
+ defer mutex.RUnlock()
+
+ if _, ok := c.viewPageMap[page]; !ok {
+ return false
+ }
+ _, ok := c.viewPageMap[page][c.GetHash(id)]
+ return ok
+}
+
+func (c *ClientCache) GetViewCount(page string) int {
+ page = strings.Clone(page)
+
+ mutex := c.getPageMutex(page)
+ mutex.RLock()
+ defer mutex.RUnlock()
+
+ if userSet, ok := c.viewPageMap[page]; ok {
+ return len(userSet)
+ }
+ return 0
+}
+
+func (c *ClientCache) View(id string, page string) {
+ page = strings.Clone(page)
+ hash := c.GetHash(id)
+
+ mutex := c.getPageMutex(page)
+ mutex.Lock()
+ defer mutex.Unlock()
+
+ if _, ok := c.viewPageMap[page]; !ok {
+ c.viewPageMap[page] = make(map[string]struct{})
+ }
+ c.viewPageMap[page][hash] = struct{}{}
+}
+
+func batchSave(tx *sql.Tx, table string, pageMap map[string]map[string]struct{}) (err error) {
+ if _, err = tx.Exec(fmt.Sprintf("delete from %s;", table)); err != nil {
+ return fmt.Errorf("fail to truncate table %s: %w", table, err)
+ }
+
+ userIdBytes := make(map[string][]byte)
+
+ sqlStatement := fmt.Sprintf(`
+ INSERT OR IGNORE INTO %s (page_ref, user_id)
+ VALUES %s(?, ?);
+ `, table, strings.Repeat("(?, ?), ", 99))
+ sqlStatementVars := make([]any, 0, 200)
+
+ for pageRef, userSet := range pageMap {
+ for userId := range userSet {
+ if _, ok := userIdBytes[userId]; !ok {
+ userIdBytes[userId], err = base64.RawStdEncoding.DecodeString(userId)
+ if err != nil {
+ slog.Warn("couldn't parse one of user hashes into bytes back", slog.String("hash", userId), slog.String("error", err.Error()))
+ continue
+ }
+ }
+
+ sqlStatementVars = append(sqlStatementVars, any(pageRef), any(userIdBytes[userId]))
+ if len(sqlStatementVars) < 200 {
+ continue
+ }
+
+ if _, err := tx.Exec(sqlStatement, sqlStatementVars...); err != nil {
+ slog.Warn("couldn't insert blog stat pairs into db", slog.String("table", table), slog.String("error", err.Error()))
+ }
+
+ sqlStatementVars = make([]any, 0, 200)
+ }
+ }
+
+ if len(sqlStatementVars) > 0 {
+ sqlStatement = fmt.Sprintf(`
+ INSERT OR IGNORE INTO %s (page_ref, user_id)
+ VALUES %s(?, ?);
+ `, table, strings.Repeat("(?, ?), ", len(sqlStatementVars)/2-1))
+
+ if _, err := tx.Exec(sqlStatement, sqlStatementVars...); err != nil {
+ slog.Warn("couldn't insert blog stat pairs into db", slog.String("table", table), slog.String("error", err.Error()))
+ }
+ }
+
+ return nil
+}
diff --git a/views/partials/catalogue-blog-cards.html b/views/partials/catalogue-blog-cards.html
index 06d99d5..37b3f57 100644
--- a/views/partials/catalogue-blog-cards.html
+++ b/views/partials/catalogue-blog-cards.html
@@ -8,14 +8,17 @@
<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>
- <div hx-trigger="click from:#user_search_bar" hx-get="/endpoint1" hx-target="#users-list"></div>
<p class="flex-3/12 grow-0 text-[1vmax] italic text-secondary text-right">{{ .PublishedTime }}</p>
</div>
<p class="text-[1vmax] text-main-dark">{{ .ShortDescription }}</p>
+ <p class="text-[1vmax] text-secondary italic">
+ <i class="fas fa-thumbs-up w-[1vmax] h-[1vmax]"></i>
+ <span>{{ .LikeCount }}</span>
+ <span class="font-extrabold select-none">//</span>
+ <i class="fas fa-eye w-[1vmax] h-[1vmax]"></i>
+ <span>{{ .ViewCount }}</span>
+ </p>
<p class="text-[1vmax] text-secondary">{{ $.L.TagsLabel }} {{ template "catalogue-blog-card-tags.html" . }}</p>
</div>
</div>
diff --git a/views/partials/general-page-header.html b/views/partials/general-page-header.html
index 63803bd..6cb87b7 100644
--- a/views/partials/general-page-header.html
+++ b/views/partials/general-page-header.html
@@ -1,8 +1,8 @@
<div {{ if .PublishedDate }} hx-get="/api/v1/tz" hx-vals='js:{timestamp: "{{ .PublishedDate }}", tz: clientTimeZone}' hx-target="#published-date" hx-swap="innerHTML" hx-trigger="load" {{ end }}
class="flex flex-row mb-[0.4vmin]">
<i onclick="changeUrl('/../', true);" class="fas fa-circle-left hover:bg-main-dark hover:bg-opacity-10 cursor-pointer transition-colors duration-300 rounded-md text-[2vmax] mt-auto mb-auto px-[0.8vmin]"></i>
- <p class="text-[2vmax] font-spectral italic font-extrabold select-none mt-auto pl-[0.8vmin] pr-[1.2vmin]">//</p>
- <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>
+ <p class="text-[2.5vmin] font-spectral italic font-extrabold select-none mt-auto pl-[0.8vmin] pr-[1.2vmin]">//</p>
+ <p class="text-[2.5vmin] font-spectral italic text-left mt-auto">{{ .Title }}</p>
+ <p id="published-date" class="grow text-[1.75vmin] font-spectral text-secondary text-right mt-auto">{{ .PublishedDate }}</p>
</div>
{{ block "header" . }}{{ end }}