summaryrefslogtreecommitdiff
diff options
from:
to:
context:
space:
mode:
authorGravatar Saya Andy <145215889+SayaAndy@users.noreply.github.com> 2025-08-30 16:04:50 +0700
committerGravatar GitHub <noreply@github.com> 2025-08-30 16:04:50 +0700
commitd695dc09f44236be33fecc7a5ab9a326823170d9 (patch)
treec71c2c8f37a7609d6c8a7b558b71a1a1b4c9ec1c
parenta3d04476daa507cf941a5b82355c744c6622246c (diff)
parent1c14a4088b068fefe425cad1acdde2d603bd27a9 (diff)
downloadweb-d695dc09f44236be33fecc7a5ab9a326823170d9.tar.gz
web-d695dc09f44236be33fecc7a5ab9a326823170d9.zip
v0.7.0 (#6)v0.7.0
feat: like button with persistent storage and like count feat: show like count on blog search feat: update galleries on resize (with smooth animations) feat: add decor for night & ram themes feat: show search buttons in catalogue separately without overflow feat: add overflow to search tags in catalogue in mobile mode feat: update go (1.24.5 > 1.24.6) feat: add favicon refactor: shorten logs of blog search
-rw-r--r--.github/workflows/build-and-deploy-prod.yml2
-rw-r--r--.github/workflows/build-and-deploy-stage.yml2
-rw-r--r--Dockerfile10
-rw-r--r--config/config.go15
-rw-r--r--config/config.local.yaml26
-rw-r--r--config/config.prod.yaml6
-rw-r--r--config/config.stage.yaml6
-rw-r--r--deploy/playbook.yml7
-rw-r--r--go.mod43
-rw-r--r--go.sum76
-rw-r--r--internal/lightgallery/html_renderer.go47
-rw-r--r--internal/router/api-v1-blog-search.go3
-rw-r--r--internal/router/api-v1-like.go122
-rw-r--r--internal/router/client-cache.go229
-rw-r--r--internal/router/lang-blog-title.go3
-rw-r--r--internal/router/lang-blog.go6
-rw-r--r--locale/localization.en.yaml1
-rw-r--r--locale/localization.go1
-rw-r--r--locale/localization.ru.yaml1
-rw-r--r--main.go71
-rw-r--r--migrations/1_create_stats_tables.down.sql2
-rw-r--r--migrations/1_create_stats_tables.up.sql11
-rw-r--r--static/favicon.icobin0 -> 5694 bytes
-rw-r--r--static/input.css49
-rw-r--r--views/layouts/general-page.html140
-rw-r--r--views/pages/blog-catalogue.html77
-rw-r--r--views/pages/blog-page.html22
-rw-r--r--views/pages/global-map.html2
-rw-r--r--views/partials/blog-page-like-button.html7
-rw-r--r--views/partials/catalogue-blog-cards.html3
30 files changed, 845 insertions, 145 deletions
diff --git a/.github/workflows/build-and-deploy-prod.yml b/.github/workflows/build-and-deploy-prod.yml
index 53e307e..a180d39 100644
--- a/.github/workflows/build-and-deploy-prod.yml
+++ b/.github/workflows/build-and-deploy-prod.yml
@@ -75,4 +75,4 @@ jobs:
ANSIBLE_HOST_KEY_CHECKING: False
working-directory: ./deploy
run: |
- ansible-playbook -i inventory.yml -l prod playbook.yml --private-key ~/.ssh/id_ed25519 -u svc_github -e saya_today_web_b2_key_id=${{ secrets.B2_KEY_ID }} -e saya_today_web_b2_application_key=${{ secrets.B2_APPLICATION_KEY }} -e saya_today_web_environment=prod -e saya_today_web_tag=${{ github.ref_name }}
+ ansible-playbook -i inventory.yml -l prod playbook.yml --private-key ~/.ssh/id_ed25519 -u svc_github -e saya_today_web_auth_salt=${{ secrets.AUTH_SALT }} -e saya_today_web_b2_key_id=${{ secrets.B2_KEY_ID }} -e saya_today_web_b2_application_key=${{ secrets.B2_APPLICATION_KEY }} -e saya_today_web_environment=prod -e saya_today_web_tag=${{ github.ref_name }}
diff --git a/.github/workflows/build-and-deploy-stage.yml b/.github/workflows/build-and-deploy-stage.yml
index 104d9ac..0aea0a4 100644
--- a/.github/workflows/build-and-deploy-stage.yml
+++ b/.github/workflows/build-and-deploy-stage.yml
@@ -86,4 +86,4 @@ jobs:
ANSIBLE_HOST_KEY_CHECKING: False
working-directory: ./deploy
run: |
- ansible-playbook -i inventory.yml -l stage playbook.yml --private-key ~/.ssh/id_ed25519 -u svc_github -e saya_today_web_b2_key_id=${{ secrets.B2_KEY_ID }} -e saya_today_web_b2_application_key=${{ secrets.B2_APPLICATION_KEY }} -e saya_today_web_environment=stage -e saya_today_web_tag=commit-${{ steps.ghss_vars.outputs.sha_short }}
+ ansible-playbook -i inventory.yml -l stage playbook.yml --private-key ~/.ssh/id_ed25519 -u svc_github -e saya_today_web_auth_salt=${{ secrets.AUTH_SALT }} -e saya_today_web_b2_key_id=${{ secrets.B2_KEY_ID }} -e saya_today_web_b2_application_key=${{ secrets.B2_APPLICATION_KEY }} -e saya_today_web_environment=stage -e saya_today_web_tag=commit-${{ steps.ghss_vars.outputs.sha_short }}
diff --git a/Dockerfile b/Dockerfile
index cd3e0eb..eeee6c5 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,22 +1,26 @@
-FROM golang:1.24.5-alpine3.22 AS build-stage
+FROM golang:1.24.6-alpine3.22 AS build-stage
+
+RUN apk add --no-cache sqlite-dev musl-dev gcc
WORKDIR /builddir
COPY . .
-RUN go build -o sayana-web .
+RUN CGO_ENABLED=1 go build -o sayana-web .
FROM alpine:3.22.1 AS runtime-stage
ENV ENVIRONMENT=""
+ENV AUTH_SALT=""
ENV B2_KEY_ID=""
ENV B2_APPLICATION_KEY=""
WORKDIR /app
COPY --from=build-stage /builddir/sayana-web /app/sayana-web
+COPY --from=build-stage /builddir/migrations /app/migrations
COPY --from=build-stage /builddir/static /app/static
COPY --from=build-stage /builddir/views /app/views
COPY --from=build-stage /builddir/locale/*.yaml /app/locale/
COPY --from=build-stage /builddir/config/config*.yaml /app/config/
-RUN apk add --no-cache tzdata
+RUN apk add --no-cache tzdata sqlite
ENTRYPOINT /app/sayana-web -c /app/config/config.${ENVIRONMENT}.yaml
diff --git a/config/config.go b/config/config.go
index 8c0321e..7438fe1 100644
--- a/config/config.go
+++ b/config/config.go
@@ -13,6 +13,7 @@ type Config struct {
BlogPages BlogPagesConfig `json:"BlogPages" yaml:"blogPages" validate:"required"`
LocalePath string `json:"LocalePath" yaml:"localePath" validate:"required,filepath"`
AvailableLanguages []AvailableLanguageConfig `json:"AvailableLanguages" yaml:"availableLanguages" validate:"required"`
+ Auth AuthConfig `json:"Auth" yaml:"auth" validate:"required"`
}
type BlogPagesConfig struct {
@@ -39,6 +40,20 @@ type AvailableLanguageConfig struct {
LocFile string `json:"LocFile" yaml:"locFile" validate:"required,filepath"`
}
+type AuthConfig struct {
+ Salt string `json:"Salt" yaml:"salt" validate:"required"`
+ Db DbConfig `json:"Db" yaml:"db" validate:"required"`
+}
+
+type DbConfig struct {
+ Type string `json:"Type" yaml:"type" validate:"required,oneof=sqlite3"`
+ Cfg Sqlite3Config `json:"Config" yaml:"config"`
+}
+
+type Sqlite3Config struct {
+ DSN string `json:"DSN" yaml:"dsn" validate:"required"`
+}
+
func LoadConfig(path string, config *Config) error {
fileBytes, err := os.ReadFile(path)
if err != nil {
diff --git a/config/config.local.yaml b/config/config.local.yaml
new file mode 100644
index 0000000..0b48e47
--- /dev/null
+++ b/config/config.local.yaml
@@ -0,0 +1,26 @@
+logLevel: debug
+blogPages:
+ storage:
+ type: b2
+ config:
+ bucketName: sayana-pages
+ region: eu-central-003
+ prefix: 'stage-'
+ keyID: '${B2_KEY_ID}'
+ applicationKey: '${B2_APPLICATION_KEY}'
+localePath: ./locale/
+availableLanguages:
+ - name: ru
+ alt: Русский
+ flag: https://f003.backblazeb2.com/file/sayana-static/flags/ru.svg
+ locFile: localization.ru.yaml
+ - name: en
+ alt: English
+ flag: https://f003.backblazeb2.com/file/sayana-static/flags/gb.svg
+ locFile: localization.en.yaml
+auth:
+ db:
+ type: sqlite3
+ config:
+ dsn: 'file:/tmp/auth.db?cache=shared&mode=rwc'
+ salt: '123'
diff --git a/config/config.prod.yaml b/config/config.prod.yaml
index 2d050bf..e1294c0 100644
--- a/config/config.prod.yaml
+++ b/config/config.prod.yaml
@@ -18,3 +18,9 @@ availableLanguages:
alt: English
flag: https://f003.backblazeb2.com/file/sayana-static/flags/gb.svg
locFile: localization.en.yaml
+auth:
+ db:
+ type: sqlite3
+ config:
+ dsn: 'file:/data/auth.db?cache=private&mode=rwc&_locking_mode=EXCLUSIVE&_mutex=no&_auto_vacuum=2'
+ salt: '${AUTH_SALT}'
diff --git a/config/config.stage.yaml b/config/config.stage.yaml
index a34a33e..30233c1 100644
--- a/config/config.stage.yaml
+++ b/config/config.stage.yaml
@@ -18,3 +18,9 @@ availableLanguages:
alt: English
flag: https://f003.backblazeb2.com/file/sayana-static/flags/gb.svg
locFile: localization.en.yaml
+auth:
+ db:
+ type: sqlite3
+ config:
+ dsn: 'file:/data/auth.db?cache=private&mode=rwc&_locking_mode=EXCLUSIVE&_mutex=no&_auto_vacuum=2'
+ salt: '${AUTH_SALT}'
diff --git a/deploy/playbook.yml b/deploy/playbook.yml
index ca22ac3..dfceaf7 100644
--- a/deploy/playbook.yml
+++ b/deploy/playbook.yml
@@ -4,6 +4,10 @@
remote_user: svc_github
tasks:
+ - name: Create a volume
+ community.docker.docker_volume:
+ name: "{{ saya_today_web_name }}-volume"
+
- name: Deploy saya-today-web Docker Container
community.docker.docker_container:
name: "{{ saya_today_web_name }}"
@@ -13,10 +17,13 @@
B2_KEY_ID: "{{ saya_today_web_b2_key_id }}"
B2_APPLICATION_KEY: "{{ saya_today_web_b2_application_key }}"
ENVIRONMENT: "{{ saya_today_web_environment }}"
+ AUTH_SALT: "{{ saya_today_web_auth_salt }}"
network_mode: caddy
networks:
- name: caddy
ipv4_address: "{{ saya_today_web_ipv4_address }}"
+ volumes:
+ - "{{ saya_today_web_name }}-volume:/data:rw"
restart_policy: unless-stopped
no_log: true
...
diff --git a/go.mod b/go.mod
index c6f267d..71aa20b 100644
--- a/go.mod
+++ b/go.mod
@@ -1,33 +1,36 @@
module github.com/SayaAndy/saya-today-web
-go 1.24.5
+go 1.24.6
-require github.com/gofiber/fiber/v2 v2.52.9
+require (
+ github.com/Backblaze/blazer v0.7.2
+ github.com/go-playground/validator/v10 v10.27.0
+ github.com/gofiber/fiber/v2 v2.52.9
+ github.com/golang-migrate/migrate/v4 v4.18.3
+ github.com/mattn/go-sqlite3 v1.14.32
+ github.com/yuin/goldmark v1.7.13
+ golang.org/x/crypto v0.41.0
+ gopkg.in/yaml.v3 v3.0.1
+)
require (
- github.com/Backblaze/blazer v0.7.2 // indirect
- github.com/andybalholm/brotli v1.1.0 // indirect
- github.com/gabriel-vasile/mimetype v1.4.8 // indirect
+ github.com/andybalholm/brotli v1.2.0 // indirect
+ github.com/gabriel-vasile/mimetype v1.4.10 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
- github.com/go-playground/validator/v10 v10.27.0 // indirect
- github.com/gofiber/template v1.8.3 // indirect
- github.com/gofiber/template/html/v2 v2.1.3 // indirect
- github.com/gofiber/utils v1.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
- github.com/klauspost/compress v1.17.9 // indirect
+ github.com/hashicorp/errwrap v1.1.0 // indirect
+ github.com/hashicorp/go-multierror v1.1.1 // indirect
+ github.com/klauspost/compress v1.18.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
- github.com/mattn/go-colorable v0.1.13 // indirect
+ github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
- github.com/rivo/uniseg v0.2.0 // indirect
+ github.com/rivo/uniseg v0.4.7 // indirect
+ github.com/stretchr/testify v1.10.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
- github.com/valyala/fasthttp v1.51.0 // indirect
- github.com/valyala/tcplisten v1.0.0 // indirect
- github.com/yuin/goldmark v1.7.13 // indirect
- golang.org/x/crypto v0.33.0 // indirect
- golang.org/x/net v0.34.0 // indirect
- golang.org/x/sys v0.30.0 // indirect
- golang.org/x/text v0.22.0 // indirect
- gopkg.in/yaml.v3 v3.0.1 // indirect
+ github.com/valyala/fasthttp v1.65.0 // indirect
+ go.uber.org/atomic v1.7.0 // indirect
+ golang.org/x/sys v0.35.0 // indirect
+ golang.org/x/text v0.28.0 // indirect
)
diff --git a/go.sum b/go.sum
index e418881..22ef8ff 100644
--- a/go.sum
+++ b/go.sum
@@ -1,9 +1,14 @@
github.com/Backblaze/blazer v0.7.2 h1:UWNHMLB+Nf+UmbO2qkVvgriODLEMz4kIyr2Hm+DVXQM=
github.com/Backblaze/blazer v0.7.2/go.mod h1:T4y3EYa9IQ5J0PKc/C/J8/CEnSd3qa/lgNw938wZg10=
-github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
-github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
-github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
-github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
+github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
+github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
+github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
+github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
+github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
@@ -12,47 +17,56 @@ github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHO
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/gofiber/fiber/v2 v2.52.9 h1:YjKl5DOiyP3j0mO61u3NTmK7or8GzzWzCFzkboyP5cw=
github.com/gofiber/fiber/v2 v2.52.9/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
-github.com/gofiber/template v1.8.3 h1:hzHdvMwMo/T2kouz2pPCA0zGiLCeMnoGsQZBTSYgZxc=
-github.com/gofiber/template v1.8.3/go.mod h1:bs/2n0pSNPOkRa5VJ8zTIvedcI/lEYxzV3+YPXdBvq8=
-github.com/gofiber/template/html/v2 v2.1.3 h1:n1LYBtmr9C0V/k/3qBblXyMxV5B0o/gpb6dFLp8ea+o=
-github.com/gofiber/template/html/v2 v2.1.3/go.mod h1:U5Fxgc5KpyujU9OqKzy6Kn6Qup6Tm7zdsISR+VpnHRE=
-github.com/gofiber/utils v1.1.0 h1:vdEBpn7AzIUJRhe+CiTOJdUcTg4Q9RK+pEa0KPbLdrM=
-github.com/gofiber/utils v1.1.0/go.mod h1:poZpsnhBykfnY1Mc0KeEa6mSHrS3dV0+oBWyeQmb2e0=
+github.com/golang-migrate/migrate/v4 v4.18.3 h1:EYGkoOsvgHHfm5U/naS1RP/6PL/Xv3S4B/swMiAmDLs=
+github.com/golang-migrate/migrate/v4 v4.18.3/go.mod h1:99BKpIi6ruaaXRM1A77eqZ+FWPQ3cfRa+ZVy5bmWMaY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
-github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
+github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
+github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
+github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
+github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
+github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
+github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
+github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
-github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
-github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
-github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
+github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
+github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
+github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
+github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
-github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
+github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
+github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
-github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
-github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
-github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
-github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
+github.com/valyala/fasthttp v1.65.0 h1:j/u3uzFEGFfRxw79iYzJN+TteTJwbYkru9uDp3d0Yf8=
+github.com/valyala/fasthttp v1.65.0/go.mod h1:P/93/YkKPMsKSnATEeELUCkG8a7Y+k99uxNHVbKINr4=
+github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
+github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
-golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
-golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
-golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
-golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
-golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
+go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
+golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
+golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
-golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
-golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
-golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
-golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
+golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
+golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
+golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
+golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/internal/lightgallery/html_renderer.go b/internal/lightgallery/html_renderer.go
index e20e9ec..17a6f2b 100644
--- a/internal/lightgallery/html_renderer.go
+++ b/internal/lightgallery/html_renderer.go
@@ -92,32 +92,35 @@ func (r *LightGalleryHTMLRenderer) renderLightGallery(w util.BufWriter, source [
w.WriteString(fmt.Sprintf(`
<script>
function createLightGallery%s() {
- const $lgContainer = document.getElementById('lg-%s');
- const inlineGallery = lightGallery($lgContainer, {
- container: $lgContainer,
- dynamic: true,
- dynamicEl: [%s],
- width: "100%%",
- height: "50vmin",
- hash: false,
- closable: false,
- showMaximizeIcon: true,
- appendSubHtmlTo: ".lg-sub-html",
- isMobile: () => false,
- slideDelay: 0,
- plugins: [lgZoom, lgThumbnail],
- thumbWidth: calculateVmin(10),
- thumbHeight: "10vmin",
- thumbMargin: 4
- });
+ const $lgContainer = document.getElementById('lg-%s');
+ const config = {
+ container: $lgContainer,
+ dynamic: true,
+ dynamicEl: [%s],
+ width: "100%%",
+ height: "50vmin",
+ hash: false,
+ closable: false,
+ showMaximizeIcon: true,
+ appendSubHtmlTo: ".lg-sub-html",
+ isMobile: () => false,
+ slideDelay: 0,
+ plugins: [lgZoom, lgThumbnail],
+ thumbWidth: calculateVmin(10),
+ thumbHeight: "10vmin",
+ thumbMargin: 4
+ };
+ const inlineGallery = lightGallery($lgContainer, config);
- setTimeout(() => {
- inlineGallery.openGallery();
- }, 200);
+ setTimeout(() => {
+ inlineGallery.openGallery();
+ }, 200);
+
+ galleryMap.set(inlineGallery, createLightGallery%s);
}
document.addEventListener('DOMContentLoaded', createLightGallery%s);
-</script>`, galleryID, galleryID, strings.Join(dynamicElements, ","), galleryID))
+</script>`, galleryID, galleryID, strings.Join(dynamicElements, ","), galleryID, galleryID))
}
return ast.WalkContinue, nil
diff --git a/internal/router/api-v1-blog-search.go b/internal/router/api-v1-blog-search.go
index 51ace85..e22fabe 100644
--- a/internal/router/api-v1-blog-search.go
+++ b/internal/router/api-v1-blog-search.go
@@ -48,7 +48,7 @@ func Api_V1_BlogSearch(l map[string]*locale.LocaleConfig, langs []string, b2Clie
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))
+ decodedQuery, _ := url.QueryUnescape(string(encodedQuery))
matches := re.FindAllStringSubmatch(decodedQuery, -1)
tags := make([]string, 0, len(matches))
@@ -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/api-v1-like.go b/internal/router/api-v1-like.go
new file mode 100644
index 0000000..327dafa
--- /dev/null
+++ b/internal/router/api-v1-like.go
@@ -0,0 +1,122 @@
+package router
+
+import (
+ "fmt"
+ "log/slog"
+ "net/url"
+ "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("blog-page-like-button", "views/partials/blog-page-like-button.html")
+}
+
+func Api_V1_Like_Put(l map[string]*locale.LocaleConfig, 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))
+ }
+
+ newLikeStatus, err := strconv.ParseBool(c.FormValue("like", "true"))
+ if err != nil {
+ return c.Status(fiber.ErrBadRequest.Code).SendString("invalid 'like' value")
+ }
+
+ ip := c.IP()
+ 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{
+ "L": l[lang],
+ "Liked": newLikeStatus,
+ "LikedCount": CCache.GetLikeCount(page),
+ })
+ 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(l map[string]*locale.LocaleConfig, 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{
+ "L": l[lang],
+ "Liked": likeStatus,
+ "LikedCount": CCache.GetLikeCount(page),
+ })
+ 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
new file mode 100644
index 0000000..81d7641
--- /dev/null
+++ b/internal/router/client-cache.go
@@ -0,0 +1,229 @@
+package router
+
+import (
+ "database/sql"
+ "encoding/base64"
+ "fmt"
+ "log/slog"
+ "strings"
+ "sync"
+
+ "golang.org/x/crypto/argon2"
+)
+
+type PageLike struct {
+ PageRef string
+ UserId string
+}
+
+type ClientCache struct {
+ hashMap map[string]string
+ 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
+
+func NewClientCache(db *sql.DB, salt []byte) (*ClientCache, error) {
+ tx, err := db.Begin()
+ if err != nil {
+ return nil, fmt.Errorf("fail to init transaction with db to fill cache: %w", err)
+ }
+
+ rows, err := tx.Query("select * from blog_likes;")
+ if err != nil {
+ tx.Rollback()
+ return nil, fmt.Errorf("fail to query db for blog_likes to fill cache: %w", err)
+ }
+
+ likePageMap := make(map[string]map[string]struct{})
+ pageMutexMap := make(map[string]*sync.RWMutex)
+
+ 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_likes to fill cache: %w", err)
+ }
+ userIdString := base64.RawStdEncoding.EncodeToString(userId)
+ if _, ok := likePageMap[pageRef]; !ok {
+ likePageMap[pageRef] = make(map[string]struct{})
+ pageMutexMap[pageRef] = &sync.RWMutex{}
+ }
+ likePageMap[pageRef][userIdString] = struct{}{}
+ }
+
+ if err = tx.Commit(); err != nil {
+ return nil, fmt.Errorf("fail to commit transaction in db: %w", err)
+ }
+
+ return &ClientCache{
+ hashMap: make(map[string]string),
+ likePageMap: likePageMap,
+ pageMutexMap: pageMutexMap,
+ salt: salt,
+ db: db,
+ }, nil
+}
+
+func (c *ClientCache) Close() error {
+ tx, err := c.db.Begin()
+ if err != nil {
+ 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([]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)
+ }
+ }
+
+ 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()))
+ }
+ }
+
+ return tx.Commit()
+}
+
+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()
+
+ 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))
+ return val
+ }
+
+ 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) 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
+ }
+ _, ok := c.likePageMap[page][c.GetHash(id)]
+ return ok
+}
+
+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)
+ }
+ return 0
+}
+
+func (c *ClientCache) LikeOn(id string, page string) (alreadyLiked bool) {
+ 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{}{}
+ return
+ }
+
+ c.likePageMap[page] = make(map[string]struct{})
+ c.likePageMap[page][hash] = struct{}{}
+ return
+}
+
+func (c *ClientCache) LikeOff(id string, page string) (alreadyUnliked bool) {
+ 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
+ }
+ 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 b7b5d7e..bafb0d2 100644
--- a/internal/router/lang-blog-title.go
+++ b/internal/router/lang-blog-title.go
@@ -22,6 +22,9 @@ func init() {
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 {
+ ip := c.IP()
+ slog.Debug("client entering blog page", slog.String("ip", ip), slog.String("page", c.Path()))
+
lang := c.Params("lang")
if !slices.Contains(langs, lang) {
c.Set(fiber.HeaderContentType, fiber.MIMETextPlainCharsetUTF8)
diff --git a/internal/router/lang-blog.go b/internal/router/lang-blog.go
index 650c4b1..dd3f079 100644
--- a/internal/router/lang-blog.go
+++ b/internal/router/lang-blog.go
@@ -37,7 +37,7 @@ func Lang_Blog(l map[string]*locale.LocaleConfig, langs []string, b2Client *b2.B
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))
+ decodedQuery, _ := url.QueryUnescape(string(encodedQuery))
matches := re.FindAllStringSubmatch(decodedQuery, -1)
queryTags := make([]string, 0, len(matches))
@@ -54,11 +54,11 @@ func Lang_Blog(l map[string]*locale.LocaleConfig, langs []string, b2Client *b2.B
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]++
}
}
+ slog.Debug("enlist pages for catalogue", slog.Int("tag_count", len(tagsMap)), slog.Int("page_count", len(pages)), slog.String("path", c.Path()))
type Tag struct {
Name string `json:"Name" yaml:"name"`
@@ -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")
}
diff --git a/locale/localization.en.yaml b/locale/localization.en.yaml
index f5ee6c9..48ef9da 100644
--- a/locale/localization.en.yaml
+++ b/locale/localization.en.yaml
@@ -9,3 +9,4 @@ BlogSearch:
ChooseAllTags: 'Choose All'
GlobalMap:
Header: 'Global Map'
+LikeButton: 'Like!'
diff --git a/locale/localization.go b/locale/localization.go
index 56b6462..b557e08 100644
--- a/locale/localization.go
+++ b/locale/localization.go
@@ -11,6 +11,7 @@ type LocaleConfig struct {
TagsLabel string `yaml:"TagsLabel" json:"TagsLabel"`
BlogSearch BlogSearchConfig `yaml:"BlogSearch" json:"BlogSearch"`
GlobalMap GlobalMapConfig `yaml:"GlobalMap" json:"GlobalMap"`
+ LikeButton string `yaml:"LikeButton" json:"LikeButton"`
}
type BlogSearchConfig struct {
diff --git a/locale/localization.ru.yaml b/locale/localization.ru.yaml
index c236d1e..d6e9fe4 100644
--- a/locale/localization.ru.yaml
+++ b/locale/localization.ru.yaml
@@ -9,3 +9,4 @@ BlogSearch:
ChooseAllTags: 'Выбрать все'
GlobalMap:
Header: 'Глобальная карта'
+LikeButton: 'Нраица!'
diff --git a/main.go b/main.go
index 566e796..020f8ef 100644
--- a/main.go
+++ b/main.go
@@ -1,10 +1,13 @@
package main
import (
+ "database/sql"
+ "errors"
"flag"
- "log"
"log/slog"
"os"
+ "os/signal"
+ "syscall"
"github.com/SayaAndy/saya-today-web/config"
"github.com/SayaAndy/saya-today-web/internal/b2"
@@ -14,9 +17,14 @@ import (
"github.com/SayaAndy/saya-today-web/locale"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/redirect"
+ "github.com/golang-migrate/migrate/v4"
+ "github.com/golang-migrate/migrate/v4/database/sqlite3"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/parser"
gmhtml "github.com/yuin/goldmark/renderer/html"
+
+ _ "github.com/golang-migrate/migrate/v4/source/file"
+ _ "github.com/mattn/go-sqlite3"
)
var (
@@ -52,6 +60,32 @@ func main() {
slog.SetLogLoggerLevel(cfg.LogLevel)
slog.Info("starting sayana-web server...")
+ db, err := sql.Open(cfg.Auth.Db.Type, cfg.Auth.Db.Cfg.DSN)
+ if err != nil {
+ slog.Error("fail to initialize db", slog.String("error", err.Error()))
+ os.Exit(1)
+ }
+
+ driver, err := sqlite3.WithInstance(db, &sqlite3.Config{})
+ if err != nil {
+ slog.Error("fail to initialize driver for migrating db", slog.String("error", err.Error()))
+ os.Exit(1)
+ }
+
+ m, err := migrate.NewWithDatabaseInstance(
+ "file://migrations",
+ cfg.Auth.Db.Type, driver)
+ if err != nil {
+ slog.Error("fail to initialize migration client", slog.String("error", err.Error()))
+ os.Exit(1)
+ }
+
+ if err = m.Up(); err != nil && err == errors.New("no change") {
+ slog.Error("fail to apply migrations", slog.String("error", err.Error()))
+ os.Exit(1)
+ }
+ slog.Info("successfully applied migrations")
+
b2Client, err = b2.NewB2Client(&cfg.BlogPages.Storage.Config)
if err != nil {
slog.Error("fail to initialize b2 client", slog.String("error", err.Error()))
@@ -68,7 +102,9 @@ func main() {
localization[lang.Name] = localeCfg
}
- app := fiber.New(fiber.Config{})
+ app := fiber.New(fiber.Config{
+ ProxyHeader: "X-Forwarded-For",
+ })
app.Use(redirect.New(redirect.Config{
Rules: map[string]string{
@@ -78,14 +114,43 @@ func main() {
StatusCode: 301,
}))
+ router.CCache, err = router.NewClientCache(db, []byte(cfg.Auth.Salt))
+ if err != nil {
+ slog.Error("fail to initialize cache", slog.String("error", err.Error()))
+ os.Exit(1)
+ }
+
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("/api/v1/like", router.Api_V1_Like_Get(localization, b2Client))
+ app.Put("/api/v1/like", router.Api_V1_Like_Put(localization, b2Client))
app.Static("/", "./static")
- log.Fatal(app.Listen(":3000"))
+ go func() {
+ if err := app.Listen(":3000"); err != nil {
+ slog.Error("error while running fiber server", slog.String("error", err.Error()))
+ panic(err)
+ }
+ }()
+
+ sigChan := make(chan os.Signal, 1)
+ signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
+
+ <-sigChan
+ slog.Info("gracefully shutting down...")
+ 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/migrations/1_create_stats_tables.down.sql b/migrations/1_create_stats_tables.down.sql
new file mode 100644
index 0000000..ee4593c
--- /dev/null
+++ b/migrations/1_create_stats_tables.down.sql
@@ -0,0 +1,2 @@
+DROP TABLE IF EXISTS blog_views;
+DROP TABLE IF EXISTS blog_likes;
diff --git a/migrations/1_create_stats_tables.up.sql b/migrations/1_create_stats_tables.up.sql
new file mode 100644
index 0000000..8852268
--- /dev/null
+++ b/migrations/1_create_stats_tables.up.sql
@@ -0,0 +1,11 @@
+CREATE TABLE IF NOT EXISTS blog_likes (
+ page_ref VARCHAR(32) NOT NULL,
+ user_id VARCHAR(32) NOT NULL,
+ PRIMARY KEY (page_ref, user_id)
+) WITHOUT ROWID;
+
+CREATE TABLE IF NOT EXISTS blog_views (
+ page_ref VARCHAR(32) NOT NULL,
+ user_id VARCHAR(32) NOT NULL,
+ PRIMARY KEY (page_ref, user_id)
+) WITHOUT ROWID;
diff --git a/static/favicon.ico b/static/favicon.ico
new file mode 100644
index 0000000..33eb226
--- /dev/null
+++ b/static/favicon.ico
Binary files differ
diff --git a/static/input.css b/static/input.css
index 9f834a6..47e7d16 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);
@@ -545,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%;
}
@@ -576,7 +616,7 @@ body[data-theme~="ram"] .leaflet-tile {
}
}
-.star {
+.night-star {
color: var(--color-yellow-100);
animation-name: blink;
animation-duration: 5s;
@@ -585,6 +625,13 @@ body[data-theme~="ram"] .leaflet-tile {
-webkit-user-select: none;
}
+.ram-frame {
+ width: 20vmin;
+ height: 20vmin;
+ user-select: none;
+ -webkit-user-select: none;
+}
+
@keyframes blink {
0% {
opacity: 1;
diff --git a/views/layouts/general-page.html b/views/layouts/general-page.html
index cd19bd0..a7cf66e 100644
--- a/views/layouts/general-page.html
+++ b/views/layouts/general-page.html
@@ -8,13 +8,119 @@
<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>
<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 sfc32(a, b, c, d) {
+ return function() {
+ a |= 0; b |= 0; c |= 0; d |= 0;
+ let t = (a + b | 0) + d | 0;
+ d = d + 1 | 0;
+ a = b ^ b >>> 9;
+ b = c + (c << 3) | 0;
+ c = (c << 21 | c >>> 11);
+ c = c + t | 0;
+ return (t >>> 0) / 4294967296;
+ }
+ }
+
+ function cyrb128(str) {
+ let h1 = 1779033703, h2 = 3144134277,
+ h3 = 1013904242, h4 = 2773480762;
+ for (let i = 0, k; i < str.length; i++) {
+ k = str.charCodeAt(i);
+ h1 = h2 ^ Math.imul(h1 ^ k, 597399067);
+ h2 = h3 ^ Math.imul(h2 ^ k, 2869860233);
+ h3 = h4 ^ Math.imul(h3 ^ k, 951274213);
+ h4 = h1 ^ Math.imul(h4 ^ k, 2716044179);
+ }
+ h1 = Math.imul(h3 ^ (h1 >>> 18), 597399067);
+ h2 = Math.imul(h4 ^ (h2 >>> 22), 2869860233);
+ h3 = Math.imul(h1 ^ (h3 >>> 17), 951274213);
+ h4 = Math.imul(h2 ^ (h4 >>> 19), 2716044179);
+ h1 ^= (h2 ^ h3 ^ h4), h2 ^= h1, h3 ^= h1, h4 ^= h1;
+ return [h1>>>0, h2>>>0, h3>>>0, h4>>>0];
+ }
+
+ var seed = cyrb128("{{ .Title }}");
+ var rand = sfc32(seed[0], seed[1], seed[2], seed[3]);
+
+ 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 switchThemeDecor(theme) {
+ document.querySelectorAll('.theme-decor').forEach((e) => {
+ e.remove();
+ });
+
+ if (theme == "" || typeof theme == "undefined") {
+ theme = document.body.getAttribute('data-theme');
+ }
+
+ vmin = calculateVmin(1);
+ var clWidth = document.documentElement.clientWidth;
+ var clHeight = document.documentElement.clientHeight;
+
+ switch (theme) {
+ case "olive": break;
+ case "lettuce": break;
+ case "night":
+ for (i = 0; i < 25; i++) {
+ let star = document.createElement("div");
+ star.append("*");
+ star.classList.add("night-star", "theme-decor", "z-0", "absolute");
+ const x = Math.floor(rand() * clWidth);
+ const y = Math.floor(rand() * clHeight * 0.8);
+ const rot = rand() * 90;
+ const animDuration = (rand() * 10) + 3;
+ const sz = Math.floor(rand() * 8) + 12;
+ star.style.transform = `translateX(${x}px) translateY(${y}px) rotate(${rot}deg)`;
+ star.style.animationDuration = `${animDuration}s`;
+ star.style.fontSize = `${sz}px`;
+ document.body.append(star);
+ }
+ break;
+ case "ram":
+ const frameCenters = new Array(4);
+ for (i = 1; i <= 4; i++) {
+ let frame = document.createElement("img");
+ frame.src = `https://f003.backblazeb2.com/file/sayana-static/themes/ram/ram-frame${i}.webp`;
+ frame.classList.add("ram-frame", "theme-decor", "z-0", "absolute");
+ let x, y;
+ outerLoop:
+ while (true) {
+ x = Math.floor(rand() * clWidth - 20*vmin);
+ y = Math.floor(rand() * clHeight - 20*vmin);
+ for (j = 0; j < i-1; j++) {
+ const xd = frameCenters[j].x - x;
+ const yd = frameCenters[j].y - y;
+ if (Math.sqrt(xd*xd + yd*yd) < 30*vmin) {
+ continue outerLoop;
+ }
+ }
+ break;
+ }
+ frameCenters[i-1] = {x: x, y: y};
+ const rot = rand() * 90 - 45;
+ frame.style.transform = `translateX(${x}px) translateY(${y}px) rotate(${rot}deg)`;
+ document.body.append(frame);
+ }
+ break;
+ }
+ }
+
function switchTheme(theme) {
if (theme == "" || typeof theme == "undefined") {
const currentTheme = document.body.getAttribute('data-theme');
@@ -27,6 +133,7 @@
}
localStorage.setItem('theme', theme);
document.body.setAttribute('data-theme', theme);
+ switchThemeDecor(theme);
}
const savedTheme = localStorage.getItem('theme') || 'ram';
@@ -66,17 +173,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]">
@@ -98,17 +207,6 @@
}
});
- 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;
@@ -209,7 +307,15 @@
});
</script>
- {{ block "footer" . }}{{ end }}
+ <script>
+ let decorResizeTimeout;
+ window.addEventListener('resize', function() {
+ clearTimeout(decorResizeTimeout);
+ decorResizeTimeout = setTimeout(switchThemeDecor, 300);
+ });
+ </script>
+
+ {{ block "bottom-embeds" . }}{{ end }}
</body>
</html>
diff --git a/views/pages/blog-catalogue.html b/views/pages/blog-catalogue.html
index 9fa8ea7..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">
@@ -56,7 +59,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..65ee31d 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">
@@ -12,6 +12,10 @@
height: 100% !important;
}
</style>
+
+<script>
+ const galleryMap = new Map();
+</script>
{{ end }}
{{ define "body" }}
@@ -26,6 +30,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>
@@ -66,10 +74,18 @@
{{ .MapLocationAreaMeters }}
)});
+ let galleryResizeTimeout;
window.addEventListener('resize', function() {
- setTimeout(() => {
+ clearTimeout(galleryResizeTimeout);
+ galleryResizeTimeout = setTimeout(() => {
map.invalidateSize();
- }, 100);
+ var oldGalleryMap = new Map(galleryMap);
+ oldGalleryMap.forEach((createFunc, g, map) => {
+ galleryMap.delete(g);
+ g.destroy();
+ createFunc();
+ });
+ }, 300);
});
</script>
{{- end }}
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..afc3027
--- /dev/null
+++ b/views/partials/blog-page-like-button.html
@@ -0,0 +1,7 @@
+<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 text-background-dark border-inset{{ else }}bg-main-dark hover:bg-main-medium text-background-light border-outset{{ end }} cursor-pointer border-[0.2vmax] border-background-dark">
+ <i class="fas fa-thumbs-up w-[0.8vmax] h-[0.8vmax] mr-[0.4vmax]"></i>
+ <span>{{ .L.LikeButton }}</span>
+ <span class="italic ml-[0.2vmax] text-background-light">({{ .LikedCount }})</span>
+</button> \ No newline at end of file
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>